@striae-org/striae 3.3.0 → 4.0.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 (53) hide show
  1. package/.env.example +1 -1
  2. package/app/components/actions/case-export/core-export.ts +5 -2
  3. package/app/components/actions/case-import/confirmation-import.ts +24 -23
  4. package/app/components/actions/case-import/image-operations.ts +20 -49
  5. package/app/components/actions/case-import/orchestrator.ts +1 -1
  6. package/app/components/actions/case-import/storage-operations.ts +54 -89
  7. package/app/components/actions/case-import/validation.ts +2 -13
  8. package/app/components/actions/case-manage.ts +15 -27
  9. package/app/components/actions/generate-pdf.ts +3 -7
  10. package/app/components/actions/image-manage.ts +63 -129
  11. package/app/components/button/button.module.css +12 -8
  12. package/app/components/sidebar/case-export/case-export.tsx +11 -6
  13. package/app/components/sidebar/cases/case-sidebar.tsx +21 -6
  14. package/app/components/sidebar/sidebar.module.css +0 -2
  15. package/app/components/user/delete-account.tsx +7 -7
  16. package/app/config-example/config.json +2 -8
  17. package/app/hooks/useInactivityTimeout.ts +2 -5
  18. package/app/root.tsx +94 -63
  19. package/app/routes/auth/login.tsx +3 -5
  20. package/app/routes/auth/route.ts +4 -3
  21. package/app/routes/striae/striae.tsx +4 -8
  22. package/app/services/audit/audit-api-client.ts +40 -0
  23. package/app/services/audit/audit-worker-client.ts +14 -17
  24. package/app/styles/root.module.css +13 -101
  25. package/app/tailwind.css +9 -2
  26. package/app/utils/auth.ts +5 -32
  27. package/app/utils/data-api-client.ts +43 -0
  28. package/app/utils/data-operations.ts +59 -75
  29. package/app/utils/image-api-client.ts +130 -0
  30. package/app/utils/pdf-api-client.ts +43 -0
  31. package/app/utils/permissions.ts +10 -23
  32. package/app/utils/user-api-client.ts +90 -0
  33. package/functions/api/_shared/firebase-auth.ts +255 -0
  34. package/functions/api/audit/[[path]].ts +150 -0
  35. package/functions/api/data/[[path]].ts +141 -0
  36. package/functions/api/image/[[path]].ts +127 -0
  37. package/functions/api/pdf/[[path]].ts +110 -0
  38. package/functions/api/user/[[path]].ts +196 -0
  39. package/package.json +2 -1
  40. package/scripts/deploy-all.sh +22 -8
  41. package/scripts/deploy-config.sh +143 -148
  42. package/scripts/deploy-pages-secrets.sh +231 -0
  43. package/scripts/deploy-worker-secrets.sh +1 -1
  44. package/workers/audit-worker/wrangler.jsonc.example +1 -8
  45. package/workers/data-worker/wrangler.jsonc.example +1 -8
  46. package/workers/image-worker/wrangler.jsonc.example +1 -8
  47. package/workers/keys-worker/wrangler.jsonc.example +2 -9
  48. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  49. package/workers/pdf-worker/wrangler.jsonc.example +1 -8
  50. package/workers/user-worker/src/user-worker.example.ts +121 -41
  51. package/workers/user-worker/wrangler.jsonc.example +1 -8
  52. package/wrangler.toml.example +1 -1
  53. package/app/styles/legal-pages.module.css +0 -113
package/.env.example CHANGED
@@ -64,7 +64,7 @@ DATA_BUCKET_NAME=your_data_bucket_name_here
64
64
  DATA_WORKER_DOMAIN=your_data_worker_domain_here
65
65
  # Auto-generated by scripts/deploy-config.sh when placeholders are detected.
66
66
  MANIFEST_SIGNING_PRIVATE_KEY=your_manifest_signing_private_key_here
67
- MANIFEST_SIGNING_KEY_ID=forensic-signing-key-v1
67
+ MANIFEST_SIGNING_KEY_ID=your_manifest_signing_key_id_here
68
68
  MANIFEST_SIGNING_PUBLIC_KEY=your_manifest_signing_public_key_here
69
69
 
70
70
  # ================================
