@striae-org/striae 4.3.2 → 4.3.4
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/app/components/actions/case-import/orchestrator.ts +1 -1
- package/app/components/actions/case-manage.ts +50 -14
- package/app/components/audit/user-audit.module.css +49 -0
- package/app/components/audit/viewer/audit-entries-list.tsx +130 -48
- package/app/components/navbar/navbar.tsx +25 -12
- package/app/components/sidebar/case-import/case-import.tsx +56 -14
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +7 -6
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +9 -5
- package/app/components/sidebar/cases/cases-modal.module.css +19 -0
- package/app/components/sidebar/cases/cases-modal.tsx +23 -8
- package/app/routes/striae/hooks/use-striae-reset-helpers.ts +102 -0
- package/app/routes/striae/striae.tsx +72 -74
- package/app/routes/striae/utils/case-export.ts +37 -0
- package/app/routes/striae/utils/open-case-helper.ts +18 -0
- package/app/services/audit/audit-console-logger.ts +1 -1
- package/app/services/audit/audit-export-csv.ts +1 -1
- package/app/services/audit/audit-export-signing.ts +2 -2
- package/app/services/audit/audit-export.service.ts +1 -1
- package/app/services/audit/audit-worker-client.ts +1 -1
- package/app/services/audit/audit.service.ts +5 -75
- package/app/services/audit/builders/audit-event-builders-case-file.ts +3 -0
- package/app/services/audit/index.ts +2 -2
- package/app/types/audit.ts +8 -7
- package/app/utils/data/case-filters.ts +1 -1
- package/app/utils/ui/case-messages.ts +69 -0
- package/app/utils/ui/index.ts +1 -0
- package/package.json +5 -5
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
|
@@ -17,30 +17,29 @@ import { FilesModal } from '~/components/sidebar/files/files-modal';
|
|
|
17
17
|
import { NotesEditorModal } from '~/components/sidebar/notes/notes-editor-modal';
|
|
18
18
|
import { UserAuditViewer } from '~/components/audit/user-audit-viewer';
|
|
19
19
|
import { fetchUserApi } from '~/utils/api';
|
|
20
|
-
import { resolveEarliestAnnotationTimestamp } from '~/utils/ui';
|
|
21
20
|
import { type AnnotationData, type FileData } from '~/types';
|
|
22
|
-
import
|
|
23
|
-
import { checkCaseIsReadOnly, validateCaseNumber, renameCase, deleteCase, checkExistingCase, createNewCase, archiveCase, getCaseArchiveDetails } from '~/components/actions/case-manage';
|
|
21
|
+
import { validateCaseNumber, renameCase, deleteCase, checkExistingCase, createNewCase, archiveCase, getCaseArchiveDetails } from '~/components/actions/case-manage';
|
|
24
22
|
import { checkReadOnlyCaseExists, deleteReadOnlyCase } from '~/components/actions/case-review';
|
|
25
|
-
import { canCreateCase
|
|
23
|
+
import { canCreateCase } from '~/utils/data';
|
|
24
|
+
import {
|
|
25
|
+
resolveEarliestAnnotationTimestamp,
|
|
26
|
+
CREATE_READ_ONLY_CASE_EXISTS_ERROR,
|
|
27
|
+
CLEAR_READ_ONLY_CASE_PARTIAL_FAILURE,
|
|
28
|
+
DELETE_CASE_CONFIRMATION,
|
|
29
|
+
DELETE_FILE_CONFIRMATION,
|
|
30
|
+
DELETE_CASE_FAILED,
|
|
31
|
+
DELETE_FILE_FAILED,
|
|
32
|
+
RENAME_CASE_FAILED
|
|
33
|
+
} from '~/utils/ui';
|
|
34
|
+
import { useStriaeResetHelpers } from './hooks/use-striae-reset-helpers';
|
|
35
|
+
import { getExportProgressLabel, loadCaseExportActions } from './utils/case-export';
|
|
36
|
+
import { resolveOpenCaseHelperText } from './utils/open-case-helper';
|
|
26
37
|
import styles from './striae.module.css';
|
|
27
38
|
|
|
28
39
|
interface StriaePage {
|
|
29
40
|
user: User;
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
type CaseExportActionsModule = typeof CaseExportActions;
|
|
33
|
-
|
|
34
|
-
let caseExportActionsPromise: Promise<CaseExportActionsModule> | null = null;
|
|
35
|
-
|
|
36
|
-
const loadCaseExportActions = (): Promise<CaseExportActionsModule> => {
|
|
37
|
-
if (!caseExportActionsPromise) {
|
|
38
|
-
caseExportActionsPromise = import('~/components/actions/case-export');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return caseExportActionsPromise;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
43
|
export const Striae = ({ user }: StriaePage) => {
|
|
45
44
|
// Image and error states
|
|
46
45
|
const [selectedImage, setSelectedImage] = useState<string>();
|
|
@@ -61,6 +60,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
61
60
|
const [showNotes, setShowNotes] = useState(false);
|
|
62
61
|
const [isUploading, setIsUploading] = useState(false);
|
|
63
62
|
const [isReadOnlyCase, setIsReadOnlyCase] = useState(false);
|
|
63
|
+
const [isReviewOnlyCase, setIsReviewOnlyCase] = useState(false);
|
|
64
64
|
|
|
65
65
|
// Annotation states
|
|
66
66
|
const [activeAnnotations, setActiveAnnotations] = useState<Set<string>>(new Set());
|
|
@@ -98,24 +98,39 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
98
98
|
archiveReason?: string;
|
|
99
99
|
}>({ archived: false });
|
|
100
100
|
|
|
101
|
+
const {
|
|
102
|
+
clearSelectedImageState,
|
|
103
|
+
clearCaseContextState,
|
|
104
|
+
clearLoadedCaseState,
|
|
105
|
+
} = useStriaeResetHelpers({
|
|
106
|
+
setSelectedImage,
|
|
107
|
+
setSelectedFilename,
|
|
108
|
+
setImageId,
|
|
109
|
+
setAnnotationData,
|
|
110
|
+
setError,
|
|
111
|
+
setImageLoaded,
|
|
112
|
+
setCurrentCase,
|
|
113
|
+
setFiles,
|
|
114
|
+
setActiveAnnotations,
|
|
115
|
+
setIsBoxAnnotationMode,
|
|
116
|
+
setIsReadOnlyCase,
|
|
117
|
+
setIsReviewOnlyCase,
|
|
118
|
+
setArchiveDetails,
|
|
119
|
+
setShowNotes,
|
|
120
|
+
setIsAuditTrailOpen,
|
|
121
|
+
setIsRenameCaseModalOpen,
|
|
122
|
+
});
|
|
123
|
+
|
|
101
124
|
|
|
102
125
|
useEffect(() => {
|
|
103
126
|
// Set clear.jpg when case changes or is cleared
|
|
104
|
-
|
|
105
|
-
setSelectedFilename(undefined);
|
|
106
|
-
setImageId(undefined);
|
|
107
|
-
setAnnotationData(null);
|
|
108
|
-
setError(undefined);
|
|
109
|
-
setImageLoaded(false);
|
|
127
|
+
clearSelectedImageState();
|
|
110
128
|
|
|
111
129
|
// Reset annotation and UI states when case is cleared
|
|
112
130
|
if (!currentCase) {
|
|
113
|
-
|
|
114
|
-
setIsBoxAnnotationMode(false);
|
|
115
|
-
setIsReadOnlyCase(false);
|
|
116
|
-
setArchiveDetails({ archived: false });
|
|
131
|
+
clearCaseContextState();
|
|
117
132
|
}
|
|
118
|
-
}, [currentCase]);
|
|
133
|
+
}, [currentCase, clearSelectedImageState, clearCaseContextState]);
|
|
119
134
|
|
|
120
135
|
// Fetch user company data when component mounts
|
|
121
136
|
useEffect(() => {
|
|
@@ -154,18 +169,23 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
154
169
|
const checkReadOnlyStatus = async () => {
|
|
155
170
|
if (!currentCase || !user?.uid) {
|
|
156
171
|
setIsReadOnlyCase(false);
|
|
172
|
+
setIsReviewOnlyCase(false);
|
|
157
173
|
return;
|
|
158
174
|
}
|
|
159
175
|
|
|
160
176
|
try {
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
177
|
+
// Imported review cases are tracked in the user's read-only case list.
|
|
178
|
+
// This includes archived ZIP imports and distinguishes them from manually archived regular cases.
|
|
179
|
+
const readOnlyCaseEntry = await checkReadOnlyCaseExists(user, currentCase);
|
|
164
180
|
const details = await getCaseArchiveDetails(user, currentCase);
|
|
181
|
+
const reviewOnly = Boolean(readOnlyCaseEntry);
|
|
182
|
+
setIsReviewOnlyCase(reviewOnly);
|
|
183
|
+
setIsReadOnlyCase(reviewOnly || details.archived);
|
|
165
184
|
setArchiveDetails(details);
|
|
166
185
|
} catch (error) {
|
|
167
186
|
console.error('Error checking read-only status:', error);
|
|
168
187
|
setIsReadOnlyCase(false);
|
|
188
|
+
setIsReviewOnlyCase(false);
|
|
169
189
|
setArchiveDetails({ archived: false });
|
|
170
190
|
}
|
|
171
191
|
};
|
|
@@ -250,11 +270,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
250
270
|
|
|
251
271
|
if (includeImages) {
|
|
252
272
|
await caseExportActions.downloadCaseAsZip(user, exportCaseNumber, format, (progress) => {
|
|
253
|
-
const label = progress
|
|
254
|
-
: progress < 50 ? 'Preparing archive'
|
|
255
|
-
: progress < 80 ? 'Adding images'
|
|
256
|
-
: progress < 96 ? 'Finalizing'
|
|
257
|
-
: 'Downloading';
|
|
273
|
+
const label = getExportProgressLabel(progress);
|
|
258
274
|
onProgress?.(Math.round(progress), label);
|
|
259
275
|
});
|
|
260
276
|
showNotification(`Case ${exportCaseNumber} exported successfully.`, 'success');
|
|
@@ -317,7 +333,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
317
333
|
try {
|
|
318
334
|
const existingReadOnlyCase = await checkReadOnlyCaseExists(user, newCaseName);
|
|
319
335
|
if (existingReadOnlyCase) {
|
|
320
|
-
showNotification(
|
|
336
|
+
showNotification(CREATE_READ_ONLY_CASE_EXISTS_ERROR(newCaseName), 'error');
|
|
321
337
|
return;
|
|
322
338
|
}
|
|
323
339
|
|
|
@@ -327,7 +343,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
327
343
|
setIsRenameCaseModalOpen(false);
|
|
328
344
|
showNotification(`Case renamed to ${newCaseName}.`, 'success');
|
|
329
345
|
} catch (renameError) {
|
|
330
|
-
showNotification(renameError instanceof Error ? renameError.message :
|
|
346
|
+
showNotification(renameError instanceof Error ? renameError.message : RENAME_CASE_FAILED, 'error');
|
|
331
347
|
} finally {
|
|
332
348
|
setIsRenamingCase(false);
|
|
333
349
|
}
|
|
@@ -339,9 +355,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
339
355
|
return;
|
|
340
356
|
}
|
|
341
357
|
|
|
342
|
-
const confirmed = window.confirm(
|
|
343
|
-
`Are you sure you want to delete case ${currentCase}? This will permanently delete all associated files and cannot be undone. If any image assets are already missing (404), they will be skipped and the case deletion will continue.`
|
|
344
|
-
);
|
|
358
|
+
const confirmed = window.confirm(DELETE_CASE_CONFIRMATION(currentCase));
|
|
345
359
|
|
|
346
360
|
if (!confirmed) {
|
|
347
361
|
return;
|
|
@@ -350,11 +364,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
350
364
|
setIsDeletingCase(true);
|
|
351
365
|
try {
|
|
352
366
|
const deleteResult = await deleteCase(user, currentCase);
|
|
353
|
-
|
|
354
|
-
setFiles([]);
|
|
355
|
-
setShowNotes(false);
|
|
356
|
-
setIsAuditTrailOpen(false);
|
|
357
|
-
setIsRenameCaseModalOpen(false);
|
|
367
|
+
clearLoadedCaseState();
|
|
358
368
|
if (deleteResult.missingImages.length > 0) {
|
|
359
369
|
showNotification(
|
|
360
370
|
`Case deleted. ${deleteResult.missingImages.length} image(s) were not found and were skipped during deletion.`,
|
|
@@ -364,7 +374,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
364
374
|
showNotification('Case deleted successfully.', 'success');
|
|
365
375
|
}
|
|
366
376
|
} catch (deleteError) {
|
|
367
|
-
showNotification(deleteError instanceof Error ? deleteError.message :
|
|
377
|
+
showNotification(deleteError instanceof Error ? deleteError.message : DELETE_CASE_FAILED, 'error');
|
|
368
378
|
} finally {
|
|
369
379
|
setIsDeletingCase(false);
|
|
370
380
|
}
|
|
@@ -383,9 +393,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
383
393
|
|
|
384
394
|
const selectedFile = files.find((file) => file.id === imageId);
|
|
385
395
|
const selectedFileName = selectedFile?.originalFilename || imageId;
|
|
386
|
-
const confirmed = window.confirm(
|
|
387
|
-
`Are you sure you want to delete ${selectedFileName}? This action cannot be undone.`
|
|
388
|
-
);
|
|
396
|
+
const confirmed = window.confirm(DELETE_FILE_CONFIRMATION(selectedFileName));
|
|
389
397
|
|
|
390
398
|
if (!confirmed) {
|
|
391
399
|
return;
|
|
@@ -396,7 +404,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
396
404
|
const deleteResult = await deleteFile(user, currentCase, imageId, 'User-requested deletion via navbar file management');
|
|
397
405
|
const updatedFiles = files.filter((file) => file.id !== imageId);
|
|
398
406
|
setFiles(updatedFiles);
|
|
399
|
-
|
|
407
|
+
clearSelectedImageState();
|
|
400
408
|
setShowNotes(false);
|
|
401
409
|
if (deleteResult.imageMissing) {
|
|
402
410
|
showNotification(
|
|
@@ -407,13 +415,18 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
407
415
|
showNotification('File deleted successfully.', 'success');
|
|
408
416
|
}
|
|
409
417
|
} catch (deleteError) {
|
|
410
|
-
showNotification(deleteError instanceof Error ? deleteError.message :
|
|
418
|
+
showNotification(deleteError instanceof Error ? deleteError.message : DELETE_FILE_FAILED, 'error');
|
|
411
419
|
} finally {
|
|
412
420
|
setIsDeletingFile(false);
|
|
413
421
|
}
|
|
414
422
|
};
|
|
415
423
|
|
|
416
424
|
const handleClearROCase = async () => {
|
|
425
|
+
if (!isReviewOnlyCase) {
|
|
426
|
+
showNotification('Only imported review cases can be cleared from workspace.', 'error');
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
417
430
|
if (!currentCase) {
|
|
418
431
|
showNotification('No read-only case is currently loaded.', 'error');
|
|
419
432
|
return;
|
|
@@ -431,15 +444,10 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
431
444
|
try {
|
|
432
445
|
const success = await deleteReadOnlyCase(user, caseToRemove);
|
|
433
446
|
if (!success) {
|
|
434
|
-
showNotification(
|
|
447
|
+
showNotification(CLEAR_READ_ONLY_CASE_PARTIAL_FAILURE(caseToRemove), 'error');
|
|
435
448
|
return;
|
|
436
449
|
}
|
|
437
|
-
|
|
438
|
-
setFiles([]);
|
|
439
|
-
handleImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
|
|
440
|
-
setShowNotes(false);
|
|
441
|
-
setIsAuditTrailOpen(false);
|
|
442
|
-
setIsRenameCaseModalOpen(false);
|
|
450
|
+
clearLoadedCaseState();
|
|
443
451
|
showNotification(`Read-only case "${caseToRemove}" cleared.`, 'success');
|
|
444
452
|
} catch (clearError) {
|
|
445
453
|
showNotification(clearError instanceof Error ? clearError.message : 'Failed to clear read-only case.', 'error');
|
|
@@ -461,6 +469,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
461
469
|
try {
|
|
462
470
|
await archiveCase(user, currentCase, archiveReason);
|
|
463
471
|
setIsReadOnlyCase(true);
|
|
472
|
+
setIsReviewOnlyCase(false);
|
|
464
473
|
setArchiveDetails({
|
|
465
474
|
archived: true,
|
|
466
475
|
archivedAt: new Date().toISOString(),
|
|
@@ -506,7 +515,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
506
515
|
|
|
507
516
|
const existingReadOnlyCase = await checkReadOnlyCaseExists(user, nextCaseNumber);
|
|
508
517
|
if (existingReadOnlyCase) {
|
|
509
|
-
showNotification(
|
|
518
|
+
showNotification(CREATE_READ_ONLY_CASE_EXISTS_ERROR(nextCaseNumber), 'error');
|
|
510
519
|
return;
|
|
511
520
|
}
|
|
512
521
|
|
|
@@ -531,17 +540,8 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
531
540
|
|
|
532
541
|
const handleOpenCaseModal = async () => {
|
|
533
542
|
setIsOpenCaseModalOpen(true);
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (userData && !userData.permitted) {
|
|
537
|
-
const limitsDescription = await getLimitsDescription(user);
|
|
538
|
-
setOpenCaseHelperText(limitsDescription || 'Load an existing case or create a new one.');
|
|
539
|
-
} else {
|
|
540
|
-
setOpenCaseHelperText('Load an existing case or create a new one.');
|
|
541
|
-
}
|
|
542
|
-
} catch {
|
|
543
|
-
setOpenCaseHelperText('Load an existing case or create a new one.');
|
|
544
|
-
}
|
|
543
|
+
const helperText = await resolveOpenCaseHelperText(user);
|
|
544
|
+
setOpenCaseHelperText(helperText);
|
|
545
545
|
};
|
|
546
546
|
|
|
547
547
|
// Function to refresh annotation data (called when notes are saved)
|
|
@@ -566,10 +566,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
566
566
|
}
|
|
567
567
|
} else if (!result.caseNumber && !result.isReadOnly) {
|
|
568
568
|
// Read-only case cleared - reset all UI state
|
|
569
|
-
|
|
570
|
-
setFiles([]);
|
|
571
|
-
handleImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
|
|
572
|
-
setShowNotes(false);
|
|
569
|
+
clearLoadedCaseState();
|
|
573
570
|
}
|
|
574
571
|
}
|
|
575
572
|
};
|
|
@@ -746,6 +743,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
746
743
|
isUploading={isUploading}
|
|
747
744
|
company={userCompany}
|
|
748
745
|
isReadOnly={isReadOnlyCase}
|
|
746
|
+
isReviewOnlyCase={isReviewOnlyCase}
|
|
749
747
|
currentCase={currentCase}
|
|
750
748
|
currentFileName={selectedFilename}
|
|
751
749
|
isCurrentImageConfirmed={isCurrentImageConfirmed}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type * as CaseExportActions from '~/components/actions/case-export';
|
|
2
|
+
|
|
3
|
+
export type CaseExportActionsModule = typeof CaseExportActions;
|
|
4
|
+
|
|
5
|
+
let caseExportActionsPromise: Promise<CaseExportActionsModule> | null = null;
|
|
6
|
+
|
|
7
|
+
export const loadCaseExportActions = (): Promise<CaseExportActionsModule> => {
|
|
8
|
+
if (!caseExportActionsPromise) {
|
|
9
|
+
caseExportActionsPromise = import('~/components/actions/case-export').catch((error: unknown) => {
|
|
10
|
+
// Clear cached failures so transient chunk/network errors can recover on retry.
|
|
11
|
+
caseExportActionsPromise = null;
|
|
12
|
+
throw error;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return caseExportActionsPromise;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const getExportProgressLabel = (progress: number): string => {
|
|
20
|
+
if (progress < 30) {
|
|
21
|
+
return 'Loading case data';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (progress < 50) {
|
|
25
|
+
return 'Preparing archive';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (progress < 80) {
|
|
29
|
+
return 'Adding images';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (progress < 96) {
|
|
33
|
+
return 'Finalizing';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return 'Downloading';
|
|
37
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import { getLimitsDescription, getUserData } from '~/utils/data';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_OPEN_CASE_HELPER_TEXT = 'Load an existing case or create a new one.';
|
|
5
|
+
|
|
6
|
+
export const resolveOpenCaseHelperText = async (user: User): Promise<string> => {
|
|
7
|
+
try {
|
|
8
|
+
const userData = await getUserData(user);
|
|
9
|
+
if (userData && !userData.permitted) {
|
|
10
|
+
const limitsDescription = await getLimitsDescription(user);
|
|
11
|
+
return limitsDescription || DEFAULT_OPEN_CASE_HELPER_TEXT;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return DEFAULT_OPEN_CASE_HELPER_TEXT;
|
|
15
|
+
} catch {
|
|
16
|
+
return DEFAULT_OPEN_CASE_HELPER_TEXT;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
@@ -42,7 +42,7 @@ export const AUDIT_CSV_ENTRY_HEADERS = [
|
|
|
42
42
|
'Confirmed Files'
|
|
43
43
|
];
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
const formatForCSV = (value?: string | number | null): string => {
|
|
46
46
|
if (value === undefined || value === null) return '';
|
|
47
47
|
const str = String(value);
|
|
48
48
|
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
|
@@ -22,14 +22,14 @@ interface SignAuditExportInput {
|
|
|
22
22
|
hash: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
interface AuditExportSignature {
|
|
26
26
|
algorithm: string;
|
|
27
27
|
keyId: string;
|
|
28
28
|
signedAt: string;
|
|
29
29
|
value: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
interface SignedAuditExportPayload {
|
|
33
33
|
signatureMetadata: AuditExportSigningPayload;
|
|
34
34
|
signature: AuditExportSignature;
|
|
35
35
|
}
|
|
@@ -8,7 +8,7 @@ import { type AuditExportContext, signAuditExport } from './audit-export-signing
|
|
|
8
8
|
* Audit Export Service
|
|
9
9
|
* Handles exporting audit trails to various formats for compliance and forensic analysis
|
|
10
10
|
*/
|
|
11
|
-
|
|
11
|
+
class AuditExportService {
|
|
12
12
|
private static instance: AuditExportService;
|
|
13
13
|
|
|
14
14
|
private constructor() {}
|
|
@@ -2,7 +2,6 @@ import type { User } from 'firebase/auth';
|
|
|
2
2
|
import type {
|
|
3
3
|
ValidationAuditEntry,
|
|
4
4
|
CreateAuditEntryParams,
|
|
5
|
-
AuditTrail,
|
|
6
5
|
AuditQueryParams,
|
|
7
6
|
WorkflowPhase,
|
|
8
7
|
AuditAction,
|
|
@@ -18,7 +17,6 @@ import {
|
|
|
18
17
|
import {
|
|
19
18
|
applyAuditEntryFilters,
|
|
20
19
|
applyAuditPagination,
|
|
21
|
-
generateAuditSummary,
|
|
22
20
|
sortAuditEntriesNewestFirst
|
|
23
21
|
} from './audit-query-helpers';
|
|
24
22
|
import { logAuditEntryToConsole } from './audit-console-logger';
|
|
@@ -58,7 +56,7 @@ import {
|
|
|
58
56
|
* Audit Service for ValidationAuditEntry system
|
|
59
57
|
* Provides comprehensive audit logging throughout the confirmation workflow
|
|
60
58
|
*/
|
|
61
|
-
|
|
59
|
+
class AuditService {
|
|
62
60
|
private static instance: AuditService;
|
|
63
61
|
private auditBuffer: ValidationAuditEntry[] = [];
|
|
64
62
|
private workflowId: string | null = null;
|
|
@@ -383,13 +381,15 @@ export class AuditService {
|
|
|
383
381
|
public async logCaseCreation(
|
|
384
382
|
user: User,
|
|
385
383
|
caseNumber: string,
|
|
386
|
-
caseName: string
|
|
384
|
+
caseName: string,
|
|
385
|
+
renamedFromCaseNumber?: string
|
|
387
386
|
): Promise<void> {
|
|
388
387
|
await this.logEventForUser(user,
|
|
389
388
|
buildCaseCreationAuditParams({
|
|
390
389
|
user,
|
|
391
390
|
caseNumber,
|
|
392
|
-
caseName
|
|
391
|
+
caseName,
|
|
392
|
+
renamedFromCaseNumber
|
|
393
393
|
})
|
|
394
394
|
);
|
|
395
395
|
}
|
|
@@ -721,37 +721,6 @@ export class AuditService {
|
|
|
721
721
|
);
|
|
722
722
|
}
|
|
723
723
|
|
|
724
|
-
/**
|
|
725
|
-
* Log user account deletion event
|
|
726
|
-
*/
|
|
727
|
-
public async logAccountDeletion(
|
|
728
|
-
user: User,
|
|
729
|
-
result: AuditResult,
|
|
730
|
-
deletionReason: 'user-requested' | 'admin-initiated' | 'policy-violation' | 'inactive-account' = 'user-requested',
|
|
731
|
-
confirmationMethod: 'uid-email' | 'password' | 'admin-override' = 'uid-email',
|
|
732
|
-
casesCount?: number,
|
|
733
|
-
filesCount?: number,
|
|
734
|
-
dataRetentionPeriod?: number,
|
|
735
|
-
emailNotificationSent?: boolean,
|
|
736
|
-
sessionId?: string,
|
|
737
|
-
errors: string[] = []
|
|
738
|
-
): Promise<void> {
|
|
739
|
-
// Wrapper that extracts user data and calls the simplified version
|
|
740
|
-
return this.logAccountDeletionSimple(
|
|
741
|
-
user.uid,
|
|
742
|
-
user.email || '',
|
|
743
|
-
result,
|
|
744
|
-
deletionReason,
|
|
745
|
-
confirmationMethod,
|
|
746
|
-
casesCount,
|
|
747
|
-
filesCount,
|
|
748
|
-
dataRetentionPeriod,
|
|
749
|
-
emailNotificationSent,
|
|
750
|
-
sessionId,
|
|
751
|
-
errors
|
|
752
|
-
);
|
|
753
|
-
}
|
|
754
|
-
|
|
755
724
|
/**
|
|
756
725
|
* Log user account deletion event with simplified user data
|
|
757
726
|
*/
|
|
@@ -1011,32 +980,6 @@ export class AuditService {
|
|
|
1011
980
|
return await this.getAuditEntries(queryParams, params?.requestingUser);
|
|
1012
981
|
}
|
|
1013
982
|
|
|
1014
|
-
/**
|
|
1015
|
-
* Get audit trail for a case
|
|
1016
|
-
*/
|
|
1017
|
-
public async getAuditTrail(caseNumber: string): Promise<AuditTrail | null> {
|
|
1018
|
-
try {
|
|
1019
|
-
// Implement retrieval from storage
|
|
1020
|
-
const entries = await this.getAuditEntries({ caseNumber });
|
|
1021
|
-
if (!entries || entries.length === 0) {
|
|
1022
|
-
return null;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
const summary = generateAuditSummary(entries);
|
|
1026
|
-
const workflowId = this.workflowId || `${caseNumber}-archived`;
|
|
1027
|
-
|
|
1028
|
-
return {
|
|
1029
|
-
caseNumber,
|
|
1030
|
-
workflowId,
|
|
1031
|
-
entries,
|
|
1032
|
-
summary
|
|
1033
|
-
};
|
|
1034
|
-
} catch (error) {
|
|
1035
|
-
console.error('🚨 Audit: Failed to get audit trail:', error);
|
|
1036
|
-
return null;
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
983
|
/**
|
|
1041
984
|
* Get audit entries based on query parameters
|
|
1042
985
|
*/
|
|
@@ -1143,19 +1086,6 @@ export class AuditService {
|
|
|
1143
1086
|
}
|
|
1144
1087
|
}
|
|
1145
1088
|
|
|
1146
|
-
/**
|
|
1147
|
-
* Clear audit buffer (for testing)
|
|
1148
|
-
*/
|
|
1149
|
-
public clearBuffer(): void {
|
|
1150
|
-
this.auditBuffer = [];
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
/**
|
|
1154
|
-
* Get current buffer size (for monitoring)
|
|
1155
|
-
*/
|
|
1156
|
-
public getBufferSize(): number {
|
|
1157
|
-
return this.auditBuffer.length;
|
|
1158
|
-
}
|
|
1159
1089
|
}
|
|
1160
1090
|
|
|
1161
1091
|
// Export singleton instance
|
|
@@ -6,6 +6,7 @@ interface BuildCaseCreationAuditParamsInput {
|
|
|
6
6
|
user: User;
|
|
7
7
|
caseNumber: string;
|
|
8
8
|
caseName: string;
|
|
9
|
+
renamedFromCaseNumber?: string;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export const buildCaseCreationAuditParams = (
|
|
@@ -22,7 +23,9 @@ export const buildCaseCreationAuditParams = (
|
|
|
22
23
|
caseNumber: input.caseNumber,
|
|
23
24
|
workflowPhase: 'casework',
|
|
24
25
|
caseDetails: {
|
|
26
|
+
oldCaseName: input.renamedFromCaseNumber,
|
|
25
27
|
newCaseName: input.caseName,
|
|
28
|
+
createdByRename: Boolean(input.renamedFromCaseNumber),
|
|
26
29
|
createdDate: new Date().toISOString(),
|
|
27
30
|
totalFiles: 0,
|
|
28
31
|
totalAnnotations: 0
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
1
|
+
export { auditService } from './audit.service';
|
|
2
|
+
export { auditExportService } from './audit-export.service';
|
package/app/types/audit.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface ValidationAuditEntry {
|
|
|
44
44
|
* Detailed information for each audit entry
|
|
45
45
|
* Contains action-specific data and metadata
|
|
46
46
|
*/
|
|
47
|
-
|
|
47
|
+
interface AuditDetails {
|
|
48
48
|
// Core identification
|
|
49
49
|
fileName?: string;
|
|
50
50
|
fileType?: AuditFileType;
|
|
@@ -194,9 +194,10 @@ export interface AuditQueryParams {
|
|
|
194
194
|
/**
|
|
195
195
|
* Case management specific audit details
|
|
196
196
|
*/
|
|
197
|
-
|
|
197
|
+
interface CaseAuditDetails {
|
|
198
198
|
oldCaseName?: string;
|
|
199
199
|
newCaseName?: string;
|
|
200
|
+
createdByRename?: boolean;
|
|
200
201
|
totalFiles?: number;
|
|
201
202
|
totalAnnotations?: number;
|
|
202
203
|
confirmedFileNames?: string[];
|
|
@@ -210,7 +211,7 @@ export interface CaseAuditDetails {
|
|
|
210
211
|
/**
|
|
211
212
|
* File operation specific audit details
|
|
212
213
|
*/
|
|
213
|
-
|
|
214
|
+
interface FileAuditDetails {
|
|
214
215
|
fileId?: string;
|
|
215
216
|
originalFileName?: string;
|
|
216
217
|
fileSize: number;
|
|
@@ -225,7 +226,7 @@ export interface FileAuditDetails {
|
|
|
225
226
|
/**
|
|
226
227
|
* Annotation operation specific audit details
|
|
227
228
|
*/
|
|
228
|
-
|
|
229
|
+
interface AnnotationAuditDetails {
|
|
229
230
|
annotationId?: string;
|
|
230
231
|
annotationType?: 'measurement' | 'identification' | 'comparison' | 'note' | 'region';
|
|
231
232
|
annotationData?: unknown; // The actual annotation data structure
|
|
@@ -238,7 +239,7 @@ export interface AnnotationAuditDetails {
|
|
|
238
239
|
/**
|
|
239
240
|
* User session specific audit details
|
|
240
241
|
*/
|
|
241
|
-
|
|
242
|
+
interface SessionAuditDetails {
|
|
242
243
|
sessionId?: string;
|
|
243
244
|
userAgent?: string;
|
|
244
245
|
sessionDuration?: number;
|
|
@@ -249,7 +250,7 @@ export interface SessionAuditDetails {
|
|
|
249
250
|
/**
|
|
250
251
|
* Security incident specific audit details
|
|
251
252
|
*/
|
|
252
|
-
|
|
253
|
+
interface SecurityAuditDetails {
|
|
253
254
|
incidentType?: 'unauthorized-access' | 'data-breach' | 'malware' | 'injection' | 'brute-force' | 'privilege-escalation';
|
|
254
255
|
severity?: 'low' | 'medium' | 'high' | 'critical';
|
|
255
256
|
targetResource?: string;
|
|
@@ -272,7 +273,7 @@ export interface SecurityAuditDetails {
|
|
|
272
273
|
/**
|
|
273
274
|
* User profile and authentication specific audit details
|
|
274
275
|
*/
|
|
275
|
-
|
|
276
|
+
interface UserProfileAuditDetails {
|
|
276
277
|
profileField?: 'displayName' | 'email' | 'organization' | 'role' | 'preferences' | 'avatar' | 'badgeId';
|
|
277
278
|
oldValue?: string;
|
|
278
279
|
newValue?: string;
|
|
@@ -86,7 +86,7 @@ export function filterCasesForModal(
|
|
|
86
86
|
): CasesModalCaseItem[] {
|
|
87
87
|
const archiveFilteredCases = preferences.showArchivedOnly
|
|
88
88
|
? cases.filter((entry) => entry.archived && !entry.isReadOnly)
|
|
89
|
-
: cases.filter((entry) => !entry.
|
|
89
|
+
: cases.filter((entry) => !entry.isReadOnly);
|
|
90
90
|
|
|
91
91
|
return archiveFilteredCases.filter((entry) =>
|
|
92
92
|
matchesConfirmationFilter(entry.caseNumber, preferences.confirmationFilter, caseConfirmationStatus)
|