From 43eb0e464d82391848c2a60c1d7950201e78b116 Mon Sep 17 00:00:00 2001 From: yosaphatprs Date: Thu, 13 Nov 2025 12:31:48 +0700 Subject: [PATCH] feat: create audit trail, show audit trail from DB, partial proof webservice --- .../prisma/migrations/0_init/migration.sql | 91 ++++++ .../migration.sql | 18 ++ .../api/prisma/migrations/migration_lock.toml | 3 + backend/api/prisma/schema.prisma | 27 ++ backend/api/src/app.module.ts | 2 + .../api/src/modules/audit/audit.controller.ts | 10 +- .../api/src/modules/audit/audit.service.ts | 263 +++++++++++++----- .../modules/proof/proof.controller.spec.ts | 18 ++ .../api/src/modules/proof/proof.controller.ts | 12 + backend/api/src/modules/proof/proof.module.ts | 12 + .../src/modules/proof/proof.service.spec.ts | 18 ++ .../api/src/modules/proof/proof.service.ts | 13 + .../modules/rekammedis/rekammedis.service.ts | 12 + .../src/components/DialogConfirm.vue | 7 +- .../src/views/dashboard/AuditTrailView.vue | 134 +++------ .../dashboard/PemberianObatDetailView.vue | 97 ++++--- 16 files changed, 534 insertions(+), 203 deletions(-) create mode 100644 backend/api/prisma/migrations/0_init/migration.sql create mode 100644 backend/api/prisma/migrations/20251110074017_add_audit_table/migration.sql create mode 100644 backend/api/prisma/migrations/migration_lock.toml create mode 100644 backend/api/src/modules/proof/proof.controller.spec.ts create mode 100644 backend/api/src/modules/proof/proof.controller.ts create mode 100644 backend/api/src/modules/proof/proof.module.ts create mode 100644 backend/api/src/modules/proof/proof.service.spec.ts create mode 100644 backend/api/src/modules/proof/proof.service.ts diff --git a/backend/api/prisma/migrations/0_init/migration.sql b/backend/api/prisma/migrations/0_init/migration.sql new file mode 100644 index 0000000..23ac416 --- /dev/null +++ b/backend/api/prisma/migrations/0_init/migration.sql @@ -0,0 +1,91 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateTable +CREATE TABLE "public"."blockchain_log_queue" ( + "id" BIGSERIAL NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'PENDING', + "event" VARCHAR(50) NOT NULL, + "user_id" BIGINT NOT NULL, + "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + "payload" JSONB NOT NULL, + "processed_at" TIMESTAMPTZ(6), + "transactionid" VARCHAR(64), + + CONSTRAINT "blockchain_log_queue_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."pemberian_obat" ( + "id" SERIAL NOT NULL, + "id_visit" VARCHAR(25) NOT NULL, + "obat" VARCHAR(100) NOT NULL, + "jumlah_obat" INTEGER NOT NULL, + "aturan_pakai" TEXT, + + CONSTRAINT "pemberian_obat_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."pemberian_tindakan" ( + "id" SERIAL NOT NULL, + "id_visit" VARCHAR(25) NOT NULL, + "tindakan" VARCHAR(100) NOT NULL, + "kategori_tindakan" VARCHAR(50), + "kelompok_tindakan" VARCHAR(50), + + CONSTRAINT "pemberian_tindakan_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."rekam_medis" ( + "id_visit" VARCHAR(25) NOT NULL, + "waktu_visit" TIMESTAMP(6) NOT NULL, + "no_rm" VARCHAR(20) NOT NULL, + "nama_pasien" VARCHAR(100) NOT NULL, + "umur" INTEGER, + "jenis_kelamin" VARCHAR(10), + "gol_darah" VARCHAR(3), + "pekerjaan" VARCHAR(100), + "suku" VARCHAR(100), + "kode_diagnosa" VARCHAR(20), + "diagnosa" TEXT, + "anamnese" TEXT, + "sistolik" INTEGER, + "diastolik" INTEGER, + "nadi" INTEGER, + "suhu" DECIMAL(4,1), + "nafas" INTEGER, + "tinggi_badan" DECIMAL(10,5), + "berat_badan" DECIMAL(10,5), + "jenis_kasus" VARCHAR(50), + "tindak_lanjut" TEXT, + + CONSTRAINT "rekam_medis_pkey" PRIMARY KEY ("id_visit") +); + +-- CreateTable +CREATE TABLE "public"."users" ( + "id" BIGSERIAL NOT NULL, + "nama_lengkap" VARCHAR(255) NOT NULL, + "username" VARCHAR(255) NOT NULL, + "password_hash" VARCHAR(255) NOT NULL, + "role" VARCHAR(50) DEFAULT 'user', + "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "public"."users"("username" ASC); + +-- AddForeignKey +ALTER TABLE "public"."blockchain_log_queue" ADD CONSTRAINT "fk_log_user" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "public"."pemberian_obat" ADD CONSTRAINT "fk_pemberian_obat_visit" FOREIGN KEY ("id_visit") REFERENCES "public"."rekam_medis"("id_visit") ON DELETE CASCADE ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "public"."pemberian_tindakan" ADD CONSTRAINT "fk_tindakan_visit" FOREIGN KEY ("id_visit") REFERENCES "public"."rekam_medis"("id_visit") ON DELETE CASCADE ON UPDATE NO ACTION; + diff --git a/backend/api/prisma/migrations/20251110074017_add_audit_table/migration.sql b/backend/api/prisma/migrations/20251110074017_add_audit_table/migration.sql new file mode 100644 index 0000000..5eafc9e --- /dev/null +++ b/backend/api/prisma/migrations/20251110074017_add_audit_table/migration.sql @@ -0,0 +1,18 @@ +-- CreateEnum +CREATE TYPE "AuditEvent" AS ENUM ('tindakan_dokter_created', 'obat_created', 'rekam_medis_created', 'tindakan_dokter_updated', 'obat_updated', 'rekam_medis_updated', 'tindakan_dokter_deleted', 'obat_deleted', 'rekam_medis_deleted'); + +-- CreateEnum +CREATE TYPE "resultStatus" AS ENUM ('tampered', 'non_tampered'); + +-- CreateTable +CREATE TABLE "audit" ( + "id" VARCHAR(50) NOT NULL, + "event" "AuditEvent" NOT NULL, + "payload" TEXT NOT NULL, + "timestamp" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_id" BIGINT NOT NULL, + "last_sync" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "result" "resultStatus" NOT NULL, + + CONSTRAINT "audit_pkey" PRIMARY KEY ("id") +); diff --git a/backend/api/prisma/migrations/migration_lock.toml b/backend/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/backend/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/backend/api/prisma/schema.prisma b/backend/api/prisma/schema.prisma index 2cf7c05..ddb8860 100644 --- a/backend/api/prisma/schema.prisma +++ b/backend/api/prisma/schema.prisma @@ -75,3 +75,30 @@ model users { updated_at DateTime? @default(now()) @db.Timestamptz(6) blockchain_log_queue blockchain_log_queue[] } + +enum AuditEvent { + tindakan_dokter_created + obat_created + rekam_medis_created + tindakan_dokter_updated + obat_updated + rekam_medis_updated + tindakan_dokter_deleted + obat_deleted + rekam_medis_deleted +} + +enum resultStatus { + tampered + non_tampered +} + +model audit { + id String @id @db.VarChar(50) + event AuditEvent + payload String + timestamp DateTime @default(now()) @db.Timestamptz(6) + user_id BigInt + last_sync DateTime @default(now()) @db.Timestamptz(6) + result resultStatus +} \ No newline at end of file diff --git a/backend/api/src/app.module.ts b/backend/api/src/app.module.ts index 0d8d764..7d75886 100644 --- a/backend/api/src/app.module.ts +++ b/backend/api/src/app.module.ts @@ -11,6 +11,7 @@ import { PrismaModule } from './modules/prisma/prisma.module'; import { AuthModule } from './modules/auth/auth.module'; import { FabricModule } from './modules/fabric/fabric.module'; import { AuditModule } from './modules/audit/audit.module'; +import { ProofModule } from './modules/proof/proof.module'; @Module({ imports: [ @@ -26,6 +27,7 @@ import { AuditModule } from './modules/audit/audit.module'; PrismaModule, FabricModule, AuditModule, + ProofModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/api/src/modules/audit/audit.controller.ts b/backend/api/src/modules/audit/audit.controller.ts index 94882c4..1f92570 100644 --- a/backend/api/src/modules/audit/audit.controller.ts +++ b/backend/api/src/modules/audit/audit.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; import { AuthGuard } from '../auth/guard/auth.guard'; import { AuditService } from './audit.service'; @@ -12,8 +12,14 @@ export class AuditController { @Query('pageSize') pageSize: number, @Query('bookmark') bookmark: string, ) { - console.log('Audit trail accessed'); const result = await this.auditService.getAuditTrails(pageSize, bookmark); return result; } + + @Post('/trail') + @UseGuards(AuthGuard) + async createAuditTrail() { + const result = await this.auditService.storeAuditTrail(); + return result; + } } diff --git a/backend/api/src/modules/audit/audit.service.ts b/backend/api/src/modules/audit/audit.service.ts index 42c13ae..3f841b4 100644 --- a/backend/api/src/modules/audit/audit.service.ts +++ b/backend/api/src/modules/audit/audit.service.ts @@ -5,11 +5,25 @@ import { ObatService } from '../obat/obat.service'; import { RekammedisService } from '../rekammedis/rekammedis.service'; import { TindakanDokterService } from '../tindakandokter/tindakandokter.service'; import { sha256 } from '@api/common/crypto/hash'; +import type { + AuditEvent, + resultStatus as ResultStatus, +} from '@dist/generated/prisma'; + +type AuditRecordPayload = { + id: string; + event: AuditEvent; + payload: string; + timestamp: Date; + user_id: bigint; + last_sync: Date; + result: ResultStatus; +}; @Injectable() export class AuditService { constructor( - // private readonly prisma: PrismaService, + private readonly prisma: PrismaService, private readonly logService: LogService, private readonly obatService: ObatService, private readonly rekamMedisService: RekammedisService, @@ -17,76 +31,195 @@ export class AuditService { ) {} async getAuditTrails(pageSize: number, bookmark: string) { - if (!pageSize || pageSize <= 0) { - pageSize = 10; - } - if (!bookmark) { - bookmark = ''; - } - const logs = await this.logService.getLogsWithPagination( - pageSize, - bookmark, - ); - console.log('Fetched logs:', logs); - const flattenLogs = logs.logs.map((log: { value: any }) => { - return { - ...log.value, - bookmark: logs.bookmark, - }; + const auditLogs = await this.prisma.audit.findMany({ + take: pageSize, + skip: bookmark ? 1 : 0, + cursor: bookmark ? { id: bookmark } : undefined, + orderBy: { timestamp: 'asc' }, }); - const formattedLogs = await Promise.all( - flattenLogs.map(async (log: any) => { - let relatedData = null; - let payloadData = null; - let payloadHash = null; - const id = log.id.split('_')[1]; + return auditLogs; + } - if (log.event === 'obat_created' || log.event === 'obat_updated') { - relatedData = await this.obatService.getObatById(parseInt(id)); - payloadData = { - id: relatedData?.id_visit, - obat: relatedData?.obat, - jumlah_obat: relatedData?.jumlah_obat, - aturan_pakai: relatedData?.aturan_pakai, - }; - payloadHash = sha256(JSON.stringify(payloadData)); - } else if ( - log.event === 'rekam_medis_created' || - log.event === 'rekam_medis_updated' + 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) ) { - relatedData = await this.rekamMedisService.getRekamMedisById(id); - payloadData = { - dokter_id: 123, - visit_id: relatedData?.id_visit, - anamnese: relatedData?.anamnese, - jenis_kasus: relatedData?.jenis_kasus, - tindak_lanjut: relatedData?.tindak_lanjut, - }; - payloadHash = sha256(JSON.stringify(payloadData)); - } else if ( - log.event === 'tindakan_dokter_created' || - log.event === 'tindakan_dokter_updated' - ) { - relatedData = await this.tindakanDokterService.getTindakanDokterById( - parseInt(id), - ); - payloadData = { - id_visit: relatedData?.id_visit, - tindakan: relatedData?.tindakan, - kategori_tindakan: relatedData?.kategori_tindakan, - kelompok_tindakan: relatedData?.kelompok_tindakan, - }; - payloadHash = sha256(JSON.stringify(payloadData)); + break; } - return { - ...log, - isTampered: payloadHash === log.payload, - }; - }), - ); + const records = ( + await Promise.all( + logs.map((logEntry) => this.buildAuditRecord(logEntry)), + ) + ).filter((record): record is AuditRecordPayload => record !== null); - return formattedLogs; + if (records.length > 0) { + 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: record.user_id, + last_sync: record.last_sync, + result: record.result, + }, + }), + ), + ); + processedCount += records.length; + } + + if (nextBookmark === '' || nextBookmark === bookmark) { + break; + } + + bookmark = nextBookmark; + } + } catch (error) { + console.error('Error storing audit trail:', error); + throw error; + } + } + + private async buildAuditRecord( + logEntry: any, + ): 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; + + 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); + 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); + 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); + 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) { + console.warn(`Failed to resolve related data for log ${logId}:`, err); + } + + const isNotTampered = dbHash + ? await this.compareData(blockchainHash, dbHash) + : false; + + const result: ResultStatus = isNotTampered ? 'non_tampered' : 'tampered'; + + 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; } } diff --git a/backend/api/src/modules/proof/proof.controller.spec.ts b/backend/api/src/modules/proof/proof.controller.spec.ts new file mode 100644 index 0000000..fbd5847 --- /dev/null +++ b/backend/api/src/modules/proof/proof.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ProofController } from './proof.controller'; + +describe('ProofController', () => { + let controller: ProofController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ProofController], + }).compile(); + + controller = module.get(ProofController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/api/src/modules/proof/proof.controller.ts b/backend/api/src/modules/proof/proof.controller.ts new file mode 100644 index 0000000..fd93115 --- /dev/null +++ b/backend/api/src/modules/proof/proof.controller.ts @@ -0,0 +1,12 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ProofService } from './proof.service'; + +@Controller('proof') +export class ProofController { + constructor(private proofService: ProofService) {} + + @Post('/request') + requestProof(@Body('id_visit') id_visit: string) { + return this.proofService.getProof(id_visit); + } +} diff --git a/backend/api/src/modules/proof/proof.module.ts b/backend/api/src/modules/proof/proof.module.ts new file mode 100644 index 0000000..32d7181 --- /dev/null +++ b/backend/api/src/modules/proof/proof.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ProofController } from './proof.controller'; +import { ProofService } from './proof.service'; +import { RekamMedisModule } from '../rekammedis/rekammedis.module'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [RekamMedisModule, PrismaModule], + providers: [ProofService], + controllers: [ProofController], +}) +export class ProofModule {} diff --git a/backend/api/src/modules/proof/proof.service.spec.ts b/backend/api/src/modules/proof/proof.service.spec.ts new file mode 100644 index 0000000..21d16bc --- /dev/null +++ b/backend/api/src/modules/proof/proof.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ProofService } from './proof.service'; + +describe('ProofService', () => { + let service: ProofService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ProofService], + }).compile(); + + service = module.get(ProofService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/api/src/modules/proof/proof.service.ts b/backend/api/src/modules/proof/proof.service.ts new file mode 100644 index 0000000..8aa0e3c --- /dev/null +++ b/backend/api/src/modules/proof/proof.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { RekammedisService } from '../rekammedis/rekammedis.service'; + +@Injectable() +export class ProofService { + constructor( + private prismaService: PrismaService, + private rekamMedisService: RekammedisService, + ) {} + + async getProof(id_visit: any) {} +} diff --git a/backend/api/src/modules/rekammedis/rekammedis.service.ts b/backend/api/src/modules/rekammedis/rekammedis.service.ts index 493bd40..da88dad 100644 --- a/backend/api/src/modules/rekammedis/rekammedis.service.ts +++ b/backend/api/src/modules/rekammedis/rekammedis.service.ts @@ -394,6 +394,18 @@ export class RekammedisService { }; } + async getAgeByIdVisit(id_visit: string) { + const age = await this.prisma.rekam_medis.findUnique({ + select: { + umur: true, + }, + where: { + id_visit: id_visit, + }, + }); + return age; + } + async countRekamMedis() { return this.prisma.rekam_medis.count(); } diff --git a/frontend/hospital-log/src/components/DialogConfirm.vue b/frontend/hospital-log/src/components/DialogConfirm.vue index 8077036..74871ff 100644 --- a/frontend/hospital-log/src/components/DialogConfirm.vue +++ b/frontend/hospital-log/src/components/DialogConfirm.vue @@ -1,5 +1,6 @@