@striae-org/striae 3.0.4

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 (223) hide show
  1. package/.env.example +100 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +18 -0
  4. package/README.md +133 -0
  5. package/app/components/actions/case-export/core-export.ts +328 -0
  6. package/app/components/actions/case-export/data-processing.ts +167 -0
  7. package/app/components/actions/case-export/download-handlers.ts +900 -0
  8. package/app/components/actions/case-export/index.ts +41 -0
  9. package/app/components/actions/case-export/metadata-helpers.ts +107 -0
  10. package/app/components/actions/case-export/types-constants.ts +56 -0
  11. package/app/components/actions/case-export/validation-utils.ts +25 -0
  12. package/app/components/actions/case-export.ts +4 -0
  13. package/app/components/actions/case-import/annotation-import.ts +35 -0
  14. package/app/components/actions/case-import/confirmation-import.ts +363 -0
  15. package/app/components/actions/case-import/image-operations.ts +61 -0
  16. package/app/components/actions/case-import/index.ts +39 -0
  17. package/app/components/actions/case-import/orchestrator.ts +420 -0
  18. package/app/components/actions/case-import/storage-operations.ts +270 -0
  19. package/app/components/actions/case-import/validation.ts +189 -0
  20. package/app/components/actions/case-import/zip-processing.ts +413 -0
  21. package/app/components/actions/case-manage.ts +524 -0
  22. package/app/components/actions/case-review.ts +4 -0
  23. package/app/components/actions/confirm-export.ts +351 -0
  24. package/app/components/actions/generate-pdf.ts +210 -0
  25. package/app/components/actions/image-manage.ts +385 -0
  26. package/app/components/actions/notes-manage.ts +33 -0
  27. package/app/components/actions/signout.module.css +15 -0
  28. package/app/components/actions/signout.tsx +50 -0
  29. package/app/components/audit/user-audit-viewer.tsx +975 -0
  30. package/app/components/audit/user-audit.module.css +568 -0
  31. package/app/components/auth/auth-provider.tsx +78 -0
  32. package/app/components/auth/mfa-enrollment.module.css +268 -0
  33. package/app/components/auth/mfa-enrollment.tsx +398 -0
  34. package/app/components/auth/mfa-verification.module.css +251 -0
  35. package/app/components/auth/mfa-verification.tsx +295 -0
  36. package/app/components/button/button.module.css +63 -0
  37. package/app/components/button/button.tsx +46 -0
  38. package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
  39. package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
  40. package/app/components/canvas/canvas.module.css +314 -0
  41. package/app/components/canvas/canvas.tsx +449 -0
  42. package/app/components/canvas/confirmation/confirmation.module.css +187 -0
  43. package/app/components/canvas/confirmation/confirmation.tsx +214 -0
  44. package/app/components/colors/colors.module.css +59 -0
  45. package/app/components/colors/colors.tsx +68 -0
  46. package/app/components/form/base-form.tsx +21 -0
  47. package/app/components/form/form-button.tsx +28 -0
  48. package/app/components/form/form-field.tsx +53 -0
  49. package/app/components/form/form-message.tsx +17 -0
  50. package/app/components/form/form-toggle.tsx +23 -0
  51. package/app/components/form/form.module.css +427 -0
  52. package/app/components/form/index.ts +6 -0
  53. package/app/components/icon/icon.module.css +3 -0
  54. package/app/components/icon/icon.tsx +27 -0
  55. package/app/components/icon/icons.svg +102 -0
  56. package/app/components/icon/manifest.json +110 -0
  57. package/app/components/sidebar/case-export/case-export.module.css +386 -0
  58. package/app/components/sidebar/case-export/case-export.tsx +317 -0
  59. package/app/components/sidebar/case-import/case-import.module.css +626 -0
  60. package/app/components/sidebar/case-import/case-import.tsx +404 -0
  61. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
  62. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
  63. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
  64. package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
  65. package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
  66. package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
  67. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
  68. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
  69. package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
  70. package/app/components/sidebar/case-import/index.ts +18 -0
  71. package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
  72. package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
  73. package/app/components/sidebar/cases/cases-modal.module.css +166 -0
  74. package/app/components/sidebar/cases/cases-modal.tsx +201 -0
  75. package/app/components/sidebar/cases/cases.module.css +713 -0
  76. package/app/components/sidebar/files/files-modal.module.css +209 -0
  77. package/app/components/sidebar/files/files-modal.tsx +239 -0
  78. package/app/components/sidebar/hash/hash-utility.module.css +366 -0
  79. package/app/components/sidebar/hash/hash-utility.tsx +982 -0
  80. package/app/components/sidebar/notes/notes-modal.tsx +51 -0
  81. package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
  82. package/app/components/sidebar/notes/notes.module.css +360 -0
  83. package/app/components/sidebar/sidebar-container.tsx +149 -0
  84. package/app/components/sidebar/sidebar.module.css +321 -0
  85. package/app/components/sidebar/sidebar.tsx +215 -0
  86. package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
  87. package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
  88. package/app/components/theme-provider/theme-provider.tsx +131 -0
  89. package/app/components/theme-provider/theme.ts +155 -0
  90. package/app/components/toast/toast.module.css +137 -0
  91. package/app/components/toast/toast.tsx +56 -0
  92. package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
  93. package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
  94. package/app/components/toolbar/toolbar.module.css +42 -0
  95. package/app/components/toolbar/toolbar.tsx +167 -0
  96. package/app/components/user/delete-account.module.css +274 -0
  97. package/app/components/user/delete-account.tsx +471 -0
  98. package/app/components/user/inactivity-warning.module.css +145 -0
  99. package/app/components/user/inactivity-warning.tsx +84 -0
  100. package/app/components/user/manage-profile.module.css +190 -0
  101. package/app/components/user/manage-profile.tsx +253 -0
  102. package/app/components/user/mfa-phone-update.tsx +739 -0
  103. package/app/config-example/admin-service.json +13 -0
  104. package/app/config-example/config.json +17 -0
  105. package/app/config-example/firebase.ts +21 -0
  106. package/app/config-example/inactivity.ts +13 -0
  107. package/app/config-example/meta-config.json +6 -0
  108. package/app/contexts/auth.context.ts +12 -0
  109. package/app/entry.client.tsx +12 -0
  110. package/app/entry.server.tsx +44 -0
  111. package/app/hooks/useInactivityTimeout.ts +110 -0
  112. package/app/root.tsx +170 -0
  113. package/app/routes/_index.tsx +16 -0
  114. package/app/routes/auth/emailActionHandler.module.css +232 -0
  115. package/app/routes/auth/emailActionHandler.tsx +405 -0
  116. package/app/routes/auth/emailVerification.tsx +120 -0
  117. package/app/routes/auth/login.module.css +523 -0
  118. package/app/routes/auth/login.tsx +654 -0
  119. package/app/routes/auth/passwordReset.module.css +274 -0
  120. package/app/routes/auth/passwordReset.tsx +154 -0
  121. package/app/routes/auth/route.ts +16 -0
  122. package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
  123. package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
  124. package/app/routes/mobile-prevented/route.ts +14 -0
  125. package/app/routes/striae/striae.module.css +30 -0
  126. package/app/routes/striae/striae.tsx +417 -0
  127. package/app/services/audit-export.service.ts +755 -0
  128. package/app/services/audit.service.ts +1454 -0
  129. package/app/services/firebase-errors.ts +106 -0
  130. package/app/services/firebase.ts +15 -0
  131. package/app/styles/legal-pages.module.css +113 -0
  132. package/app/styles/root.module.css +146 -0
  133. package/app/tailwind.css +225 -0
  134. package/app/types/annotations.ts +45 -0
  135. package/app/types/audit.ts +301 -0
  136. package/app/types/case.ts +90 -0
  137. package/app/types/export.ts +8 -0
  138. package/app/types/file.ts +30 -0
  139. package/app/types/import.ts +107 -0
  140. package/app/types/index.ts +24 -0
  141. package/app/types/user.ts +38 -0
  142. package/app/utils/SHA256.ts +461 -0
  143. package/app/utils/annotation-timestamp.ts +25 -0
  144. package/app/utils/audit-export-signature.ts +117 -0
  145. package/app/utils/auth-action-settings.ts +48 -0
  146. package/app/utils/auth.ts +34 -0
  147. package/app/utils/batch-operations.ts +135 -0
  148. package/app/utils/confirmation-signature.ts +193 -0
  149. package/app/utils/data-operations.ts +871 -0
  150. package/app/utils/device-detection.ts +5 -0
  151. package/app/utils/html-sanitizer.ts +80 -0
  152. package/app/utils/id-generator.ts +36 -0
  153. package/app/utils/meta.ts +48 -0
  154. package/app/utils/mfa-phone.ts +97 -0
  155. package/app/utils/mfa.ts +79 -0
  156. package/app/utils/password-policy.ts +28 -0
  157. package/app/utils/permissions.ts +562 -0
  158. package/app/utils/signature-utils.ts +160 -0
  159. package/app/utils/style.ts +83 -0
  160. package/app/utils/version.ts +5 -0
  161. package/firebase.json +11 -0
  162. package/functions/[[path]].ts +10 -0
  163. package/package.json +138 -0
  164. package/postcss.config.js +6 -0
  165. package/public/.well-known/publickey.info@striae.org.asc +17 -0
  166. package/public/.well-known/security.txt +7 -0
  167. package/public/_headers +28 -0
  168. package/public/_routes.json +13 -0
  169. package/public/assets/striae.jpg +0 -0
  170. package/public/clear.jpg +0 -0
  171. package/public/favicon.ico +0 -0
  172. package/public/favicon.svg +9 -0
  173. package/public/icon-256.png +0 -0
  174. package/public/icon-512.png +0 -0
  175. package/public/logo-dark.png +0 -0
  176. package/public/manifest.json +25 -0
  177. package/public/oin-badge.png +0 -0
  178. package/public/shortcut.png +0 -0
  179. package/public/social-image.png +0 -0
  180. package/public/striae-ascii.txt +10 -0
  181. package/scripts/deploy-all.sh +100 -0
  182. package/scripts/deploy-config.sh +940 -0
  183. package/scripts/deploy-pages.sh +34 -0
  184. package/scripts/deploy-worker-secrets.sh +215 -0
  185. package/scripts/dev.cjs +23 -0
  186. package/scripts/install-workers.sh +88 -0
  187. package/scripts/run-eslint.cjs +35 -0
  188. package/scripts/update-compatibility-dates.cjs +124 -0
  189. package/scripts/update-markdown-versions.cjs +43 -0
  190. package/tailwind.config.ts +22 -0
  191. package/tsconfig.json +33 -0
  192. package/vite.config.ts +35 -0
  193. package/worker-configuration.d.ts +7490 -0
  194. package/workers/audit-worker/package.json +17 -0
  195. package/workers/audit-worker/src/audit-worker.example.ts +195 -0
  196. package/workers/audit-worker/worker-configuration.d.ts +7448 -0
  197. package/workers/audit-worker/wrangler.jsonc.example +29 -0
  198. package/workers/data-worker/package.json +17 -0
  199. package/workers/data-worker/src/data-worker.example.ts +267 -0
  200. package/workers/data-worker/src/signature-utils.ts +79 -0
  201. package/workers/data-worker/src/signing-payload-utils.ts +290 -0
  202. package/workers/data-worker/worker-configuration.d.ts +7448 -0
  203. package/workers/data-worker/wrangler.jsonc.example +30 -0
  204. package/workers/image-worker/package.json +17 -0
  205. package/workers/image-worker/src/image-worker.example.ts +180 -0
  206. package/workers/image-worker/worker-configuration.d.ts +7447 -0
  207. package/workers/image-worker/wrangler.jsonc.example +22 -0
  208. package/workers/keys-worker/package.json +17 -0
  209. package/workers/keys-worker/src/keys.example.ts +66 -0
  210. package/workers/keys-worker/src/keys.ts +66 -0
  211. package/workers/keys-worker/worker-configuration.d.ts +7447 -0
  212. package/workers/keys-worker/wrangler.jsonc.example +22 -0
  213. package/workers/pdf-worker/package.json +17 -0
  214. package/workers/pdf-worker/src/format-striae.ts +534 -0
  215. package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
  216. package/workers/pdf-worker/src/report-types.ts +69 -0
  217. package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
  218. package/workers/pdf-worker/wrangler.jsonc.example +26 -0
  219. package/workers/user-worker/package.json +17 -0
  220. package/workers/user-worker/src/user-worker.example.ts +636 -0
  221. package/workers/user-worker/worker-configuration.d.ts +7448 -0
  222. package/workers/user-worker/wrangler.jsonc.example +29 -0
  223. package/wrangler.toml.example +8 -0
