@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.
- package/.env.example +100 -0
- package/LICENSE +190 -0
- package/NOTICE +18 -0
- package/README.md +133 -0
- package/app/components/actions/case-export/core-export.ts +328 -0
- package/app/components/actions/case-export/data-processing.ts +167 -0
- package/app/components/actions/case-export/download-handlers.ts +900 -0
- package/app/components/actions/case-export/index.ts +41 -0
- package/app/components/actions/case-export/metadata-helpers.ts +107 -0
- package/app/components/actions/case-export/types-constants.ts +56 -0
- package/app/components/actions/case-export/validation-utils.ts +25 -0
- package/app/components/actions/case-export.ts +4 -0
- package/app/components/actions/case-import/annotation-import.ts +35 -0
- package/app/components/actions/case-import/confirmation-import.ts +363 -0
- package/app/components/actions/case-import/image-operations.ts +61 -0
- package/app/components/actions/case-import/index.ts +39 -0
- package/app/components/actions/case-import/orchestrator.ts +420 -0
- package/app/components/actions/case-import/storage-operations.ts +270 -0
- package/app/components/actions/case-import/validation.ts +189 -0
- package/app/components/actions/case-import/zip-processing.ts +413 -0
- package/app/components/actions/case-manage.ts +524 -0
- package/app/components/actions/case-review.ts +4 -0
- package/app/components/actions/confirm-export.ts +351 -0
- package/app/components/actions/generate-pdf.ts +210 -0
- package/app/components/actions/image-manage.ts +385 -0
- package/app/components/actions/notes-manage.ts +33 -0
- package/app/components/actions/signout.module.css +15 -0
- package/app/components/actions/signout.tsx +50 -0
- package/app/components/audit/user-audit-viewer.tsx +975 -0
- package/app/components/audit/user-audit.module.css +568 -0
- package/app/components/auth/auth-provider.tsx +78 -0
- package/app/components/auth/mfa-enrollment.module.css +268 -0
- package/app/components/auth/mfa-enrollment.tsx +398 -0
- package/app/components/auth/mfa-verification.module.css +251 -0
- package/app/components/auth/mfa-verification.tsx +295 -0
- package/app/components/button/button.module.css +63 -0
- package/app/components/button/button.tsx +46 -0
- package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
- package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
- package/app/components/canvas/canvas.module.css +314 -0
- package/app/components/canvas/canvas.tsx +449 -0
- package/app/components/canvas/confirmation/confirmation.module.css +187 -0
- package/app/components/canvas/confirmation/confirmation.tsx +214 -0
- package/app/components/colors/colors.module.css +59 -0
- package/app/components/colors/colors.tsx +68 -0
- package/app/components/form/base-form.tsx +21 -0
- package/app/components/form/form-button.tsx +28 -0
- package/app/components/form/form-field.tsx +53 -0
- package/app/components/form/form-message.tsx +17 -0
- package/app/components/form/form-toggle.tsx +23 -0
- package/app/components/form/form.module.css +427 -0
- package/app/components/form/index.ts +6 -0
- package/app/components/icon/icon.module.css +3 -0
- package/app/components/icon/icon.tsx +27 -0
- package/app/components/icon/icons.svg +102 -0
- package/app/components/icon/manifest.json +110 -0
- package/app/components/sidebar/case-export/case-export.module.css +386 -0
- package/app/components/sidebar/case-export/case-export.tsx +317 -0
- package/app/components/sidebar/case-import/case-import.module.css +626 -0
- package/app/components/sidebar/case-import/case-import.tsx +404 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
- package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
- package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
- package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
- package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
- package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
- package/app/components/sidebar/case-import/index.ts +18 -0
- package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
- package/app/components/sidebar/cases/cases-modal.module.css +166 -0
- package/app/components/sidebar/cases/cases-modal.tsx +201 -0
- package/app/components/sidebar/cases/cases.module.css +713 -0
- package/app/components/sidebar/files/files-modal.module.css +209 -0
- package/app/components/sidebar/files/files-modal.tsx +239 -0
- package/app/components/sidebar/hash/hash-utility.module.css +366 -0
- package/app/components/sidebar/hash/hash-utility.tsx +982 -0
- package/app/components/sidebar/notes/notes-modal.tsx +51 -0
- package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
- package/app/components/sidebar/notes/notes.module.css +360 -0
- package/app/components/sidebar/sidebar-container.tsx +149 -0
- package/app/components/sidebar/sidebar.module.css +321 -0
- package/app/components/sidebar/sidebar.tsx +215 -0
- package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
- package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
- package/app/components/theme-provider/theme-provider.tsx +131 -0
- package/app/components/theme-provider/theme.ts +155 -0
- package/app/components/toast/toast.module.css +137 -0
- package/app/components/toast/toast.tsx +56 -0
- package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
- package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
- package/app/components/toolbar/toolbar.module.css +42 -0
- package/app/components/toolbar/toolbar.tsx +167 -0
- package/app/components/user/delete-account.module.css +274 -0
- package/app/components/user/delete-account.tsx +471 -0
- package/app/components/user/inactivity-warning.module.css +145 -0
- package/app/components/user/inactivity-warning.tsx +84 -0
- package/app/components/user/manage-profile.module.css +190 -0
- package/app/components/user/manage-profile.tsx +253 -0
- package/app/components/user/mfa-phone-update.tsx +739 -0
- package/app/config-example/admin-service.json +13 -0
- package/app/config-example/config.json +17 -0
- package/app/config-example/firebase.ts +21 -0
- package/app/config-example/inactivity.ts +13 -0
- package/app/config-example/meta-config.json +6 -0
- package/app/contexts/auth.context.ts +12 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +44 -0
- package/app/hooks/useInactivityTimeout.ts +110 -0
- package/app/root.tsx +170 -0
- package/app/routes/_index.tsx +16 -0
- package/app/routes/auth/emailActionHandler.module.css +232 -0
- package/app/routes/auth/emailActionHandler.tsx +405 -0
- package/app/routes/auth/emailVerification.tsx +120 -0
- package/app/routes/auth/login.module.css +523 -0
- package/app/routes/auth/login.tsx +654 -0
- package/app/routes/auth/passwordReset.module.css +274 -0
- package/app/routes/auth/passwordReset.tsx +154 -0
- package/app/routes/auth/route.ts +16 -0
- package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
- package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
- package/app/routes/mobile-prevented/route.ts +14 -0
- package/app/routes/striae/striae.module.css +30 -0
- package/app/routes/striae/striae.tsx +417 -0
- package/app/services/audit-export.service.ts +755 -0
- package/app/services/audit.service.ts +1454 -0
- package/app/services/firebase-errors.ts +106 -0
- package/app/services/firebase.ts +15 -0
- package/app/styles/legal-pages.module.css +113 -0
- package/app/styles/root.module.css +146 -0
- package/app/tailwind.css +225 -0
- package/app/types/annotations.ts +45 -0
- package/app/types/audit.ts +301 -0
- package/app/types/case.ts +90 -0
- package/app/types/export.ts +8 -0
- package/app/types/file.ts +30 -0
- package/app/types/import.ts +107 -0
- package/app/types/index.ts +24 -0
- package/app/types/user.ts +38 -0
- package/app/utils/SHA256.ts +461 -0
- package/app/utils/annotation-timestamp.ts +25 -0
- package/app/utils/audit-export-signature.ts +117 -0
- package/app/utils/auth-action-settings.ts +48 -0
- package/app/utils/auth.ts +34 -0
- package/app/utils/batch-operations.ts +135 -0
- package/app/utils/confirmation-signature.ts +193 -0
- package/app/utils/data-operations.ts +871 -0
- package/app/utils/device-detection.ts +5 -0
- package/app/utils/html-sanitizer.ts +80 -0
- package/app/utils/id-generator.ts +36 -0
- package/app/utils/meta.ts +48 -0
- package/app/utils/mfa-phone.ts +97 -0
- package/app/utils/mfa.ts +79 -0
- package/app/utils/password-policy.ts +28 -0
- package/app/utils/permissions.ts +562 -0
- package/app/utils/signature-utils.ts +160 -0
- package/app/utils/style.ts +83 -0
- package/app/utils/version.ts +5 -0
- package/firebase.json +11 -0
- package/functions/[[path]].ts +10 -0
- package/package.json +138 -0
- package/postcss.config.js +6 -0
- package/public/.well-known/publickey.info@striae.org.asc +17 -0
- package/public/.well-known/security.txt +7 -0
- package/public/_headers +28 -0
- package/public/_routes.json +13 -0
- package/public/assets/striae.jpg +0 -0
- package/public/clear.jpg +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/logo-dark.png +0 -0
- package/public/manifest.json +25 -0
- package/public/oin-badge.png +0 -0
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/striae-ascii.txt +10 -0
- package/scripts/deploy-all.sh +100 -0
- package/scripts/deploy-config.sh +940 -0
- package/scripts/deploy-pages.sh +34 -0
- package/scripts/deploy-worker-secrets.sh +215 -0
- package/scripts/dev.cjs +23 -0
- package/scripts/install-workers.sh +88 -0
- package/scripts/run-eslint.cjs +35 -0
- package/scripts/update-compatibility-dates.cjs +124 -0
- package/scripts/update-markdown-versions.cjs +43 -0
- package/tailwind.config.ts +22 -0
- package/tsconfig.json +33 -0
- package/vite.config.ts +35 -0
- package/worker-configuration.d.ts +7490 -0
- package/workers/audit-worker/package.json +17 -0
- package/workers/audit-worker/src/audit-worker.example.ts +195 -0
- package/workers/audit-worker/worker-configuration.d.ts +7448 -0
- package/workers/audit-worker/wrangler.jsonc.example +29 -0
- package/workers/data-worker/package.json +17 -0
- package/workers/data-worker/src/data-worker.example.ts +267 -0
- package/workers/data-worker/src/signature-utils.ts +79 -0
- package/workers/data-worker/src/signing-payload-utils.ts +290 -0
- package/workers/data-worker/worker-configuration.d.ts +7448 -0
- package/workers/data-worker/wrangler.jsonc.example +30 -0
- package/workers/image-worker/package.json +17 -0
- package/workers/image-worker/src/image-worker.example.ts +180 -0
- package/workers/image-worker/worker-configuration.d.ts +7447 -0
- package/workers/image-worker/wrangler.jsonc.example +22 -0
- package/workers/keys-worker/package.json +17 -0
- package/workers/keys-worker/src/keys.example.ts +66 -0
- package/workers/keys-worker/src/keys.ts +66 -0
- package/workers/keys-worker/worker-configuration.d.ts +7447 -0
- package/workers/keys-worker/wrangler.jsonc.example +22 -0
- package/workers/pdf-worker/package.json +17 -0
- package/workers/pdf-worker/src/format-striae.ts +534 -0
- package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
- package/workers/pdf-worker/src/report-types.ts +69 -0
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
- package/workers/pdf-worker/wrangler.jsonc.example +26 -0
- package/workers/user-worker/package.json +17 -0
- package/workers/user-worker/src/user-worker.example.ts +636 -0
- package/workers/user-worker/worker-configuration.d.ts +7448 -0
- package/workers/user-worker/wrangler.jsonc.example +29 -0
- 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
|
+
};
|