feat: create audit trail, show audit trail from DB, partial proof webservice
This commit is contained in:
parent
37c3676c59
commit
43eb0e464d
91
backend/api/prisma/migrations/0_init/migration.sql
Normal file
91
backend/api/prisma/migrations/0_init/migration.sql
Normal file
|
|
@ -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;
|
||||
|
||||
|
|
@ -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")
|
||||
);
|
||||
3
backend/api/prisma/migrations/migration_lock.toml
Normal file
3
backend/api/prisma/migrations/migration_lock.toml
Normal file
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AuditRecordPayload | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
backend/api/src/modules/proof/proof.controller.spec.ts
Normal file
18
backend/api/src/modules/proof/proof.controller.spec.ts
Normal file
|
|
@ -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>(ProofController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
12
backend/api/src/modules/proof/proof.controller.ts
Normal file
12
backend/api/src/modules/proof/proof.controller.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
12
backend/api/src/modules/proof/proof.module.ts
Normal file
12
backend/api/src/modules/proof/proof.module.ts
Normal file
|
|
@ -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 {}
|
||||
18
backend/api/src/modules/proof/proof.service.spec.ts
Normal file
18
backend/api/src/modules/proof/proof.service.spec.ts
Normal file
|
|
@ -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>(ProofService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
13
backend/api/src/modules/proof/proof.service.ts
Normal file
13
backend/api/src/modules/proof/proof.service.ts
Normal file
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import ButtonDark from "./dashboard/ButtonDark.vue";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
|
|
@ -52,12 +53,10 @@ defineExpose({
|
|||
<h3 class="text-lg font-bold">{{ title }}</h3>
|
||||
<p class="py-4">{{ message }}</p>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost" @click="handleCancel">
|
||||
<ButtonDark @click="handleConfirm" :text="confirmText" />
|
||||
<button class="btn btn-error btn-sm" @click="handleCancel">
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button class="btn btn-error" @click="handleConfirm">
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import {
|
|||
ITEMS_PER_PAGE_OPTIONS,
|
||||
} from "../../constants/pagination";
|
||||
import type { BlockchainLog } from "../../constants/interfaces";
|
||||
import ButtonDark from "../../components/dashboard/ButtonDark.vue";
|
||||
import DialogConfirm from "../../components/DialogConfirm.vue";
|
||||
import PaginationControls from "../../components/dashboard/PaginationControls.vue";
|
||||
|
||||
type AuditLogType = "obat" | "rekam_medis" | "tindakan" | "unknown";
|
||||
|
||||
|
|
@ -31,8 +34,6 @@ interface AuditLogResponse {
|
|||
totalCount: number;
|
||||
}
|
||||
|
||||
const bookmark = ref("");
|
||||
const prevBookmark = ref("");
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const api = useApi();
|
||||
|
|
@ -102,6 +103,17 @@ const formatTimestamp = (rawValue?: string) => {
|
|||
return date.toLocaleString("id-ID", options).replace(/\./g, ":");
|
||||
};
|
||||
|
||||
const runAuditTrail = async () => {
|
||||
console.log("Running audit trail...");
|
||||
try {
|
||||
const result = await api.post("/audit/trail", {});
|
||||
|
||||
console.log("Audit trail run result:", result);
|
||||
} catch (error) {
|
||||
// console.error("Error running audit trail:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const deriveType = (entry: Partial<AuditLogEntry>): AuditLogType => {
|
||||
const { type, event = "", id = "" } = entry;
|
||||
if (type && type !== "unknown") {
|
||||
|
|
@ -154,11 +166,7 @@ const normalizeEntry = (entry: any): AuditLogEntry => {
|
|||
id: flattened.id,
|
||||
});
|
||||
|
||||
const isTampered = Boolean(
|
||||
flattened.isTampered ||
|
||||
(typeof flattened.status === "string" &&
|
||||
flattened.status.toLowerCase().includes("tamper"))
|
||||
);
|
||||
const isTampered = Boolean(flattened.result.toLowerCase() === "tampered");
|
||||
|
||||
const statusLabel = flattened.status
|
||||
? flattened.status
|
||||
|
|
@ -185,10 +193,9 @@ const normalizeEntry = (entry: any): AuditLogEntry => {
|
|||
};
|
||||
};
|
||||
|
||||
const handleNextBookmark = () => {
|
||||
if (bookmark.value) {
|
||||
fetchData(bookmark.value);
|
||||
}
|
||||
const auditDialog = ref<InstanceType<typeof DialogConfirm> | null>(null);
|
||||
const showAuditModal = () => {
|
||||
auditDialog.value?.show();
|
||||
};
|
||||
|
||||
const updateQueryParams = () => {
|
||||
|
|
@ -246,24 +253,11 @@ const fetchData = async (bookmarkParam?: string, isInitial?: boolean) => {
|
|||
? { data: response, totalCount: response.length }
|
||||
: response;
|
||||
|
||||
const bookmarkValue = Array.isArray(response)
|
||||
? (response[0] as any)?.bookmark
|
||||
: (response as any).bookmark;
|
||||
// console.log(
|
||||
// "Bookmark:",
|
||||
// Array.isArray(response)
|
||||
// ? (response[0] as any)?.bookmark
|
||||
// : (response as any).bookmark
|
||||
// );
|
||||
|
||||
const normalized = Array.isArray(payload.data)
|
||||
? payload.data.map((item) => normalizeEntry(item))
|
||||
: [];
|
||||
|
||||
bookmark.value = bookmarkValue;
|
||||
localStorage.setItem("bookmark-page-audit", bookmark.value || "");
|
||||
logs.value = normalized;
|
||||
console.log(logs.value);
|
||||
pagination.totalCount.value = payload.totalCount ?? normalized.length;
|
||||
|
||||
if (!isInitial) {
|
||||
|
|
@ -283,13 +277,6 @@ const handleSearch = () => {
|
|||
debouncedFetch();
|
||||
};
|
||||
|
||||
const handlePageSizeClick = (size: number) => {
|
||||
handlePageSizeChange(size); // your existing logic
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pageSize.value = size;
|
||||
fetchData();
|
||||
|
|
@ -421,6 +408,16 @@ onMounted(async () => {
|
|||
placeholder="Cari berdasarkan ID Log"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<DialogConfirm
|
||||
ref="auditDialog"
|
||||
title="Konfirmasi"
|
||||
message="Apakah anda yakin ingin menjalankan audit? (Proses ini mungkin
|
||||
memakan waktu lama)"
|
||||
confirm-text="Iya, Jalankan"
|
||||
cancel-text="Batal"
|
||||
@confirm="runAuditTrail"
|
||||
/>
|
||||
<ButtonDark text="Lakukan Audit" @click="showAuditModal" />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
|
|
@ -430,65 +427,22 @@ onMounted(async () => {
|
|||
empty-message="Tidak ada log audit"
|
||||
:is-aksi="false"
|
||||
/>
|
||||
<div>
|
||||
<!-- Pagination Info -->
|
||||
<div class="text-sm text-gray-600 px-4 py-4">
|
||||
Menampilkan data {{ 1 }} - {{ logs.length }}
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="flex justify-between items-center px-4 pb-4">
|
||||
<!-- Items per page selector -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-600">Data per halaman:</span>
|
||||
<div class="dropdown dropdown-top">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="btn btn-xs shadow-none rounded-full bg-white text-dark font-bold border border-gray-200 hover:bg-gray-50"
|
||||
>
|
||||
{{ pageSize }} ▲
|
||||
</div>
|
||||
<ul
|
||||
tabindex="-1"
|
||||
class="dropdown-content menu bg-white rounded-box z-10 w-16 p-2 shadow-lg border border-gray-100"
|
||||
>
|
||||
<li v-for="size in ITEMS_PER_PAGE_OPTIONS" :key="size">
|
||||
<a
|
||||
@click="(event) => handlePageSizeClick(size)"
|
||||
:class="{
|
||||
'bg-gray-100': pageSize === size,
|
||||
}"
|
||||
>
|
||||
{{ size }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page navigation -->
|
||||
<div class="join text-sm space-x-1">
|
||||
<button
|
||||
@click=""
|
||||
class="join-item btn btn-sm bg-white text-dark border-none shadow-none hover:bg-gray-100"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
{{ console.log("") }}
|
||||
<button
|
||||
@click="handleNextBookmark"
|
||||
:disabled="bookmark === ''"
|
||||
:class="
|
||||
bookmark === '' ? 'opacity-50 cursor-not-allowed' : ''
|
||||
"
|
||||
class="join-item btn btn-sm bg-white text-dark border-none shadow-none hover:bg-gray-100"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PaginationControls
|
||||
v-if="!api.isLoading.value && logs.length > 0"
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total-count="pagination.totalCount"
|
||||
:start-index="pagination.startIndex"
|
||||
:end-index="pagination.endIndex"
|
||||
:can-go-next="pagination.canGoNext"
|
||||
:can-go-previous="pagination.canGoPrevious"
|
||||
:page-size-options="[...ITEMS_PER_PAGE_OPTIONS]"
|
||||
:get-page-numbers="pagination.getPageNumbers"
|
||||
@page-change="pagination.goToPage"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
@next="pagination.nextPage"
|
||||
@previous="pagination.previousPage"
|
||||
/>
|
||||
</div>
|
||||
</Sidebar>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import { LOG_TABLE_COLUMNS } from "../../constants/pagination";
|
|||
const data = ref<Obat>();
|
||||
const dataLog = ref<BlockchainLog[]>([]);
|
||||
|
||||
const dataLogRaw = ref<any[]>([]);
|
||||
const currentHash = ref<string>("");
|
||||
const route = useRoute();
|
||||
const api = useApi();
|
||||
|
|
@ -31,49 +30,63 @@ const fetchLogData = async () => {
|
|||
const result = await api.get<{
|
||||
currentDataHash: string;
|
||||
data: Record<string, any>;
|
||||
isTampered: boolean;
|
||||
}>(`/obat/${route.params.id}/log`);
|
||||
currentHash.value = result.currentDataHash || "";
|
||||
if ("data" in result && Array.isArray(result.data)) {
|
||||
dataLogRaw.value = result.data;
|
||||
} else {
|
||||
const apiResponse = result as any;
|
||||
const dataArray: any[] = [];
|
||||
Object.keys(apiResponse).forEach((key) => {
|
||||
if (key !== "totalCount") {
|
||||
const item = apiResponse[Number(key)];
|
||||
if (item) {
|
||||
dataArray.push(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
const flattenedData = dataArray.map((item) => {
|
||||
const date = new Date(item.value.timestamp);
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
timeZone: "Asia/Jakarta", // This sets the time to WIB
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false, // Use 24-hour format
|
||||
};
|
||||
const indonesianTime = date.toLocaleString("id-ID", options);
|
||||
const formattedTime = indonesianTime.replace(/\./g, ":");
|
||||
return {
|
||||
id: item.value.id,
|
||||
event: item.value.event,
|
||||
hash: item.value.payload,
|
||||
userId: item.value.user_id,
|
||||
timestamp: formattedTime,
|
||||
txId: item.txId,
|
||||
status:
|
||||
item.value.payload === currentHash.value ? "Valid" : "Changed",
|
||||
};
|
||||
});
|
||||
|
||||
dataLog.value = flattenedData;
|
||||
}
|
||||
currentHash.value = result.currentDataHash || "";
|
||||
const apiResponse = result as any;
|
||||
const dataArray: any[] = apiResponse.logs || [];
|
||||
const flattenedData = dataArray.map((item, index) => {
|
||||
const date = new Date(item.timestamp);
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
timeZone: "Asia/Jakarta",
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
};
|
||||
const indonesianTime = date.toLocaleString("id-ID", options);
|
||||
const formattedTime = indonesianTime.replace(/\./g, ":");
|
||||
|
||||
const statusLabel = (() => {
|
||||
if (item.status === "ORIGINAL") {
|
||||
return "ORIGINAL DATA";
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
if (result.isTampered) {
|
||||
return `TAMPERED after ${item.status}`;
|
||||
}
|
||||
|
||||
if (dataArray[dataArray.length - 1].payload === currentHash.value) {
|
||||
return "DATA SAME WITH ORIGINAL";
|
||||
}
|
||||
|
||||
return `VALID ${item.status}`;
|
||||
}
|
||||
|
||||
if (index === dataArray.length - 1) {
|
||||
return item.status;
|
||||
}
|
||||
|
||||
return `OLD ${item.status}`;
|
||||
})();
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
event: item.event,
|
||||
hash: item.payload,
|
||||
userId: item.user_id,
|
||||
timestamp: formattedTime,
|
||||
txId: item.txId,
|
||||
status: statusLabel,
|
||||
};
|
||||
});
|
||||
|
||||
dataLog.value = flattenedData;
|
||||
} catch (error) {
|
||||
dataLog.value = [];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user