@@ -0,0 +1,351 @@
1
+ import { User } from 'firebase/auth';
2
+ import { calculateSHA256Secure } from '~/utils/SHA256';
3
+ import { getUserData } from '~/utils/permissions';
4
+ import { getCaseData, updateCaseData, signConfirmationData } from '~/utils/data-operations';
5
+ import { ConfirmationData, CaseConfirmations, CaseDataWithConfirmations, ConfirmationImportData } from '~/types';
6
+ import { auditService } from '~/services/audit.service';
7
+
8
+ /**
9
+ * Store a confirmation for a specific image, linked to the original image ID
10
+ */
11
+ export async function storeConfirmation(
12
+ user: User,
13
+ caseNumber: string,
14
+ currentImageId: string,
15
+ confirmationData: ConfirmationData,
16
+ originalImageFileName?: string
17
+ ): Promise<boolean> {
18
+ const startTime = Date.now();
19
+ let originalImageId: string | undefined; // Declare at function level for error handling
20
+
21
+ try {
22
+ // Start workflow for confirmation creation
23
+ auditService.startWorkflow(caseNumber);
24
+
25
+ // Get the current case data using centralized function
26
+ const caseData = await getCaseData(user, caseNumber) as CaseDataWithConfirmations;
27
+ if (!caseData) {
28
+ throw new Error('Case not found');
29
+ }
30
+
31
+ // Find the original image ID for the current image
32
+ if (caseData.originalImageIds) {
33
+ // Find the original ID by looking up the current image ID in the mapping
34
+ for (const [origId, currentId] of Object.entries(caseData.originalImageIds)) {
35
+ if (currentId === currentImageId) {
36
+ originalImageId = origId;
37
+ break;
38
+ }
39
+ }
40
+ }
41
+
42
+ if (!originalImageId) {
43
+ throw new Error('Could not find original image ID for current image');
44
+ }
45
+
46
+ // Initialize confirmations object if it doesn't exist
47
+ if (!caseData.confirmations) {
48
+ caseData.confirmations = {};
49
+ }
50
+
51
+ // Initialize array for this original image if it doesn't exist
52
+ if (!caseData.confirmations[originalImageId]) {
53
+ caseData.confirmations[originalImageId] = [];
54
+ }
55
+
56
+ // Add the confirmation data directly (already complete from modal)
57
+ caseData.confirmations[originalImageId].push(confirmationData);
58
+
59
+ // Store the updated case data using centralized function
60
+ await updateCaseData(user, caseNumber, caseData);
61
+
62
+ console.log(`Confirmation stored for original image ${originalImageId}:`, confirmationData);
63
+
64
+ // Log successful confirmation creation
65
+ const endTime = Date.now();
66
+ await auditService.logConfirmationCreation(
67
+ user,
68
+ caseNumber,
69
+ confirmationData.confirmationId,
70
+ 'success',
71
+ [],
72
+ undefined, // Original examiner UID not available in this context
73
+ {
74
+ processingTimeMs: endTime - startTime,
75
+ fileSizeBytes: 0 // Not applicable for confirmation creation
76
+ },
77
+ originalImageId,
78
+ originalImageFileName
79
+ );
80
+
81
+ auditService.endWorkflow();
82
+
83
+ return true;
84
+
85
+ } catch (error) {
86
+ console.error('Failed to store confirmation:', error);
87
+
88
+ // Log failed confirmation creation
89
+ const endTime = Date.now();
90
+ await auditService.logConfirmationCreation(
91
+ user,
92
+ caseNumber,
93
+ confirmationData?.confirmationId || 'unknown',
94
+ 'failure',
95
+ [error instanceof Error ? error.message : 'Unknown error'],
96
+ undefined,
97
+ {
98
+ processingTimeMs: endTime - startTime,
99
+ fileSizeBytes: 0
100
+ },
101
+ originalImageId || currentImageId, // Use originalImageId if available, fallback to currentImageId
102
+ originalImageFileName
103
+ );
104
+
105
+ auditService.endWorkflow();
106
+
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Get all confirmations for a case (useful for the original analyst)
113
+ */
114
+ export async function getCaseConfirmations(
115
+ user: User,
116
+ caseNumber: string
117
+ ): Promise<CaseConfirmations | null> {
118
+ try {
119
+ const caseData = await getCaseData(user, caseNumber) as CaseDataWithConfirmations;
120
+ if (!caseData) {
121
+ console.error('Case not found');
122
+ return null;
123
+ }
124
+
125
+ return caseData.confirmations || null;
126
+
127
+ } catch (error) {
128
+ console.error('Failed to get case confirmations:', error);
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Get case data with forensic manifest information if available
135
+ */
136
+ export async function getCaseDataWithManifest(
137
+ user: User,
138
+ caseNumber: string
139
+ ): Promise<{ confirmations: CaseConfirmations | null; forensicManifestCreatedAt?: string }> {
140
+ try {
141
+ const caseData = await getCaseData(user, caseNumber) as CaseDataWithConfirmations & { forensicManifestCreatedAt?: string };
142
+ if (!caseData) {
143
+ console.error('Case not found');
144
+ return { confirmations: null };
145
+ }
146
+
147
+ return {
148
+ confirmations: caseData.confirmations || null,
149
+ forensicManifestCreatedAt: caseData.forensicManifestCreatedAt
150
+ };
151
+
152
+ } catch (error) {
153
+ console.error('Failed to get case data with manifest:', error);
154
+ return { confirmations: null };
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Get confirmations for a specific original image ID
160
+ */
161
+ export async function getImageConfirmations(
162
+ user: User,
163
+ caseNumber: string,
164
+ originalImageId: string
165
+ ): Promise<ConfirmationData[]> {
166
+ try {
167
+ const confirmations = await getCaseConfirmations(user, caseNumber);
168
+ return confirmations?.[originalImageId] || [];
169
+ } catch (error) {
170
+ console.error('Failed to get image confirmations:', error);
171
+ return [];
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Exports confirmation data as a JSON file with SHA256 hash for forensic integrity
177
+ */
178
+ export async function exportConfirmationData(
179
+ user: User,
180
+ caseNumber: string
181
+ ): Promise<void> {
182
+ const startTime = Date.now();
183
+ let signatureKeyId: string | undefined;
184
+ let signaturePresent = false;
185
+ let signatureValid = false;
186
+
187
+ try {
188
+ // Start audit workflow
189
+ auditService.startWorkflow(caseNumber);
190
+
191
+ // Get all confirmation data and forensic manifest info for the case
192
+ const { confirmations: caseConfirmations, forensicManifestCreatedAt } = await getCaseDataWithManifest(user, caseNumber);
193
+
194
+ if (!caseConfirmations || Object.keys(caseConfirmations).length === 0) {
195
+ throw new Error('No confirmation data found for this case');
196
+ }
197
+
198
+ // Get user metadata for export (same as case exports)
199
+ let userMetadata = {
200
+ exportedBy: user.email || 'Unknown User',
201
+ exportedByUid: user.uid,
202
+ exportedByName: user.displayName || 'N/A',
203
+ exportedByCompany: 'N/A'
204
+ };
205
+
206
+ try {
207
+ const userData = await getUserData(user);
208
+ if (userData) {
209
+ userMetadata = {
210
+ exportedBy: user.email || 'Unknown User',
211
+ exportedByUid: userData.uid,
212
+ exportedByName: `${userData.firstName} ${userData.lastName}`.trim(),
213
+ exportedByCompany: userData.company
214
+ };
215
+ }
216
+ } catch (error) {
217
+ console.warn('Failed to fetch user data for confirmation export metadata:', error);
218
+ }
219
+
220
+ // Try to get the forensic manifest createdAt timestamp from the original case export
221
+ let originalExportCreatedAt: string | undefined = forensicManifestCreatedAt;
222
+
223
+ if (!originalExportCreatedAt) {
224
+ console.warn(`No forensic manifest timestamp found for case ${caseNumber}. This case may have been imported before forensic linking was implemented, or the original export did not include a forensic manifest.`);
225
+ }
226
+
227
+ // Create export data with metadata
228
+ const exportData = {
229
+ metadata: {
230
+ caseNumber,
231
+ exportDate: new Date().toISOString(),
232
+ ...userMetadata,
233
+ totalConfirmations: Object.keys(caseConfirmations).length,
234
+ version: '2.0',
235
+ ...(originalExportCreatedAt && { originalExportCreatedAt })
236
+ },
237
+ confirmations: caseConfirmations
238
+ };
239
+
240
+ // Convert to JSON string for hash calculation
241
+ const jsonString = JSON.stringify(exportData, null, 2);
242
+
243
+ // Calculate SHA-256 hash for data integrity using secure version for forensic data
244
+ const hash = await calculateSHA256Secure(jsonString);
245
+
246
+ // Add hash prior to signing
247
+ const unsignedExportData: ConfirmationImportData = {
248
+ ...exportData,
249
+ metadata: {
250
+ ...exportData.metadata,
251
+ hash: hash.toUpperCase()
252
+ }
253
+ };
254
+
255
+ // Request server-side signature to prevent tamper-by-rehash attacks
256
+ const signingResult = await signConfirmationData(user, caseNumber, unsignedExportData);
257
+ signaturePresent = true;
258
+ signatureValid = true;
259
+ signatureKeyId = signingResult.signature.keyId;
260
+
261
+ const finalExportData: ConfirmationImportData = {
262
+ ...unsignedExportData,
263
+ metadata: {
264
+ ...unsignedExportData.metadata,
265
+ signatureVersion: signingResult.signatureVersion,
266
+ signature: signingResult.signature
267
+ }
268
+ };
269
+
270
+ // Convert final data to JSON blob
271
+ const finalJsonString = JSON.stringify(finalExportData, null, 2);
272
+ const blob = new Blob([finalJsonString], { type: 'application/json' });
273
+
274
+ // Create download
275
+ const url = URL.createObjectURL(blob);
276
+ const a = document.createElement('a');
277
+ a.href = url;
278
+
279
+ // Use local timezone for filename timestamp
280
+ const now = new Date();
281
+ const year = now.getFullYear();
282
+ const month = String(now.getMonth() + 1).padStart(2, '0');
283
+ const day = String(now.getDate()).padStart(2, '0');
284
+ const hours = String(now.getHours()).padStart(2, '0');
285
+ const minutes = String(now.getMinutes()).padStart(2, '0');
286
+ const seconds = String(now.getSeconds()).padStart(2, '0');
287
+ const timestampString = `${year}${month}${day}-${hours}${minutes}${seconds}`;
288
+
289
+ a.download = `confirmation-data-${caseNumber}-${timestampString}.json`;
290
+ document.body.appendChild(a);
291
+ a.click();
292
+ document.body.removeChild(a);
293
+ URL.revokeObjectURL(url);
294
+
295
+ console.log(`Confirmation data exported for case ${caseNumber} with hash ${hash.toUpperCase()}`);
296
+
297
+ // Log successful confirmation export
298
+ const endTime = Date.now();
299
+ const confirmationCount = Object.keys(caseConfirmations).length;
300
+ await auditService.logConfirmationExport(
301
+ user,
302
+ caseNumber,
303
+ `confirmation-data-${caseNumber}-${timestampString}.json`,
304
+ confirmationCount,
305
+ 'success',
306
+ [],
307
+ undefined, // Original examiner UID not available here
308
+ {
309
+ processingTimeMs: endTime - startTime,
310
+ fileSizeBytes: new Blob([jsonString]).size,
311
+ validationStepsCompleted: confirmationCount,
312
+ validationStepsFailed: 0
313
+ },
314
+ {
315
+ present: signaturePresent,
316
+ valid: signatureValid,
317
+ keyId: signatureKeyId
318
+ }
319
+ );
320
+
321
+ auditService.endWorkflow();
322
+
323
+ } catch (error) {
324
+ console.error('Failed to export confirmation data:', error);
325
+
326
+ // Log failed confirmation export
327
+ const endTime = Date.now();
328
+ await auditService.logConfirmationExport(
329
+ user,
330
+ caseNumber,
331
+ `confirmation-data-${caseNumber}-error.json`,
332
+ 0,
333
+ 'failure',
334
+ [error instanceof Error ? error.message : 'Unknown error'],
335
+ undefined,
336
+ {
337
+ processingTimeMs: endTime - startTime,
338
+ fileSizeBytes: 0
339
+ },
340
+ {
341
+ present: signaturePresent,
342
+ valid: signatureValid,
343
+ keyId: signatureKeyId
344
+ }
345
+ );
346
+
347
+ auditService.endWorkflow();
348
+
349
+ throw error;
350
+ }
351
+ }
@@ -0,0 +1,210 @@
1
+ import paths from '~/config/config.json';
2
+ import { AnnotationData } from '~/types/annotations';
3
+ import { auditService } from '~/services/audit.service';
4
+ import { User } from 'firebase/auth';
5
+
6
+ interface GeneratePDFParams {
7
+ user: User;
8
+ selectedImage: string | undefined;
9
+ selectedFilename: string | undefined;
10
+ userCompany: string;
11
+ userFirstName: string;
12
+ currentCase: string;
13
+ annotationData: AnnotationData | null;
14
+ activeAnnotations: Set<string>;
15
+ setIsGeneratingPDF: (isGenerating: boolean) => void;
16
+ setToastType: (type: 'success' | 'error') => void;
17
+ setToastMessage: (message: string) => void;
18
+ setShowToast: (show: boolean) => void;
19
+ setToastDuration?: (duration: number) => void;
20
+ }
21
+
22
+ export const generatePDF = async ({
23
+ user,
24
+ selectedImage,
25
+ selectedFilename,
26
+ userCompany,
27
+ userFirstName,
28
+ currentCase,
29
+ annotationData,
30
+ activeAnnotations,
31
+ setIsGeneratingPDF,
32
+ setToastType,
33
+ setToastMessage,
34
+ setShowToast,
35
+ setToastDuration
36
+ }: GeneratePDFParams) => {
37
+ setIsGeneratingPDF(true);
38
+
39
+ // Track processing time for audit logging
40
+ const startTime = Date.now();
41
+
42
+ // Show generating toast immediately with duration 0 (stays until manually closed or completion)
43
+ setToastType('success');
44
+ setToastMessage('Generating PDF report... This may take up to a minute.');
45
+ if (setToastDuration) setToastDuration(0);
46
+ setShowToast(true);
47
+
48
+ try {
49
+ // Format current date in user's timezone
50
+ const now = new Date();
51
+ const currentDate = `${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getDate().toString().padStart(2, '0')}/${now.getFullYear()}`;
52
+
53
+ // Format notes updated date in user's timezone if it exists
54
+ let notesUpdatedFormatted = '';
55
+ if (annotationData?.updatedAt) {
56
+ const updatedDate = new Date(annotationData.updatedAt);
57
+ notesUpdatedFormatted = `${(updatedDate.getMonth() + 1).toString().padStart(2, '0')}/${updatedDate.getDate().toString().padStart(2, '0')}/${updatedDate.getFullYear()}`;
58
+ }
59
+
60
+ const pdfData = {
61
+ imageUrl: selectedImage,
62
+ filename: selectedFilename,
63
+ userCompany: userCompany,
64
+ firstName: userFirstName,
65
+ caseNumber: currentCase,
66
+ annotationData,
67
+ activeAnnotations: Array.from(activeAnnotations), // Convert Set to Array
68
+ currentDate, // Pass formatted current date
69
+ notesUpdatedFormatted // Pass formatted notes updated date
70
+ };
71
+
72
+ const pdfRequest = {
73
+ reportFormat: 'striae',
74
+ data: pdfData,
75
+ };
76
+
77
+ const response = await fetch(paths.pdf_worker_url, {
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/json',
81
+ },
82
+ body: JSON.stringify(pdfRequest)
83
+ });
84
+
85
+ if (response.ok) {
86
+ const blob = await response.blob();
87
+ const url = URL.createObjectURL(blob);
88
+ const a = document.createElement('a');
89
+ a.href = url;
90
+
91
+ // Generate filename based on annotation data
92
+ let filename = 'striae-report';
93
+
94
+ if (annotationData) {
95
+ const { leftCase, leftItem, rightCase, rightItem } = annotationData;
96
+
97
+ // Build left and right parts
98
+ const leftPart = [leftCase, leftItem].filter(Boolean).join('-');
99
+ const rightPart = [rightCase, rightItem].filter(Boolean).join('-');
100
+
101
+ if (leftPart && rightPart) {
102
+ filename = `striae-report-${leftPart}--${rightPart}`;
103
+ } else if (leftPart) {
104
+ filename = `striae-report-${leftPart}`;
105
+ } else if (rightPart) {
106
+ filename = `striae-report-${rightPart}`;
107
+ }
108
+ }
109
+
110
+ // Fallback to case number if no annotation data
111
+ if (filename === 'striae-report' && currentCase) {
112
+ filename = `striae-report-${currentCase}`;
113
+ }
114
+
115
+ // Final fallback to timestamp
116
+ if (filename === 'striae-report') {
117
+ filename = `striae-report-${Date.now()}`;
118
+ }
119
+
120
+ // Sanitize filename and ensure .pdf extension
121
+ filename = filename.replace(/[<>:"/\\|?*]/g, '-') + '.pdf';
122
+
123
+ a.download = filename;
124
+ document.body.appendChild(a);
125
+ a.click();
126
+ document.body.removeChild(a);
127
+ URL.revokeObjectURL(url);
128
+
129
+ // Log successful PDF generation audit
130
+ try {
131
+ const processingTime = Date.now() - startTime;
132
+ await auditService.logPDFGeneration(
133
+ user,
134
+ filename,
135
+ currentCase || 'unknown-case',
136
+ 'success',
137
+ processingTime,
138
+ blob.size,
139
+ [],
140
+ selectedImage, // Source file ID
141
+ selectedFilename // Source original filename
142
+ );
143
+ } catch (auditError) {
144
+ console.error('Failed to log PDF generation audit:', auditError);
145
+ // Continue with success flow even if audit logging fails
146
+ }
147
+
148
+ // Show success toast
149
+ setToastType('success');
150
+ setToastMessage('PDF generated successfully!');
151
+ if (setToastDuration) setToastDuration(4000); // Reset to default duration for success message
152
+ setShowToast(true);
153
+ } else {
154
+ const errorText = await response.text();
155
+ console.error('PDF generation failed:', errorText);
156
+
157
+ // Log failed PDF generation audit
158
+ try {
159
+ const processingTime = Date.now() - startTime;
160
+ await auditService.logPDFGeneration(
161
+ user,
162
+ `failed-pdf-${Date.now()}.pdf`,
163
+ currentCase || 'unknown-case',
164
+ 'failure',
165
+ processingTime,
166
+ 0, // No file size for failed generation
167
+ [errorText || 'PDF generation failed'],
168
+ selectedImage, // Source file ID
169
+ selectedFilename // Source original filename
170
+ );
171
+ } catch (auditError) {
172
+ console.error('Failed to log PDF generation failure audit:', auditError);
173
+ // Continue with error flow even if audit logging fails
174
+ }
175
+
176
+ setToastType('error');
177
+ setToastMessage('Failed to generate PDF report');
178
+ if (setToastDuration) setToastDuration(4000); // Reset to default duration for error message
179
+ setShowToast(true);
180
+ }
181
+ } catch (error) {
182
+ console.error('Error generating PDF:', error);
183
+
184
+ // Log error PDF generation audit
185
+ try {
186
+ const processingTime = Date.now() - startTime;
187
+ await auditService.logPDFGeneration(
188
+ user,
189
+ `error-pdf-${Date.now()}.pdf`,
190
+ currentCase || 'unknown-case',
191
+ 'failure',
192
+ processingTime,
193
+ 0, // No file size for failed generation
194
+ [error instanceof Error ? error.message : 'Unknown error generating PDF'],
195
+ selectedImage, // Source file ID
196
+ selectedFilename // Source original filename
197
+ );
198
+ } catch (auditError) {
199
+ console.error('Failed to log PDF generation error audit:', auditError);
200
+ // Continue with error flow even if audit logging fails
201
+ }
202
+
203
+ setToastType('error');
204
+ setToastMessage('Error generating PDF report');
205
+ if (setToastDuration) setToastDuration(4000); // Reset to default duration for error message
206
+ setShowToast(true);
207
+ } finally {
208
+ setIsGeneratingPDF(false);
209
+ }
210
+ };