@striae-org/striae 3.2.1 → 3.2.2
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/app/components/actions/case-export/core-export.ts +2 -2
- package/app/components/actions/case-export/data-processing.ts +19 -4
- package/app/components/actions/case-export/download-handlers.ts +6 -5
- package/app/components/actions/case-export/metadata-helpers.ts +1 -1
- package/app/components/actions/case-import/annotation-import.ts +2 -2
- package/app/components/actions/case-import/confirmation-import.ts +3 -3
- package/app/components/actions/case-import/image-operations.ts +1 -1
- package/app/components/actions/case-import/orchestrator.ts +4 -4
- package/app/components/actions/case-import/storage-operations.ts +7 -7
- package/app/components/actions/case-import/validation.ts +3 -3
- package/app/components/actions/case-import/zip-processing.ts +3 -3
- package/app/components/actions/case-manage.ts +3 -3
- package/app/components/actions/confirm-export.ts +3 -3
- package/app/components/actions/generate-pdf.ts +3 -3
- package/app/components/actions/image-manage.ts +3 -3
- package/app/components/actions/notes-manage.ts +3 -3
- package/app/components/actions/signout.tsx +1 -1
- package/app/components/audit/user-audit-viewer.tsx +2 -3
- package/app/components/auth/auth-provider.tsx +2 -2
- package/app/components/auth/mfa-enrollment.tsx +3 -3
- package/app/components/auth/mfa-verification.tsx +4 -4
- package/app/components/canvas/box-annotations/box-annotations.tsx +2 -2
- package/app/components/canvas/canvas.tsx +1 -1
- package/app/components/canvas/confirmation/confirmation.tsx +1 -1
- package/app/components/sidebar/case-import/case-import.tsx +2 -2
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +1 -1
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +1 -1
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +3 -3
- package/app/components/sidebar/case-import/hooks/useImportExecution.ts +2 -2
- package/app/components/sidebar/cases/case-sidebar.tsx +5 -4
- package/app/components/sidebar/cases/cases-modal.tsx +1 -1
- package/app/components/sidebar/files/files-modal.tsx +3 -2
- package/app/components/sidebar/notes/notes-sidebar.tsx +3 -3
- package/app/components/sidebar/sidebar-container.tsx +4 -3
- package/app/components/sidebar/sidebar.tsx +2 -2
- package/app/components/sidebar/upload/image-upload-zone.tsx +2 -2
- package/app/components/theme-provider/theme-provider.tsx +1 -1
- package/app/components/user/delete-account.tsx +1 -1
- package/app/components/user/manage-profile.tsx +2 -2
- package/app/components/user/mfa-phone-update.tsx +2 -2
- package/app/contexts/auth.context.ts +1 -1
- package/app/routes/auth/emailActionHandler.tsx +2 -2
- package/app/routes/auth/emailVerification.tsx +2 -2
- package/app/routes/auth/login.tsx +5 -5
- package/app/routes/auth/passwordReset.tsx +2 -2
- package/app/routes/striae/striae.tsx +2 -2
- package/app/services/audit/audit-console-logger.ts +46 -0
- package/app/services/audit/audit-export-csv.ts +126 -0
- package/app/services/audit/audit-export-report.ts +174 -0
- package/app/services/audit/audit-export-signing.ts +85 -0
- package/app/services/audit/audit-export.service.ts +334 -0
- package/app/services/audit/audit-file-type.ts +13 -0
- package/app/services/audit/audit-query-helpers.ts +88 -0
- package/app/services/audit/audit-worker-client.ts +95 -0
- package/app/services/audit/audit.service.ts +990 -0
- package/app/services/audit/builders/audit-entry-builder.ts +32 -0
- package/app/services/audit/builders/audit-event-builders-annotation.ts +150 -0
- package/app/services/audit/builders/audit-event-builders-case-file.ts +249 -0
- package/app/services/audit/builders/audit-event-builders-user-security.ts +449 -0
- package/app/services/audit/builders/audit-event-builders-workflow.ts +272 -0
- package/app/services/audit/builders/index.ts +40 -0
- package/app/services/audit/index.ts +2 -0
- package/app/types/case.ts +2 -2
- package/app/types/exceljs-bare.d.ts +3 -1
- package/app/types/user.ts +1 -1
- package/app/utils/audit-export-signature.ts +2 -2
- package/app/utils/confirmation-signature.ts +3 -3
- package/app/utils/data-operations.ts +5 -5
- package/app/utils/mfa-phone.ts +1 -1
- package/app/utils/mfa.ts +1 -1
- package/app/utils/permissions.ts +2 -2
- package/package.json +7 -8
- package/worker-configuration.d.ts +4435 -562
- package/workers/data-worker/src/data-worker.example.ts +3 -3
- package/workers/pdf-worker/src/{generated-assets.ts → assets/generated-assets.ts} +117 -117
- package/workers/pdf-worker/src/{format-striae.ts → formats/format-striae.ts} +535 -535
- package/workers/pdf-worker/src/pdf-worker.example.ts +1 -1
- package/app/services/audit-export.service.ts +0 -755
- package/app/services/audit.service.ts +0 -1474
- /package/app/services/{firebase-errors.ts → firebase/errors.ts} +0 -0
- /package/app/services/{firebase.ts → firebase/index.ts} +0 -0
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import type {
|
|
3
|
+
ValidationAuditEntry,
|
|
4
|
+
CreateAuditEntryParams,
|
|
5
|
+
AuditTrail,
|
|
6
|
+
AuditQueryParams,
|
|
7
|
+
WorkflowPhase,
|
|
8
|
+
AuditAction,
|
|
9
|
+
AuditResult,
|
|
10
|
+
PerformanceMetrics
|
|
11
|
+
} from '~/types';
|
|
12
|
+
import { generateWorkflowId } from '../../utils/id-generator';
|
|
13
|
+
import {
|
|
14
|
+
fetchAuditEntriesForUser,
|
|
15
|
+
persistAuditEntryForUser
|
|
16
|
+
} from './audit-worker-client';
|
|
17
|
+
import {
|
|
18
|
+
applyAuditEntryFilters,
|
|
19
|
+
applyAuditPagination,
|
|
20
|
+
generateAuditSummary,
|
|
21
|
+
sortAuditEntriesNewestFirst
|
|
22
|
+
} from './audit-query-helpers';
|
|
23
|
+
import { logAuditEntryToConsole } from './audit-console-logger';
|
|
24
|
+
import {
|
|
25
|
+
buildAccountDeletionAuditParams,
|
|
26
|
+
buildAnnotationCreateAuditParams,
|
|
27
|
+
buildAnnotationDeleteAuditParams,
|
|
28
|
+
buildAnnotationEditAuditParams,
|
|
29
|
+
buildCaseCreationAuditParams,
|
|
30
|
+
buildCaseDeletionAuditParams,
|
|
31
|
+
buildCaseExportAuditParams,
|
|
32
|
+
buildCaseImportAuditParams,
|
|
33
|
+
buildCaseRenameAuditParams,
|
|
34
|
+
buildConfirmationCreationAuditParams,
|
|
35
|
+
buildConfirmationExportAuditParams,
|
|
36
|
+
buildConfirmationImportAuditParams,
|
|
37
|
+
buildEmailVerificationAuditParams,
|
|
38
|
+
buildEmailVerificationByEmailAuditParams,
|
|
39
|
+
buildFileAccessAuditParams,
|
|
40
|
+
buildFileDeletionAuditParams,
|
|
41
|
+
buildFileUploadAuditParams,
|
|
42
|
+
buildMarkEmailVerificationSuccessfulAuditParams,
|
|
43
|
+
buildMfaAuthenticationAuditParams,
|
|
44
|
+
buildMfaEnrollmentAuditParams,
|
|
45
|
+
buildPDFGenerationAuditParams,
|
|
46
|
+
buildPasswordResetAuditParams,
|
|
47
|
+
buildSecurityViolationAuditParams,
|
|
48
|
+
buildUserLoginAuditParams,
|
|
49
|
+
buildUserLogoutAuditParams,
|
|
50
|
+
buildUserProfileUpdateAuditParams,
|
|
51
|
+
buildUserRegistrationAuditParams,
|
|
52
|
+
buildValidationAuditEntry
|
|
53
|
+
} from './builders/index';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Audit Service for ValidationAuditEntry system
|
|
57
|
+
* Provides comprehensive audit logging throughout the confirmation workflow
|
|
58
|
+
*/
|
|
59
|
+
export class AuditService {
|
|
60
|
+
private static instance: AuditService;
|
|
61
|
+
private auditBuffer: ValidationAuditEntry[] = [];
|
|
62
|
+
private workflowId: string | null = null;
|
|
63
|
+
|
|
64
|
+
private constructor() {}
|
|
65
|
+
|
|
66
|
+
public static getInstance(): AuditService {
|
|
67
|
+
if (!AuditService.instance) {
|
|
68
|
+
AuditService.instance = new AuditService();
|
|
69
|
+
}
|
|
70
|
+
return AuditService.instance;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Initialize a new workflow session with unique ID
|
|
75
|
+
*/
|
|
76
|
+
public startWorkflow(caseNumber: string): string {
|
|
77
|
+
const workflowId = generateWorkflowId(caseNumber);
|
|
78
|
+
this.workflowId = workflowId;
|
|
79
|
+
console.log(`🔍 Audit: Started workflow ${this.workflowId}`);
|
|
80
|
+
return workflowId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* End current workflow session
|
|
85
|
+
*/
|
|
86
|
+
public endWorkflow(): void {
|
|
87
|
+
if (this.workflowId) {
|
|
88
|
+
console.log(`🔍 Audit: Ended workflow ${this.workflowId}`);
|
|
89
|
+
this.workflowId = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create and log an audit entry
|
|
95
|
+
*/
|
|
96
|
+
public async logEvent(params: CreateAuditEntryParams): Promise<void> {
|
|
97
|
+
const startTime = Date.now();
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const auditEntry = buildValidationAuditEntry(params);
|
|
101
|
+
|
|
102
|
+
// Add to buffer for batch processing
|
|
103
|
+
this.auditBuffer.push(auditEntry);
|
|
104
|
+
|
|
105
|
+
// Log to console for immediate feedback
|
|
106
|
+
logAuditEntryToConsole(auditEntry);
|
|
107
|
+
|
|
108
|
+
// Persist to storage asynchronously
|
|
109
|
+
await this.persistAuditEntry(auditEntry);
|
|
110
|
+
|
|
111
|
+
const endTime = Date.now();
|
|
112
|
+
console.log(`🔍 Audit: Event logged in ${endTime - startTime}ms`);
|
|
113
|
+
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error('🚨 Audit: Failed to log event:', error);
|
|
116
|
+
// Don't throw - audit failures shouldn't break the main workflow
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Log case export event
|
|
122
|
+
*/
|
|
123
|
+
public async logCaseExport(
|
|
124
|
+
user: User,
|
|
125
|
+
caseNumber: string,
|
|
126
|
+
fileName: string,
|
|
127
|
+
result: AuditResult,
|
|
128
|
+
errors: string[] = [],
|
|
129
|
+
performanceMetrics?: PerformanceMetrics,
|
|
130
|
+
exportFormat?: 'json' | 'csv' | 'xlsx' | 'zip',
|
|
131
|
+
protectionEnabled?: boolean,
|
|
132
|
+
signatureDetails?: {
|
|
133
|
+
present?: boolean;
|
|
134
|
+
valid?: boolean;
|
|
135
|
+
keyId?: string;
|
|
136
|
+
}
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
await this.logEvent(
|
|
139
|
+
buildCaseExportAuditParams({
|
|
140
|
+
user,
|
|
141
|
+
caseNumber,
|
|
142
|
+
fileName,
|
|
143
|
+
result,
|
|
144
|
+
errors,
|
|
145
|
+
performanceMetrics,
|
|
146
|
+
exportFormat,
|
|
147
|
+
signatureDetails
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Log case import event
|
|
154
|
+
*/
|
|
155
|
+
public async logCaseImport(
|
|
156
|
+
user: User,
|
|
157
|
+
caseNumber: string,
|
|
158
|
+
fileName: string,
|
|
159
|
+
result: AuditResult,
|
|
160
|
+
hashValid: boolean,
|
|
161
|
+
errors: string[] = [],
|
|
162
|
+
originalExaminerUid?: string,
|
|
163
|
+
performanceMetrics?: PerformanceMetrics,
|
|
164
|
+
exporterUidValidated?: boolean, // Separate flag for validation status
|
|
165
|
+
signatureDetails?: {
|
|
166
|
+
present?: boolean;
|
|
167
|
+
valid?: boolean;
|
|
168
|
+
keyId?: string;
|
|
169
|
+
}
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
await this.logEvent(
|
|
172
|
+
buildCaseImportAuditParams({
|
|
173
|
+
user,
|
|
174
|
+
caseNumber,
|
|
175
|
+
fileName,
|
|
176
|
+
result,
|
|
177
|
+
hashValid,
|
|
178
|
+
errors,
|
|
179
|
+
originalExaminerUid,
|
|
180
|
+
performanceMetrics,
|
|
181
|
+
exporterUidValidated,
|
|
182
|
+
signatureDetails
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Log confirmation creation event
|
|
189
|
+
*/
|
|
190
|
+
public async logConfirmationCreation(
|
|
191
|
+
user: User,
|
|
192
|
+
caseNumber: string,
|
|
193
|
+
confirmationId: string,
|
|
194
|
+
result: AuditResult,
|
|
195
|
+
errors: string[] = [],
|
|
196
|
+
originalExaminerUid?: string,
|
|
197
|
+
performanceMetrics?: PerformanceMetrics,
|
|
198
|
+
imageFileId?: string,
|
|
199
|
+
originalImageFileName?: string
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
await this.logEvent(
|
|
202
|
+
buildConfirmationCreationAuditParams({
|
|
203
|
+
user,
|
|
204
|
+
caseNumber,
|
|
205
|
+
confirmationId,
|
|
206
|
+
result,
|
|
207
|
+
errors,
|
|
208
|
+
originalExaminerUid,
|
|
209
|
+
performanceMetrics,
|
|
210
|
+
imageFileId,
|
|
211
|
+
originalImageFileName
|
|
212
|
+
})
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Log confirmation export event
|
|
218
|
+
*/
|
|
219
|
+
public async logConfirmationExport(
|
|
220
|
+
user: User,
|
|
221
|
+
caseNumber: string,
|
|
222
|
+
fileName: string,
|
|
223
|
+
confirmationCount: number,
|
|
224
|
+
result: AuditResult,
|
|
225
|
+
errors: string[] = [],
|
|
226
|
+
originalExaminerUid?: string,
|
|
227
|
+
performanceMetrics?: PerformanceMetrics,
|
|
228
|
+
signatureDetails?: {
|
|
229
|
+
present: boolean;
|
|
230
|
+
valid: boolean;
|
|
231
|
+
keyId?: string;
|
|
232
|
+
}
|
|
233
|
+
): Promise<void> {
|
|
234
|
+
await this.logEvent(
|
|
235
|
+
buildConfirmationExportAuditParams({
|
|
236
|
+
user,
|
|
237
|
+
caseNumber,
|
|
238
|
+
fileName,
|
|
239
|
+
result,
|
|
240
|
+
errors,
|
|
241
|
+
originalExaminerUid,
|
|
242
|
+
performanceMetrics,
|
|
243
|
+
signatureDetails
|
|
244
|
+
})
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Log confirmation import event
|
|
250
|
+
*/
|
|
251
|
+
public async logConfirmationImport(
|
|
252
|
+
user: User,
|
|
253
|
+
caseNumber: string,
|
|
254
|
+
fileName: string,
|
|
255
|
+
result: AuditResult,
|
|
256
|
+
hashValid: boolean,
|
|
257
|
+
confirmationsImported: number,
|
|
258
|
+
errors: string[] = [],
|
|
259
|
+
reviewingExaminerUid?: string,
|
|
260
|
+
performanceMetrics?: PerformanceMetrics,
|
|
261
|
+
exporterUidValidated?: boolean, // Separate flag for validation status
|
|
262
|
+
totalConfirmationsInFile?: number, // Total confirmations in the import file
|
|
263
|
+
signatureDetails?: {
|
|
264
|
+
present: boolean;
|
|
265
|
+
valid: boolean;
|
|
266
|
+
keyId?: string;
|
|
267
|
+
}
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
await this.logEvent(
|
|
270
|
+
buildConfirmationImportAuditParams({
|
|
271
|
+
user,
|
|
272
|
+
caseNumber,
|
|
273
|
+
fileName,
|
|
274
|
+
result,
|
|
275
|
+
hashValid,
|
|
276
|
+
confirmationsImported,
|
|
277
|
+
errors,
|
|
278
|
+
reviewingExaminerUid,
|
|
279
|
+
performanceMetrics,
|
|
280
|
+
exporterUidValidated,
|
|
281
|
+
totalConfirmationsInFile,
|
|
282
|
+
signatureDetails
|
|
283
|
+
})
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// =============================================================================
|
|
288
|
+
// COMPREHENSIVE AUDIT LOGGING METHODS
|
|
289
|
+
// =============================================================================
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Log case creation event
|
|
293
|
+
*/
|
|
294
|
+
public async logCaseCreation(
|
|
295
|
+
user: User,
|
|
296
|
+
caseNumber: string,
|
|
297
|
+
caseName: string
|
|
298
|
+
): Promise<void> {
|
|
299
|
+
await this.logEvent(
|
|
300
|
+
buildCaseCreationAuditParams({
|
|
301
|
+
user,
|
|
302
|
+
caseNumber,
|
|
303
|
+
caseName
|
|
304
|
+
})
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Log case rename event
|
|
310
|
+
*/
|
|
311
|
+
public async logCaseRename(
|
|
312
|
+
user: User,
|
|
313
|
+
caseNumber: string,
|
|
314
|
+
oldName: string,
|
|
315
|
+
newName: string
|
|
316
|
+
): Promise<void> {
|
|
317
|
+
await this.logEvent(
|
|
318
|
+
buildCaseRenameAuditParams({
|
|
319
|
+
user,
|
|
320
|
+
caseNumber,
|
|
321
|
+
oldName,
|
|
322
|
+
newName
|
|
323
|
+
})
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Log case deletion event
|
|
329
|
+
*/
|
|
330
|
+
public async logCaseDeletion(
|
|
331
|
+
user: User,
|
|
332
|
+
caseNumber: string,
|
|
333
|
+
caseName: string,
|
|
334
|
+
deleteReason: string,
|
|
335
|
+
backupCreated: boolean = false
|
|
336
|
+
): Promise<void> {
|
|
337
|
+
await this.logEvent(
|
|
338
|
+
buildCaseDeletionAuditParams({
|
|
339
|
+
user,
|
|
340
|
+
caseNumber,
|
|
341
|
+
caseName,
|
|
342
|
+
deleteReason,
|
|
343
|
+
backupCreated
|
|
344
|
+
})
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Log file upload event
|
|
350
|
+
*/
|
|
351
|
+
public async logFileUpload(
|
|
352
|
+
user: User,
|
|
353
|
+
fileName: string,
|
|
354
|
+
fileSize: number,
|
|
355
|
+
mimeType: string,
|
|
356
|
+
uploadMethod: 'drag-drop' | 'file-picker' | 'api' | 'import',
|
|
357
|
+
caseNumber: string,
|
|
358
|
+
result: AuditResult = 'success',
|
|
359
|
+
processingTime?: number,
|
|
360
|
+
fileId?: string
|
|
361
|
+
): Promise<void> {
|
|
362
|
+
await this.logEvent(
|
|
363
|
+
buildFileUploadAuditParams({
|
|
364
|
+
user,
|
|
365
|
+
fileName,
|
|
366
|
+
fileSize,
|
|
367
|
+
mimeType,
|
|
368
|
+
uploadMethod,
|
|
369
|
+
caseNumber,
|
|
370
|
+
result,
|
|
371
|
+
processingTime,
|
|
372
|
+
fileId
|
|
373
|
+
})
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Log file deletion event
|
|
379
|
+
*/
|
|
380
|
+
public async logFileDeletion(
|
|
381
|
+
user: User,
|
|
382
|
+
fileName: string,
|
|
383
|
+
fileSize: number,
|
|
384
|
+
deleteReason: string,
|
|
385
|
+
caseNumber: string,
|
|
386
|
+
fileId?: string,
|
|
387
|
+
originalFileName?: string
|
|
388
|
+
): Promise<void> {
|
|
389
|
+
await this.logEvent(
|
|
390
|
+
buildFileDeletionAuditParams({
|
|
391
|
+
user,
|
|
392
|
+
fileName,
|
|
393
|
+
fileSize,
|
|
394
|
+
deleteReason,
|
|
395
|
+
caseNumber,
|
|
396
|
+
fileId,
|
|
397
|
+
originalFileName
|
|
398
|
+
})
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Log file access event (e.g., viewing an image)
|
|
404
|
+
*/
|
|
405
|
+
public async logFileAccess(
|
|
406
|
+
user: User,
|
|
407
|
+
fileName: string,
|
|
408
|
+
fileId: string,
|
|
409
|
+
accessMethod: 'direct-url' | 'signed-url' | 'download',
|
|
410
|
+
caseNumber: string,
|
|
411
|
+
result: AuditResult = 'success',
|
|
412
|
+
processingTime?: number,
|
|
413
|
+
accessReason?: string,
|
|
414
|
+
originalFileName?: string
|
|
415
|
+
): Promise<void> {
|
|
416
|
+
await this.logEvent(
|
|
417
|
+
buildFileAccessAuditParams({
|
|
418
|
+
user,
|
|
419
|
+
fileName,
|
|
420
|
+
fileId,
|
|
421
|
+
accessMethod,
|
|
422
|
+
caseNumber,
|
|
423
|
+
result,
|
|
424
|
+
processingTime,
|
|
425
|
+
accessReason,
|
|
426
|
+
originalFileName
|
|
427
|
+
})
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Log annotation creation event
|
|
433
|
+
*/
|
|
434
|
+
public async logAnnotationCreate(
|
|
435
|
+
user: User,
|
|
436
|
+
annotationId: string,
|
|
437
|
+
annotationType: 'measurement' | 'identification' | 'comparison' | 'note' | 'region',
|
|
438
|
+
annotationData: unknown,
|
|
439
|
+
caseNumber: string,
|
|
440
|
+
tool?: string,
|
|
441
|
+
imageFileId?: string,
|
|
442
|
+
originalImageFileName?: string
|
|
443
|
+
): Promise<void> {
|
|
444
|
+
await this.logEvent(
|
|
445
|
+
buildAnnotationCreateAuditParams({
|
|
446
|
+
user,
|
|
447
|
+
annotationId,
|
|
448
|
+
annotationType,
|
|
449
|
+
annotationData,
|
|
450
|
+
caseNumber,
|
|
451
|
+
tool,
|
|
452
|
+
imageFileId,
|
|
453
|
+
originalImageFileName
|
|
454
|
+
})
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Log annotation edit event
|
|
460
|
+
*/
|
|
461
|
+
public async logAnnotationEdit(
|
|
462
|
+
user: User,
|
|
463
|
+
annotationId: string,
|
|
464
|
+
previousValue: unknown,
|
|
465
|
+
newValue: unknown,
|
|
466
|
+
caseNumber: string,
|
|
467
|
+
tool?: string,
|
|
468
|
+
imageFileId?: string,
|
|
469
|
+
originalImageFileName?: string
|
|
470
|
+
): Promise<void> {
|
|
471
|
+
await this.logEvent(
|
|
472
|
+
buildAnnotationEditAuditParams({
|
|
473
|
+
user,
|
|
474
|
+
annotationId,
|
|
475
|
+
previousValue,
|
|
476
|
+
newValue,
|
|
477
|
+
caseNumber,
|
|
478
|
+
tool,
|
|
479
|
+
imageFileId,
|
|
480
|
+
originalImageFileName
|
|
481
|
+
})
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Log annotation deletion event
|
|
487
|
+
*/
|
|
488
|
+
public async logAnnotationDelete(
|
|
489
|
+
user: User,
|
|
490
|
+
annotationId: string,
|
|
491
|
+
annotationData: unknown,
|
|
492
|
+
caseNumber: string,
|
|
493
|
+
deleteReason?: string,
|
|
494
|
+
imageFileId?: string,
|
|
495
|
+
originalImageFileName?: string
|
|
496
|
+
): Promise<void> {
|
|
497
|
+
await this.logEvent(
|
|
498
|
+
buildAnnotationDeleteAuditParams({
|
|
499
|
+
user,
|
|
500
|
+
annotationId,
|
|
501
|
+
annotationData,
|
|
502
|
+
caseNumber,
|
|
503
|
+
deleteReason,
|
|
504
|
+
imageFileId,
|
|
505
|
+
originalImageFileName
|
|
506
|
+
})
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Log user login event
|
|
512
|
+
*/
|
|
513
|
+
public async logUserLogin(
|
|
514
|
+
user: User,
|
|
515
|
+
sessionId: string,
|
|
516
|
+
loginMethod: 'firebase' | 'sso' | 'api-key' | 'manual',
|
|
517
|
+
userAgent?: string
|
|
518
|
+
): Promise<void> {
|
|
519
|
+
await this.logEvent(
|
|
520
|
+
buildUserLoginAuditParams({
|
|
521
|
+
user,
|
|
522
|
+
sessionId,
|
|
523
|
+
loginMethod,
|
|
524
|
+
userAgent
|
|
525
|
+
})
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Log user logout event
|
|
531
|
+
*/
|
|
532
|
+
public async logUserLogout(
|
|
533
|
+
user: User,
|
|
534
|
+
sessionId: string,
|
|
535
|
+
sessionDuration: number,
|
|
536
|
+
logoutReason: 'user-initiated' | 'timeout' | 'security' | 'error'
|
|
537
|
+
): Promise<void> {
|
|
538
|
+
await this.logEvent(
|
|
539
|
+
buildUserLogoutAuditParams({
|
|
540
|
+
user,
|
|
541
|
+
sessionId,
|
|
542
|
+
sessionDuration,
|
|
543
|
+
logoutReason
|
|
544
|
+
})
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Log user profile update event
|
|
550
|
+
*/
|
|
551
|
+
public async logUserProfileUpdate(
|
|
552
|
+
user: User,
|
|
553
|
+
profileField: 'displayName' | 'email' | 'organization' | 'role' | 'preferences' | 'avatar',
|
|
554
|
+
oldValue: string,
|
|
555
|
+
newValue: string,
|
|
556
|
+
result: AuditResult,
|
|
557
|
+
sessionId?: string,
|
|
558
|
+
errors: string[] = []
|
|
559
|
+
): Promise<void> {
|
|
560
|
+
await this.logEvent(
|
|
561
|
+
buildUserProfileUpdateAuditParams({
|
|
562
|
+
user,
|
|
563
|
+
profileField,
|
|
564
|
+
oldValue,
|
|
565
|
+
newValue,
|
|
566
|
+
result,
|
|
567
|
+
sessionId,
|
|
568
|
+
errors
|
|
569
|
+
})
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Log password reset event
|
|
575
|
+
*/
|
|
576
|
+
public async logPasswordReset(
|
|
577
|
+
userEmail: string,
|
|
578
|
+
resetMethod: 'email' | 'sms' | 'security-questions' | 'admin-reset',
|
|
579
|
+
result: AuditResult,
|
|
580
|
+
resetToken?: string,
|
|
581
|
+
verificationMethod?: 'email-link' | 'sms-code' | 'totp' | 'backup-codes',
|
|
582
|
+
verificationAttempts?: number,
|
|
583
|
+
passwordComplexityMet?: boolean,
|
|
584
|
+
previousPasswordReused?: boolean,
|
|
585
|
+
sessionId?: string,
|
|
586
|
+
errors: string[] = []
|
|
587
|
+
): Promise<void> {
|
|
588
|
+
await this.logEvent(
|
|
589
|
+
buildPasswordResetAuditParams({
|
|
590
|
+
userEmail,
|
|
591
|
+
resetMethod,
|
|
592
|
+
result,
|
|
593
|
+
resetToken,
|
|
594
|
+
verificationMethod,
|
|
595
|
+
verificationAttempts,
|
|
596
|
+
passwordComplexityMet,
|
|
597
|
+
previousPasswordReused,
|
|
598
|
+
sessionId,
|
|
599
|
+
errors
|
|
600
|
+
})
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Log user account deletion event
|
|
606
|
+
*/
|
|
607
|
+
public async logAccountDeletion(
|
|
608
|
+
user: User,
|
|
609
|
+
result: AuditResult,
|
|
610
|
+
deletionReason: 'user-requested' | 'admin-initiated' | 'policy-violation' | 'inactive-account' = 'user-requested',
|
|
611
|
+
confirmationMethod: 'uid-email' | 'password' | 'admin-override' = 'uid-email',
|
|
612
|
+
casesCount?: number,
|
|
613
|
+
filesCount?: number,
|
|
614
|
+
dataRetentionPeriod?: number,
|
|
615
|
+
emailNotificationSent?: boolean,
|
|
616
|
+
sessionId?: string,
|
|
617
|
+
errors: string[] = []
|
|
618
|
+
): Promise<void> {
|
|
619
|
+
// Wrapper that extracts user data and calls the simplified version
|
|
620
|
+
return this.logAccountDeletionSimple(
|
|
621
|
+
user.uid,
|
|
622
|
+
user.email || '',
|
|
623
|
+
result,
|
|
624
|
+
deletionReason,
|
|
625
|
+
confirmationMethod,
|
|
626
|
+
casesCount,
|
|
627
|
+
filesCount,
|
|
628
|
+
dataRetentionPeriod,
|
|
629
|
+
emailNotificationSent,
|
|
630
|
+
sessionId,
|
|
631
|
+
errors
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Log user account deletion event with simplified user data
|
|
637
|
+
*/
|
|
638
|
+
public async logAccountDeletionSimple(
|
|
639
|
+
userId: string,
|
|
640
|
+
userEmail: string,
|
|
641
|
+
result: AuditResult,
|
|
642
|
+
deletionReason: 'user-requested' | 'admin-initiated' | 'policy-violation' | 'inactive-account' = 'user-requested',
|
|
643
|
+
confirmationMethod: 'uid-email' | 'password' | 'admin-override' = 'uid-email',
|
|
644
|
+
casesCount?: number,
|
|
645
|
+
filesCount?: number,
|
|
646
|
+
dataRetentionPeriod?: number,
|
|
647
|
+
emailNotificationSent?: boolean,
|
|
648
|
+
sessionId?: string,
|
|
649
|
+
errors: string[] = []
|
|
650
|
+
): Promise<void> {
|
|
651
|
+
await this.logEvent(
|
|
652
|
+
buildAccountDeletionAuditParams({
|
|
653
|
+
userId,
|
|
654
|
+
userEmail,
|
|
655
|
+
result,
|
|
656
|
+
deletionReason,
|
|
657
|
+
confirmationMethod,
|
|
658
|
+
casesCount,
|
|
659
|
+
filesCount,
|
|
660
|
+
dataRetentionPeriod,
|
|
661
|
+
emailNotificationSent,
|
|
662
|
+
sessionId,
|
|
663
|
+
errors
|
|
664
|
+
})
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Log user registration/creation event
|
|
670
|
+
*/
|
|
671
|
+
public async logUserRegistration(
|
|
672
|
+
user: User,
|
|
673
|
+
firstName: string,
|
|
674
|
+
lastName: string,
|
|
675
|
+
company: string,
|
|
676
|
+
registrationMethod: 'email-password' | 'sso' | 'admin-created' | 'api',
|
|
677
|
+
userAgent?: string,
|
|
678
|
+
sessionId?: string
|
|
679
|
+
): Promise<void> {
|
|
680
|
+
await this.logEvent(
|
|
681
|
+
buildUserRegistrationAuditParams({
|
|
682
|
+
user,
|
|
683
|
+
firstName,
|
|
684
|
+
lastName,
|
|
685
|
+
company,
|
|
686
|
+
registrationMethod,
|
|
687
|
+
userAgent,
|
|
688
|
+
sessionId
|
|
689
|
+
})
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Log successful MFA enrollment event
|
|
695
|
+
*/
|
|
696
|
+
public async logMfaEnrollment(
|
|
697
|
+
user: User,
|
|
698
|
+
phoneNumber: string,
|
|
699
|
+
mfaMethod: 'sms' | 'totp' | 'hardware-key',
|
|
700
|
+
result: AuditResult,
|
|
701
|
+
enrollmentAttempts?: number,
|
|
702
|
+
sessionId?: string,
|
|
703
|
+
userAgent?: string,
|
|
704
|
+
errors: string[] = []
|
|
705
|
+
): Promise<void> {
|
|
706
|
+
await this.logEvent(
|
|
707
|
+
buildMfaEnrollmentAuditParams({
|
|
708
|
+
user,
|
|
709
|
+
phoneNumber,
|
|
710
|
+
mfaMethod,
|
|
711
|
+
result,
|
|
712
|
+
enrollmentAttempts,
|
|
713
|
+
sessionId,
|
|
714
|
+
userAgent,
|
|
715
|
+
errors
|
|
716
|
+
})
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Log MFA authentication/verification event
|
|
722
|
+
*/
|
|
723
|
+
public async logMfaAuthentication(
|
|
724
|
+
user: User,
|
|
725
|
+
mfaMethod: 'sms' | 'totp' | 'hardware-key',
|
|
726
|
+
result: AuditResult,
|
|
727
|
+
verificationAttempts?: number,
|
|
728
|
+
sessionId?: string,
|
|
729
|
+
userAgent?: string,
|
|
730
|
+
errors: string[] = []
|
|
731
|
+
): Promise<void> {
|
|
732
|
+
await this.logEvent(
|
|
733
|
+
buildMfaAuthenticationAuditParams({
|
|
734
|
+
user,
|
|
735
|
+
mfaMethod,
|
|
736
|
+
result,
|
|
737
|
+
verificationAttempts,
|
|
738
|
+
sessionId,
|
|
739
|
+
userAgent,
|
|
740
|
+
errors
|
|
741
|
+
})
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Log email verification event
|
|
747
|
+
*/
|
|
748
|
+
public async logEmailVerification(
|
|
749
|
+
user: User,
|
|
750
|
+
result: AuditResult,
|
|
751
|
+
verificationMethod: 'email-link' | 'admin-verification',
|
|
752
|
+
verificationAttempts?: number,
|
|
753
|
+
sessionId?: string,
|
|
754
|
+
userAgent?: string,
|
|
755
|
+
errors: string[] = []
|
|
756
|
+
): Promise<void> {
|
|
757
|
+
await this.logEvent(
|
|
758
|
+
buildEmailVerificationAuditParams({
|
|
759
|
+
user,
|
|
760
|
+
result,
|
|
761
|
+
verificationMethod,
|
|
762
|
+
verificationAttempts,
|
|
763
|
+
sessionId,
|
|
764
|
+
userAgent,
|
|
765
|
+
errors
|
|
766
|
+
})
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Log email verification event when no authenticated User object is available.
|
|
772
|
+
*/
|
|
773
|
+
public async logEmailVerificationByEmail(
|
|
774
|
+
userEmail: string,
|
|
775
|
+
result: AuditResult,
|
|
776
|
+
verificationMethod: 'email-link' | 'admin-verification',
|
|
777
|
+
verificationAttempts?: number,
|
|
778
|
+
sessionId?: string,
|
|
779
|
+
userAgent?: string,
|
|
780
|
+
errors: string[] = [],
|
|
781
|
+
userId: string = ''
|
|
782
|
+
): Promise<void> {
|
|
783
|
+
await this.logEvent(
|
|
784
|
+
buildEmailVerificationByEmailAuditParams({
|
|
785
|
+
userEmail,
|
|
786
|
+
result,
|
|
787
|
+
verificationMethod,
|
|
788
|
+
verificationAttempts,
|
|
789
|
+
sessionId,
|
|
790
|
+
userAgent,
|
|
791
|
+
errors,
|
|
792
|
+
userId
|
|
793
|
+
})
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Mark pending email verification as successful (retroactive)
|
|
799
|
+
* Called when user completes MFA enrollment, which implies email verification was successful
|
|
800
|
+
*/
|
|
801
|
+
public async markEmailVerificationSuccessful(
|
|
802
|
+
user: User,
|
|
803
|
+
reason: string = 'MFA enrollment completed',
|
|
804
|
+
sessionId?: string,
|
|
805
|
+
userAgent?: string
|
|
806
|
+
): Promise<void> {
|
|
807
|
+
await this.logEvent(
|
|
808
|
+
buildMarkEmailVerificationSuccessfulAuditParams({
|
|
809
|
+
user,
|
|
810
|
+
reason,
|
|
811
|
+
sessionId,
|
|
812
|
+
userAgent
|
|
813
|
+
})
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Log PDF generation event
|
|
819
|
+
*/
|
|
820
|
+
public async logPDFGeneration(
|
|
821
|
+
user: User,
|
|
822
|
+
fileName: string,
|
|
823
|
+
caseNumber: string,
|
|
824
|
+
result: AuditResult,
|
|
825
|
+
processingTime: number,
|
|
826
|
+
fileSize?: number,
|
|
827
|
+
errors: string[] = [],
|
|
828
|
+
sourceFileId?: string,
|
|
829
|
+
sourceFileName?: string
|
|
830
|
+
): Promise<void> {
|
|
831
|
+
await this.logEvent(
|
|
832
|
+
buildPDFGenerationAuditParams({
|
|
833
|
+
user,
|
|
834
|
+
fileName,
|
|
835
|
+
caseNumber,
|
|
836
|
+
result,
|
|
837
|
+
processingTime,
|
|
838
|
+
fileSize,
|
|
839
|
+
errors,
|
|
840
|
+
sourceFileId,
|
|
841
|
+
sourceFileName
|
|
842
|
+
})
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Log security violation event
|
|
848
|
+
*/
|
|
849
|
+
public async logSecurityViolation(
|
|
850
|
+
user: User | null,
|
|
851
|
+
incidentType: 'unauthorized-access' | 'data-breach' | 'malware' | 'injection' | 'brute-force' | 'privilege-escalation',
|
|
852
|
+
severity: 'low' | 'medium' | 'high' | 'critical',
|
|
853
|
+
description: string,
|
|
854
|
+
targetResource?: string,
|
|
855
|
+
blockedBySystem: boolean = true
|
|
856
|
+
): Promise<void> {
|
|
857
|
+
await this.logEvent(
|
|
858
|
+
buildSecurityViolationAuditParams({
|
|
859
|
+
user,
|
|
860
|
+
incidentType,
|
|
861
|
+
severity,
|
|
862
|
+
description,
|
|
863
|
+
targetResource,
|
|
864
|
+
blockedBySystem
|
|
865
|
+
})
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// =============================================================================
|
|
870
|
+
// HELPER METHODS
|
|
871
|
+
// =============================================================================
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Get audit entries for display (public method for components)
|
|
875
|
+
*/
|
|
876
|
+
public async getAuditEntriesForUser(userId: string, params?: {
|
|
877
|
+
startDate?: string;
|
|
878
|
+
endDate?: string;
|
|
879
|
+
caseNumber?: string;
|
|
880
|
+
action?: AuditAction;
|
|
881
|
+
result?: AuditResult;
|
|
882
|
+
workflowPhase?: WorkflowPhase;
|
|
883
|
+
offset?: number;
|
|
884
|
+
limit?: number;
|
|
885
|
+
}): Promise<ValidationAuditEntry[]> {
|
|
886
|
+
const queryParams: AuditQueryParams = {
|
|
887
|
+
userId,
|
|
888
|
+
...params
|
|
889
|
+
};
|
|
890
|
+
return await this.getAuditEntries(queryParams);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Get audit trail for a case
|
|
895
|
+
*/
|
|
896
|
+
public async getAuditTrail(caseNumber: string): Promise<AuditTrail | null> {
|
|
897
|
+
try {
|
|
898
|
+
// Implement retrieval from storage
|
|
899
|
+
const entries = await this.getAuditEntries({ caseNumber });
|
|
900
|
+
if (!entries || entries.length === 0) {
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const summary = generateAuditSummary(entries);
|
|
905
|
+
const workflowId = this.workflowId || `${caseNumber}-archived`;
|
|
906
|
+
|
|
907
|
+
return {
|
|
908
|
+
caseNumber,
|
|
909
|
+
workflowId,
|
|
910
|
+
entries,
|
|
911
|
+
summary
|
|
912
|
+
};
|
|
913
|
+
} catch (error) {
|
|
914
|
+
console.error('🚨 Audit: Failed to get audit trail:', error);
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Get audit entries based on query parameters
|
|
921
|
+
*/
|
|
922
|
+
private async getAuditEntries(params: AuditQueryParams): Promise<ValidationAuditEntry[]> {
|
|
923
|
+
try {
|
|
924
|
+
// If userId is provided, fetch from server
|
|
925
|
+
if (params.userId) {
|
|
926
|
+
const serverEntries = await fetchAuditEntriesForUser({
|
|
927
|
+
userId: params.userId,
|
|
928
|
+
startDate: params.startDate,
|
|
929
|
+
endDate: params.endDate
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
if (serverEntries) {
|
|
933
|
+
const filteredEntries = applyAuditEntryFilters(serverEntries, {
|
|
934
|
+
...params,
|
|
935
|
+
userId: undefined
|
|
936
|
+
});
|
|
937
|
+
return applyAuditPagination(filteredEntries, params);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
console.error('🚨 Audit: Failed to fetch entries from server');
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Fallback to buffer for backward compatibility
|
|
944
|
+
const filteredEntries = applyAuditEntryFilters([...this.auditBuffer], params);
|
|
945
|
+
const sortedEntries = sortAuditEntriesNewestFirst(filteredEntries);
|
|
946
|
+
return applyAuditPagination(sortedEntries, params);
|
|
947
|
+
} catch (error) {
|
|
948
|
+
console.error('🚨 Audit: Failed to get audit entries:', error);
|
|
949
|
+
return [];
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Persist audit entry to storage
|
|
955
|
+
*/
|
|
956
|
+
private async persistAuditEntry(entry: ValidationAuditEntry): Promise<void> {
|
|
957
|
+
try {
|
|
958
|
+
const persistResult = await persistAuditEntryForUser(entry);
|
|
959
|
+
|
|
960
|
+
if (!persistResult.ok) {
|
|
961
|
+
console.error(
|
|
962
|
+
'🚨 Audit: Failed to persist entry:',
|
|
963
|
+
persistResult.status,
|
|
964
|
+
persistResult.errorData
|
|
965
|
+
);
|
|
966
|
+
} else {
|
|
967
|
+
console.log(`🔍 Audit: Entry persisted (${persistResult.entryCount} total entries)`);
|
|
968
|
+
}
|
|
969
|
+
} catch (error) {
|
|
970
|
+
console.error('🚨 Audit: Storage error:', error);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Clear audit buffer (for testing)
|
|
976
|
+
*/
|
|
977
|
+
public clearBuffer(): void {
|
|
978
|
+
this.auditBuffer = [];
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Get current buffer size (for monitoring)
|
|
983
|
+
*/
|
|
984
|
+
public getBufferSize(): number {
|
|
985
|
+
return this.auditBuffer.length;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Export singleton instance
|
|
990
|
+
export const auditService = AuditService.getInstance();
|