@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
@@ -0,0 +1,301 @@
1
+ import type { User } from 'firebase/auth';
2
+ import type { AnnotationData } from '~/types';
3
+
4
+ import { fetchDataApi } from '../../api';
5
+ import { canAccessCase, validateUserSession } from '../permissions';
6
+ import {
7
+ DEFAULT_CONFIRMATION_SUMMARY_MAX_AGE_MS,
8
+ buildConfirmationSummaryPath,
9
+ computeCaseConfirmationAggregate,
10
+ getIsoNow,
11
+ isStaleTimestamp,
12
+ normalizeConfirmationSummaryDocument,
13
+ toFileConfirmationSummary,
14
+ trackCaseHit,
15
+ trackCaseMiss,
16
+ trackEnsureCall,
17
+ trackForceRefreshCall,
18
+ trackMissingFileRefresh,
19
+ trackRefreshedFileEntry,
20
+ trackRemovedFileEntry,
21
+ trackStaleCaseRefresh,
22
+ trackStaleFileRefresh,
23
+ trackSummaryWrite,
24
+ type CaseConfirmationSummary,
25
+ type ConfirmationSummaryEnsureOptions,
26
+ type FileConfirmationSummary,
27
+ type UserConfirmationSummaryDocument
28
+ } from '../confirmation-summary/summary-core';
29
+
30
+ async function saveConfirmationSummaryDocument(
31
+ user: User,
32
+ summary: UserConfirmationSummaryDocument
33
+ ): Promise<void> {
34
+ const response = await fetchDataApi(user, buildConfirmationSummaryPath(user), {
35
+ method: 'PUT',
36
+ headers: {
37
+ 'Content-Type': 'application/json'
38
+ },
39
+ body: JSON.stringify(summary)
40
+ });
41
+
42
+ if (!response.ok) {
43
+ throw new Error(`Failed to save confirmation summary: ${response.status} ${response.statusText}`);
44
+ }
45
+ }
46
+
47
+ async function getFileAnnotationsForSummary(
48
+ user: User,
49
+ caseNumber: string,
50
+ fileId: string
51
+ ): Promise<AnnotationData | null> {
52
+ try {
53
+ const response = await fetchDataApi(
54
+ user,
55
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
56
+ {
57
+ method: 'GET'
58
+ }
59
+ );
60
+
61
+ if (response.status === 404) {
62
+ return null;
63
+ }
64
+
65
+ if (!response.ok) {
66
+ throw new Error(`Failed to fetch file annotations: ${response.status} ${response.statusText}`);
67
+ }
68
+
69
+ return await response.json() as AnnotationData;
70
+ } catch (error) {
71
+ console.error(`Error fetching annotations for ${caseNumber}/${fileId}:`, error);
72
+ return null;
73
+ }
74
+ }
75
+
76
+ export const getConfirmationSummaryDocument = async (
77
+ user: User
78
+ ): Promise<UserConfirmationSummaryDocument> => {
79
+ const sessionValidation = await validateUserSession(user);
80
+ if (!sessionValidation.valid) {
81
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
82
+ }
83
+
84
+ const response = await fetchDataApi(user, buildConfirmationSummaryPath(user), {
85
+ method: 'GET'
86
+ });
87
+
88
+ if (!response.ok) {
89
+ throw new Error(`Failed to fetch confirmation summary: ${response.status} ${response.statusText}`);
90
+ }
91
+
92
+ const payload = await response.json().catch(() => null) as unknown;
93
+ return normalizeConfirmationSummaryDocument(payload);
94
+ };
95
+
96
+ export const getCaseConfirmationSummary = async (
97
+ user: User,
98
+ caseNumber: string
99
+ ): Promise<CaseConfirmationSummary | null> => {
100
+ const summary = await getConfirmationSummaryDocument(user);
101
+ return summary.cases[caseNumber] ?? null;
102
+ };
103
+
104
+ export const ensureCaseConfirmationSummary = async (
105
+ user: User,
106
+ caseNumber: string,
107
+ files: Array<{ id: string }>,
108
+ options: ConfirmationSummaryEnsureOptions = {}
109
+ ): Promise<CaseConfirmationSummary> => {
110
+ trackEnsureCall();
111
+
112
+ const sessionValidation = await validateUserSession(user);
113
+ if (!sessionValidation.valid) {
114
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
115
+ }
116
+
117
+ const accessCheck = await canAccessCase(user, caseNumber);
118
+ if (!accessCheck.allowed) {
119
+ throw new Error(`Access denied: ${accessCheck.reason}`);
120
+ }
121
+
122
+ const summary = await getConfirmationSummaryDocument(user);
123
+ const existingCase = summary.cases[caseNumber];
124
+ const filesById: Record<string, FileConfirmationSummary> = existingCase ? { ...existingCase.filesById } : {};
125
+ const fileIds = new Set(files.map((file) => file.id));
126
+ const maxAgeMs =
127
+ typeof options.maxAgeMs === 'number' && Number.isFinite(options.maxAgeMs) && options.maxAgeMs > 0
128
+ ? options.maxAgeMs
129
+ : DEFAULT_CONFIRMATION_SUMMARY_MAX_AGE_MS;
130
+ const caseIsStale =
131
+ options.forceRefresh === true ||
132
+ !existingCase ||
133
+ isStaleTimestamp(existingCase.updatedAt, maxAgeMs);
134
+
135
+ if (!existingCase) {
136
+ trackCaseMiss();
137
+ } else {
138
+ trackCaseHit();
139
+ }
140
+
141
+ if (options.forceRefresh === true) {
142
+ trackForceRefreshCall();
143
+ }
144
+
145
+ if (caseIsStale) {
146
+ trackStaleCaseRefresh();
147
+ }
148
+
149
+ let changed = !existingCase;
150
+
151
+ for (const fileId of Object.keys(filesById)) {
152
+ if (!fileIds.has(fileId)) {
153
+ delete filesById[fileId];
154
+ trackRemovedFileEntry();
155
+ changed = true;
156
+ }
157
+ }
158
+
159
+ const filesToRefresh = files
160
+ .map((file) => {
161
+ const existingFileSummary = filesById[file.id];
162
+ if (!existingFileSummary) {
163
+ return {
164
+ fileId: file.id,
165
+ reason: 'missing' as const
166
+ };
167
+ }
168
+
169
+ if (caseIsStale) {
170
+ return {
171
+ fileId: file.id,
172
+ reason: 'stale' as const
173
+ };
174
+ }
175
+
176
+ if (isStaleTimestamp(existingFileSummary.updatedAt, maxAgeMs)) {
177
+ return {
178
+ fileId: file.id,
179
+ reason: 'stale' as const
180
+ };
181
+ }
182
+
183
+ return null;
184
+ })
185
+ .filter((entry): entry is { fileId: string; reason: 'missing' | 'stale' } => entry !== null);
186
+
187
+ for (const entry of filesToRefresh) {
188
+ if (entry.reason === 'missing') {
189
+ trackMissingFileRefresh();
190
+ } else {
191
+ trackStaleFileRefresh();
192
+ }
193
+ }
194
+
195
+ if (filesToRefresh.length > 0) {
196
+ const refreshedFiles = await Promise.all(
197
+ filesToRefresh.map(async (entry) => {
198
+ const annotations = await getFileAnnotationsForSummary(user, caseNumber, entry.fileId);
199
+ return {
200
+ fileId: entry.fileId,
201
+ summary: toFileConfirmationSummary(annotations)
202
+ };
203
+ })
204
+ );
205
+
206
+ for (const refreshedFile of refreshedFiles) {
207
+ filesById[refreshedFile.fileId] = refreshedFile.summary;
208
+ trackRefreshedFileEntry();
209
+ changed = true;
210
+ }
211
+ }
212
+
213
+ const aggregate = computeCaseConfirmationAggregate(filesById);
214
+ const updatedCaseSummary: CaseConfirmationSummary = {
215
+ includeConfirmation: aggregate.includeConfirmation,
216
+ isConfirmed: aggregate.isConfirmed,
217
+ updatedAt: getIsoNow(),
218
+ filesById
219
+ };
220
+
221
+ const aggregateChanged =
222
+ !existingCase ||
223
+ existingCase.includeConfirmation !== updatedCaseSummary.includeConfirmation ||
224
+ existingCase.isConfirmed !== updatedCaseSummary.isConfirmed;
225
+
226
+ if (changed || aggregateChanged || caseIsStale) {
227
+ summary.updatedAt = getIsoNow();
228
+ summary.cases[caseNumber] = updatedCaseSummary;
229
+ await saveConfirmationSummaryDocument(user, summary);
230
+ trackSummaryWrite();
231
+ return updatedCaseSummary;
232
+ }
233
+
234
+ return existingCase as CaseConfirmationSummary;
235
+ };
236
+
237
+ export const upsertFileConfirmationSummary = async (
238
+ user: User,
239
+ caseNumber: string,
240
+ fileId: string,
241
+ annotationData: AnnotationData | null
242
+ ): Promise<void> => {
243
+ const summary = await getConfirmationSummaryDocument(user);
244
+ const caseSummary = summary.cases[caseNumber] ?? {
245
+ includeConfirmation: false,
246
+ isConfirmed: false,
247
+ updatedAt: getIsoNow(),
248
+ filesById: {}
249
+ };
250
+
251
+ caseSummary.filesById[fileId] = toFileConfirmationSummary(annotationData);
252
+
253
+ const aggregate = computeCaseConfirmationAggregate(caseSummary.filesById);
254
+ caseSummary.includeConfirmation = aggregate.includeConfirmation;
255
+ caseSummary.isConfirmed = aggregate.isConfirmed;
256
+ caseSummary.updatedAt = getIsoNow();
257
+
258
+ summary.cases[caseNumber] = caseSummary;
259
+ summary.updatedAt = getIsoNow();
260
+
261
+ await saveConfirmationSummaryDocument(user, summary);
262
+ };
263
+
264
+ export const removeFileConfirmationSummary = async (
265
+ user: User,
266
+ caseNumber: string,
267
+ fileId: string
268
+ ): Promise<void> => {
269
+ const summary = await getConfirmationSummaryDocument(user);
270
+ const caseSummary = summary.cases[caseNumber];
271
+ if (!caseSummary || !caseSummary.filesById[fileId]) {
272
+ return;
273
+ }
274
+
275
+ delete caseSummary.filesById[fileId];
276
+
277
+ const aggregate = computeCaseConfirmationAggregate(caseSummary.filesById);
278
+ caseSummary.includeConfirmation = aggregate.includeConfirmation;
279
+ caseSummary.isConfirmed = aggregate.isConfirmed;
280
+ caseSummary.updatedAt = getIsoNow();
281
+
282
+ summary.cases[caseNumber] = caseSummary;
283
+ summary.updatedAt = getIsoNow();
284
+
285
+ await saveConfirmationSummaryDocument(user, summary);
286
+ };
287
+
288
+ export const removeCaseConfirmationSummary = async (
289
+ user: User,
290
+ caseNumber: string
291
+ ): Promise<void> => {
292
+ const summary = await getConfirmationSummaryDocument(user);
293
+ if (!summary.cases[caseNumber]) {
294
+ return;
295
+ }
296
+
297
+ delete summary.cases[caseNumber];
298
+ summary.updatedAt = getIsoNow();
299
+
300
+ await saveConfirmationSummaryDocument(user, summary);
301
+ };
@@ -0,0 +1,196 @@
1
+ import type { User } from 'firebase/auth';
2
+ import type { AnnotationData } from '~/types';
3
+
4
+ import { fetchDataApi } from '../../api';
5
+ import { canAccessCase, canModifyCase, validateUserSession } from '../permissions';
6
+ import { removeFileConfirmationSummary, upsertFileConfirmationSummary } from './confirmation-summary-operations';
7
+ import type { DataOperationOptions } from './types';
8
+
9
+ /**
10
+ * Get file annotation data from R2 storage.
11
+ */
12
+ export const getFileAnnotations = async (
13
+ user: User,
14
+ caseNumber: string,
15
+ fileId: string
16
+ ): Promise<AnnotationData | null> => {
17
+ try {
18
+ const sessionValidation = await validateUserSession(user);
19
+ if (!sessionValidation.valid) {
20
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
21
+ }
22
+
23
+ const accessCheck = await canAccessCase(user, caseNumber);
24
+ if (!accessCheck.allowed) {
25
+ throw new Error(`Access denied: ${accessCheck.reason}`);
26
+ }
27
+
28
+ if (!fileId || typeof fileId !== 'string') {
29
+ throw new Error('Invalid file ID provided');
30
+ }
31
+
32
+ const response = await fetchDataApi(
33
+ user,
34
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
35
+ {
36
+ method: 'GET'
37
+ }
38
+ );
39
+
40
+ if (response.status === 404) {
41
+ return null;
42
+ }
43
+
44
+ if (!response.ok) {
45
+ throw new Error(`Failed to fetch file annotations: ${response.status} ${response.statusText}`);
46
+ }
47
+
48
+ return await response.json() as AnnotationData;
49
+ } catch (error) {
50
+ console.error(`Error fetching annotations for ${caseNumber}/${fileId}:`, error);
51
+ return null;
52
+ }
53
+ };
54
+
55
+ /**
56
+ * Save file annotation data to R2 storage.
57
+ */
58
+ export const saveFileAnnotations = async (
59
+ user: User,
60
+ caseNumber: string,
61
+ fileId: string,
62
+ annotationData: AnnotationData,
63
+ options: DataOperationOptions = {}
64
+ ): Promise<void> => {
65
+ try {
66
+ const sessionValidation = await validateUserSession(user);
67
+ if (!sessionValidation.valid) {
68
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
69
+ }
70
+
71
+ if (options.skipValidation !== true) {
72
+ const modifyCheck = await canModifyCase(user, caseNumber);
73
+ if (!modifyCheck.allowed) {
74
+ throw new Error(`Modification denied: ${modifyCheck.reason}`);
75
+ }
76
+ }
77
+
78
+ if (!fileId || typeof fileId !== 'string') {
79
+ throw new Error('Invalid file ID provided');
80
+ }
81
+
82
+ if (!annotationData || typeof annotationData !== 'object') {
83
+ throw new Error('Invalid annotation data provided');
84
+ }
85
+
86
+ // Enforce immutability once confirmation data exists on an image.
87
+ const existingResponse = await fetchDataApi(
88
+ user,
89
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
90
+ {
91
+ method: 'GET'
92
+ }
93
+ );
94
+
95
+ if (existingResponse.ok) {
96
+ const existingAnnotations = await existingResponse.json() as AnnotationData;
97
+ if (existingAnnotations?.confirmationData) {
98
+ throw new Error('Cannot modify annotations for a confirmed image');
99
+ }
100
+ } else if (existingResponse.status !== 404) {
101
+ throw new Error(`Failed to verify existing annotations: ${existingResponse.status} ${existingResponse.statusText}`);
102
+ }
103
+
104
+ const dataToSave = {
105
+ ...annotationData,
106
+ updatedAt: new Date().toISOString()
107
+ };
108
+
109
+ const response = await fetchDataApi(
110
+ user,
111
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
112
+ {
113
+ method: 'PUT',
114
+ headers: {
115
+ 'Content-Type': 'application/json'
116
+ },
117
+ body: JSON.stringify(dataToSave)
118
+ }
119
+ );
120
+
121
+ if (!response.ok) {
122
+ throw new Error(`Failed to save file annotations: ${response.status} ${response.statusText}`);
123
+ }
124
+
125
+ try {
126
+ await upsertFileConfirmationSummary(user, caseNumber, fileId, dataToSave);
127
+ } catch (summaryError) {
128
+ console.warn(`Failed to update confirmation summary for ${caseNumber}/${fileId}:`, summaryError);
129
+ }
130
+ } catch (error) {
131
+ console.error(`Error saving annotations for ${caseNumber}/${fileId}:`, error);
132
+ throw error;
133
+ }
134
+ };
135
+
136
+ /**
137
+ * Delete file annotation data from R2 storage.
138
+ */
139
+ export const deleteFileAnnotations = async (
140
+ user: User,
141
+ caseNumber: string,
142
+ fileId: string,
143
+ options: { skipValidation?: boolean } = {}
144
+ ): Promise<void> => {
145
+ try {
146
+ const sessionValidation = await validateUserSession(user);
147
+ if (!sessionValidation.valid) {
148
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
149
+ }
150
+
151
+ if (options.skipValidation !== true) {
152
+ const modifyCheck = await canModifyCase(user, caseNumber);
153
+ if (!modifyCheck.allowed) {
154
+ throw new Error(`Delete denied: ${modifyCheck.reason}`);
155
+ }
156
+ }
157
+
158
+ const response = await fetchDataApi(
159
+ user,
160
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
161
+ {
162
+ method: 'DELETE'
163
+ }
164
+ );
165
+
166
+ if (!response.ok && response.status !== 404) {
167
+ throw new Error(`Failed to delete file annotations: ${response.status} ${response.statusText}`);
168
+ }
169
+
170
+ try {
171
+ await removeFileConfirmationSummary(user, caseNumber, fileId);
172
+ } catch (summaryError) {
173
+ console.warn(`Failed to update confirmation summary after delete for ${caseNumber}/${fileId}:`, summaryError);
174
+ }
175
+ } catch (error) {
176
+ console.error(`Error deleting annotations for ${caseNumber}/${fileId}:`, error);
177
+ throw error;
178
+ }
179
+ };
180
+
181
+ /**
182
+ * Check if a file has annotations.
183
+ */
184
+ export const fileHasAnnotations = async (
185
+ user: User,
186
+ caseNumber: string,
187
+ fileId: string
188
+ ): Promise<boolean> => {
189
+ try {
190
+ const annotations = await getFileAnnotations(user, caseNumber, fileId);
191
+ return annotations !== null;
192
+ } catch (error) {
193
+ console.error(`Error checking annotations for ${caseNumber}/${fileId}:`, error);
194
+ return false;
195
+ }
196
+ };
@@ -0,0 +1,7 @@
1
+ export * from './types';
2
+ export * from './confirmation-summary-operations';
3
+ export * from './case-operations';
4
+ export * from './file-annotation-operations';
5
+ export * from './batch-operations';
6
+ export * from './validation-operations';
7
+ export * from './signing-operations';