@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.
- package/LICENSE +1 -1
- package/app/components/actions/case-manage.ts +50 -17
- package/app/components/audit/viewer/audit-entries-list.tsx +5 -2
- package/app/components/audit/viewer/use-audit-viewer-data.ts +6 -3
- package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
- package/app/components/canvas/confirmation/confirmation.tsx +6 -2
- package/app/components/colors/colors.module.css +4 -3
- package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
- package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
- package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
- package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
- package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
- package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
- package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
- package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
- package/app/components/navbar/navbar.tsx +34 -9
- package/app/components/sidebar/cases/case-sidebar.tsx +93 -73
- package/app/components/sidebar/cases/cases-modal.module.css +312 -10
- package/app/components/sidebar/cases/cases-modal.tsx +737 -116
- package/app/components/sidebar/cases/cases.module.css +43 -0
- package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
- package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
- package/app/components/sidebar/files/files-modal.module.css +285 -44
- package/app/components/sidebar/files/files-modal.tsx +482 -177
- package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
- package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
- package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
- package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
- package/app/components/sidebar/notes/class-details-shared.ts +239 -0
- package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +77 -76
- package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
- package/app/components/sidebar/notes/notes.module.css +262 -14
- package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
- package/app/components/sidebar/sidebar-container.tsx +2 -0
- package/app/components/sidebar/sidebar.tsx +15 -1
- package/app/{tailwind.css → global.css} +1 -3
- package/app/hooks/useCaseListPreferences.ts +99 -0
- package/app/hooks/useFileListPreferences.ts +106 -0
- package/app/hooks/useOverlayDismiss.ts +6 -4
- package/app/root.tsx +1 -1
- package/app/routes/striae/striae.tsx +7 -0
- package/app/services/audit/audit.service.ts +2 -2
- package/app/services/audit/builders/audit-event-builders-case-file.ts +1 -1
- package/app/types/annotations.ts +48 -1
- package/app/types/audit.ts +1 -0
- package/app/utils/data/case-filters.ts +127 -0
- package/app/utils/data/confirmation-summary/summary-core.ts +295 -0
- package/app/utils/data/data-operations.ts +17 -861
- package/app/utils/data/file-filters.ts +201 -0
- package/app/utils/data/index.ts +11 -1
- package/app/utils/data/operations/batch-operations.ts +113 -0
- package/app/utils/data/operations/case-operations.ts +168 -0
- package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
- package/app/utils/data/operations/file-annotation-operations.ts +196 -0
- package/app/utils/data/operations/index.ts +7 -0
- package/app/utils/data/operations/signing-operations.ts +225 -0
- package/app/utils/data/operations/types.ts +42 -0
- package/app/utils/data/operations/validation-operations.ts +48 -0
- package/app/utils/forensics/export-verification.ts +40 -111
- package/functions/api/_shared/firebase-auth.ts +2 -7
- package/functions/api/image/[[path]].ts +23 -22
- package/functions/api/pdf/[[path]].ts +27 -8
- package/package.json +7 -13
- package/scripts/deploy-primershear-emails.sh +1 -1
- package/worker-configuration.d.ts +2 -2
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +1 -1
- package/workers/image-worker/src/image-worker.example.ts +16 -5
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/package.json +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/src/formats/format-striae.ts +84 -124
- package/workers/pdf-worker/src/pdf-worker.example.ts +58 -61
- package/workers/pdf-worker/src/report-layout.ts +227 -0
- package/workers/pdf-worker/src/report-types.ts +23 -3
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -1
- package/workers/user-worker/src/user-worker.example.ts +17 -0
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/NOTICE +0 -13
- package/app/components/sidebar/notes/notes-modal.tsx +0 -52
- package/postcss.config.js +0 -6
- package/tailwind.config.ts +0 -22
- package/workers/pdf-worker/src/assets/icon-256.png +0 -0
- /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';
|