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)
|
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||||
blockchain_log_queue blockchain_log_queue[]
|
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 { AuthModule } from './modules/auth/auth.module';
|
||||||
import { FabricModule } from './modules/fabric/fabric.module';
|
import { FabricModule } from './modules/fabric/fabric.module';
|
||||||
import { AuditModule } from './modules/audit/audit.module';
|
import { AuditModule } from './modules/audit/audit.module';
|
||||||
|
import { ProofModule } from './modules/proof/proof.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -26,6 +27,7 @@ import { AuditModule } from './modules/audit/audit.module';
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
FabricModule,
|
FabricModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
|
ProofModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
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 { AuthGuard } from '../auth/guard/auth.guard';
|
||||||
import { AuditService } from './audit.service';
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
|
@ -12,8 +12,14 @@ export class AuditController {
|
||||||
@Query('pageSize') pageSize: number,
|
@Query('pageSize') pageSize: number,
|
||||||
@Query('bookmark') bookmark: string,
|
@Query('bookmark') bookmark: string,
|
||||||
) {
|
) {
|
||||||
console.log('Audit trail accessed');
|
|
||||||
const result = await this.auditService.getAuditTrails(pageSize, bookmark);
|
const result = await this.auditService.getAuditTrails(pageSize, bookmark);
|
||||||
return result;
|
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 { RekammedisService } from '../rekammedis/rekammedis.service';
|
||||||
import { TindakanDokterService } from '../tindakandokter/tindakandokter.service';
|
import { TindakanDokterService } from '../tindakandokter/tindakandokter.service';
|
||||||
import { sha256 } from '@api/common/crypto/hash';
|
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()
|
@Injectable()
|
||||||
export class AuditService {
|
export class AuditService {
|
||||||
constructor(
|
constructor(
|
||||||
// private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly logService: LogService,
|
private readonly logService: LogService,
|
||||||
private readonly obatService: ObatService,
|
private readonly obatService: ObatService,
|
||||||
private readonly rekamMedisService: RekammedisService,
|
private readonly rekamMedisService: RekammedisService,
|
||||||
|
|
@ -17,76 +31,195 @@ export class AuditService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAuditTrails(pageSize: number, bookmark: string) {
|
async getAuditTrails(pageSize: number, bookmark: string) {
|
||||||
if (!pageSize || pageSize <= 0) {
|
const auditLogs = await this.prisma.audit.findMany({
|
||||||
pageSize = 10;
|
take: pageSize,
|
||||||
}
|
skip: bookmark ? 1 : 0,
|
||||||
if (!bookmark) {
|
cursor: bookmark ? { id: bookmark } : undefined,
|
||||||
bookmark = '';
|
orderBy: { timestamp: 'asc' },
|
||||||
}
|
|
||||||
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 formattedLogs = await Promise.all(
|
return auditLogs;
|
||||||
flattenLogs.map(async (log: any) => {
|
}
|
||||||
let relatedData = null;
|
|
||||||
let payloadData = null;
|
|
||||||
let payloadHash = null;
|
|
||||||
const id = log.id.split('_')[1];
|
|
||||||
|
|
||||||
if (log.event === 'obat_created' || log.event === 'obat_updated') {
|
async storeAuditTrail() {
|
||||||
relatedData = await this.obatService.getObatById(parseInt(id));
|
console.log('Storing audit trail...');
|
||||||
payloadData = {
|
const BATCH_SIZE = 25;
|
||||||
id: relatedData?.id_visit,
|
let bookmark = '';
|
||||||
obat: relatedData?.obat,
|
let processedCount = 0;
|
||||||
jumlah_obat: relatedData?.jumlah_obat,
|
|
||||||
aturan_pakai: relatedData?.aturan_pakai,
|
try {
|
||||||
};
|
while (true) {
|
||||||
payloadHash = sha256(JSON.stringify(payloadData));
|
const pageResults = await this.logService.getLogsWithPagination(
|
||||||
} else if (
|
BATCH_SIZE,
|
||||||
log.event === 'rekam_medis_created' ||
|
bookmark,
|
||||||
log.event === 'rekam_medis_updated'
|
);
|
||||||
|
|
||||||
|
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);
|
break;
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const records = (
|
||||||
...log,
|
await Promise.all(
|
||||||
isTampered: payloadHash === log.payload,
|
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() {
|
async countRekamMedis() {
|
||||||
return this.prisma.rekam_medis.count();
|
return this.prisma.rekam_medis.count();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
import ButtonDark from "./dashboard/ButtonDark.vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
@ -52,12 +53,10 @@ defineExpose({
|
||||||
<h3 class="text-lg font-bold">{{ title }}</h3>
|
<h3 class="text-lg font-bold">{{ title }}</h3>
|
||||||
<p class="py-4">{{ message }}</p>
|
<p class="py-4">{{ message }}</p>
|
||||||
<div class="modal-action">
|
<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 }}
|
{{ cancelText }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-error" @click="handleConfirm">
|
|
||||||
{{ confirmText }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog" class="modal-backdrop">
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import {
|
||||||
ITEMS_PER_PAGE_OPTIONS,
|
ITEMS_PER_PAGE_OPTIONS,
|
||||||
} from "../../constants/pagination";
|
} from "../../constants/pagination";
|
||||||
import type { BlockchainLog } from "../../constants/interfaces";
|
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";
|
type AuditLogType = "obat" | "rekam_medis" | "tindakan" | "unknown";
|
||||||
|
|
||||||
|
|
@ -31,8 +34,6 @@ interface AuditLogResponse {
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookmark = ref("");
|
|
||||||
const prevBookmark = ref("");
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
@ -102,6 +103,17 @@ const formatTimestamp = (rawValue?: string) => {
|
||||||
return date.toLocaleString("id-ID", options).replace(/\./g, ":");
|
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 deriveType = (entry: Partial<AuditLogEntry>): AuditLogType => {
|
||||||
const { type, event = "", id = "" } = entry;
|
const { type, event = "", id = "" } = entry;
|
||||||
if (type && type !== "unknown") {
|
if (type && type !== "unknown") {
|
||||||
|
|
@ -154,11 +166,7 @@ const normalizeEntry = (entry: any): AuditLogEntry => {
|
||||||
id: flattened.id,
|
id: flattened.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isTampered = Boolean(
|
const isTampered = Boolean(flattened.result.toLowerCase() === "tampered");
|
||||||
flattened.isTampered ||
|
|
||||||
(typeof flattened.status === "string" &&
|
|
||||||
flattened.status.toLowerCase().includes("tamper"))
|
|
||||||
);
|
|
||||||
|
|
||||||
const statusLabel = flattened.status
|
const statusLabel = flattened.status
|
||||||
? flattened.status
|
? flattened.status
|
||||||
|
|
@ -185,10 +193,9 @@ const normalizeEntry = (entry: any): AuditLogEntry => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextBookmark = () => {
|
const auditDialog = ref<InstanceType<typeof DialogConfirm> | null>(null);
|
||||||
if (bookmark.value) {
|
const showAuditModal = () => {
|
||||||
fetchData(bookmark.value);
|
auditDialog.value?.show();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateQueryParams = () => {
|
const updateQueryParams = () => {
|
||||||
|
|
@ -246,24 +253,11 @@ const fetchData = async (bookmarkParam?: string, isInitial?: boolean) => {
|
||||||
? { data: response, totalCount: response.length }
|
? { data: response, totalCount: response.length }
|
||||||
: response;
|
: 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)
|
const normalized = Array.isArray(payload.data)
|
||||||
? payload.data.map((item) => normalizeEntry(item))
|
? payload.data.map((item) => normalizeEntry(item))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
bookmark.value = bookmarkValue;
|
|
||||||
localStorage.setItem("bookmark-page-audit", bookmark.value || "");
|
|
||||||
logs.value = normalized;
|
logs.value = normalized;
|
||||||
console.log(logs.value);
|
|
||||||
pagination.totalCount.value = payload.totalCount ?? normalized.length;
|
pagination.totalCount.value = payload.totalCount ?? normalized.length;
|
||||||
|
|
||||||
if (!isInitial) {
|
if (!isInitial) {
|
||||||
|
|
@ -283,13 +277,6 @@ const handleSearch = () => {
|
||||||
debouncedFetch();
|
debouncedFetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageSizeClick = (size: number) => {
|
|
||||||
handlePageSizeChange(size); // your existing logic
|
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
|
||||||
document.activeElement.blur();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageSizeChange = (size: number) => {
|
const handlePageSizeChange = (size: number) => {
|
||||||
pageSize.value = size;
|
pageSize.value = size;
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|
@ -421,6 +408,16 @@ onMounted(async () => {
|
||||||
placeholder="Cari berdasarkan ID Log"
|
placeholder="Cari berdasarkan ID Log"
|
||||||
@search="handleSearch"
|
@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>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|
@ -430,65 +427,22 @@ onMounted(async () => {
|
||||||
empty-message="Tidak ada log audit"
|
empty-message="Tidak ada log audit"
|
||||||
:is-aksi="false"
|
:is-aksi="false"
|
||||||
/>
|
/>
|
||||||
<div>
|
<PaginationControls
|
||||||
<!-- Pagination Info -->
|
v-if="!api.isLoading.value && logs.length > 0"
|
||||||
<div class="text-sm text-gray-600 px-4 py-4">
|
:page="pagination.page"
|
||||||
Menampilkan data {{ 1 }} - {{ logs.length }}
|
:page-size="pagination.pageSize"
|
||||||
</div>
|
:total-count="pagination.totalCount"
|
||||||
|
:start-index="pagination.startIndex"
|
||||||
<!-- Pagination Controls -->
|
:end-index="pagination.endIndex"
|
||||||
<div class="flex justify-between items-center px-4 pb-4">
|
:can-go-next="pagination.canGoNext"
|
||||||
<!-- Items per page selector -->
|
:can-go-previous="pagination.canGoPrevious"
|
||||||
<div class="flex items-center gap-2">
|
:page-size-options="[...ITEMS_PER_PAGE_OPTIONS]"
|
||||||
<span class="text-xs text-gray-600">Data per halaman:</span>
|
:get-page-numbers="pagination.getPageNumbers"
|
||||||
<div class="dropdown dropdown-top">
|
@page-change="pagination.goToPage"
|
||||||
<div
|
@page-size-change="handlePageSizeChange"
|
||||||
tabindex="0"
|
@next="pagination.nextPage"
|
||||||
role="button"
|
@previous="pagination.previousPage"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import { LOG_TABLE_COLUMNS } from "../../constants/pagination";
|
||||||
const data = ref<Obat>();
|
const data = ref<Obat>();
|
||||||
const dataLog = ref<BlockchainLog[]>([]);
|
const dataLog = ref<BlockchainLog[]>([]);
|
||||||
|
|
||||||
const dataLogRaw = ref<any[]>([]);
|
|
||||||
const currentHash = ref<string>("");
|
const currentHash = ref<string>("");
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
@ -31,49 +30,63 @@ const fetchLogData = async () => {
|
||||||
const result = await api.get<{
|
const result = await api.get<{
|
||||||
currentDataHash: string;
|
currentDataHash: string;
|
||||||
data: Record<string, any>;
|
data: Record<string, any>;
|
||||||
|
isTampered: boolean;
|
||||||
}>(`/obat/${route.params.id}/log`);
|
}>(`/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) {
|
} catch (error) {
|
||||||
dataLog.value = [];
|
dataLog.value = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user