@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
package/.env.example CHANGED
@@ -101,3 +101,11 @@ BROWSER_API_TOKEN=your_cloudflare_browser_rendering_api_token_here
101
101
  # □ Configure KV binding in user-worker/wrangler.jsonc
102
102
  # □ Configure R2 binding in data-worker/wrangler.jsonc
103
103
  # □ Configure R2 binding in audit-worker/wrangler.jsonc
104
+
105
+ # ================================
106
+ # PRIMERSHEAR PDF FORMAT
107
+ # ================================
108
+ # Comma-separated list of email addresses that will receive the primershear PDF format.
109
+ # Leave empty to disable the feature. Never commit this value to source control.
110
+ # Example: PRIMERSHEAR_EMAILS=analyst@org.com,user2@org.com
111
+ PRIMERSHEAR_EMAILS=
@@ -1,8 +1,9 @@
1
1
  import type { User } from 'firebase/auth';
2
2
  import { type AnnotationData, type CaseExportData, type AllCasesExportData, type ExportOptions } from '~/types';
3
+ import { getCaseData } from '~/utils/data';
3
4
  import { fetchFiles } from '../image-manage';
4
5
  import { getNotes } from '../notes-manage';
5
- import { checkExistingCase, validateCaseNumber, listCases } from '../case-manage';
6
+ import { validateCaseNumber, listCases } from '../case-manage';
6
7
  import { getUserExportMetadata } from './metadata-helpers';
7
8
 