@@ -183,7 +183,8 @@ export async function exportAllCases(
183
183
  export async function exportCaseData(
184
184
  user: User,
185
185
  caseNumber: string,
186
- options: ExportOptions = {}
186
+ options: ExportOptions = {},
187
+ onProgress?: (current: number, total: number, label: string) => void
187
188
  ): Promise<CaseExportData> {
188
189
  // NOTE: startTime and fileName tracking moved to download handlers
189
190
 
@@ -225,7 +226,8 @@ export async function exportCaseData(
225
226
  let earliestAnnotationDate: string | undefined;
226
227
  let latestAnnotationDate: string | undefined;
227
228
 
228
- for (const file of files) {
229
+ for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
230
+ const file = files[fileIndex];
229
231
  let annotations: AnnotationData | undefined;
230
232
  let hasAnnotations = false;
231
233
 
@@ -287,6 +289,7 @@ export async function exportCaseData(
287
289
  annotations,
288
290
  hasAnnotations
289
291
  });
292
+ onProgress?.(fileIndex + 1, files.length, `Loading file ${fileIndex + 1} of ${files.length}`);
290
293
  }
291
294
 
292
295
  // Build export data
@@ -1,14 +1,11 @@
1
1
  import type { User } from 'firebase/auth';
2
- import paths from '~/config/config.json';
3
- import { getDataApiKey } from '~/utils/auth';
2
+ import { fetchDataApi } from '~/utils/data-api-client';
4
3
  import { type ConfirmationImportResult, type ConfirmationImportData } from '~/types';
5
4
  import { checkExistingCase } from '../case-manage';
6
5
  import { extractConfirmationImportPackage } from './confirmation-package';
7
6
  import { validateExporterUid, validateConfirmationHash, validateConfirmationSignatureFile } from './validation';
8
7
  import { auditService } from '~/services/audit';
9
8
 
10
- const DATA_WORKER_URL = paths.data_worker_url;
11
-
12
9
  interface CaseDataFile {
13
10
  id: string;
14
11
  originalFilename?: string;
@@ -113,13 +110,13 @@ export async function importConfirmationData(
113
110
  onProgress?.('Processing confirmations', 60, 'Validating timestamps and updating annotations...');
114
111
 
115
112
  // Get case data to find image IDs
116
- const apiKey = await getDataApiKey();
117
- const caseResponse = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/data.json`, {
118
- method: 'GET',
119
- headers: {
120
- 'X-Custom-Auth-Key': apiKey
113
+ const caseResponse = await fetchDataApi(
114
+ user,
115
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/data.json`,
116
+ {
117
+ method: 'GET'
121
118
  }
122
- });
119
+ );
123
120
 
124
121
  if (!caseResponse.ok) {
125
122
  throw new Error(`Failed to fetch case data: ${caseResponse.status}`);
@@ -159,12 +156,13 @@ export async function importConfirmationData(
159
156
  const displayFilename = currentFile?.originalFilename || currentImageId;
160
157
 
161
158
  // Get current annotation data for this image
162
- const annotationResponse = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/${encodeURIComponent(currentImageId)}/data.json`, {
163
- method: 'GET',
164
- headers: {
165
- 'X-Custom-Auth-Key': apiKey
159
+ const annotationResponse = await fetchDataApi(
160
+ user,
161
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/${encodeURIComponent(currentImageId)}/data.json`,
162
+ {
163
+ method: 'GET'
166
164
  }
167
- });
165
+ );
168
166
 
169
167
  let annotationData: AnnotationImportData = {};
170
168
  if (annotationResponse.ok) {
@@ -216,14 +214,17 @@ export async function importConfirmationData(
216
214
  };
217
215
 
218
216
  // Save updated annotation data
219
- const saveResponse = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/${encodeURIComponent(currentImageId)}/data.json`, {
220
- method: 'PUT',
221
- headers: {
222
- 'Content-Type': 'application/json',
223
- 'X-Custom-Auth-Key': apiKey
224
- },
225
- body: JSON.stringify(updatedAnnotationData)
226
- });
217
+ const saveResponse = await fetchDataApi(
218
+ user,
219
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/${encodeURIComponent(currentImageId)}/data.json`,
220
+ {
221
+ method: 'PUT',
222
+ headers: {
223
+ 'Content-Type': 'application/json'
224
+ },
225
+ body: JSON.stringify(updatedAnnotationData)
226
+ }
227
+ );
227
228
 
