@striae-org/striae 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +1 -1
  2. package/app/components/actions/case-manage.ts +50 -17
  3. package/app/components/audit/viewer/audit-entries-list.tsx +5 -2
  4. package/app/components/audit/viewer/use-audit-viewer-data.ts +6 -3
  5. package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
  6. package/app/components/canvas/confirmation/confirmation.tsx +6 -2
  7. package/app/components/colors/colors.module.css +4 -3
  8. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
  9. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
  10. package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
  11. package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
  12. package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
  13. package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
  14. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
  15. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
  16. package/app/components/navbar/navbar.tsx +34 -9
  17. package/app/components/sidebar/cases/case-sidebar.tsx +93 -73
  18. package/app/components/sidebar/cases/cases-modal.module.css +312 -10
  19. package/app/components/sidebar/cases/cases-modal.tsx +737 -116
  20. package/app/components/sidebar/cases/cases.module.css +43 -0
  21. package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
  22. package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
  23. package/app/components/sidebar/files/files-modal.module.css +285 -44
  24. package/app/components/sidebar/files/files-modal.tsx +482 -177
  25. package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
  26. package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
  27. package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
  28. package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
  29. package/app/components/sidebar/notes/class-details-shared.ts +239 -0
  30. package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +77 -76
  31. package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
  32. package/app/components/sidebar/notes/notes.module.css +262 -14
  33. package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
  34. package/app/components/sidebar/sidebar-container.tsx +2 -0
  35. package/app/components/sidebar/sidebar.tsx +15 -1
  36. package/app/{tailwind.css → global.css} +1 -3
  37. package/app/hooks/useCaseListPreferences.ts +99 -0
  38. package/app/hooks/useFileListPreferences.ts +106 -0
  39. package/app/hooks/useOverlayDismiss.ts +6 -4
  40. package/app/root.tsx +1 -1
  41. package/app/routes/striae/striae.tsx +7 -0
  42. package/app/services/audit/audit.service.ts +2 -2
  43. package/app/services/audit/builders/audit-event-builders-case-file.ts +1 -1
  44. package/app/types/annotations.ts +48 -1
  45. package/app/types/audit.ts +1 -0
  46. package/app/utils/data/case-filters.ts +127 -0
  47. package/app/utils/data/confirmation-summary/summary-core.ts +295 -0
  48. package/app/utils/data/data-operations.ts +17 -861
  49. package/app/utils/data/file-filters.ts +201 -0
  50. package/app/utils/data/index.ts +11 -1
  51. package/app/utils/data/operations/batch-operations.ts +113 -0
  52. package/app/utils/data/operations/case-operations.ts +168 -0
  53. package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
  54. package/app/utils/data/operations/file-annotation-operations.ts +196 -0
  55. package/app/utils/data/operations/index.ts +7 -0
  56. package/app/utils/data/operations/signing-operations.ts +225 -0
  57. package/app/utils/data/operations/types.ts +42 -0
  58. package/app/utils/data/operations/validation-operations.ts +48 -0
  59. package/app/utils/forensics/export-verification.ts +40 -111
  60. package/functions/api/_shared/firebase-auth.ts +2 -7
  61. package/functions/api/image/[[path]].ts +23 -22
  62. package/functions/api/pdf/[[path]].ts +27 -8
  63. package/package.json +7 -13
  64. package/scripts/deploy-primershear-emails.sh +1 -1
  65. package/worker-configuration.d.ts +2 -2
  66. package/workers/audit-worker/package.json +1 -1
  67. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  68. package/workers/data-worker/package.json +1 -1
  69. package/workers/data-worker/wrangler.jsonc.example +1 -1
  70. package/workers/image-worker/package.json +1 -1
  71. package/workers/image-worker/src/image-worker.example.ts +16 -5
  72. package/workers/image-worker/wrangler.jsonc.example +1 -1
  73. package/workers/keys-worker/package.json +1 -1
  74. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  75. package/workers/pdf-worker/package.json +1 -1
  76. package/workers/pdf-worker/src/formats/format-striae.ts +84 -124
  77. package/workers/pdf-worker/src/pdf-worker.example.ts +58 -61
  78. package/workers/pdf-worker/src/report-layout.ts +227 -0
  79. package/workers/pdf-worker/src/report-types.ts +23 -3
  80. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  81. package/workers/user-worker/package.json +1 -1
  82. package/workers/user-worker/src/user-worker.example.ts +17 -0
  83. package/workers/user-worker/wrangler.jsonc.example +1 -1
  84. package/wrangler.toml.example +1 -1
  85. package/NOTICE +0 -13
  86. package/app/components/sidebar/notes/notes-modal.tsx +0 -52
  87. package/postcss.config.js +0 -6
  88. package/tailwind.config.ts +0 -22
  89. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  90. /package/workers/pdf-worker/src/assets/{generated-assets.ts → generated-assets.example.ts} +0 -0
