@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
@@ -1,1079 +1 @@
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
- signForensicManifest,
15
- moveCaseConfirmationSummary,
16
- removeCaseConfirmationSummary
17
- } from '~/utils/data';
18
- import { type CaseData, type ReadOnlyCaseData, type FileData, type AuditTrail, type CaseExportData, type ValidationAuditEntry } from '~/types';
19
- import { auditService } from '~/services/audit';
20
- import { fetchImageApi } from '~/utils/api';
21
- import { exportCaseData, formatDateForFilename } from '~/components/actions/case-export';
22
- import { getImageUrl } from './image-manage';
23
- import {
24
- calculateSHA256Secure,
25
- createPublicSigningKeyFileName,
26
- encryptExportDataWithAllImages,
27
- generateForensicManifestSecure,
28
- getCurrentEncryptionPublicKeyDetails,
29
- getCurrentPublicSigningKeyDetails,
30
- getVerificationPublicKey,
31
- } from '~/utils/forensics';
32
- import { signAuditExport } from '~/services/audit/audit-export-signing';
33
- import { generateAuditSummary, sortAuditEntriesNewestFirst } from '~/services/audit/audit-query-helpers';
34
-
35
- /**
36
- * Delete a file without individual audit logging (for bulk operations)
37
- * This reduces API calls during bulk deletions
38
- */
39
- interface DeleteFileWithoutAuditOptions {
40
- skipCaseDataUpdate?: boolean;
41
- skipValidation?: boolean;
42
- }
43
-
44
- interface DeleteFileWithoutAuditResult {
45
- imageMissing: boolean;
46
- fileName: string;
47
- }
48
-
49
- export interface DeleteCaseResult {
50
- missingImages: string[];
51
- }
52
-
53
- function generateArchiveImageFilename(originalFilename: string, id: string): string {
54
- const lastDotIndex = originalFilename.lastIndexOf('.');
55
-
56
- if (lastDotIndex === -1) {
57
- return `${originalFilename}-${id}`;
58
- }
59
-
60
- const basename = originalFilename.substring(0, lastDotIndex);
61
- const extension = originalFilename.substring(lastDotIndex);
62
-
63
- return `${basename}-${id}${extension}`;
64
- }
65
-
66
- const deleteFileWithoutAudit = async (
67
- user: User,
68
- caseNumber: string,
69
- fileId: string,
70
- options: DeleteFileWithoutAuditOptions = {}
71
- ): Promise<DeleteFileWithoutAuditResult> => {
72
- // Get the case data to find file info
73
- const caseData = await getCaseData(user, caseNumber, {
74
- skipValidation: options.skipValidation === true
75
- });
76
- if (!caseData) {
77
- throw new Error('Case not found');
78
- }
79
-
80
- const fileToDelete = (caseData.files || []).find((f: FileData) => f.id === fileId);
81
- if (!fileToDelete) {
82
- throw new Error('File not found in case');
83
- }
84
-
85
- let imageMissing = false;
86
-
87
- // Delete image file and fail fast on non-404 failures so case deletion can be retried safely
88
- const imageResponse = await fetchImageApi(user, `/${encodeURIComponent(fileId)}`, {
89
- method: 'DELETE'
90
- });
91
-
92
- if (!imageResponse.ok && imageResponse.status === 404) {
93
- imageMissing = true;
94
- }
95
-
96
- if (!imageResponse.ok && imageResponse.status !== 404) {
97
- throw new Error(`Failed to delete image: ${imageResponse.status} ${imageResponse.statusText}`);
98
- }
99
-
100
- // Delete annotation data (404s are handled by deleteFileAnnotations)
101
- await deleteFileAnnotations(user, caseNumber, fileId, {
102
- skipValidation: options.skipValidation === true
103
- });
104
-
105
- if (options.skipCaseDataUpdate === true) {
106
- return {
107
- imageMissing,
108
- fileName: fileToDelete.originalFilename
109
- };
110
- }
111
-
112
- // Update case data to remove file reference
113
- const updatedData: CaseData = {
114
- ...caseData,
115
- files: (caseData.files || []).filter((f: FileData) => f.id !== fileId)
116
- };
117
-
118
- await updateCaseData(user, caseNumber, updatedData);
119
-
120
- return {
121
- imageMissing,
122
- fileName: fileToDelete.originalFilename
123
- };
124
- };
125
-
126
- const CASE_NUMBER_REGEX = /^[A-Za-z0-9-]+$/;
127
-
128
- /**
129
- * Type guard to check if case data has isReadOnly property
130
- */
131
- const isReadOnlyCaseData = (caseData: CaseData): caseData is ReadOnlyCaseData => {
132
- return 'isReadOnly' in caseData && typeof (caseData as ReadOnlyCaseData).isReadOnly === 'boolean';
133
- };
134
- const MAX_CASE_NUMBER_LENGTH = 25;
135
-
136
- export const listCases = async (user: User): Promise<string[]> => {
137
- try {
138
- // Use centralized function to get user cases
139
- const userCases = await getUserCases(user);
140
- const caseNumbers = userCases.map(c => c.caseNumber);
141
- return sortCaseNumbers(caseNumbers);
142
-
143
- } catch (error) {
144
- console.error('Error listing cases:', error);
145
- return [];
146
- }
147
- };
148
-
149
- const sortCaseNumbers = (cases: string[]): string[] => {
150
- return cases.sort((a, b) => {
151
- // Extract all numbers and letters
152
- const getComponents = (str: string) => {
153
- const numbers = str.match(/\d+/g)?.map(Number) || [];
154
- const letters = str.match(/[A-Za-z]+/g)?.join('') || '';
155
- return { numbers, letters };
156
- };
157
-
158
- const aComponents = getComponents(a);
159
- const bComponents = getComponents(b);
160
-
161
- // Compare numbers first
162
- const maxLength = Math.max(aComponents.numbers.length, bComponents.numbers.length);
163
- for (let i = 0; i < maxLength; i++) {
164
- const aNum = aComponents.numbers[i] || 0;
165
- const bNum = bComponents.numbers[i] || 0;
166
- if (aNum !== bNum) return aNum - bNum;
167
- }
168
-
169
- // If all numbers match, compare letters
170
- return aComponents.letters.localeCompare(bComponents.letters);
171
- });
172
- };
173
-
174
- export const validateCaseNumber = (caseNumber: string): boolean => {
175
- return CASE_NUMBER_REGEX.test(caseNumber) &&
176
- caseNumber.length <= MAX_CASE_NUMBER_LENGTH;
177
- };
178
-
179
- export const checkExistingCase = async (user: User, caseNumber: string): Promise<CaseData | null> => {
180
- try {
181
- // Try to get case data - if user doesn't have access, it means case doesn't exist for them
182
- const caseData = await getCaseData(user, caseNumber);
183
-
184
- if (!caseData) {
185
- return null;
186
- }
187
-
188
- // Imported review cases are read-only and should not be treated as regular cases.
189
- // Archived cases remain regular case records even if legacy data includes isReadOnly.
190
- if ('isReadOnly' in caseData && caseData.isReadOnly && !caseData.archived) {
191
- return null;
192
- }
193
-
194
- // Verify the case number matches (extra safety check)
195
- if (caseData.caseNumber === caseNumber) {
196
- return caseData;
197
- }
198
-
199
- return null;
200
-
201
- } catch (error) {
202
- // If access denied, treat as case doesn't exist for this user
203
- if (error instanceof Error && error.message.includes('Access denied')) {
204
- return null;
205
- }
206
- console.error('Error checking existing case:', error);
207
- return null;
208
- }
209
- };
210
-
211
- export const checkCaseIsReadOnly = async (user: User, caseNumber: string): Promise<boolean> => {
212
- try {
213
- const caseData = await getCaseData(user, caseNumber);
214
- if (!caseData) {
215
- // Case doesn't exist, so it's not read-only
216
- return false;
217
- }
218
-
219
- // Use type guard to check for isReadOnly property safely
220
- return isReadOnlyCaseData(caseData) ? !!caseData.isReadOnly : false;
221
-
222
- } catch (error) {
223
- console.error('Error checking if case is read-only:', error);
224
- return false;
225
- }
226
- };
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
-
256
- export const createNewCase = async (user: User, caseNumber: string): Promise<CaseData> => {
257
- const startTime = Date.now();
258
-
259
- try {
260
- // Validate user session first
261
- const sessionValidation = await validateUserSession(user);
262
- if (!sessionValidation.valid) {
263
- throw new Error(`Session validation failed: ${sessionValidation.reason}`);
264
- }
265
-
266
- // Check if user can create a new case
267
- const permission = await canCreateCase(user);
268
- if (!permission.canCreate) {
269
- throw new Error(permission.reason || 'You cannot create more cases.');
270
- }
271
-
272
- const newCase: CaseData = {
273
- createdAt: new Date().toISOString(),
274
- caseNumber,
275
- files: []
276
- };
277
-
278
- const caseMetadata = {
279
- createdAt: newCase.createdAt,
280
- caseNumber: newCase.caseNumber
281
- };
282
-
283
- // Add case to user data first (so user has permission to create case data)
284
- await addUserCase(user, caseMetadata);
285
-
286
- // Create case file using centralized function
287
- await updateCaseData(user, caseNumber, newCase);
288
-
289
- // Log successful case creation
290
- const endTime = Date.now();
291
- await auditService.logCaseCreation(
292
- user,
293
- caseNumber,
294
- caseNumber // Using case number as case name for now
295
- );
296
-
297
- console.log(`✅ Case created: ${caseNumber} (${endTime - startTime}ms)`);
298
- return newCase;
299
-
300
- } catch (error) {
301
- // Log failed case creation
302
- const endTime = Date.now();
303
- try {
304
- await auditService.logEvent({
305
- userId: user.uid,
306
- userEmail: user.email || '',
307
- action: 'case-create',
308
- result: 'failure',
309
- fileName: `${caseNumber}.case`,
310
- fileType: 'case-package',
311
- validationErrors: [error instanceof Error ? error.message : 'Unknown error'],
312
- caseNumber,
313
- caseDetails: {
314
- newCaseName: caseNumber
315
- },
316
- performanceMetrics: {
317
- processingTimeMs: endTime - startTime,
318
- fileSizeBytes: 0
319
- }
320
- });
321
- } catch (auditError) {
322
- console.error('Failed to log case creation failure:', auditError);
323
- }
324
-
325
- console.error('Error creating new case:', error);
326
- throw error;
327
- }
328
- };
329
-
330
- export const renameCase = async (
331
- user: User,
332
- oldCaseNumber: string,
333
- newCaseNumber: string
334
- ): Promise<void> => {
335
- const startTime = Date.now();
336
-
337
- try {
338
- // Validate case numbers
339
- if (!validateCaseNumber(oldCaseNumber) || !validateCaseNumber(newCaseNumber)) {
340
- throw new Error('Invalid case number format');
341
- }
342
-
343
- // Check if new case exists
344
- const existingCase = await checkExistingCase(user, newCaseNumber);
345
- if (existingCase) {
346
- throw new Error('New case number already exists');
347
- }
348
-
349
- // Get the old case data to find all files that need annotation cleanup
350
- const oldCaseData = await getCaseData(user, oldCaseNumber);
351
- if (!oldCaseData) {
352
- throw new Error('Old case not found');
353
- }
354
-
355
- // 1) Create new case number in USER DB's entry (KV storage)
356
- const newCaseMetadata = {
357
- createdAt: new Date().toISOString(),
358
- caseNumber: newCaseNumber
359
- };
360
- await addUserCase(user, newCaseMetadata);
361
-
362
- // 2) Copy R2 case data from old case number to new case number in R2
363
- await duplicateCaseData(user, oldCaseNumber, newCaseNumber);
364
-
365
- // 3) Delete individual file annotations from the old case (before losing access)
366
- if (oldCaseData.files && oldCaseData.files.length > 0) {
367
- // Process annotation deletions in batches to avoid rate limiting
368
- const BATCH_SIZE = 5;
369
- const files = oldCaseData.files;
370
-
371
- for (let i = 0; i < files.length; i += BATCH_SIZE) {
372
- const batch = files.slice(i, i + BATCH_SIZE);
373
-
374
- // Delete annotation files in this batch
375
- await Promise.all(
376
- batch.map(async file => {
377
- try {
378
- await deleteFileAnnotations(user, oldCaseNumber, file.id);
379
- } catch (error) {
380
- // Continue if annotation file doesn't exist or fails to delete
381
- console.warn(`Failed to delete annotations for ${file.originalFilename}:`, error);
382
- }
383
- })
384
- );
385
-
386
- // Add delay between batches to reduce rate limiting
387
- if (i + BATCH_SIZE < files.length) {
388
- await new Promise(resolve => setTimeout(resolve, 150));
389
- }
390
- }
391
- }
392
-
393
- // 4) Delete R2 case data with old case number
394
- await deleteCaseData(user, oldCaseNumber);
395
-
396
- // 5) Move confirmation summary metadata to the new case number
397
- await moveCaseConfirmationSummary(user, oldCaseNumber, newCaseNumber);
398
-
399
- // 6) Delete old case number in user's KV entry
400
- await removeUserCase(user, oldCaseNumber);
401
-
402
- // Log successful case rename under the original case number context
403
- const endTime = Date.now();
404
- await auditService.logCaseRename(
405
- user,
406
- oldCaseNumber,
407
- oldCaseNumber,
408
- newCaseNumber
409
- );
410
-
411
- // Log creation of the new case number as a rename-derived case
412
- await auditService.logCaseCreation(
413
- user,
414
- newCaseNumber,
415
- newCaseNumber,
416
- oldCaseNumber
417
- );
418
-
419
- console.log(`✅ Case renamed: ${oldCaseNumber} → ${newCaseNumber} (${endTime - startTime}ms)`);
420
-
421
- } catch (error) {
422
- // Log failed case rename
423
- const endTime = Date.now();
424
- try {
425
- await auditService.logEvent({
426
- userId: user.uid,
427
- userEmail: user.email || '',
428
- action: 'case-rename',
429
- result: 'failure',
430
- fileName: `${oldCaseNumber}.case`,
431
- fileType: 'case-package',
432
- validationErrors: [error instanceof Error ? error.message : 'Unknown error'],
433
- caseNumber: oldCaseNumber,
434
- caseDetails: {
435
- oldCaseName: oldCaseNumber,
436
- newCaseName: newCaseNumber,
437
- lastModified: new Date().toISOString()
438
- },
439
- performanceMetrics: {
440
- processingTimeMs: endTime - startTime,
441
- fileSizeBytes: 0
442
- }
443
- });
444
- } catch (auditError) {
445
- console.error('Failed to log case rename failure:', auditError);
446
- }
447
-
448
- console.error('Error renaming case:', error);
449
- throw error;
450
- }
451
- };
452
-
453
- export const deleteCase = async (user: User, caseNumber: string): Promise<DeleteCaseResult> => {
454
- const startTime = Date.now();
455
-
456
- try {
457
- if (!validateCaseNumber(caseNumber)) {
458
- throw new Error('Invalid case number');
459
- }
460
-
461
- // Validate user session
462
- const sessionValidation = await validateUserSession(user);
463
- if (!sessionValidation.valid) {
464
- throw new Error(`Session validation failed: ${sessionValidation.reason}`);
465
- }
466
-
467
- // Get case data using centralized function
468
- const caseData = await getCaseData(user, caseNumber);
469
- if (!caseData) {
470
- throw new Error('Case not found');
471
- }
472
-
473
- // Store case info for audit logging
474
- const fileCount = caseData.files?.length || 0;
475
- const caseName = caseData.caseNumber || caseNumber;
476
-
477
- // Process file deletions in batches to reduce audit rate limiting
478
- if (caseData.files && caseData.files.length > 0) {
479
- const BATCH_SIZE = 3; // Reduced batch size for better stability
480
- const BATCH_DELAY = 300; // Increased delay between batches
481
- const files = caseData.files;
482
- const deletedFiles: Array<{id: string, originalFilename: string, fileSize: number}> = [];
483
- const failedFiles: Array<{id: string, originalFilename: string, error: string}> = [];
484
- const missingImages: string[] = [];
485
-
486
- console.log(`🗑️ Deleting ${files.length} files in batches of ${BATCH_SIZE}...`);
487
-
488
- // Process files in batches
489
- for (let i = 0; i < files.length; i += BATCH_SIZE) {
490
- const batch = files.slice(i, i + BATCH_SIZE);
491
- const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
492
- const totalBatches = Math.ceil(files.length / BATCH_SIZE);
493
-
494
- console.log(`📦 Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)...`);
495
-
496
- // Delete files in this batch with individual error handling
497
- await Promise.allSettled(
498
- batch.map(async file => {
499
- try {
500
- // Delete file without individual audit logging to reduce API calls
501
- // We'll do bulk audit logging at the end
502
- const deleteResult = await deleteFileWithoutAudit(user, caseNumber, file.id, {
503
- // Archived cases are immutable; during deletion we can skip per-file case-data mutations.
504
- skipCaseDataUpdate: !!caseData.archived,
505
- skipValidation: !!caseData.archived
506
- });
507
-
508
- if (deleteResult.imageMissing) {
509
- missingImages.push(deleteResult.fileName);
510
- }
511
-
512
- deletedFiles.push({
513
- id: file.id,
514
- originalFilename: file.originalFilename,
515
- fileSize: 0 // We don't track file size, use 0
516
- });
517
- } catch (error) {
518
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
519
- console.error(`❌ Failed to delete file ${file.originalFilename}:`, errorMessage);
520
- failedFiles.push({
521
- id: file.id,
522
- originalFilename: file.originalFilename,
523
- error: errorMessage
524
- });
525
- }
526
- })
527
- );
528
-
529
- // Add delay between batches to reduce rate limiting
530
- if (i + BATCH_SIZE < files.length) {
531
- console.log(`⏱️ Waiting ${BATCH_DELAY}ms before next batch...`);
532
- await new Promise(resolve => setTimeout(resolve, BATCH_DELAY));
533
- }
534
- }
535
-
536
- // Single consolidated audit entry for all file operations
537
- try {
538
- const endTime = Date.now();
539
- const successCount = deletedFiles.length;
540
- const failureCount = failedFiles.length;
541
-
542
- await auditService.logEvent({
543
- userId: user.uid,
544
- userEmail: user.email || '',
545
- action: 'file-delete',
546
- result: failureCount === 0 ? 'success' : 'failure',
547
- fileName: `Bulk deletion: ${successCount} succeeded, ${failureCount} failed`,
548
- fileType: 'case-package',
549
- caseNumber,
550
- caseDetails: {
551
- newCaseName: `${caseNumber} - Bulk file deletion`,
552
- deleteReason: `Case deletion: processed ${files.length} files (${successCount} deleted, ${failureCount} failed)`,
553
- backupCreated: false,
554
- lastModified: new Date().toISOString()
555
- },
556
- performanceMetrics: {
557
- processingTimeMs: endTime - startTime,
558
- fileSizeBytes: deletedFiles.reduce((total, file) => total + file.fileSize, 0)
559
- },
560
- // Include details of failed files if any
561
- ...(failedFiles.length > 0 && {
562
- validationErrors: failedFiles.map(f => `${f.originalFilename}: ${f.error}`)
563
- })
564
- });
565
-
566
- console.log(`✅ Batch deletion complete: ${successCount} files deleted, ${failureCount} failed`);
567
- } catch (auditError) {
568
- console.error('⚠️ Failed to log batch file deletion (continuing with case deletion):', auditError);
569
- }
570
-
571
- if (failedFiles.length > 0) {
572
- throw new Error(
573
- `Case deletion aborted: failed to delete ${failedFiles.length} file(s): ${failedFiles.map(f => f.originalFilename).join(', ')}`
574
- );
575
- }
576
-
577
- // Remove case from user data first (so user loses access immediately)
578
- await removeUserCase(user, caseNumber);
579
-
580
- // Delete case data using centralized function (skip validation since user no longer has access)
581
- await deleteCaseData(user, caseNumber, { skipValidation: true });
582
-
583
- // Clean up confirmation status metadata for this case
584
- try {
585
- await removeCaseConfirmationSummary(user, caseNumber);
586
- } catch (summaryError) {
587
- console.warn(`Failed to remove confirmation summary for case ${caseNumber}:`, summaryError);
588
- }
589
-
590
- // Add a small delay before audit logging to reduce rate limiting
591
- await new Promise(resolve => setTimeout(resolve, 100));
592
-
593
- // Log successful case deletion with file details
594
- const endTime = Date.now();
595
- await auditService.logCaseDeletion(
596
- user,
597
- caseNumber,
598
- caseName,
599
- `User-requested deletion via case actions (${fileCount} files deleted)` +
600
- (missingImages.length > 0 ? `; ${missingImages.length} image(s) were already missing` : ''),
601
- false // No backup created for standard deletions
602
- );
603
-
604
- console.log(`✅ Case deleted: ${caseNumber} (${fileCount} files) (${endTime - startTime}ms)`);
605
- return { missingImages };
606
- }
607
-
608
- // Remove case from user data first (so user loses access immediately)
609
- await removeUserCase(user, caseNumber);
610
-
611
- // Delete case data using centralized function (skip validation since user no longer has access)
612
- await deleteCaseData(user, caseNumber, { skipValidation: true });
613
-
614
- // Clean up confirmation status metadata for this case
615
- try {
616
- await removeCaseConfirmationSummary(user, caseNumber);
617
- } catch (summaryError) {
618
- console.warn(`Failed to remove confirmation summary for case ${caseNumber}:`, summaryError);
619
- }
620
-
621
- // Add a small delay before audit logging to reduce rate limiting
622
- await new Promise(resolve => setTimeout(resolve, 100));
623
-
624
- // Log successful case deletion with file details
625
- const endTime = Date.now();
626
- await auditService.logCaseDeletion(
627
- user,
628
- caseNumber,
629
- caseName,
630
- `User-requested deletion via case actions (${fileCount} files deleted)`,
631
- false // No backup created for standard deletions
632
- );
633
-
634
- console.log(`✅ Case deleted: ${caseNumber} (${fileCount} files) (${endTime - startTime}ms)`);
635
- return { missingImages: [] };
636
-
637
- } catch (error) {
638
- // Log failed case deletion
639
- const endTime = Date.now();
640
- try {
641
- await auditService.logEvent({
642
- userId: user.uid,
643
- userEmail: user.email || '',
644
- action: 'case-delete',
645
- result: 'failure',
646
- fileName: `${caseNumber}.case`,
647
- fileType: 'case-package',
648
- validationErrors: [error instanceof Error ? error.message : 'Unknown error'],
649
- caseNumber,
650
- caseDetails: {
651
- newCaseName: caseNumber,
652
- deleteReason: 'Failed deletion attempt',
653
- backupCreated: false,
654
- lastModified: new Date().toISOString()
655
- },
656
- performanceMetrics: {
657
- processingTimeMs: endTime - startTime,
658
- fileSizeBytes: 0
659
- }
660
- });
661
- } catch (auditError) {
662
- console.error('Failed to log case deletion failure:', auditError);
663
- }
664
-
665
- console.error('Error deleting case:', error);
666
- throw error;
667
- }
668
- };
669
-
670
- const getVerificationPublicSigningKey = (preferredKeyId?: string): { keyId: string | null; publicKeyPem: string } => {
671
- const preferredKey = preferredKeyId ? getVerificationPublicKey(preferredKeyId) : null;
672
- const currentDetails = getCurrentPublicSigningKeyDetails();
673
- const resolvedPem = preferredKey ?? currentDetails.publicKeyPem;
674
- const resolvedKeyId = preferredKey ? preferredKeyId ?? null : currentDetails.keyId;
675
-
676
- if (!resolvedPem || resolvedPem.trim().length === 0) {
677
- throw new Error('No public signing key is configured for archive packaging.');
678
- }
679
-
680
- return {
681
- keyId: resolvedKeyId,
682
- publicKeyPem: resolvedPem.endsWith('\n') ? resolvedPem : `${resolvedPem}\n`,
683
- };
684
- };
685
-
686
- const fetchImageAsBlob = async (user: User, fileData: FileData, caseNumber: string): Promise<Blob | null> => {
687
- try {
688
- const imageAccess = await getImageUrl(user, fileData, caseNumber, 'Archive Package');
689
- const { blob, revoke, url } = imageAccess;
690
-
691
- if (!blob) {
692
- const signedResponse = await fetch(url, {
693
- method: 'GET',
694
- headers: {
695
- 'Accept': 'application/octet-stream,image/*'
696
- }
697
- });
698
-
699
- if (!signedResponse.ok) {
700
- throw new Error(`Signed URL fetch failed with status ${signedResponse.status}`);
701
- }
702
-
703
- return await signedResponse.blob();
704
- }
705
-
706
- try {
707
- return blob;
708
- } finally {
709
- revoke();
710
- }
711
- } catch (error) {
712
- console.error('Failed to fetch image for archive package:', error);
713
- return null;
714
- }
715
- };
716
-
717
- export const archiveCase = async (
718
- user: User,
719
- caseNumber: string,
720
- archiveReason?: string
721
- ): Promise<void> => {
722
- const startTime = Date.now();
723
-
724
- try {
725
- if (!validateCaseNumber(caseNumber)) {
726
- throw new Error('Invalid case number');
727
- }
728
-
729
- const sessionValidation = await validateUserSession(user);
730
- if (!sessionValidation.valid) {
731
- throw new Error(`Session validation failed: ${sessionValidation.reason}`);
732
- }
733
-
734
- const caseData = await getCaseData(user, caseNumber);
735
- if (!caseData) {
736
- throw new Error('Case not found');
737
- }
738
-
739
- if (caseData.archived) {
740
- throw new Error('This case is already archived.');
741
- }
742
-
743
- const archivedAt = new Date().toISOString();
744
- let archivedByDisplay = user.uid;
745
-
746
- try {
747
- const userData = await getUserData(user);
748
- const fullName = [userData?.firstName?.trim(), userData?.lastName?.trim()]
749
- .filter(Boolean)
750
- .join(' ')
751
- .trim();
752
- const badgeId = userData?.badgeId?.trim();
753
-
754
- if (fullName && badgeId) {
755
- archivedByDisplay = `${fullName}, ${badgeId}`;
756
- } else if (fullName) {
757
- archivedByDisplay = fullName;
758
- } else if (badgeId) {
759
- archivedByDisplay = badgeId;
760
- }
761
- } catch (userDataError) {
762
- console.warn('Failed to resolve user profile details for archive display value:', userDataError);
763
- }
764
-
765
- const archiveData: CaseData = {
766
- ...caseData,
767
- archived: true,
768
- archivedAt,
769
- archivedBy: user.uid,
770
- archivedByDisplay,
771
- archiveReason: archiveReason?.trim() || undefined,
772
- isReadOnly: false,
773
- } as CaseData;
774
-
775
- const exportData = await exportCaseData(user, caseNumber, { includeMetadata: true });
776
- const archivedExportData: CaseExportData = {
777
- ...exportData,
778
- metadata: {
779
- ...exportData.metadata,
780
- archived: true,
781
- archivedAt,
782
- archivedBy: user.uid,
783
- archivedByDisplay,
784
- archiveReason: archiveReason?.trim() || undefined,
785
- },
786
- };
787
- const caseJsonContent = JSON.stringify(archivedExportData, null, 2);
788
-
789
- const JSZip = (await import('jszip')).default;
790
- const zip = new JSZip();
791
- zip.file(`${caseNumber}_data.json`, caseJsonContent);
792
-
793
- const imageFolder = zip.folder('images');
794
- const imageBlobs: Record<string, Blob> = {};
795
- if (imageFolder && exportData.files) {
796
- for (const fileEntry of exportData.files) {
797
- const imageBlob = await fetchImageAsBlob(user, fileEntry.fileData, caseNumber);
798
- if (!imageBlob) {
799
- continue;
800
- }
801
-
802
- const exportFileName = generateArchiveImageFilename(
803
- fileEntry.fileData.originalFilename,
804
- fileEntry.fileData.id
805
- );
806
- imageFolder.file(exportFileName, imageBlob);
807
- imageBlobs[exportFileName] = imageBlob;
808
- }
809
- }
810
-
811
- const forensicManifest = await generateForensicManifestSecure(caseJsonContent, imageBlobs);
812
- const manifestSigningResponse = await signForensicManifest(user, caseNumber, forensicManifest);
813
-
814
- const signingKey = getVerificationPublicSigningKey(manifestSigningResponse.signature.keyId);
815
- const publicKeyFileName = createPublicSigningKeyFileName(signingKey.keyId);
816
- zip.file(publicKeyFileName, signingKey.publicKeyPem);
817
-
818
- zip.file(
819
- 'FORENSIC_MANIFEST.json',
820
- JSON.stringify(
821
- {
822
- ...forensicManifest,
823
- manifestVersion: manifestSigningResponse.manifestVersion,
824
- signature: manifestSigningResponse.signature,
825
- },
826
- null,
827
- 2
828
- )
829
- );
830
-
831
- const auditEntries = await auditService.getAuditEntriesForUser(user.uid, {
832
- caseNumber,
833
- startDate: caseData.createdAt,
834
- endDate: archivedAt,
835
- });
836
-
837
- // Ensure the bundled archive trail includes the archival event itself.
838
- const archiveAuditEntry: ValidationAuditEntry = {
839
- timestamp: archivedAt,
840
- userId: user.uid,
841
- userEmail: user.email || '',
842
- action: 'case-archive',
843
- result: 'success',
844
- details: {
845
- fileName: `${caseNumber}.case`,
846
- fileType: 'case-package',
847
- validationErrors: [],
848
- caseNumber,
849
- workflowPhase: 'casework',
850
- caseDetails: {
851
- newCaseName: caseNumber,
852
- archiveReason: archiveReason?.trim() || 'No reason provided',
853
- totalFiles: archiveData.files?.length || 0,
854
- lastModified: archivedAt,
855
- },
856
- performanceMetrics: {
857
- processingTimeMs: Date.now() - startTime,
858
- fileSizeBytes: 0,
859
- },
860
- },
861
- };
862
-
863
- const auditEntriesWithArchive = sortAuditEntriesNewestFirst([
864
- ...auditEntries,
865
- archiveAuditEntry,
866
- ]);
867
-
868
- const auditTrail: AuditTrail = {
869
- caseNumber,
870
- workflowId: `${caseNumber}-archive-${Date.now()}`,
871
- entries: auditEntriesWithArchive,
872
- summary: generateAuditSummary(auditEntriesWithArchive),
873
- };
874
-
875
- const auditTrailPayload = {
876
- metadata: {
877
- exportTimestamp: new Date().toISOString(),
878
- exportVersion: '1.0',
879
- totalEntries: auditTrail.summary.totalEvents,
880
- application: 'Striae',
881
- exportType: 'trail' as const,
882
- scopeType: 'case' as const,
883
- scopeIdentifier: caseNumber,
884
- },
885
- auditTrail,
886
- };
887
-
888
- const auditTrailRawContent = JSON.stringify(auditTrailPayload, null, 2);
889
- const auditTrailHash = await calculateSHA256Secure(auditTrailRawContent);
890
- const signedAuditExportPayload = await signAuditExport(
891
- {
892
- exportFormat: 'json',
893
- exportType: 'trail',
894
- generatedAt: auditTrailPayload.metadata.exportTimestamp,
895
- totalEntries: auditTrail.summary.totalEvents,
896
- hash: auditTrailHash.toUpperCase(),
897
- },
898
- {
899
- user,
900
- scopeType: 'case',
901
- scopeIdentifier: caseNumber,
902
- caseNumber,
903
- }
904
- );
905
-
906
- const signedAuditTrail = {
907
- metadata: {
908
- ...auditTrailPayload.metadata,
909
- hash: auditTrailHash.toUpperCase(),
910
- signatureVersion: signedAuditExportPayload.signatureMetadata.signatureVersion,
911
- signatureMetadata: signedAuditExportPayload.signatureMetadata,
912
- signature: signedAuditExportPayload.signature,
913
- },
914
- auditTrail,
915
- };
916
-
917
- const auditTrailJson = JSON.stringify(signedAuditTrail, null, 2);
918
- const auditSignatureJson = JSON.stringify(signedAuditExportPayload, null, 2);
919
- zip.file('audit/case-audit-trail.json', auditTrailJson);
920
- zip.file('audit/case-audit-signature.json', auditSignatureJson);
921
-
922
- const encryptionKeyDetails = getCurrentEncryptionPublicKeyDetails();
923
-
924
- if (!encryptionKeyDetails.publicKeyPem || !encryptionKeyDetails.keyId) {
925
- throw new Error(
926
- 'Archive encryption is mandatory. Your Striae instance does not have a configured encryption public key. ' +
927
- 'Please contact your administrator to set up export encryption.'
928
- );
929
- }
930
-
931
- try {
932
- const filesToEncrypt: Array<{ filename: string; blob: Blob }> = [
933
- ...Object.entries(imageBlobs).map(([filename, blob]) => ({
934
- filename,
935
- blob
936
- })),
937
- {
938
- filename: 'audit/case-audit-trail.json',
939
- blob: new Blob([auditTrailJson], { type: 'application/json' })
940
- },
941
- {
942
- filename: 'audit/case-audit-signature.json',
943
- blob: new Blob([auditSignatureJson], { type: 'application/json' })
944
- }
945
- ];
946
-
947
- const encryptionResult = await encryptExportDataWithAllImages(
948
- caseJsonContent,
949
- filesToEncrypt,
950
- encryptionKeyDetails.publicKeyPem,
951
- encryptionKeyDetails.keyId
952
- );
953
-
954
- zip.file(`${caseNumber}_data.json`, encryptionResult.ciphertext);
955
-
956
- for (let index = 0; index < filesToEncrypt.length; index += 1) {
957
- const originalFilename = filesToEncrypt[index].filename;
958
- const encryptedContent = encryptionResult.encryptedImages[index];
959
-
960
- if (originalFilename.startsWith('audit/')) {
961
- zip.file(originalFilename, encryptedContent);
962
- continue;
963
- }
964
-
965
- if (imageFolder) {
966
- imageFolder.file(originalFilename, encryptedContent);
967
- }
968
- }
969
-
970
- zip.file('ENCRYPTION_MANIFEST.json', JSON.stringify(encryptionResult.encryptionManifest, null, 2));
971
- } catch (error) {
972
- console.error('Archive encryption failed:', error);
973
- throw new Error(`Failed to encrypt archive package: ${error instanceof Error ? error.message : 'Unknown error'}`);
974
- }
975
-
976
- zip.file(
977
- 'README.txt',
978
- [
979
- 'Striae Archived Case Package',
980
- '===========================',
981
- '',
982
- `Case Number: ${caseNumber}`,
983
- `Archived At: ${archivedAt}`,
984
- `Archived By: ${archivedByDisplay}`,
985
- `Archive Reason: ${archiveReason?.trim() || 'Not provided'}`,
986
- '',
987
- 'Package Contents',
988
- '- Case data JSON export with all image references',
989
- '- images/ folder with exported image files (encrypted)',
990
- '- Full case audit trail export and signed audit metadata',
991
- '- Forensic manifest with server-side signature',
992
- '- ENCRYPTION_MANIFEST.json with encryption metadata and encrypted image hashes',
993
- `- ${publicKeyFileName} for verification`,
994
- '',
995
- 'This package is intended for read-only review and verification workflows.',
996
- 'This package is encrypted. Only Striae can decrypt and re-import it.',
997
- ].join('\n')
998
- );
999
-
1000
- const zipBlob = await zip.generateAsync({
1001
- type: 'blob',
1002
- compression: 'DEFLATE',
1003
- compressionOptions: { level: 6 },
1004
- });
1005
-
1006
- await updateCaseData(user, caseNumber, archiveData);
1007
-
1008
- // Clean up confirmation status metadata for this archived case
1009
- try {
1010
- await removeCaseConfirmationSummary(user, caseNumber);
1011
- } catch (summaryError) {
1012
- console.warn(`Failed to remove confirmation summary for case ${caseNumber}:`, summaryError);
1013
- }
1014
-
1015
- await auditService.logCaseArchive(
1016
- user,
1017
- caseNumber,
1018
- caseNumber,
1019
- archiveReason?.trim() || 'No reason provided',
1020
- 'success',
1021
- [],
1022
- archiveData.files?.length || 0,
1023
- archivedAt,
1024
- Date.now() - startTime
1025
- );
1026
-
1027
- const downloadUrl = URL.createObjectURL(zipBlob);
1028
- const archiveFileName = `striae-case-${caseNumber}-archive-${formatDateForFilename(new Date())}-encrypted.zip`;
1029
- const anchor = document.createElement('a');
1030
- anchor.href = downloadUrl;
1031
- anchor.download = archiveFileName;
1032
- anchor.click();
1033
- URL.revokeObjectURL(downloadUrl);
1034
-
1035
- await auditService.logEvent({
1036
- userId: user.uid,
1037
- userEmail: user.email || '',
1038
- action: 'case-export',
1039
- result: 'success',
1040
- fileName: archiveFileName,
1041
- fileType: 'case-package',
1042
- caseNumber,
1043
- workflowPhase: 'case-export',
1044
- caseDetails: {
1045
- newCaseName: caseNumber,
1046
- totalFiles: exportData.files?.length || 0,
1047
- totalAnnotations: exportData.summary?.totalBoxAnnotations || 0,
1048
- lastModified: archivedAt,
1049
- },
1050
- securityChecks: {
1051
- selfConfirmationPrevented: true,
1052
- fileIntegrityValid: true,
1053
- manifestSignaturePresent: true,
1054
- manifestSignatureValid: true,
1055
- manifestSignatureKeyId: manifestSigningResponse.signature.keyId,
1056
- },
1057
- performanceMetrics: {
1058
- processingTimeMs: Date.now() - startTime,
1059
- fileSizeBytes: zipBlob.size,
1060
- validationStepsCompleted: 4,
1061
- validationStepsFailed: 0,
1062
- },
1063
- });
1064
- } catch (error) {
1065
- await auditService.logCaseArchive(
1066
- user,
1067
- caseNumber,
1068
- caseNumber,
1069
- archiveReason?.trim() || 'No reason provided',
1070
- 'failure',
1071
- [error instanceof Error ? error.message : 'Unknown archive error'],
1072
- undefined,
1073
- undefined,
1074
- Date.now() - startTime
1075
- );
1076
-
1077
- throw error;
1078
- }
1079
- };
1
+ export * from './case-manage/index';