import { Injectable, Logger, InternalServerErrorException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { LogService } from '../log/log.service'; import { ObatService } from '../obat/obat.service'; import { RekammedisService } from '../rekammedis/rekammedis.service'; import { TindakanDokterService } from '../tindakandokter/tindakandokter.service'; import type { AuditEvent, resultStatus as ResultStatus, } from '@dist/generated/prisma'; import { AuditGateway } from './audit.gateway'; type AuditRecordPayload = { id: string; event: AuditEvent; payload: string; timestamp: Date; user_id: bigint; last_sync: Date; result: ResultStatus; }; @Injectable() export class AuditService { private readonly logger = new Logger(AuditService.name); constructor( private readonly prisma: PrismaService, private readonly logService: LogService, private readonly obatService: ObatService, private readonly rekamMedisService: RekammedisService, private readonly tindakanDokterService: TindakanDokterService, private auditGateway: AuditGateway, ) {} async getAuditTrails( search: string, page: number, pageSize: number, type?: string, tampered?: string, orderBy?: string, order?: 'asc' | 'desc', ) { this.logger.debug( `Fetching audit trails: page=${page}, pageSize=${pageSize}, type=${type}`, ); if (type === 'all' || type === 'initial') { type = undefined; } else if (type === 'rekam_medis') { type = 'REKAM'; } else if (type === 'tindakan') { type = 'TINDAKAN'; } else if (type === 'obat') { type = 'OBAT'; } if (tampered === 'all' || tampered === 'initial' || !tampered) { tampered = undefined; } try { const auditLogs = await this.prisma.audit.findMany({ take: pageSize, skip: (page - 1) * pageSize, orderBy: orderBy ? { [orderBy]: order || 'asc' } : { timestamp: 'desc' }, where: { id: type && type !== 'all' ? { startsWith: type } : undefined, result: tampered ? (tampered as ResultStatus) : undefined, OR: search ? [{ id: { contains: search } }] : undefined, }, }); const count = await this.prisma.audit.count({ where: { id: type && type !== 'all' ? { startsWith: type } : undefined, result: tampered ? (tampered as ResultStatus) : undefined, OR: search ? [{ id: { contains: search } }] : undefined, }, }); return { ...auditLogs, totalCount: count, }; } catch (error) { this.logger.error('Failed to fetch audit trails', error.stack); throw new InternalServerErrorException('Failed to fetch audit trails'); } } async storeAuditTrail() { console.log('Storing audit trail...'); const BATCH_SIZE = 25; let bookmark = ''; let processedCount = 0; try { while (true) { const pageResults = await this.logService.getLogsWithPagination( BATCH_SIZE, bookmark, ); const logs: any[] = Array.isArray(pageResults?.logs) ? pageResults.logs : []; const nextBookmark: string = pageResults?.bookmark ?? ''; if ( logs.length === 0 && (nextBookmark === '' || nextBookmark === bookmark) ) { break; } const records = ( await Promise.all( logs.map((logEntry, index) => this.buildAuditRecord(logEntry, index), ), ) ).filter((record): record is AuditRecordPayload => record !== null); if (records.length > 0) { this.logger.debug(`Processing ${records.length} audit records`); await this.prisma.$transaction( records.map((record) => this.prisma.audit.upsert({ where: { id: record.id }, create: record, update: { event: record.event, payload: record.payload, timestamp: record.timestamp, user_id: BigInt(record.user_id), last_sync: record.last_sync, result: record.result, }, }), ), ); processedCount += records.length; } if (nextBookmark === '' || nextBookmark === bookmark) { const completeData = { status: 'COMPLETED' }; this.auditGateway.sendComplete(completeData); break; } bookmark = nextBookmark; } } catch (error) { this.logger.error('Error storing audit trail', error.stack); this.auditGateway.sendError({ status: 'ERROR', message: error.message || 'Failed to store audit trail', }); throw new InternalServerErrorException('Failed to store audit trail'); } } async getCountAuditTamperedData() { try { const [ auditTamperedCount, auditNonTamperedCount, rekamMedisTamperedCount, tindakanDokterTamperedCount, obatTamperedCount, ] = await Promise.all([ this.prisma.audit.count({ where: { result: 'tampered' }, }), this.prisma.audit.count({ where: { result: 'non_tampered' }, }), this.prisma.audit.count({ where: { result: 'tampered', id: { startsWith: 'REKAM' } }, }), this.prisma.audit.count({ where: { result: 'tampered', id: { startsWith: 'TINDAKAN' } }, }), this.prisma.audit.count({ where: { result: 'tampered', id: { startsWith: 'OBAT' } }, }), ]); return { auditTamperedCount, auditNonTamperedCount, rekamMedisTamperedCount, tindakanDokterTamperedCount, obatTamperedCount, }; } catch (error) { this.logger.error('Failed to get audit tampered count', error.stack); throw new InternalServerErrorException('Failed to get audit statistics'); } } private async buildAuditRecord( logEntry: any, index?: number, ): Promise { if (!logEntry?.value) { return null; } const { value } = logEntry; const logId: string | undefined = value.id; if (!logId) { return null; } const now = new Date(); const timestamp = this.parseTimestamp(value.timestamp) ?? now; const userId = value.user_id; const blockchainHash: string | undefined = value.payload; let data: any = null; if (!blockchainHash) { return null; } let dbHash: string | null = null; try { if (logId.startsWith('OBAT')) { const obatId = this.extractNumericId(logId); if (obatId !== null) { const obat = await this.obatService.getObatById(obatId); data = obat; if (obat) { dbHash = this.obatService.createHashingPayload({ obat: obat.obat, jumlah_obat: obat.jumlah_obat, aturan_pakai: obat.aturan_pakai, }); } } } else if (logId.startsWith('REKAM')) { const rekamMedisId = this.extractStringId(logId); if (rekamMedisId) { const rekamMedis = await this.rekamMedisService.getRekamMedisById(rekamMedisId); data = rekamMedis; if (rekamMedis) { dbHash = this.rekamMedisService.createHashingPayload({ dokter_id: 123, visit_id: rekamMedis.id_visit ?? '', anamnese: rekamMedis.anamnese ?? '', jenis_kasus: rekamMedis.jenis_kasus ?? '', tindak_lanjut: rekamMedis.tindak_lanjut ?? '', }); } } } else if (logId.startsWith('TINDAKAN')) { const tindakanId = this.extractNumericId(logId); if (tindakanId !== null) { const tindakanDokter = await this.tindakanDokterService.getTindakanDokterById(tindakanId); data = tindakanDokter; if (tindakanDokter) { dbHash = this.tindakanDokterService.createHashingPayload({ id_visit: tindakanDokter.id_visit, tindakan: tindakanDokter.tindakan, kategori_tindakan: tindakanDokter.kategori_tindakan, kelompok_tindakan: tindakanDokter.kelompok_tindakan, }); } } } else { return null; } } catch (err) { this.logger.warn( `Failed to resolve related data for log ${logId}: ${err.message}`, ); } let isNotTampered = false; const eventType = logEntry.value.event?.split('_').at(-1); const isDeleteEvent = eventType === 'deleted'; const hasRow = Boolean(data); if (!hasRow) { isNotTampered = isDeleteEvent; } else if (isDeleteEvent || data.deleted_status === 'DELETED') { isNotTampered = isDeleteEvent && data.deleted_status === 'DELETED'; } else { const hashesMatch = dbHash && (await this.compareData(blockchainHash, dbHash)); isNotTampered = Boolean(hashesMatch); } const result: ResultStatus = isNotTampered ? 'non_tampered' : 'tampered'; const progressData = { status: 'RUNNING', progress_count: index ?? 0, }; this.auditGateway.sendProgress(progressData); return { id: logId, event: value.event as AuditEvent, payload: blockchainHash, timestamp, user_id: userId, last_sync: now, result, }; } private parseTimestamp(rawTimestamp?: string) { if (!rawTimestamp) { return null; } const parsed = new Date(rawTimestamp); if (Number.isNaN(parsed.getTime())) { return null; } return parsed; } private extractNumericId(rawId: string): number | null { const [, numericPart] = rawId.split('_'); const parsed = parseInt(numericPart, 10); return Number.isNaN(parsed) ? null : parsed; } private extractStringId(rawId: string): string | null { const parts = rawId.split('_'); return parts.length > 1 ? parts[1] : null; } async compareData(blockchainHash: string, postgreHash: string) { return blockchainHash === postgreHash; } }