8
9
  /**
@@ -104,9 +105,9 @@ export async function exportAllCases(
104
105
  // Get case creation date even for failed exports
105
106
  let caseCreatedDate = new Date().toISOString(); // fallback
106
107
  try {
107
- const existingCase = await checkExistingCase(user, caseNumber);
108
- if (existingCase?.createdAt) {
109
- caseCreatedDate = existingCase.createdAt;
108
+ const caseData = await getCaseData(user, caseNumber);
109
+ if (caseData?.createdAt) {
110
+ caseCreatedDate = caseData.createdAt;
110
111
  }
111
112
  } catch {
112
113
  // Use fallback date if case lookup fails
@@ -200,9 +201,9 @@ export async function exportCaseData(
200
201
  throw new Error('Invalid case number format');
201
202
  }
202
203
 
203
- // Check if case exists
204
- const existingCase = await checkExistingCase(user, caseNumber);
205
- if (!existingCase) {
204
+ // Check if case exists and is accessible (supports regular and read-only/archived cases)
205
+ const caseData = await getCaseData(user, caseNumber);
206
+ if (!caseData) {
206
207
  throw new Error(`Case "${caseNumber}" does not exist`);
207
208
  }
208
209
 
@@ -296,7 +297,12 @@ export async function exportCaseData(
296
297
  const exportData: CaseExportData = {
297
298
  metadata: {
298
299
  caseNumber,
299
- caseCreatedDate: existingCase.createdAt,
300
+ caseCreatedDate: caseData.createdAt,
301
+ archived: caseData.archived,
302
+ archivedAt: caseData.archivedAt,
303
+ archivedBy: caseData.archivedBy,
304
+ archivedByDisplay: caseData.archivedByDisplay,
305
+ archiveReason: caseData.archiveReason,
300
306
  exportDate: new Date().toISOString(),
301
307
  ...userMetadata,
302
308
  striaeExportSchemaVersion: '1.0',
@@ -72,6 +72,7 @@ export function generateMetadataRows(exportData: CaseExportData): TabularCell[][
72
72
  ['Exported By (UID)', exportData.metadata.exportedByUid || 'N/A'],
73
73
  ['Exported By (Name)', exportData.metadata.exportedByName || 'N/A'],
74
74
  ['Exported By (Company)', exportData.metadata.exportedByCompany || 'N/A'],
75
+ ['Exported By (Badge/ID)', exportData.metadata.exportedByBadgeId || 'N/A'],
75
76
  ['Striae Export Schema Version', exportData.metadata.striaeExportSchemaVersion],
76
77
  ['Total Files', exportData.metadata.totalFiles.toString()],
77
78
  [''],
@@ -264,6 +264,7 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
264
264
  ['Exported By (UID)', exportData.metadata.exportedByUid || 'N/A'],
265
265
  ['Exported By (Name)', exportData.metadata.exportedByName || 'N/A'],
266
266
  ['Exported By (Company)', exportData.metadata.exportedByCompany || 'N/A'],
267
+ ['Exported By (Badge/ID)', exportData.metadata.exportedByBadgeId || 'N/A'],
267
268
  ['Striae Export Schema Version', '1.0'],
268
269
  ['Total Cases', exportData.cases.length],
269
270
  ['Successful Exports', exportData.cases.filter(c => !c.summary?.exportError).length],
@@ -291,6 +292,7 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
291
292
  'Exported By (UID)',
292
293
  'Exported By (Name)',
293
294
  'Exported By (Company)',
295
+ 'Exported By (Badge/ID)',
294
296
  'Schema Version',
295
297
  'Total Files',
296
298
  'Files with Annotations',
@@ -312,6 +314,7 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
312
314
  caseData.metadata.exportedByUid || 'N/A',
313
315
  caseData.metadata.exportedByName || 'N/A',
314
316
  caseData.metadata.exportedByCompany || 'N/A',
317
+ caseData.metadata.exportedByBadgeId || 'N/A',
315
318
  caseData.metadata.striaeExportSchemaVersion,
316
319
  caseData.metadata.totalFiles,
317
320
  caseData.summary?.filesWithAnnotations || 0,
@@ -944,6 +947,7 @@ Exported By (Email): ${exportData.metadata.exportedBy || 'N/A'}
944
947
  Exported By (UID): ${exportData.metadata.exportedByUid || 'N/A'}
945
948
  Exported By (Name): ${exportData.metadata.exportedByName || 'N/A'}
946
949
  Exported By (Company): ${exportData.metadata.exportedByCompany || 'N/A'}
950
+ Exported By (Badge/ID): ${exportData.metadata.exportedByBadgeId || 'N/A'}
947
951
  Striae Export Schema Version: ${exportData.metadata.striaeExportSchemaVersion}
948
952
 
949
953
  Summary:
@@ -1005,6 +1009,9 @@ async function generateJSONContent(
1005
1009
  if (jsonData.metadata.exportedByCompany) {
1006
1010
  jsonData.metadata.exportedByCompany = '[User Info Excluded]';
1007
1011
  }
1012
+ if (jsonData.metadata.exportedByBadgeId) {
1013
+ jsonData.metadata.exportedByBadgeId = '[User Info Excluded]';
1014
+ }
1008
1015
  }
1009
1016
 
1010
1017
  const jsonString = JSON.stringify(jsonData, null, 2);
@@ -12,7 +12,8 @@ export async function getUserExportMetadata(user: User) {
12
12
  exportedBy: user.email,
13
13
  exportedByUid: userData.uid,
14
14
  exportedByName: `${userData.firstName} ${userData.lastName}`.trim(),
15
- exportedByCompany: userData.company
15
+ exportedByCompany: userData.company,
16
+ ...(userData.badgeId ? { exportedByBadgeId: userData.badgeId } : {})
16
17
  };
17
18
  }
18
19
  } catch (error) {
@@ -14,6 +14,7 @@ interface CaseDataFile {
14
14
  interface CaseDataResponse {
15
15
  files?: CaseDataFile[];
16
16
  originalImageIds?: Record<string, string>;
17
+ archived?: boolean;
17
18
  }
18
19
 
19
20
  type AnnotationImportData = Record<string, unknown> & {
@@ -123,6 +124,10 @@ export async function importConfirmationData(
123
124
  }
124
125
 
125
126
  const caseData = await caseResponse.json() as CaseDataResponse;
127
+
128
+ if (caseData.archived) {
129
+ throw new Error('Cannot import confirmations into an archived case.');
130
+ }
126
131
 
127
132
  // Build mapping from original image IDs to current image IDs
128
133
  const imageIdMapping = new Map<string, string>();
@@ -307,7 +312,8 @@ export async function importConfirmationData(
307
312
  present: signaturePresent,
308
313
  valid: signatureValid,
309
314
  keyId: signatureKeyId
310
- }
315
+ },
316
+ confirmationData.metadata.exportedByBadgeId // Reviewer's badge/ID number
311
317
  );
312
318
 
313
319
  auditService.endWorkflow();
@@ -326,6 +332,7 @@ export async function importConfirmationData(
326
332
  let hashValidForAudit = hashValid;
327
333
  let exporterUidValidatedForAudit = true;
328
334
  let reviewingExaminerUidForAudit: string | undefined = undefined;
335
+ let reviewerBadgeIdForAudit: string | undefined = undefined;
329
336
  let totalConfirmationsForAudit = 0; // Default to 0 for failed imports
330
337
  let signaturePresentForAudit = signaturePresent;
331
338
  let signatureValidForAudit = signatureValid;
@@ -336,6 +343,7 @@ export async function importConfirmationData(
336
343
  // First, try to extract basic metadata for audit purposes (if file is parseable)
337
344
  if (auditConfirmationData) {
338
345
  reviewingExaminerUidForAudit = auditConfirmationData.metadata?.exportedByUid;
346
+ reviewerBadgeIdForAudit = auditConfirmationData.metadata?.exportedByBadgeId;
339
347
  totalConfirmationsForAudit = auditConfirmationData.metadata?.totalConfirmations || 0;
340
348
  if (auditConfirmationData.metadata?.signature) {
341
349
  signaturePresentForAudit = true;
@@ -345,6 +353,7 @@ export async function importConfirmationData(
345
353
  try {
346
354
  const extracted = await extractConfirmationImportPackage(confirmationFile);
347
355
  reviewingExaminerUidForAudit = extracted.confirmationData.metadata?.exportedByUid;
356
+ reviewerBadgeIdForAudit = extracted.confirmationData.metadata?.exportedByBadgeId;
348
357
  totalConfirmationsForAudit = extracted.confirmationData.metadata?.totalConfirmations || 0;
349
358
  confirmationJsonFileNameForAudit = extracted.confirmationFileName;
350
359
  if (extracted.confirmationData.metadata?.signature) {
@@ -393,7 +402,8 @@ export async function importConfirmationData(
393
402
  present: signaturePresentForAudit,
394
403
  valid: signatureValidForAudit,
395
404
  keyId: signatureKeyIdForAudit
396
- }
405
+ },
406
+ reviewerBadgeIdForAudit // Reviewer's badge/ID number (when extractable)
397
407
  );
398
408
 
399
409
  auditService.endWorkflow();
@@ -1,11 +1,16 @@
1
1
  import type { User } from 'firebase/auth';
2
- import { type ImportOptions, type ImportResult, type ReadOnlyCaseMetadata, type FileData } from '~/types';
2
+ import {
3
+ type ImportOptions,
4
+ type ImportResult,
5
+ type ReadOnlyCaseMetadata,
6
+ type FileData,
7
+ type BundledAuditTrailData,
8
+ type ValidationAuditEntry
9
+ } from '~/types';
3
10
  import { checkExistingCase } from '../case-manage';
4
11
  import {
5
- extractForensicManifestData,
6
12
  type SignedForensicManifest,
7
- validateCaseIntegritySecure as validateForensicIntegrity,
8
- verifyForensicManifestSignature
13
+ verifyCasePackageIntegrity
9
14
  } from '~/utils/forensics';
10
15
  import { deleteFile } from '../image-manage';
11
16
  import { parseImportZip } from './zip-processing';
@@ -31,6 +36,48 @@ interface ImportState {
31
36
  caseNumber: string;
32
37
  }
33
38
 
39
+ interface BundledAuditTrailFile {
40
+ metadata?: {
41
+ exportTimestamp?: string;
42
+ totalEntries?: number;
43
+ };
44
+ auditTrail?: {
45
+ entries?: ValidationAuditEntry[];
46
+ };
47
+ }
48
+
49
+ function extractBundledAuditTrailData(
50
+ bundledAuditFiles: {
51
+ auditTrailContent?: string;
52
+ auditSignatureContent?: string;
53
+ } | undefined
54
+ ): BundledAuditTrailData | undefined {
55
+ if (!bundledAuditFiles?.auditTrailContent) {
56
+ return undefined;
57
+ }
58
+
59
+ try {
60
+ const parsed = JSON.parse(bundledAuditFiles.auditTrailContent) as BundledAuditTrailFile;
61
+ const entries = parsed.auditTrail?.entries;
62
+
63
+ if (!Array.isArray(entries)) {
64
+ return undefined;
65
+ }
66
+
67
+ return {
68
+ source: 'archive-bundle',
69
+ importedAt: new Date().toISOString(),
70
+ exportTimestamp: parsed.metadata?.exportTimestamp,
71
+ totalEntries: typeof parsed.metadata?.totalEntries === 'number'
72
+ ? parsed.metadata.totalEntries
73
+ : entries.length,
74
+ entries
75
+ };
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
34
81
  /**
35
82
  * Clean up partially imported data when an import fails
36
83
  */
