@striae-org/striae 4.3.0 → 4.3.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.
@@ -1,6 +1,7 @@
1
1
  import type { User } from 'firebase/auth';
2
2
  import { fetchDataApi } from '~/utils/api';
3
- import { type ConfirmationImportResult, type ConfirmationImportData } from '~/types';
3
+ import { upsertFileConfirmationSummary } from '~/utils/data';
4
+ import { type AnnotationData, type ConfirmationImportResult, type ConfirmationImportData } from '~/types';
4
5
  import { checkExistingCase } from '../case-manage';
5
6
  import { extractConfirmationImportPackage } from './confirmation-package';
6
7
  import { validateExporterUid, validateConfirmationHash, validateConfirmationSignatureFile } from './validation';
@@ -37,6 +38,7 @@ export async function importConfirmationData(
37
38
  let signatureKeyId: string | undefined;
38
39
  let confirmationDataForAudit: ConfirmationImportData | null = null;
39
40
  let confirmationJsonFileNameForAudit = confirmationFile.name;
41
+ const confirmedFileNames = new Set<string>();
40
42
 
41
43
  const result: ConfirmationImportResult = {
42
44
  success: false,
@@ -234,6 +236,21 @@ export async function importConfirmationData(
234
236
  if (saveResponse.ok) {
235
237
  result.imagesUpdated++;
236
238
  result.confirmationsImported += confirmations.length;
239
+ confirmedFileNames.add(displayFilename);
240
+
241
+ try {
242
+ await upsertFileConfirmationSummary(
243
+ user,
244
+ result.caseNumber,
245
+ currentImageId,
246
+ updatedAnnotationData as AnnotationData
247
+ );
248
+ } catch (summaryError) {
249
+ console.warn(
250
+ `Failed to update confirmation summary for imported confirmation ${result.caseNumber}/${currentImageId}:`,
251
+ summaryError
252
+ );
253
+ }
237
254
 
238
255
  // Audit log successful confirmation import
239
256
  try {
@@ -298,6 +315,7 @@ export async function importConfirmationData(
298
315
  result.success ? (result.errors && result.errors.length > 0 ? 'warning' : 'success') : 'failure',
299
316
  hashValid,
300
317
  result.confirmationsImported, // Successfully imported confirmations
318
+ Array.from(confirmedFileNames).sort((left, right) => left.localeCompare(right)),
301
319
  result.errors || [],
302
320
  confirmationData.metadata.exportedByUid,
303
321
  {
@@ -390,6 +408,7 @@ export async function importConfirmationData(
390
408
  'failure',
391
409
  hashValidForAudit,
392
410
  0, // No confirmations successfully imported for failures
411
+ [],
393
412
  result.errors || [],
394
413
  reviewingExaminerUidForAudit,
395
414
  {
@@ -286,6 +286,9 @@ export async function importCaseForReview(
286
286
  if (existingRegularCase && !isArchivedExport) {
287
287
  throw new Error(`Case "${result.caseNumber}" already exists in your case list. You cannot import a case for review if you were the original analyst.`);
288
288
  }
289
+ if (existingRegularCase && isArchivedExport) {
290
+ throw new Error(`Cannot import this archive because case "${result.caseNumber}" already exists in your case list (active or archived). To import this archive, the existing case must first be deleted.`);
291
+ }
289
292
 
290
293
  // Step 2b: Check if read-only case already exists
291
294
  const existingCase = await checkReadOnlyCaseExists(user, result.caseNumber);
@@ -807,7 +807,11 @@ export const archiveCase = async (
807
807
  )
808
808
  );
809
809
 
810
- const auditEntries = await auditService.getAuditEntriesForUser(user.uid, { caseNumber });
810
+ const auditEntries = await auditService.getAuditEntriesForUser(user.uid, {
811
+ caseNumber,
812
+ startDate: caseData.createdAt,
813
+ endDate: archivedAt,
814
+ });
811
815
  const auditTrail: AuditTrail = {
812
816
  caseNumber,
813
817
  workflowId: `${caseNumber}-archive-${Date.now()}`,
@@ -5,8 +5,8 @@ import {
5
5
  getCurrentPublicSigningKeyDetails,
6
6
  getVerificationPublicKey
7
7
  } from '~/utils/forensics';
8
- import { getUserData, getCaseData, updateCaseData, signConfirmationData } from '~/utils/data';
9
- import { type ConfirmationData, type CaseConfirmations, type CaseDataWithConfirmations, type ConfirmationImportData } from '~/types';
8
+ import { getUserData, getCaseData, updateCaseData, signConfirmationData, upsertFileConfirmationSummary } from '~/utils/data';
9
+ import { type AnnotationData, type ConfirmationData, type CaseConfirmations, type CaseDataWithConfirmations, type ConfirmationImportData } from '~/types';
10
10
  import { auditService } from '~/services/audit';
11
11
 
12
12
  /**
@@ -17,7 +17,8 @@ export async function storeConfirmation(
17
17
  caseNumber: string,
18
18
  currentImageId: string,
19
19
  confirmationData: ConfirmationData,
20
- originalImageFileName?: string
20
+ originalImageFileName?: string,
21
+ annotationDataForSummary?: AnnotationData
21
22
  ): Promise<boolean> {
22
23
  const startTime = Date.now();
23
24
  let originalImageId: string | undefined; // Declare at function level for error handling
@@ -63,6 +64,14 @@ export async function storeConfirmation(
63
64
  // Store the updated case data using centralized function
64
65
  await updateCaseData(user, caseNumber, caseData);
65
66
 
67
+ if (annotationDataForSummary) {
68
+ try {
69
+ await upsertFileConfirmationSummary(user, caseNumber, currentImageId, annotationDataForSummary);
70
+ } catch (summaryError) {
71
+ console.warn(`Failed to update confirmation summary for ${caseNumber}/${currentImageId}:`, summaryError);
72
+ }
73
+ }
74
+
66
75
  console.log(`Confirmation stored for original image ${originalImageId}:`, confirmationData);
67
76
 
68
77
  // Log successful confirmation creation
@@ -6,6 +6,13 @@ interface AuditEntriesListProps {
6
6
  entries: ValidationAuditEntry[];
7
7
  }
8
8
 
9
+ const isConfirmationImportEntry = (entry: ValidationAuditEntry): boolean => {
10
+ return (
11
+ entry.action === 'confirmation-import' ||
12
+ (entry.action === 'import' && entry.details.workflowPhase === 'confirmation')
13
+ );
14
+ };
15
+
9
16
  export const AuditEntriesList = ({ entries }: AuditEntriesListProps) => {
10
17
  return (
11
18
  <div className={styles.entriesList}>
@@ -47,13 +54,24 @@ export const AuditEntriesList = ({ entries }: AuditEntriesListProps) => {
47
54
  </div>
48
55
  )}
49
56
 
50
- {entry.action === 'confirmation-import' && entry.details.reviewerBadgeId && (
57
+ {isConfirmationImportEntry(entry) && entry.details.reviewerBadgeId && (
51
58
  <div className={styles.detailRow}>
52
- <span className={styles.detailLabel}>Reviewer Badge/ID:</span>
59
+ <span className={styles.detailLabel}>Confirming Examiner Badge/ID:</span>
53
60
  <span className={styles.badgeTag}>{entry.details.reviewerBadgeId}</span>
54
61
  </div>
55
62
  )}
56
63
 
64
+ {isConfirmationImportEntry(entry) &&
65
+ entry.details.caseDetails?.confirmedFileNames &&
66
+ entry.details.caseDetails.confirmedFileNames.length > 0 && (
67
+ <div className={styles.detailRow}>
68
+ <span className={styles.detailLabel}>Confirmed Files:</span>
69
+ <span className={styles.detailValue}>
70
+ {entry.details.caseDetails.confirmedFileNames.join(', ')}
71
+ </span>
72
+ </div>
73
+ )}
74
+
57
75
  {entry.result === 'failure' && entry.details.validationErrors.length > 0 && (
58
76
  <div className={styles.detailRow}>
59
77
  <span className={styles.detailLabel}>Error:</span>
@@ -48,7 +48,7 @@ export const useAuditViewerExport = ({
48
48
  const filename = auditExportService.generateFilename(
49
49
  exportContextData.scopeType,
50
50
  exportContextData.identifier,
51
- 'json'
51
+ 'csv'
52
52
  );
53
53
 
54
54
  try {
@@ -72,7 +72,7 @@ export const useAuditViewerExport = ({
72
72
  const filename = auditExportService.generateFilename(
73
73
  exportContextData.scopeType,
74
74
  exportContextData.identifier,
75
- 'csv'
75
+ 'json'
76
76
  );
77
77
 
78
78
  try {
@@ -2,6 +2,10 @@ import { useCallback, useMemo, useState } from 'react';
2
2
  import type { AuditAction, AuditResult, ValidationAuditEntry } from '~/types';
3
3
  import type { DateRangeFilter } from './types';
4
4
 
5
+ const isConfirmationImportEntry = (entry: ValidationAuditEntry): boolean => {
6
+ return entry.action === 'import' && entry.details.workflowPhase === 'confirmation';
7
+ };
8
+
5
9
  export const useAuditViewerFilters = (caseNumber?: string) => {
6
10
  const [filterAction, setFilterAction] = useState<AuditAction | 'all'>('all');
7
11
  const [filterResult, setFilterResult] = useState<AuditResult | 'all'>('all');
@@ -76,7 +80,13 @@ export const useAuditViewerFilters = (caseNumber?: string) => {
76
80
 
77
81
  const resultMatch = filterResult === 'all' || entry.result === filterResult;
78
82
  const entryBadgeId = entry.details.userProfileDetails?.badgeId?.trim().toLowerCase() || '';
79
- const badgeMatch = normalizedBadgeFilter === '' || entryBadgeId.includes(normalizedBadgeFilter);
83
+ const reviewerBadgeId = isConfirmationImportEntry(entry)
84
+ ? entry.details.reviewerBadgeId?.trim().toLowerCase() || ''
85
+ : '';
86
+ const badgeMatch =
87
+ normalizedBadgeFilter === '' ||
88
+ entryBadgeId.includes(normalizedBadgeFilter) ||
89
+ reviewerBadgeId.includes(normalizedBadgeFilter);
80
90
 
81
91
  return actionMatch && resultMatch && badgeMatch;
82
92
  });
@@ -107,7 +107,8 @@ export const Canvas = ({
107
107
  caseNumber,
108
108
  currentImageId,
109
109
  confirmationData,
110
- filename
110
+ filename,
111
+ updatedAnnotationData
111
112
  );
112
113
 
113
114
  if (success) {
@@ -384,6 +384,17 @@
384
384
  background: color-mix(in lab, #0d9488 20%, #ffffff);
385
385
  }
386
386
 
387
+ .caseMenuItemClearRO {
388
+ background: color-mix(in lab, #fd7e14 16%, #ffffff);
389
+ color: #7c3f00;
390
+ border-color: color-mix(in lab, #fd7e14 30%, transparent);
391
+ }
392
+
393
+ .caseMenuItemClearRO:hover {
394
+ background: color-mix(in lab, #fd7e14 22%, #ffffff);
395
+ border-color: color-mix(in lab, #fd7e14 36%, transparent);
396
+ }
397
+
387
398
  .caseMenuCaption {
388
399
  margin-top: 0.25rem;
389
400
  padding: 0.3rem 0.45rem 0.1rem;
@@ -26,6 +26,7 @@ interface NavbarProps {
26
26
  onOpenRenameCase?: () => void;
27
27
  onDeleteCase?: () => void;
28
28
  onArchiveCase?: () => void;
29
+ onClearROCase?: () => void;
29
30
  onOpenViewAllFiles?: () => void;
30
31
  onDeleteCurrentFile?: () => void;
31
32
  onOpenImageNotes?: () => void;
@@ -55,6 +56,7 @@ export const Navbar = ({
55
56
  onOpenRenameCase,
56
57
  onDeleteCase,
57
58
  onArchiveCase,
59
+ onClearROCase,
58
60
  onOpenViewAllFiles,
59
61
  onDeleteCurrentFile,
60
62
  onOpenImageNotes,
@@ -151,6 +153,8 @@ export const Navbar = ({
151
153
  type="button"
152
154
  role="menuitem"
153
155
  className={`${styles.caseMenuItem} ${styles.caseMenuItemOpen}`}
156
+ disabled={isReadOnly}
157
+ title={isReadOnly ? 'Clear the read-only case first to open or switch cases' : undefined}
154
158
  onClick={() => {
155
159
  onOpenCase?.();
156
160
  setIsCaseMenuOpen(false);
@@ -162,6 +166,8 @@ export const Navbar = ({
162
166
  type="button"
163
167
  role="menuitem"
164
168
  className={`${styles.caseMenuItem} ${styles.caseMenuItemList}`}
169
+ disabled={isReadOnly}
170
+ title={isReadOnly ? 'Clear the read-only case first to list all cases' : undefined}
165
171
  onClick={() => {
166
172
  onOpenListAllCases?.();
167
173
  setIsCaseMenuOpen(false);
@@ -202,8 +208,21 @@ export const Navbar = ({
202
208
  >
203
209
  Case Audit Trail
204
210
  </button>
205
- {(!isReadOnly || archiveDetails?.archived) && (
206
- <div className={styles.caseMenuSectionLabel}>Maintenance</div>
211
+ <div className={styles.caseMenuSectionLabel}>Maintenance</div>
212
+ {isReadOnly && (
213
+ <button
214
+ type="button"
215
+ role="menuitem"
216
+ className={`${styles.caseMenuItem} ${styles.caseMenuItemClearRO}`}
217
+ disabled={!hasLoadedCase}
218
+ title={!hasLoadedCase ? 'No read-only case is loaded' : undefined}
219
+ onClick={() => {
220
+ onClearROCase?.();
221
+ setIsCaseMenuOpen(false);
222
+ }}
223
+ >
224
+ Clear RO Case
225
+ </button>
207
226
  )}
208
227
  {!isReadOnly && (
209
228
  <button
@@ -226,27 +245,27 @@ export const Navbar = ({
226
245
  Rename Case
227
246
  </button>
228
247
  )}
229
- {(!isReadOnly || archiveDetails?.archived) && (
230
- <button
231
- type="button"
232
- role="menuitem"
233
- className={`${styles.caseMenuItem} ${styles.caseMenuItemDelete}`}
234
- disabled={!hasLoadedCase || disableLongRunningCaseActions}
235
- title={
236
- !hasLoadedCase
248
+ <button
249
+ type="button"
250
+ role="menuitem"
251
+ className={`${styles.caseMenuItem} ${styles.caseMenuItemDelete}`}
252
+ disabled={!hasLoadedCase || disableLongRunningCaseActions || isReadOnly}
253
+ title={
254
+ isReadOnly
255
+ ? 'Clear the read-only case first before deleting'
256
+ : !hasLoadedCase
237
257
  ? 'Load a case to delete it'
238
258
  : disableLongRunningCaseActions
239
259
  ? 'Delete is unavailable while files are uploading'
240
260
  : undefined
241
- }
242
- onClick={() => {
243
- onDeleteCase?.();
244
- setIsCaseMenuOpen(false);
245
- }}
246
- >
247
- Delete Case
248
- </button>
249
- )}
261
+ }
262
+ onClick={() => {
263
+ onDeleteCase?.();
264
+ setIsCaseMenuOpen(false);
265
+ }}
266
+ >
267
+ Delete Case
268
+ </button>
250
269
  {!isReadOnly && (
251
270
  <button
252
271
  type="button"
@@ -117,6 +117,8 @@ export const useImportExecution = ({
117
117
  }
118
118
 
119
119
  setSuccess(message);
120
+
121
+ onImportComplete?.(result);
120
122
 
121
123
  // No auto-close for confirmation imports - let user read the details and close manually
122
124
 
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
4
4
  import styles from './cases.module.css';
5
5
  import { FilesModal } from '../files/files-modal';
6
6
  import { ImageUploadZone } from '../upload/image-upload-zone';
7
- import { exportConfirmationData } from '../../actions/confirm-export';
8
7
  import {
9
8
  fetchFiles,
10
9
  deleteFile,
@@ -34,7 +33,7 @@ interface CaseSidebarProps {
34
33
  isUploading?: boolean;
35
34
  onUploadStatusChange?: (isUploading: boolean) => void;
36
35
  onUploadComplete?: (result: { successCount: number; failedFiles: string[] }) => void;
37
- onExportNotification?: (message: string, type: 'success' | 'error') => void;
36
+ onOpenCaseExport?: () => void;
38
37
  }
39
38
 
40
39
  export const CaseSidebar = ({
@@ -55,14 +54,13 @@ export const CaseSidebar = ({
55
54
  isUploading = false,
56
55
  onUploadStatusChange,
57
56
  onUploadComplete,
58
- onExportNotification
57
+ onOpenCaseExport
59
58
  }: CaseSidebarProps) => {
60
59
 
61
60
  const [, setFileError] = useState('');
62
61
  const [canUploadNewFile, setCanUploadNewFile] = useState(true);
63
62
  const [uploadFileError, setUploadFileError] = useState('');
64
63
  const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
65
- const [isExportingConfirmations, setIsExportingConfirmations] = useState(false);
66
64
  const [deletingFileId, setDeletingFileId] = useState<string | null>(null);
67
65
  const [fileConfirmationStatus, setFileConfirmationStatus] = useState<{
68
66
  [fileId: string]: { includeConfirmation: boolean; isConfirmed: boolean }
@@ -230,26 +228,6 @@ const handleImageSelect = (file: FileData) => {
230
228
  setImageLoaded(false);
231
229
  };
232
230
 
233
- const handleExportConfirmations = useCallback(async () => {
234
- if (!currentCase || !isReadOnly || !isArchivedCase) {
235
- return;
236
- }
237
-
238
- try {
239
- setIsExportingConfirmations(true);
240
- await exportConfirmationData(user, currentCase);
241
- onExportNotification?.(`Confirmation export for case ${currentCase} downloaded successfully.`, 'success');
242
- } catch (error) {
243
- console.error('Failed to export confirmations:', error);
244
- onExportNotification?.(
245
- error instanceof Error ? error.message : 'Failed to export confirmation data.',
246
- 'error'
247
- );
248
- } finally {
249
- setIsExportingConfirmations(false);
250
- }
251
- }, [currentCase, isArchivedCase, isReadOnly, onExportNotification, user]);
252
-
253
231
  const selectedFileConfirmationState = selectedFileId
254
232
  ? fileConfirmationStatus[selectedFileId]
255
233
  : undefined;
@@ -280,10 +258,10 @@ const handleImageSelect = (file: FileData) => {
280
258
  ? 'Select an image first'
281
259
  : undefined;
282
260
 
283
- const showExportConfirmationsButton = Boolean(currentCase && isReadOnly && !isArchivedCase);
261
+ const showCaseExportButton = Boolean(currentCase && isReadOnly && !isArchivedCase);
284
262
 
285
- const exportConfirmationsTitle = isUploading
286
- ? 'Cannot export confirmations while uploading'
263
+ const exportCaseTitle = isUploading
264
+ ? 'Cannot export while uploading'
287
265
  : !currentCase
288
266
  ? 'Load a case first'
289
267
  : undefined;
@@ -406,14 +384,14 @@ return (
406
384
  )}
407
385
  </div>
408
386
  <div className={styles.sidebarToggle}>
409
- {showExportConfirmationsButton ? (
387
+ {showCaseExportButton ? (
410
388
  <button
411
389
  className={styles.confirmationExportButton}
412
- onClick={() => void handleExportConfirmations()}
413
- disabled={isUploading || !currentCase || isExportingConfirmations}
414
- title={exportConfirmationsTitle}
390
+ onClick={onOpenCaseExport}
391
+ disabled={isUploading || !currentCase}
392
+ title={exportCaseTitle}
415
393
  >
416
- {isExportingConfirmations ? 'Exporting...' : 'Export Confirmations'}
394
+ Export Confirmations
417
395
  </button>
418
396
  ) : (
419
397
  <button
@@ -489,7 +489,7 @@
489
489
  cursor: not-allowed;
490
490
  }
491
491
 
492
- .confirmationExportButton {
492
+ .sidebarToggle .confirmationExportButton {
493
493
  width: 100%;
494
494
  padding: 0.625rem 0.75rem;
495
495
  background-color: #198754;
@@ -502,11 +502,11 @@
502
502
  transition: all 0.2s;
503
503
  }
504
504
 
505
- .confirmationExportButton:hover:not(:disabled) {
505
+ .sidebarToggle .confirmationExportButton:hover:not(:disabled) {
506
506
  background-color: #146c43;
507
507
  }
508
508
 
509
- .confirmationExportButton:disabled {
509
+ .sidebarToggle .confirmationExportButton:disabled {
510
510
  background-color: var(--backgroundLight);
511
511
  color: var(--textLight);
512
512
  cursor: not-allowed;
@@ -29,6 +29,7 @@ interface SidebarContainerProps {
29
29
  confirmationSaveVersion?: number;
30
30
  isUploading?: boolean;
31
31
  onUploadStatusChange?: (isUploading: boolean) => void;
32
+ onOpenCaseExport?: () => void;
32
33
  }
33
34
 
34
35
  export const SidebarContainer: React.FC<SidebarContainerProps> = (props) => {
@@ -25,6 +25,7 @@ interface SidebarProps {
25
25
  confirmationSaveVersion?: number;
26
26
  isUploading?: boolean;
27
27
  onUploadStatusChange?: (isUploading: boolean) => void;
28
+ onOpenCaseExport?: () => void;
28
29
  }
29
30
 
30
31
  export const Sidebar = ({
@@ -44,6 +45,7 @@ export const Sidebar = ({
44
45
  confirmationSaveVersion = 0,
45
46
  isUploading: initialIsUploading = false,
46
47
  onUploadStatusChange,
48
+ onOpenCaseExport,
47
49
  }: SidebarProps) => {
48
50
  const [isUploading, setIsUploading] = useState(initialIsUploading);
49
51
  const [toastMessage, setToastMessage] = useState('');
@@ -74,12 +76,6 @@ export const Sidebar = ({
74
76
  setIsToastVisible(true);
75
77
  }, []);
76
78
 
77
- const handleExportNotification = useCallback((message: string, type: 'success' | 'error') => {
78
- setToastType(type);
79
- setToastMessage(message);
80
- setIsToastVisible(true);
81
- }, []);
82
-
83
79
  return (
84
80
  <div className={styles.sidebar}>
85
81
  <CaseSidebar
@@ -100,7 +96,7 @@ export const Sidebar = ({
100
96
  isUploading={isUploading}
101
97
  onUploadStatusChange={handleUploadStatusChange}
102
98
  onUploadComplete={handleUploadComplete}
103
- onExportNotification={handleExportNotification}
99
+ onOpenCaseExport={onOpenCaseExport}
104
100
  />
105
101
  <Toast
106
102
  message={toastMessage}
@@ -21,7 +21,7 @@ import { resolveEarliestAnnotationTimestamp } from '~/utils/ui';
21
21
  import { type AnnotationData, type FileData } from '~/types';
22
22
  import type * as CaseExportActions from '~/components/actions/case-export';
23
23
  import { checkCaseIsReadOnly, validateCaseNumber, renameCase, deleteCase, checkExistingCase, createNewCase, archiveCase, getCaseArchiveDetails } from '~/components/actions/case-manage';
24
- import { checkReadOnlyCaseExists } from '~/components/actions/case-review';
24
+ import { checkReadOnlyCaseExists, deleteReadOnlyCase } from '~/components/actions/case-review';
25
25
  import { canCreateCase, getLimitsDescription, getUserData } from '~/utils/data';
26
26
  import styles from './striae.module.css';
27
27
 
@@ -413,6 +413,39 @@ export const Striae = ({ user }: StriaePage) => {
413
413
  }
414
414
  };
415
415
 
416
+ const handleClearROCase = async () => {
417
+ if (!currentCase) {
418
+ showNotification('No read-only case is currently loaded.', 'error');
419
+ return;
420
+ }
421
+
422
+ const caseToRemove = currentCase;
423
+ const confirmed = window.confirm(
424
+ `Clear the read-only case "${caseToRemove}" from the workspace? This will remove the imported review data. The original exported case is not affected.`
425
+ );
426
+
427
+ if (!confirmed) {
428
+ return;
429
+ }
430
+
431
+ try {
432
+ const success = await deleteReadOnlyCase(user, caseToRemove);
433
+ if (!success) {
434
+ showNotification(`Failed to fully clear read-only case "${caseToRemove}". Please try again.`, 'error');
435
+ return;
436
+ }
437
+ setCurrentCase('');
438
+ setFiles([]);
439
+ handleImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
440
+ setShowNotes(false);
441
+ setIsAuditTrailOpen(false);
442
+ setIsRenameCaseModalOpen(false);
443
+ showNotification(`Read-only case "${caseToRemove}" cleared.`, 'success');
444
+ } catch (clearError) {
445
+ showNotification(clearError instanceof Error ? clearError.message : 'Failed to clear read-only case.', 'error');
446
+ }
447
+ };
448
+
416
449
  const handleArchiveCaseSubmit = async (archiveReason: string) => {
417
450
  if (!currentCase) {
418
451
  showNotification('Select a case before archiving.', 'error');
@@ -514,6 +547,7 @@ export const Striae = ({ user }: StriaePage) => {
514
547
  // Function to refresh annotation data (called when notes are saved)
515
548
  const refreshAnnotationData = () => {
516
549
  setAnnotationRefreshTrigger(prev => prev + 1);
550
+ setConfirmationSaveVersion(prev => prev + 1);
517
551
  };
518
552
 
519
553
  // Handle import/clear read-only case
@@ -522,6 +556,11 @@ export const Striae = ({ user }: StriaePage) => {
522
556
  if (result.caseNumber && result.isReadOnly) {
523
557
  // Successful read-only case import - load the case
524
558
  handleCaseChange(result.caseNumber);
559
+ } else if (result.caseNumber) {
560
+ setConfirmationSaveVersion(prev => prev + 1);
561
+ if (result.caseNumber === currentCase) {
562
+ refreshAnnotationData();
563
+ }
525
564
  } else if (!result.caseNumber && !result.isReadOnly) {
526
565
  // Read-only case cleared - reset all UI state
527
566
  setCurrentCase('');
@@ -722,6 +761,9 @@ export const Striae = ({ user }: StriaePage) => {
722
761
  void handleDeleteCaseAction();
723
762
  }}
724
763
  onArchiveCase={() => setIsArchiveCaseModalOpen(true)}
764
+ onClearROCase={() => {
765
+ void handleClearROCase();
766
+ }}
725
767
  onOpenViewAllFiles={() => setIsFilesModalOpen(true)}
726
768
  onDeleteCurrentFile={() => {
727
769
  void handleDeleteCurrentFileAction();
@@ -735,6 +777,7 @@ export const Striae = ({ user }: StriaePage) => {
735
777
  onOpenCase={() => {
736
778
  void handleOpenCaseModal();
737
779
  }}
780
+ onOpenCaseExport={() => setIsCaseExportModalOpen(true)}
738
781
  imageId={imageId}
739
782
  currentCase={currentCase}
740
783
  imageLoaded={imageLoaded}
@@ -38,7 +38,8 @@ export const AUDIT_CSV_ENTRY_HEADERS = [
38
38
  'Total Files',
39
39
  'MFA Method',
40
40
  'Security Incident Type',
41
- 'Security Severity'
41
+ 'Security Severity',
42
+ 'Confirmed Files'
42
43
  ];
43
44
 
44
45
  export const formatForCSV = (value?: string | number | null): string => {
@@ -121,7 +122,8 @@ export const entryToCSVRow = (entry: ValidationAuditEntry): string => {
121
122
  caseDetails?.totalFiles?.toString() || '',
122
123
  formatForCSV(securityDetails?.mfaMethod),
123
124
  formatForCSV(securityDetails?.incidentType),
124
- formatForCSV(securityDetails?.severity)
125
+ formatForCSV(securityDetails?.severity),
126
+ formatForCSV(caseDetails?.confirmedFileNames?.join('; '))
125
127
  ];
126
128
 
127
129
  return values.join(',');
@@ -84,11 +84,13 @@ const generateConfirmationSummary = (entries: ValidationAuditEntry[]): string =>
84
84
 
85
85
  let totalConfirmationsImported = 0;
86
86
  let totalConfirmationsInFiles = 0;
87
- const reviewingExaminers = new Set<string>();
87
+ const reviewingExaminers = new Map<string, { uid: string; badgeId?: string; confirmedFiles: Set<string> }>();
88
+ const allConfirmedFiles = new Set<string>();
88
89
 
89
90
  imports.forEach(entry => {
90
91
  const metrics = entry.details.performanceMetrics;
91
92
  const caseDetails = entry.details.caseDetails;
93
+ const userProfileDetails = entry.details.userProfileDetails;
92
94
 
93
95
  if (metrics?.validationStepsCompleted) {
94
96
  totalConfirmationsImported += metrics.validationStepsCompleted;
@@ -97,10 +99,36 @@ const generateConfirmationSummary = (entries: ValidationAuditEntry[]): string =>
97
99
  totalConfirmationsInFiles += caseDetails.totalAnnotations;
98
100
  }
99
101
  if (entry.details.reviewingExaminerUid) {
100
- reviewingExaminers.add(entry.details.reviewingExaminerUid);
102
+ const uid = entry.details.reviewingExaminerUid;
103
+ const badgeId = userProfileDetails?.badgeId;
104
+ const confirmedFileNames = caseDetails?.confirmedFileNames || [];
105
+
106
+ if (!reviewingExaminers.has(uid)) {
107
+ reviewingExaminers.set(uid, {
108
+ uid,
109
+ badgeId,
110
+ confirmedFiles: new Set()
111
+ });
112
+ }
113
+
114
+ const examiner = reviewingExaminers.get(uid)!;
115
+ confirmedFileNames.forEach(file => {
116
+ examiner.confirmedFiles.add(file);
117
+ allConfirmedFiles.add(file);
118
+ });
101
119
  }
102
120
  });
103
121
 
122
+ const examinersDetail = Array.from(reviewingExaminers.values())
123
+ .map(examiner => {
124
+ const badgeInfo = examiner.badgeId ? ` (Badge: ${examiner.badgeId})` : '';
125
+ const filesInfo = examiner.confirmedFiles.size > 0
126
+ ? `\n Confirmed Files: ${Array.from(examiner.confirmedFiles).sort().join(', ')}`
127
+ : '';
128
+ return `- UID: ${examiner.uid}${badgeInfo}${filesInfo}`;
129
+ })
130
+ .join('\n');
131
+
104
132
  return [
105
133
  `Confirmation Operations: ${confirmationEntries.length}`,
106
134
  `- Imports: ${imports.length}`,
@@ -112,8 +140,12 @@ const generateConfirmationSummary = (entries: ValidationAuditEntry[]): string =>
112
140
  `Reviewing Examiners Involved: ${reviewingExaminers.size}`,
113
141
  '',
114
142
  reviewingExaminers.size > 0
115
- ? `External Reviewers: ${Array.from(reviewingExaminers).join(', ')}`
116
- : 'No external reviewers detected'
143
+ ? `External Reviewers:\n${examinersDetail}`
144
+ : 'No external reviewers detected',
145
+ '',
146
+ allConfirmedFiles.size > 0
147
+ ? `Successfully Confirmed Files (Total: ${allConfirmedFiles.size}):\n${Array.from(allConfirmedFiles).sort().map(file => ` - ${file}`).join('\n')}`
148
+ : 'No files confirmed'
117
149
  ].join('\n');
118
150
  };
119
151
 
@@ -340,6 +340,7 @@ export class AuditService {
340
340
  result: AuditResult,
341
341
  hashValid: boolean,
342
342
  confirmationsImported: number,
343
+ confirmedFileNames: string[] = [],
343
344
  errors: string[] = [],
344
345
  reviewingExaminerUid?: string,
345
346
  performanceMetrics?: PerformanceMetrics,
@@ -360,6 +361,7 @@ export class AuditService {
360
361
  result,
361
362
  hashValid,
362
363
  confirmationsImported,
364
+ confirmedFileNames,
363
365
  errors,
364
366
  reviewingExaminerUid,
365
367
  reviewerBadgeId,
@@ -19,6 +19,7 @@ export const buildValidationAuditEntry = (
19
19
  confirmationId: params.confirmationId,
20
20
  originalExaminerUid: params.originalExaminerUid,
21
21
  reviewingExaminerUid: params.reviewingExaminerUid,
22
+ reviewerBadgeId: params.reviewerBadgeId,
22
23
  workflowPhase: params.workflowPhase,
23
24
  securityChecks: params.securityChecks,
24
25
  performanceMetrics: params.performanceMetrics,
@@ -218,6 +218,7 @@ interface BuildConfirmationImportAuditParamsInput {
218
218
  result: AuditResult;
219
219
  hashValid: boolean;
220
220
  confirmationsImported: number;
221
+ confirmedFileNames?: string[];
221
222
  errors?: string[];
222
223
  reviewingExaminerUid?: string;
223
224
  reviewerBadgeId?: string;
@@ -273,8 +274,13 @@ export const buildConfirmationImportAuditParams = (
273
274
  reviewerBadgeId: input.reviewerBadgeId,
274
275
  caseDetails: input.totalConfirmationsInFile !== undefined
275
276
  ? {
276
- totalAnnotations: input.totalConfirmationsInFile
277
+ totalAnnotations: input.totalConfirmationsInFile,
278
+ confirmedFileNames: input.confirmedFileNames
277
279
  }
278
- : undefined
280
+ : input.confirmedFileNames
281
+ ? {
282
+ confirmedFileNames: input.confirmedFileNames
283
+ }
284
+ : undefined
279
285
  };
280
286
  };
@@ -199,6 +199,7 @@ export interface CaseAuditDetails {
199
199
  newCaseName?: string;
200
200
  totalFiles?: number;
201
201
  totalAnnotations?: number;
202
+ confirmedFileNames?: string[];
202
203
  createdDate?: string;
203
204
  lastModified?: string;
204
205
  deleteReason?: string;
@@ -123,8 +123,12 @@ function normalizeConfirmations(confirmations: ConfirmationMap): ConfirmationMap
123
123
 
124
124
  export function createConfirmationSigningPayload(
125
125
  confirmationData: ConfirmationImportData,
126
- signatureVersion: string = CONFIRMATION_SIGNATURE_VERSION
126
+ signatureVersion: string = CONFIRMATION_SIGNATURE_VERSION,
127
+ options: {
128
+ includeExportedByBadgeId?: boolean;
129
+ } = {}
127
130
  ): string {
131
+ const includeExportedByBadgeId = options.includeExportedByBadgeId !== false;
128
132
  const canonicalPayload = {
129
133
  signatureVersion,
130
134
  metadata: {
@@ -134,7 +138,7 @@ export function createConfirmationSigningPayload(
134
138
  exportedByUid: confirmationData.metadata.exportedByUid,
135
139
  exportedByName: confirmationData.metadata.exportedByName,
136
140
  exportedByCompany: confirmationData.metadata.exportedByCompany,
137
- ...(confirmationData.metadata.exportedByBadgeId
141
+ ...(includeExportedByBadgeId && confirmationData.metadata.exportedByBadgeId
138
142
  ? { exportedByBadgeId: confirmationData.metadata.exportedByBadgeId }
139
143
  : {}),
140
144
  totalConfirmations: confirmationData.metadata.totalConfirmations,
@@ -180,9 +184,7 @@ export async function verifyConfirmationSignature(
180
184
  };
181
185
  }
182
186
 
183
- const payload = createConfirmationSigningPayload(confirmationData, signatureVersion);
184
-
185
- return verifySignaturePayload(
187
+ const verifyPayload = (payload: string) => verifySignaturePayload(
186
188
  payload,
187
189
  signature,
188
190
  FORENSIC_MANIFEST_SIGNATURE_ALGORITHM,
@@ -197,4 +199,17 @@ export async function verifyConfirmationSignature(
197
199
  verificationPublicKeyPem
198
200
  }
199
201
  );
202
+
203
+ const primaryPayload = createConfirmationSigningPayload(confirmationData, signatureVersion);
204
+ const primaryResult = await verifyPayload(primaryPayload);
205
+
206
+ if (primaryResult.isValid || !confirmationData.metadata.exportedByBadgeId) {
207
+ return primaryResult;
208
+ }
209
+
210
+ const legacyPayload = createConfirmationSigningPayload(confirmationData, signatureVersion, {
211
+ includeExportedByBadgeId: false
212
+ });
213
+
214
+ return verifyPayload(legacyPayload);
200
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "4.3.0",
3
+ "version": "4.3.1",
4
4
  "private": false,
5
5
  "description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
6
6
  "license": "Apache-2.0",
@@ -13,6 +13,7 @@ export interface ConfirmationSignatureMetadata {
13
13
  exportedByUid: string;
14
14
  exportedByName: string;
15
15
  exportedByCompany: string;
16
+ exportedByBadgeId?: string;
16
17
  totalConfirmations: number;
17
18
  version: string;
18
19
  hash: string;
@@ -131,6 +132,7 @@ export function isValidConfirmationPayload(
131
132
  typeof metadata.exportedByUid !== 'string' ||
132
133
  typeof metadata.exportedByName !== 'string' ||
133
134
  typeof metadata.exportedByCompany !== 'string' ||
135
+ (typeof metadata.exportedByBadgeId !== 'undefined' && typeof metadata.exportedByBadgeId !== 'string') ||
134
136
  typeof metadata.totalConfirmations !== 'number' ||
135
137
  metadata.totalConfirmations < 0 ||
136
138
  typeof metadata.version !== 'string' ||
@@ -261,6 +263,9 @@ export function createConfirmationSigningPayload(confirmationData: ConfirmationS
261
263
  exportedByUid: confirmationData.metadata.exportedByUid,
262
264
  exportedByName: confirmationData.metadata.exportedByName,
263
265
  exportedByCompany: confirmationData.metadata.exportedByCompany,
266
+ ...(confirmationData.metadata.exportedByBadgeId
267
+ ? { exportedByBadgeId: confirmationData.metadata.exportedByBadgeId }
268
+ : {}),
264
269
  totalConfirmations: confirmationData.metadata.totalConfirmations,
265
270
  version: confirmationData.metadata.version,
266
271
  hash: confirmationData.metadata.hash.toUpperCase(),