228
229
  if (saveResponse.ok) {
229
230
  result.imagesUpdated++;
@@ -1,61 +1,32 @@
1
- import paths from '~/config/config.json';
2
- import { getImageApiKey } from '~/utils/auth';
3
- import { type FileData, type ImageUploadResponse } from '~/types';
4
-
5
- const IMAGE_WORKER_URL = paths.image_worker_url;
1
+ import type { User } from 'firebase/auth';
2
+ import { uploadImageApi } from '~/utils/image-api-client';
3
+ import { type FileData } from '~/types';
6
4
 
7
5
  /**
8
6
  * Upload image blob to image worker and get file data
9
7
  */
10
8
  export async function uploadImageBlob(
9
+ user: User,
11
10
  imageBlob: Blob,
12
11
  originalFilename: string,
13
12
  onProgress?: (filename: string, progress: number) => void
14
13
  ): Promise<FileData> {
15
- const imagesApiToken = await getImageApiKey();
16
-
17
- return new Promise((resolve, reject) => {
18
- const xhr = new XMLHttpRequest();
19
- const formData = new FormData();
20
-
21
- // Create a File object from the blob to preserve the filename
22
- const file = new File([imageBlob], originalFilename, { type: imageBlob.type });
23
- formData.append('file', file);
24
-
25
- xhr.upload.addEventListener('progress', (event) => {
26
- if (event.lengthComputable && onProgress) {
27
- const progress = Math.round((event.loaded / event.total) * 100);
28
- onProgress(originalFilename, progress);
29
- }
30
- });
31
-
32
- xhr.addEventListener('load', async () => {
33
- if (xhr.status === 200) {
34
- try {
35
- const imageData = JSON.parse(xhr.responseText) as ImageUploadResponse;
36
- if (!imageData.success) {
37
- throw new Error(`Upload failed: ${imageData.errors?.join(', ') || 'Unknown error'}`);
38
- }
39
-
40
- const fileData: FileData = {
41
- id: imageData.result.id,
42
- originalFilename: originalFilename,
43
- uploadedAt: new Date().toISOString()
44
- };
45
-
46
- resolve(fileData);
47
- } catch (error) {
48
- reject(error);
49
- }
50
- } else {
51
- reject(new Error(`Upload failed with status ${xhr.status}`));
52
- }
53
- });
14
+ // Create a File object from the blob to preserve the filename
15
+ const file = new File([imageBlob], originalFilename, { type: imageBlob.type });
16
+ const imageData = await uploadImageApi(user, file, (progress) => {
17
+ if (onProgress) {
18
+ onProgress(originalFilename, progress);
19
+ }
20
+ });
54
21
 
55
- xhr.addEventListener('error', () => reject(new Error('Upload failed')));
22
+ const uploadedImageId = imageData.result?.id;
23
+ if (!uploadedImageId) {
24
+ throw new Error('Upload failed: missing image identifier');
25
+ }
56
26
 
57
- xhr.open('POST', IMAGE_WORKER_URL);
58
- xhr.setRequestHeader('Authorization', `Bearer ${imagesApiToken}`);
59
- xhr.send(formData);
60
- });
27
+ return {
28
+ id: uploadedImageId,
29
+ originalFilename,
30
+ uploadedAt: new Date().toISOString()
31
+ };
61
32
  }
@@ -283,7 +283,7 @@ export async function importCaseForReview(
283
283
  const originalFileEntry = caseData.files.find(f => f.fileData.id === originalImageId);
284
284
  const originalFilename = originalFileEntry?.fileData.originalFilename || exportFilename;
285
285
 
286
- const fileData = await uploadImageBlob(blob, originalFilename, (fname, progress) => {
286
+ const fileData = await uploadImageBlob(user, blob, originalFilename, (fname, progress) => {
287
287
  const overallProgress = 30 + (uploadedCount / totalImages) * 40 + (progress / totalImages) * 0.4;
288
288
  onProgress?.('Uploading images', overallProgress, `Uploading ${fname}...`);
289
289
  });
@@ -1,9 +1,5 @@
1
1
  import type { User } from 'firebase/auth';
2
- import paths from '~/config/config.json';
3
- import {
4
- getDataApiKey,
5
- getUserApiKey
6
- } from '~/utils/auth';
2
+ import { fetchDataApi } from '~/utils/data-api-client';
7
3
  import {
8
4
  getUserReadOnlyCases,
9
5
  updateUserData,
@@ -11,7 +7,6 @@ import {
11
7
  } from '~/utils/permissions';
12
8
  import {
13
9
  type CaseExportData,
14
- type ExtendedUserData,
15
10
  type FileData,
16
11
  type CaseData,
17
12
  type ReadOnlyCaseMetadata
@@ -19,9 +14,6 @@ import {
19
14
  import { deleteFile } from '../image-manage';
20
15
  import { type SignedForensicManifest } from '~/utils/SHA256';
21
16
 
22
- const USER_WORKER_URL = paths.user_worker_url;
23
- const DATA_WORKER_URL = paths.data_worker_url;
24
-
25
17
  /**
26
18
  * Check if user already has a read-only case with the same number
27
19
  */
@@ -91,8 +83,6 @@ export async function storeCaseDataInR2(
91
83
  forensicManifest?: SignedForensicManifest
92
84
  ): Promise<void> {
93
85
  try {
94
- const apiKey = await getDataApiKey();
95
-
96
86
  // Convert the mapping to a plain object for JSON serialization
97
87
  const originalImageIds = originalImageIdMapping ?
98
88
  Object.fromEntries(originalImageIdMapping) : undefined;
@@ -122,14 +112,17 @@ export async function storeCaseDataInR2(
122
112
  };
123
113
 
124
114
  // Store in R2
125
- const response = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`, {
126
- method: 'PUT',
127
- headers: {
128
- 'Content-Type': 'application/json',
129
- 'X-Custom-Auth-Key': apiKey
130
- },
131
- body: JSON.stringify(r2CaseData)
132
- });
115
+ const response = await fetchDataApi(
116
+ user,
117
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
118
+ {
119
+ method: 'PUT',
120
+ headers: {
121
+ 'Content-Type': 'application/json'
122
+ },
123
+ body: JSON.stringify(r2CaseData)
124
+ }
125
+ );
133
126
 
134
127
  if (!response.ok) {
135
128
  throw new Error(`Failed to store case data: ${response.status}`);
@@ -146,24 +139,7 @@ export async function storeCaseDataInR2(
146
139
  */
147
140
  export async function listReadOnlyCases(user: User): Promise<ReadOnlyCaseMetadata[]> {
148
141
  try {
149
- const apiKey = await getUserApiKey();
150
-
151
- const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
152
- method: 'GET',
153
- headers: {
154
- 'Content-Type': 'application/json',
155
- 'X-Custom-Auth-Key': apiKey
156
- }
157
- });
158
-
159
- if (!response.ok) {
160
- console.error('Failed to fetch user data:', response.status);
161
- return [];
162
- }
163
-
164
- const userData: ExtendedUserData = await response.json();
165
-
166
- return userData.readOnlyCases || [];
142
+ return await getUserReadOnlyCases(user);
167
143
 
168
144
  } catch (error) {
169
145
  console.error('Error listing read-only cases:', error);
@@ -176,48 +152,20 @@ export async function listReadOnlyCases(user: User): Promise<ReadOnlyCaseMetadat
176
152
  */
177
153
  export async function removeReadOnlyCase(user: User, caseNumber: string): Promise<boolean> {
178
154
  try {
179
- const apiKey = await getUserApiKey();
180
-
181
- // Get current user data
182
- const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
183
- method: 'GET',
184
- headers: {
185
- 'Content-Type': 'application/json',
186
- 'X-Custom-Auth-Key': apiKey
187
- }
188
- });
189
-
190
- if (!response.ok) {
191
- throw new Error(`Failed to fetch user data: ${response.status}`);
192
- }
193
-
194
- const userData: ExtendedUserData = await response.json();
195
-
196
- if (!userData.readOnlyCases) {
155
+ const currentReadOnlyCases = await getUserReadOnlyCases(user);
156
+ if (!currentReadOnlyCases.length) {
197
157
  return false; // Nothing to remove
198
158
  }
199
159
 
200
160
  // Remove the case from the list
201
- const initialLength = userData.readOnlyCases.length;
202
- userData.readOnlyCases = userData.readOnlyCases.filter(c => c.caseNumber !== caseNumber);
161
+ const nextReadOnlyCases = currentReadOnlyCases.filter(c => c.caseNumber !== caseNumber);
203
162
 
204
- if (userData.readOnlyCases.length === initialLength) {
163
+ if (nextReadOnlyCases.length === currentReadOnlyCases.length) {
205
164
  return false; // Case wasn't found
206
165
  }
207
166
 
208
167
  // Update user data
209
- const updateResponse = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
210
- method: 'PUT',
211
- headers: {
212
- 'Content-Type': 'application/json',
213
- 'X-Custom-Auth-Key': apiKey
214
- },
215
- body: JSON.stringify(userData)
216
- });
217
-
218
- if (!updateResponse.ok) {
219
- throw new Error(`Failed to update user data: ${updateResponse.status}`);
220
- }
168
+ await updateUserData(user, { readOnlyCases: nextReadOnlyCases });
221
169
 
222
170
  return true;
223
171
 
@@ -232,30 +180,47 @@ export async function removeReadOnlyCase(user: User, caseNumber: string): Promis
232
180
  */
233
181
  export async function deleteReadOnlyCase(user: User, caseNumber: string): Promise<boolean> {
234
182
  try {
235
- const dataApiKey = await getDataApiKey();
236
-
237
183
  // Get case data first to get file IDs for deletion
238
- const caseResponse = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`, {
239
- headers: { 'X-Custom-Auth-Key': dataApiKey }
240
- });
184
+ const caseResponse = await fetchDataApi(
185
+ user,
186
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
187
+ {
188
+ method: 'GET'
189
+ }
190
+ );
191
+
192
+ if (caseResponse.status === 404) {
193
+ // No backing data object exists; only remove the case reference from user metadata.
194
+ await removeReadOnlyCase(user, caseNumber);
195
+ return true;
196
+ }
241
197
 
242
- if (caseResponse.ok) {
243
- const caseData = await caseResponse.json() as CaseData;
198
+ if (!caseResponse.ok) {
199
+ throw new Error(`Failed to fetch read-only case data for deletion: ${caseResponse.status}`);
200
+ }
201
+
202
+ const caseData = await caseResponse.json() as CaseData;
203
+
204
+ // Delete all files using data worker
205
+ if (caseData.files && caseData.files.length > 0) {
206
+ await Promise.all(
207
+ caseData.files.map((file: FileData) =>
208
+ deleteFile(user, caseNumber, file.id, 'Read-only case clearing - API operation')
209
+ )
210
+ );
211
+ }
244
212
 
245
- // Delete all files using data worker
246
- if (caseData.files && caseData.files.length > 0) {
247
- await Promise.all(
248
- caseData.files.map((file: FileData) =>
249
- deleteFile(user, caseNumber, file.id, 'Read-only case clearing - API operation')
250
- )
251
- );
213
+ // Delete case file using data worker
214
+ const deleteCaseResponse = await fetchDataApi(
215
+ user,
216
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
217
+ {
218
+ method: 'DELETE'
252
219
  }
220
+ );
253
221
 
254
- // Delete case file using data worker
255
- await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`, {
256
- method: 'DELETE',
257
- headers: { 'X-Custom-Auth-Key': dataApiKey }
258
- });
222
+ if (!deleteCaseResponse.ok && deleteCaseResponse.status !== 404) {
223
+ throw new Error(`Failed to delete read-only case data: ${deleteCaseResponse.status}`);
259
224
  }
260
225
 
261
226
  // Remove from user's read-only case list (separate from regular cases)
@@ -1,27 +1,16 @@
1
1
  import type { User } from 'firebase/auth';
2
- import paths from '~/config/config.json';
3
- import { getUserApiKey } from '~/utils/auth';
2
+ import { checkUserExistsApi } from '~/utils/user-api-client';
4
3
  import { type CaseExportData, type ConfirmationImportData } from '~/types';
5
4
  import { type ManifestSignatureVerificationResult } from '~/utils/SHA256';
6
5
  import { verifyConfirmationSignature } from '~/utils/confirmation-signature';
7
6
  export { removeForensicWarning, validateConfirmationHash } from '~/utils/export-verification';
8
7
 
9
- const USER_WORKER_URL = paths.user_worker_url;
10
-
11
8
  /**
12
9
  * Validate that a user exists in the database by UID and is not the current user
13
10
  */
14
11
  export async function validateExporterUid(exporterUid: string, currentUser: User): Promise<{ exists: boolean; isSelf: boolean }> {
15
12
  try {
16
- const apiKey = await getUserApiKey();
17
- const response = await fetch(`${USER_WORKER_URL}/${exporterUid}`, {
18
- method: 'GET',
19
- headers: {
20
- 'X-Custom-Auth-Key': apiKey
21
- }
22
- });
23
-
24
- const exists = response.status === 200;
13
+ const exists = await checkUserExistsApi(currentUser, exporterUid);
25
14
  const isSelf = exporterUid === currentUser.uid;
26
15
 
27
16
  return { exists, isSelf };
@@ -15,8 +15,7 @@ import {
15
15
  } from '~/utils/data-operations';
16
16
  import { type CaseData, type ReadOnlyCaseData, type FileData } from '~/types';
17
17
  import { auditService } from '~/services/audit';
18
- import { getImageApiKey } from '~/utils/auth';
19
- import paths from '~/config/config.json';
18
+ import { fetchImageApi } from '~/utils/image-api-client';
20
19
 
21
20
  /**
22
21
  * Delete a file without individual audit logging (for bulk operations)
@@ -34,34 +33,17 @@ const deleteFileWithoutAudit = async (user: User, caseNumber: string, fileId: st
34
33
  throw new Error('File not found in case');
35
34
  }
36
35
 
37
- // Delete the image file from Cloudflare Images (but don't audit this individual operation)
38
- try {
39
- const IMAGE_URL = paths.image_worker_url;
40
-
41
- const imagesApiToken = await getImageApiKey();
42
- const imageResponse = await fetch(`${IMAGE_URL}/${fileId}`, {
43
- method: 'DELETE',
44
- headers: {
45
- 'Authorization': `Bearer ${imagesApiToken}`
46
- }
47
- });
36
+ // Delete image file and fail fast on non-404 failures so case deletion can be retried safely
37
+ const imageResponse = await fetchImageApi(user, `/${encodeURIComponent(fileId)}`, {
38
+ method: 'DELETE'
39
+ });
48
40
 
49
- // Only fail if it's not a 404 (file might already be deleted)
50
- if (!imageResponse.ok && imageResponse.status !== 404) {
51
- throw new Error(`Failed to delete image: ${imageResponse.statusText}`);
52
- }
53
- } catch (error) {
54
- console.warn(`Image deletion warning for ${fileToDelete.originalFilename}:`, error);
55
- // Continue with data cleanup even if image deletion fails
41
+ if (!imageResponse.ok && imageResponse.status !== 404) {
42
+ throw new Error(`Failed to delete image: ${imageResponse.status} ${imageResponse.statusText}`);
56
43
  }
57
44
 
58
- // Delete annotation data
59
- try {
60
- await deleteFileAnnotations(user, caseNumber, fileId);
61
- } catch (error) {
62
- // Annotation file might not exist, continue
63
- console.warn(`Annotation deletion warning for ${fileToDelete.originalFilename}:`, error);
64
- }
45
+ // Delete annotation data (404s are handled by deleteFileAnnotations)
46
+ await deleteFileAnnotations(user, caseNumber, fileId);
65
47
 
66
48
  // Update case data to remove file reference
67
49
  const updatedData: CaseData = {
@@ -466,6 +448,12 @@ export const deleteCase = async (user: User, caseNumber: string): Promise<void>
466
448
  } catch (auditError) {
467
449
  console.error('⚠️ Failed to log batch file deletion (continuing with case deletion):', auditError);
468
450
  }
451
+
452
+ if (failedFiles.length > 0) {
453
+ throw new Error(
454
+ `Case deletion aborted: failed to delete ${failedFiles.length} file(s): ${failedFiles.map(f => f.originalFilename).join(', ')}`
455
+ );
456
+ }
469
457
  }
470
458
 
471
459
  // Remove case from user data first (so user loses access immediately)
@@ -1,8 +1,7 @@
1
- import paths from '~/config/config.json';
2
1
  import { type AnnotationData } from '~/types/annotations';
3
2
  import { auditService } from '~/services/audit';
4
- import { getPdfApiKey } from '~/utils/auth';
5
3
  import type { User } from 'firebase/auth';
4
+ import { fetchPdfApi } from '~/utils/pdf-api-client';
6
5
 
7
6
  interface GeneratePDFParams {
8
7
  user: User;
@@ -75,13 +74,10 @@ export const generatePDF = async ({
75
74
  data: pdfData,
76
75
  };
77
76
 
78
- const pdfAuthKey = await getPdfApiKey();
79
-
80
- const response = await fetch(paths.pdf_worker_url, {
77
+ const response = await fetchPdfApi(user, '/', {
81
78
  method: 'POST',
82
79
  headers: {
83
- 'Content-Type': 'application/json',
84
- 'X-Custom-Auth-Key': pdfAuthKey,
80
+ 'Content-Type': 'application/json'
85
81
  },
86
82
  body: JSON.stringify(pdfRequest)
87
83
  });