@@ -141,6 +188,8 @@ export async function importCaseForReview(
141
188
  caseData,
142
189
  imageFiles,
143
190
  imageIdMapping,
191
+ isArchivedExport,
192
+ bundledAuditFiles,
144
193
  metadata,
145
194
  cleanedContent,
146
195
  verificationPublicKeyPem
@@ -181,40 +230,35 @@ export async function importCaseForReview(
181
230
  if (parsedForensicManifest && cleanedContent) {
182
231
  onProgress?.('Validating comprehensive integrity', 15, 'Checking all file hashes...');
183
232
 
184
- const manifestForValidation = extractForensicManifestData(parsedForensicManifest);
185
- if (!manifestForValidation) {
186
- throw new Error(
187
- 'Forensic manifest structure is invalid. Import cannot proceed.'
188
- );
233
+ const imageBlobs: { [filename: string]: Blob } = {};
234
+ for (const [filename, blob] of Object.entries(imageFiles)) {
235
+ imageBlobs[filename] = blob;
189
236
  }
190
237
 
191
- const signatureResult = await verifyForensicManifestSignature(
192
- parsedForensicManifest,
193
- verificationPublicKeyPem
194
- );
195
- signatureValidationPassed = signatureResult.isValid;
196
- signatureKeyId = signatureResult.keyId;
238
+ const casePackageResult = await verifyCasePackageIntegrity({
239
+ cleanedContent,
240
+ imageFiles: imageBlobs,
241
+ forensicManifest: parsedForensicManifest,
242
+ verificationPublicKeyPem,
243
+ bundledAuditFiles
244
+ });
197
245
 
198
- if (!signatureResult.isValid) {
246
+ signatureValidationPassed = casePackageResult.signatureResult.isValid;
247
+ signatureKeyId = casePackageResult.signatureResult.keyId;
248
+
249
+ if (!casePackageResult.signatureResult.isValid) {
199
250
  throw new Error(
200
251
  'Manifest signature validation failed. Import cannot proceed.'
201
252
  );
202
253
  }
203
-
204
- // Extract image files for comprehensive validation
205
- const imageBlobs: { [filename: string]: Blob } = {};
206
- for (const [filename, blob] of Object.entries(imageFiles)) {
207
- imageBlobs[filename] = blob;
254
+
255
+ if (casePackageResult.bundledAuditVerification) {
256
+ throw new Error(
257
+ `${casePackageResult.bundledAuditVerification.message} Import cannot proceed.`
258
+ );
208
259
  }
209
-
210
- // Perform comprehensive validation
211
- const validation = await validateForensicIntegrity(
212
- cleanedContent,
213
- imageBlobs,
214
- manifestForValidation
215
- );
216
-
217
- if (!validation.isValid) {
260
+
261
+ if (!casePackageResult.integrityResult.isValid) {
218
262
  throw new Error(
219
263
  'Comprehensive integrity validation failed. Import cannot proceed.'
220
264
  );
@@ -239,7 +283,7 @@ export async function importCaseForReview(
239
283
 
240
284
  // Step 2a: Check if case already exists in user's regular cases (original analyst)
241
285
  const existingRegularCase = await checkExistingCase(user, result.caseNumber);
242
- if (existingRegularCase) {
286
+ if (existingRegularCase && !isArchivedExport) {
243
287
  throw new Error(`Case "${result.caseNumber}" already exists in your case list. You cannot import a case for review if you were the original analyst.`);
244
288
  }
245
289
 
@@ -318,7 +362,9 @@ export async function importCaseForReview(
318
362
  caseData,
319
363
  importedFiles,
320
364
  originalImageIdMapping,
321
- parsedForensicManifest
365
+ parsedForensicManifest,
366
+ isArchivedExport,
367
+ isArchivedExport ? extractBundledAuditTrailData(bundledAuditFiles) : undefined
322
368
  );
323
369
  importState.caseDataStored = true;
324
370
 
@@ -9,7 +9,8 @@ import {
9
9
  type CaseExportData,
10
10
  type FileData,
11
11
  type CaseData,
12
- type ReadOnlyCaseMetadata
12
+ type ReadOnlyCaseMetadata,
13
+ type BundledAuditTrailData
13
14
  } from '~/types';
14
15
  import { deleteFile } from '../image-manage';
15
16
  import { type SignedForensicManifest } from '~/utils/forensics';
@@ -80,7 +81,9 @@ export async function storeCaseDataInR2(
80
81
  caseData: CaseExportData,
81
82
  importedFiles: FileData[],
82
83
  originalImageIdMapping?: Map<string, string>,
83
- forensicManifest?: SignedForensicManifest
84
+ forensicManifest?: SignedForensicManifest,
85
+ isArchivedExport?: boolean,
86
+ bundledAuditTrail?: BundledAuditTrailData
84
87
  ): Promise<void> {
85
88
  try {
86
89
  // Convert the mapping to a plain object for JSON serialization
@@ -94,6 +97,8 @@ export async function storeCaseDataInR2(
94
97
  manifestHash: forensicManifest.manifestHash,
95
98
  signature: forensicManifest.signature
96
99
  } : undefined;
100
+
101
+ const archived = isArchivedExport === true || caseData.metadata.archived === true;
97
102
 
98
103
  // Create the case data structure that matches normal cases
99
104
  const r2CaseData = {
@@ -102,7 +107,15 @@ export async function storeCaseDataInR2(
102
107
  files: importedFiles,
103
108
  // Add read-only metadata
104
109
  isReadOnly: true,
110
+ ...(archived && {
111
+ archived: true,
112
+ archivedAt: caseData.metadata.archivedAt,
113
+ archivedBy: caseData.metadata.archivedBy,
114
+ archivedByDisplay: caseData.metadata.archivedByDisplay,
115
+ archiveReason: caseData.metadata.archiveReason,
116
+ }),
105
117
  importedAt: new Date().toISOString(),
118
+ ...(bundledAuditTrail && { bundledAuditTrail }),
106
119
  // Add original image ID mapping for confirmation linking
107
120
  originalImageIds: originalImageIds,
108
121
  // Add forensic manifest timestamp if available for confirmation exports
@@ -179,6 +192,20 @@ export async function removeReadOnlyCase(user: User, caseNumber: string): Promis
179
192
  * Completely delete a read-only case including all associated data (R2, Images, user references)
180
193
  */
181
194
  export async function deleteReadOnlyCase(user: User, caseNumber: string): Promise<boolean> {
195
+ const isBenignCleanupError = (reason: unknown): boolean => {
196
+ if (!(reason instanceof Error)) {
197
+ return false;
198
+ }
199
+
200
+ const normalizedMessage = reason.message.toLowerCase();
201
+ return (
202
+ normalizedMessage.includes('404') ||
203
+ normalizedMessage.includes('not found')
204
+ );
205
+ };
206
+
207
+ let caseDataDeleteHadFailure = false;
208
+
182
209
  try {
183
210
  // Get case data first to get file IDs for deletion
184
211
  const caseResponse = await fetchDataApi(
@@ -201,13 +228,48 @@ export async function deleteReadOnlyCase(user: User, caseNumber: string): Promis
201
228
 
202
229
  const caseData = await caseResponse.json() as CaseData;
203
230
 
204
- // Delete all files using data worker
231
+ // Delete all files using data worker (best-effort, keep going on individual failures)
205
232
  if (caseData.files && caseData.files.length > 0) {
206
- await Promise.all(
233
+ const deleteResults = await Promise.allSettled(
207
234
  caseData.files.map((file: FileData) =>
208
- deleteFile(user, caseNumber, file.id, 'Read-only case clearing - API operation')
235
+ deleteFile(
236
+ user,
237
+ caseNumber,
238
+ file.id,
239
+ 'Read-only case clearing - API operation',
240
+ {
241
+ skipValidation: true,
242
+ skipCaseDataUpdate: true,
243
+ suppressAudit: true
244
+ }
245
+ )
209
246
  )
210
247
  );
248
+
249
+ const failedDeletes = deleteResults.filter(
250
+ (result): result is PromiseRejectedResult =>
251
+ result.status === 'rejected' && !isBenignCleanupError(result.reason)
252
+ );
253
+
254
+ const benignNotFoundDeletes = deleteResults.filter(
255
+ (result): result is PromiseRejectedResult =>
256
+ result.status === 'rejected' && isBenignCleanupError(result.reason)
257
+ );
258
+
259
+ if (failedDeletes.length > 0) {
260
+ caseDataDeleteHadFailure = true;
261
+ console.warn(
262
+ `Partial read-only file cleanup for case ${caseNumber}: ` +
263
+ `${failedDeletes.length}/${caseData.files.length} file deletions failed.`
264
+ );
265
+ }
266
+
267
+ if (benignNotFoundDeletes.length > 0) {
268
+ console.info(
269
+ `Read-only cleanup for case ${caseNumber}: ` +
270
+ `${benignNotFoundDeletes.length} file deletions were already missing (404/not found) and treated as successful cleanup.`
271
+ );
272
+ }
211
273
  }
212
274
 
213
275
  // Delete case file using data worker
@@ -220,16 +282,43 @@ export async function deleteReadOnlyCase(user: User, caseNumber: string): Promis
220
282
  );
221
283
 
222
284
  if (!deleteCaseResponse.ok && deleteCaseResponse.status !== 404) {
223
- throw new Error(`Failed to delete read-only case data: ${deleteCaseResponse.status}`);
285
+ caseDataDeleteHadFailure = true;
286
+ console.error(`Failed to delete read-only case data: ${deleteCaseResponse.status}`);
224
287
  }
225
288
 
226
- // Remove from user's read-only case list (separate from regular cases)
227
- await removeReadOnlyCase(user, caseNumber);
289
+ // Remove from user's read-only case list (separate from regular cases).
290
+ // This is the source of truth for import modal visibility and should be attempted even when storage cleanup is partial.
291
+ const removedFromMetadata = await removeReadOnlyCase(user, caseNumber);
292
+
293
+ if (!removedFromMetadata) {
294
+ return false;
295
+ }
296
+
297
+ if (caseDataDeleteHadFailure) {
298
+ console.warn(
299
+ `Read-only case ${caseNumber} removed from metadata with partial storage cleanup failures.`
300
+ );
301
+ }
228
302
 
229
303
  return true;
230
304
 
231
305
  } catch (error) {
232
306
  console.error('Error deleting read-only case:', error);
307
+
308
+ // Fallback: still try to clear read-only metadata so stale entries do not persist in the UI.
309
+ try {
310
+ const removedFromMetadata = await removeReadOnlyCase(user, caseNumber);
311
+ if (removedFromMetadata) {
312
+ console.warn(
313
+ `Read-only case ${caseNumber} removed from metadata during error fallback. ` +
314
+ 'Some backing storage may require manual cleanup.'
315
+ );
316
+ return true;
317
+ }
318
+ } catch (removeError) {
319
+ console.error('Error removing read-only case metadata during fallback cleanup:', removeError);
320
+ }
321
+
233
322
  return false;
234
323
  }
235
324
  }