@striae-org/striae 5.5.2 → 6.0.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/README.md +3 -1
- package/app/components/actions/case-export/download-handlers.ts +130 -62
- package/app/components/actions/case-manage/archive-package-builder.ts +299 -0
- package/app/components/actions/case-manage/delete-helpers.ts +61 -0
- package/app/components/actions/case-manage/index.ts +2 -0
- package/app/components/actions/case-manage/operations.ts +714 -0
- package/app/components/actions/case-manage/types.ts +21 -0
- package/app/components/actions/case-manage/utils.ts +34 -0
- package/app/components/actions/case-manage.ts +1 -1079
- package/app/components/navbar/case-import/case-import.module.css +2 -2
- package/app/components/navbar/case-import/case-import.tsx +0 -8
- package/app/components/navbar/case-import/components/CasePreviewSection.tsx +1 -1
- package/app/components/navbar/case-modals/all-cases-modal.tsx +13 -1
- package/app/components/navbar/navbar.tsx +8 -5
- package/app/components/sidebar/cases/case-sidebar.tsx +3 -2
- package/app/components/sidebar/sidebar-container.tsx +7 -0
- package/{members.emails.example → app/config-example/members.emails} +1 -1
- package/{primershear.emails.example → app/config-example/primershear.emails} +1 -1
- package/app/routes/striae/striae.tsx +36 -11
- package/app/types/export.ts +1 -0
- package/app/utils/forensics/SHA256.ts +2 -2
- package/app/utils/forensics/audit-export-signature.ts +1 -1
- package/app/utils/forensics/confirmation-signature.ts +1 -1
- package/app/utils/forensics/signature-utils.ts +7 -2
- package/package.json +2 -4
- package/scripts/deploy-config.sh +33 -0
- package/scripts/deploy-members-emails.sh +4 -4
- package/scripts/deploy-primershear-emails.sh +3 -3
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +1 -1
- package/workers/data-worker/src/signature-utils.ts +7 -2
- package/workers/data-worker/src/signing-payload-utils.ts +4 -4
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
|
@@ -1,1079 +1 @@
|
|
|
1
|
-
|
|
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';
|