@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.
- package/.env.example +8 -0
- package/app/components/actions/case-export/core-export.ts +14 -8
- package/app/components/actions/case-export/data-processing.ts +1 -0
- package/app/components/actions/case-export/download-handlers.ts +7 -0
- package/app/components/actions/case-export/metadata-helpers.ts +2 -1
- package/app/components/actions/case-import/confirmation-import.ts +12 -2
- package/app/components/actions/case-import/orchestrator.ts +78 -32
- package/app/components/actions/case-import/storage-operations.ts +97 -8
- package/app/components/actions/case-import/zip-processing.ts +159 -86
- package/app/components/actions/case-manage.ts +430 -8
- package/app/components/actions/confirm-export.ts +9 -2
- package/app/components/actions/image-manage.ts +77 -44
- package/app/components/audit/user-audit-viewer.tsx +19 -8
- package/app/components/audit/user-audit.module.css +21 -0
- package/app/components/audit/viewer/audit-entries-list.tsx +7 -0
- package/app/components/audit/viewer/audit-filters-panel.tsx +1 -0
- package/app/components/audit/viewer/audit-viewer-utils.ts +2 -0
- package/app/components/audit/viewer/use-audit-viewer-data.ts +21 -1
- package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
- package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
- package/app/components/canvas/canvas.module.css +64 -54
- package/app/components/canvas/canvas.tsx +14 -16
- package/app/components/canvas/confirmation/confirmation.module.css +1 -0
- package/app/components/canvas/confirmation/confirmation.tsx +6 -12
- package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
- package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
- package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
- package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
- package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
- package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
- package/app/components/navbar/navbar.module.css +447 -0
- package/app/components/navbar/navbar.tsx +377 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +1 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +15 -16
- package/app/components/sidebar/case-export/case-export.module.css +1 -0
- package/app/components/sidebar/case-export/case-export.tsx +8 -46
- package/app/components/sidebar/case-import/case-import.module.css +23 -0
- package/app/components/sidebar/case-import/case-import.tsx +64 -16
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +25 -519
- package/app/components/sidebar/cases/cases-modal.module.css +1 -0
- package/app/components/sidebar/cases/cases-modal.tsx +6 -8
- package/app/components/sidebar/cases/cases.module.css +62 -21
- package/app/components/sidebar/files/files-modal.module.css +1 -0
- package/app/components/sidebar/files/files-modal.tsx +12 -13
- package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
- package/app/components/sidebar/notes/notes-editor-modal.tsx +66 -0
- package/app/components/sidebar/notes/notes-modal.tsx +7 -8
- package/app/components/sidebar/notes/notes-sidebar.tsx +199 -113
- package/app/components/sidebar/notes/notes.module.css +153 -0
- package/app/components/sidebar/sidebar-container.tsx +15 -28
- package/app/components/sidebar/sidebar.module.css +5 -69
- package/app/components/sidebar/sidebar.tsx +24 -125
- package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
- package/app/components/user/inactivity-warning.module.css +1 -0
- package/app/components/user/inactivity-warning.tsx +15 -2
- package/app/components/user/manage-profile.tsx +23 -10
- package/app/hooks/useOverlayDismiss.ts +52 -4
- package/app/routes/auth/login.tsx +785 -774
- package/app/routes/striae/striae.module.css +10 -3
- package/app/routes/striae/striae.tsx +469 -30
- package/app/services/audit/audit.service.ts +173 -27
- package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
- package/app/services/audit/builders/audit-event-builders-workflow.ts +2 -0
- package/app/services/audit/builders/index.ts +1 -0
- package/app/types/audit.ts +3 -1
- package/app/types/case.ts +29 -0
- package/app/types/import.ts +3 -0
- package/app/utils/data/permissions.ts +16 -1
- package/app/utils/forensics/audit-export-signature.ts +5 -1
- package/app/utils/forensics/confirmation-signature.ts +3 -0
- package/app/utils/forensics/export-verification.ts +497 -22
- package/package.json +3 -3
- package/scripts/deploy-primershear-emails.sh +2 -1
- package/worker-configuration.d.ts +1 -1
- package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/worker-configuration.d.ts +7448 -11323
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/worker-configuration.d.ts +7447 -11322
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/src/formats/format-striae.ts +8 -7
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/worker-configuration.d.ts +7448 -11323
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- 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
|
-
|
|
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<
|
|
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) {
|