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