@@ -114,6 +114,7 @@ export const Navbar = ({
114
114
  }, [isCaseMenuOpen, isFileMenuOpen]);
115
115
 
116
116
  const caseActionsDisabled = false;
117
+ const disableLongRunningCaseActions = isUploading;
117
118
  const isCaseManagementActive = true;
118
119
  const isFileManagementActive = isFileMenuOpen || hasLoadedImage;
119
120
  const canOpenImageNotes = hasLoadedImage && !isCurrentImageConfirmed;
@@ -139,7 +140,7 @@ export const Navbar = ({
139
140
  aria-haspopup="menu"
140
141
  disabled={caseActionsDisabled}
141
142
  onClick={() => setIsCaseMenuOpen((prev) => !prev)}
142
- title={isUploading ? 'Cannot access case actions while uploading' : undefined}
143
+ title={isUploading ? 'Some case actions are unavailable while files are uploading' : undefined}
143
144
  >
144
145
  Case Management
145
146
  </button>
@@ -173,8 +174,14 @@ export const Navbar = ({
173
174
  type="button"
174
175
  role="menuitem"
175
176
  className={`${styles.caseMenuItem} ${styles.caseMenuItemExport}`}
176
- disabled={!hasLoadedCase}
177
- title={!hasLoadedCase ? 'Load a case to export case data' : undefined}
177
+ disabled={!hasLoadedCase || disableLongRunningCaseActions}
178
+ title={
179
+ !hasLoadedCase
180
+ ? 'Load a case to export case data'
181
+ : disableLongRunningCaseActions
182
+ ? 'Export is unavailable while files are uploading'
183
+ : undefined
184
+ }
178
185
  onClick={() => {
179
186
  onOpenCaseExport?.();
180
187
  setIsCaseMenuOpen(false);
@@ -203,8 +210,14 @@ export const Navbar = ({
203
210
  type="button"
204
211
  role="menuitem"
205
212
  className={`${styles.caseMenuItem} ${styles.caseMenuItemRename}`}
206
- disabled={!hasLoadedCase}
207
- title={!hasLoadedCase ? 'Load a case to rename it' : undefined}
213
+ disabled={!hasLoadedCase || disableLongRunningCaseActions}
214
+ title={
215
+ !hasLoadedCase
216
+ ? 'Load a case to rename it'
217
+ : disableLongRunningCaseActions
218
+ ? 'Rename is unavailable while files are uploading'
219
+ : undefined
220
+ }
208
221
  onClick={() => {
209
222
  onOpenRenameCase?.();
210
223
  setIsCaseMenuOpen(false);
@@ -218,8 +231,14 @@ export const Navbar = ({
218
231
  type="button"
219
232
  role="menuitem"
220
233
  className={`${styles.caseMenuItem} ${styles.caseMenuItemDelete}`}
221
- disabled={!hasLoadedCase}
222
- title={!hasLoadedCase ? 'Load a case to delete it' : undefined}
234
+ disabled={!hasLoadedCase || disableLongRunningCaseActions}
235
+ title={
236
+ !hasLoadedCase
237
+ ? 'Load a case to delete it'
238
+ : disableLongRunningCaseActions
239
+ ? 'Delete is unavailable while files are uploading'
240
+ : undefined
241
+ }
223
242
  onClick={() => {
224
243
  onDeleteCase?.();
225
244
  setIsCaseMenuOpen(false);
@@ -233,8 +252,14 @@ export const Navbar = ({
233
252
  type="button"
234
253
  role="menuitem"
235
254
  className={`${styles.caseMenuItem} ${styles.caseMenuItemArchive}`}
236
- disabled={!hasLoadedCase}
237
- title={!hasLoadedCase ? 'Load a case to archive it' : undefined}
255
+ disabled={!hasLoadedCase || disableLongRunningCaseActions}
256
+ title={
257
+ !hasLoadedCase
258
+ ? 'Load a case to archive it'
259
+ : disableLongRunningCaseActions
260
+ ? 'Archive is unavailable while files are uploading'
261
+ : undefined
262
+ }
238
263
  onClick={() => {
239
264
  onArchiveCase?.();
240
265
  setIsCaseMenuOpen(false);
@@ -1,21 +1,25 @@
1
1
  import type { User } from 'firebase/auth';
2
+ import type React from 'react';
2
3
  import { useState, useEffect, useMemo, useCallback } from 'react';
3
4
  import styles from './cases.module.css';
4
5
  import { FilesModal } from '../files/files-modal';
5
6
  import { ImageUploadZone } from '../upload/image-upload-zone';
7
+ import { exportConfirmationData } from '../../actions/confirm-export';
6
8
  import {
7
9
  fetchFiles,
8
10
  deleteFile,
9
11
  } from '../../actions/image-manage';
10
12
  import {
11
13
  canUploadFile,
12
- getFileAnnotations
14
+ ensureCaseConfirmationSummary,
15
+ getCaseConfirmationSummary
13
16
  } from '~/utils/data';
14
17
  import { type FileData } from '~/types';
15
18
 
16
19
  interface CaseSidebarProps {
17
20
  user: User;
18
21
  onImageSelect: (file: FileData) => void;
22
+ onOpenCase: () => void;
19
23
  imageLoaded: boolean;
20
24
  setImageLoaded: (loaded: boolean) => void;
21
25
  onNotesClick: () => void;
@@ -23,17 +27,20 @@ interface CaseSidebarProps {
23
27
  setFiles: React.Dispatch<React.SetStateAction<FileData[]>>;
24
28
  currentCase: string | null;
25
29
  isReadOnly?: boolean;
30
+ isArchivedCase?: boolean;
26
31
  isConfirmed?: boolean;
27
32
  confirmationSaveVersion?: number;
28
33
  selectedFileId?: string;
29
34
  isUploading?: boolean;
30
35
  onUploadStatusChange?: (isUploading: boolean) => void;
31
36
  onUploadComplete?: (result: { successCount: number; failedFiles: string[] }) => void;
37
+ onExportNotification?: (message: string, type: 'success' | 'error') => void;
32
38
  }
33
39
 
34
40
  export const CaseSidebar = ({
35
41
  user,
36
42
  onImageSelect,
43
+ onOpenCase,
37
44
  imageLoaded,
38
45
  setImageLoaded,
39
46
  onNotesClick,
@@ -41,18 +48,21 @@ export const CaseSidebar = ({
41
48
  setFiles,
42
49
  currentCase,
43
50
  isReadOnly = false,
51
+ isArchivedCase = false,
44
52
  isConfirmed = false,
45
53
  confirmationSaveVersion = 0,
46
54
  selectedFileId,
47
55
  isUploading = false,
48
56
  onUploadStatusChange,
49
- onUploadComplete
57
+ onUploadComplete,
58
+ onExportNotification
50
59
  }: CaseSidebarProps) => {
51
60
 
52
61
  const [, setFileError] = useState('');
53
62
  const [canUploadNewFile, setCanUploadNewFile] = useState(true);
54
63
  const [uploadFileError, setUploadFileError] = useState('');
55
64
  const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
65
+ const [isExportingConfirmations, setIsExportingConfirmations] = useState(false);
56
66
  const [deletingFileId, setDeletingFileId] = useState<string | null>(null);
57
67
  const [fileConfirmationStatus, setFileConfirmationStatus] = useState<{
58
68
  [fileId: string]: { includeConfirmation: boolean; isConfirmed: boolean }
@@ -67,21 +77,6 @@ export const CaseSidebar = ({
67
77
  [files]
68
78
  );
69
79
 
70
- const calculateCaseConfirmationStatus = useCallback((
71
- statuses: { [fileId: string]: { includeConfirmation: boolean; isConfirmed: boolean } }
72
- ) => {
73
- const filesRequiringConfirmation = files
74
- .map((file) => statuses[file.id] || { includeConfirmation: false, isConfirmed: false })
75
- .filter((status) => status.includeConfirmation);
76
-
77
- const allConfirmedFiles = filesRequiringConfirmation.every((status) => status.isConfirmed);
78
-
79
- return {
80
- includeConfirmation: filesRequiringConfirmation.length > 0,
81
- isConfirmed: filesRequiringConfirmation.length > 0 ? allConfirmedFiles : false,
82
- };
83
- }, [files]);
84
-
85
80
  // Function to check file upload permissions (extracted for reuse)
86
81
  const checkFileUploadPermissions = useCallback(async (fileCount?: number) => {
87
82
  if (currentCase) {
@@ -135,43 +130,24 @@ export const CaseSidebar = ({
135
130
  return;
136
131
  }
137
132
 
138
- // Fetch all annotations in parallel
139
- const annotationPromises = files.map(async (file) => {
140
- try {
141
- const annotations = await getFileAnnotations(user, currentCase, file.id);
142
- return {
143
- fileId: file.id,
144
- includeConfirmation: annotations?.includeConfirmation ?? false,
145
- isConfirmed: !!annotations?.confirmationData,
146
- };
147
- } catch (err) {
148
- console.error(`Error fetching annotations for file ${file.id}:`, err);
149
- return {
150
- fileId: file.id,
151
- includeConfirmation: false,
152
- isConfirmed: false,
153
- };
154
- }
133
+ const caseSummary = await ensureCaseConfirmationSummary(user, currentCase, files).catch((error) => {
134
+ console.error(`Error fetching confirmation summary for case ${currentCase}:`, error);
135
+ return null;
155
136
  });
156
137
 
157
- // Wait for all fetches to complete
158
- const results = await Promise.all(annotationPromises);
159
-
160
- // Build the statuses map from results
161
- const statuses: { [fileId: string]: { includeConfirmation: boolean; isConfirmed: boolean } } = {};
162
- results.forEach((result) => {
163
- statuses[result.fileId] = {
164
- includeConfirmation: result.includeConfirmation,
165
- isConfirmed: result.isConfirmed,
166
- };
167
- });
138
+ if (!caseSummary) {
139
+ return;
140
+ }
168
141
 
169
142
  if (isCancelled) {
170
143
  return;
171
144
  }
172
145
 
173
- setFileConfirmationStatus(statuses);
174
- setCaseConfirmationStatus(calculateCaseConfirmationStatus(statuses));
146
+ setFileConfirmationStatus(caseSummary.filesById);
147
+ setCaseConfirmationStatus({
148
+ includeConfirmation: caseSummary.includeConfirmation,
149
+ isConfirmed: caseSummary.isConfirmed
150
+ });
175
151
  };
176
152
 
177
153
  fetchConfirmationStatuses();
@@ -179,7 +155,7 @@ export const CaseSidebar = ({
179
155
  return () => {
180
156
  isCancelled = true;
181
157
  };
182
- }, [currentCase, fileIdsKey, user, files, calculateCaseConfirmationStatus]);
158
+ }, [currentCase, fileIdsKey, user, files]);
183
159
 
184
160
  // Refresh only selected file confirmation status after confirmation-related data is persisted
185
161
  useEffect(() => {
@@ -191,24 +167,18 @@ export const CaseSidebar = ({
191
167
  }
192
168
 
193
169
  try {
194
- const annotations = await getFileAnnotations(user, currentCase, selectedFileId);
195
- const selectedStatus = {
196
- includeConfirmation: annotations?.includeConfirmation ?? false,
197
- isConfirmed: !!annotations?.confirmationData,
198
- };
170
+ const caseSummary =
171
+ await getCaseConfirmationSummary(user, currentCase) ||
172
+ await ensureCaseConfirmationSummary(user, currentCase, files);
199
173
 
200
174
  if (isCancelled) {
201
175
  return;
202
176
  }
203
177
 
204
- setFileConfirmationStatus((previous) => {
205
- const next = {
206
- ...previous,
207
- [selectedFileId]: selectedStatus,
208
- };
209
-
210
- setCaseConfirmationStatus(calculateCaseConfirmationStatus(next));
211
- return next;
178
+ setFileConfirmationStatus(caseSummary.filesById);
179
+ setCaseConfirmationStatus({
180
+ includeConfirmation: caseSummary.includeConfirmation,
181
+ isConfirmed: caseSummary.isConfirmed
212
182
  });
213
183
  } catch (err) {
214
184
  console.error(`Error refreshing confirmation status for file ${selectedFileId}:`, err);
@@ -220,7 +190,7 @@ export const CaseSidebar = ({
220
190
  return () => {
221
191
  isCancelled = true;
222
192
  };
223
- }, [currentCase, fileIdsKey, user, selectedFileId, confirmationSaveVersion, files.length, calculateCaseConfirmationStatus]);
193
+ }, [currentCase, fileIdsKey, user, selectedFileId, confirmationSaveVersion, files]);
224
194
 
225
195
  const handleFileDelete = async (fileId: string) => {
226
196
  // Don't allow file deletion for read-only cases
@@ -260,6 +230,26 @@ const handleImageSelect = (file: FileData) => {
260
230
  setImageLoaded(false);
261
231
  };
262
232
 
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
+
263
253
  const selectedFileConfirmationState = selectedFileId
264
254
  ? fileConfirmationStatus[selectedFileId]
265
255
  : undefined;
@@ -290,6 +280,14 @@ const handleImageSelect = (file: FileData) => {
290
280
  ? 'Select an image first'
291
281
  : undefined;
292
282
 
283
+ const showExportConfirmationsButton = Boolean(currentCase && isReadOnly && !isArchivedCase);
284
+
285
+ const exportConfirmationsTitle = isUploading
286
+ ? 'Cannot export confirmations while uploading'
287
+ : !currentCase
288
+ ? 'Load a case first'
289
+ : undefined;
290
+
293
291
  return (
294
292
  <>
295
293
  <div className={styles.caseSection}>
@@ -303,19 +301,30 @@ return (
303
301
  setFiles={setFiles}
304
302
  isReadOnly={isReadOnly}
305
303
  selectedFileId={selectedFileId}
304
+ confirmationSaveVersion={confirmationSaveVersion}
306
305
  />
307
306
 
308
307
  <div className={styles.filesSection}>
309
308
  <div className={currentCase ? (isReadOnly ? styles.readOnlyContainer : styles.caseHeader) : styles.emptyCaseHeader}>
310
- <h4 className={`${styles.caseNumber} ${
311
- currentCase && caseConfirmationStatus.includeConfirmation
312
- ? caseConfirmationStatus.isConfirmed
313
- ? styles.caseConfirmed
314
- : styles.caseNotConfirmed
315
- : ''
316
- }`}>
317
- {currentCase || 'No Case Selected'}
318
- </h4>
309
+ {currentCase ? (
310
+ <h4 className={`${styles.caseNumber} ${
311
+ caseConfirmationStatus.includeConfirmation
312
+ ? caseConfirmationStatus.isConfirmed
313
+ ? styles.caseConfirmed
314
+ : styles.caseNotConfirmed
315
+ : ''
316
+ }`}>
317
+ {currentCase}
318
+ </h4>
319
+ ) : (
320
+ <button
321
+ type="button"
322
+ className={styles.openCaseButton}
323
+ onClick={onOpenCase}
324
+ >
325
+ Open Case
326
+ </button>
327
+ )}
319
328
  </div>
320
329
  {currentCase && (
321
330
  <ImageUploadZone
@@ -397,14 +406,25 @@ return (
397
406
  )}
398
407
  </div>
399
408
  <div className={styles.sidebarToggle}>
400
- <button
409
+ {showExportConfirmationsButton ? (
410
+ <button
411
+ className={styles.confirmationExportButton}
412
+ onClick={() => void handleExportConfirmations()}
413
+ disabled={isUploading || !currentCase || isExportingConfirmations}
414
+ title={exportConfirmationsTitle}
415
+ >
416
+ {isExportingConfirmations ? 'Exporting...' : 'Export Confirmations'}
417
+ </button>
418
+ ) : (
419
+ <button
401
420
  onClick={onNotesClick}
402
421
  disabled={isImageNotesDisabled}
403
422
  title={imageNotesTitle}
404
423
  >
405
424
  Image Notes
406
425
  </button>
407
- </div>
426
+ )}
427
+ </div>
408
428
  </div>
409
429
  </>
410
430
  );