diff --git a/backend/api/src/app.module.ts b/backend/api/src/app.module.ts index b2a25b2..0d8d764 100644 --- a/backend/api/src/app.module.ts +++ b/backend/api/src/app.module.ts @@ -10,6 +10,7 @@ import { ConfigModule } from '@nestjs/config'; 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'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { FabricModule } from './modules/fabric/fabric.module'; ObatModule, PrismaModule, FabricModule, + AuditModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/api/src/common/fabric-gateway/index.ts b/backend/api/src/common/fabric-gateway/index.ts index 353647a..abec209 100644 --- a/backend/api/src/common/fabric-gateway/index.ts +++ b/backend/api/src/common/fabric-gateway/index.ts @@ -227,6 +227,30 @@ class FabricGateway { throw error; } } + + async getLogsWithPagination(pageSize: number, bookmark: string) { + try { + if (!this.contract) { + throw new Error('Not connected to network. Call connect() first.'); + } + + console.log( + `Evaluating getLogWithPagination transaction with pageSize: ${pageSize}, bookmark: ${bookmark}...`, + ); + const resultBytes = await this.contract.evaluateTransaction( + 'getLogsWithPagination', + pageSize.toString(), + bookmark, + ); + const resultJson = new TextDecoder().decode(resultBytes); + + const result = JSON.parse(resultJson); + return result; + } catch (error) { + console.error('Failed to get logs with pagination:', error); + throw error; + } + } } export default FabricGateway; diff --git a/backend/api/src/modules/audit/audit.controller.spec.ts b/backend/api/src/modules/audit/audit.controller.spec.ts new file mode 100644 index 0000000..e3548b4 --- /dev/null +++ b/backend/api/src/modules/audit/audit.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuditController } from './audit.controller'; + +describe('AuditController', () => { + let controller: AuditController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuditController], + }).compile(); + + controller = module.get(AuditController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/api/src/modules/audit/audit.controller.ts b/backend/api/src/modules/audit/audit.controller.ts new file mode 100644 index 0000000..94882c4 --- /dev/null +++ b/backend/api/src/modules/audit/audit.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '../auth/guard/auth.guard'; +import { AuditService } from './audit.service'; + +@Controller('audit') +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get('/trail') + @UseGuards(AuthGuard) + async getAuditTrail( + @Query('pageSize') pageSize: number, + @Query('bookmark') bookmark: string, + ) { + console.log('Audit trail accessed'); + const result = await this.auditService.getAuditTrails(pageSize, bookmark); + return result; + } +} diff --git a/backend/api/src/modules/audit/audit.module.ts b/backend/api/src/modules/audit/audit.module.ts new file mode 100644 index 0000000..fa6af66 --- /dev/null +++ b/backend/api/src/modules/audit/audit.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { AuditService } from './audit.service'; +import { AuditController } from './audit.controller'; +import { FabricModule } from '../fabric/fabric.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ObatModule } from '../obat/obat.module'; +import { RekamMedisModule } from '../rekammedis/rekammedis.module'; +import { TindakanDokterModule } from '../tindakandokter/tindakandokter.module'; +import { LogModule } from '../log/log.module'; + +@Module({ + imports: [ + LogModule, + FabricModule, + PrismaModule, + ObatModule, + RekamMedisModule, + TindakanDokterModule, + ], + providers: [AuditService], + controllers: [AuditController], +}) +export class AuditModule {} diff --git a/backend/api/src/modules/audit/audit.service.spec.ts b/backend/api/src/modules/audit/audit.service.spec.ts new file mode 100644 index 0000000..fcd4965 --- /dev/null +++ b/backend/api/src/modules/audit/audit.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuditService } from './audit.service'; + +describe('AuditService', () => { + let service: AuditService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AuditService], + }).compile(); + + service = module.get(AuditService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/api/src/modules/audit/audit.service.ts b/backend/api/src/modules/audit/audit.service.ts new file mode 100644 index 0000000..42c13ae --- /dev/null +++ b/backend/api/src/modules/audit/audit.service.ts @@ -0,0 +1,92 @@ +import { Injectable } 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 { sha256 } from '@api/common/crypto/hash'; + +@Injectable() +export class AuditService { + constructor( + // private readonly prisma: PrismaService, + private readonly logService: LogService, + private readonly obatService: ObatService, + private readonly rekamMedisService: RekammedisService, + private readonly tindakanDokterService: TindakanDokterService, + ) {} + + 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 formattedLogs = await Promise.all( + 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') { + 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' + ) { + 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)); + } + + return { + ...log, + isTampered: payloadHash === log.payload, + }; + }), + ); + + return formattedLogs; + } +} diff --git a/backend/api/src/modules/fabric/fabric.service.ts b/backend/api/src/modules/fabric/fabric.service.ts index 3a4eb8e..83671a2 100644 --- a/backend/api/src/modules/fabric/fabric.service.ts +++ b/backend/api/src/modules/fabric/fabric.service.ts @@ -41,4 +41,11 @@ export class FabricService implements OnModuleInit, OnApplicationShutdown { this.logger.log('Retrieving all logs from Fabric network'); return this.gateway.getAllLogs(); } + + async getLogsWithPagination(pageSize: number, bookmark: string) { + this.logger.log( + `Retrieving logs with pagination - Page Size: ${pageSize}, Bookmark: ${bookmark}`, + ); + return this.gateway.getLogsWithPagination(pageSize, bookmark); + } } diff --git a/backend/api/src/modules/log/log.service.ts b/backend/api/src/modules/log/log.service.ts index e0dbc62..504cd1d 100644 --- a/backend/api/src/modules/log/log.service.ts +++ b/backend/api/src/modules/log/log.service.ts @@ -22,4 +22,8 @@ export class LogService { const countLogs = await this.fabricService.getAllLogs(); return countLogs.length; } + + async getLogsWithPagination(pageSize: number, bookmark: string) { + return this.fabricService.getLogsWithPagination(pageSize, bookmark); + } } diff --git a/frontend/hospital-log/src/components/dashboard/DataTable.vue b/frontend/hospital-log/src/components/dashboard/DataTable.vue index 20eff7e..8a9d802 100644 --- a/frontend/hospital-log/src/components/dashboard/DataTable.vue +++ b/frontend/hospital-log/src/components/dashboard/DataTable.vue @@ -33,11 +33,11 @@ const formatCellValue = (item: T, columnKey: keyof T) => { if (columnKey === "event" && typeof value === "string") { const segments = value.split("_"); - if (segments.length >= 3 && segments[segments.length - 1] === "created") { + if (segments.length >= 2 && segments[segments.length - 1] === "created") { return "CREATE"; } - if (segments.length >= 3 && segments[segments.length - 1] === "updated") { + if (segments.length >= 2 && segments[segments.length - 1] === "updated") { return "UPDATE"; } } @@ -115,7 +115,10 @@ const handleDeleteCancel = () => { v-else v-for="item in data" :key="item.id" - :class="'hover:bg-dark hover:text-light transition-colors'" + :class="[ + 'hover:bg-dark hover:text-light transition-colors', + (item as Record).isTampered ? 'bg-red-300 text-dark' : '' + ]" > ; page: Ref; - totalCount: Ref; + totalCount?: Ref; startIndex: Ref; endIndex: Ref; canGoNext: Ref; diff --git a/frontend/hospital-log/src/components/dashboard/Sidebar.vue b/frontend/hospital-log/src/components/dashboard/Sidebar.vue index d1a9c0f..9ef6e7b 100644 --- a/frontend/hospital-log/src/components/dashboard/Sidebar.vue +++ b/frontend/hospital-log/src/components/dashboard/Sidebar.vue @@ -265,6 +265,39 @@ const isActive = (routeName: string) => { + +
  • + +
  • +
  • diff --git a/frontend/hospital-log/src/routes/index.ts b/frontend/hospital-log/src/routes/index.ts index 25b1704..e1bafea 100644 --- a/frontend/hospital-log/src/routes/index.ts +++ b/frontend/hospital-log/src/routes/index.ts @@ -16,6 +16,7 @@ import CreateObatView from "../views/dashboard/CreateObatView.vue"; import CreateTindakanDokterView from "../views/dashboard/CreateTindakanDokterView.vue"; import TindakanDokterEditView from "../views/dashboard/TindakanDokterEditView.vue"; import TindakanDokterDetailsView from "../views/dashboard/TindakanDokterDetailsView.vue"; +import AuditTrailView from "../views/dashboard/AuditTrailView.vue"; const routes = [ { @@ -108,6 +109,12 @@ const routes = [ component: UsersView, meta: { requiresAuth: true }, }, + { + path: "/audit-trail", + name: "audit-trail", + component: AuditTrailView, + meta: { requiresAuth: true }, + }, { path: "/:catchAll(.*)*", name: "NotFound", diff --git a/frontend/hospital-log/src/views/dashboard/AuditTrailView.vue b/frontend/hospital-log/src/views/dashboard/AuditTrailView.vue new file mode 100644 index 0000000..a3feeda --- /dev/null +++ b/frontend/hospital-log/src/views/dashboard/AuditTrailView.vue @@ -0,0 +1,499 @@ + + + + + diff --git a/frontend/hospital-log/src/views/dashboard/ObatView.vue b/frontend/hospital-log/src/views/dashboard/ObatView.vue index ad69dab..47ce3c2 100644 --- a/frontend/hospital-log/src/views/dashboard/ObatView.vue +++ b/frontend/hospital-log/src/views/dashboard/ObatView.vue @@ -43,6 +43,9 @@ const pagination = usePagination({ initialPage: Number(route.query.page) || 1, initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE, }); +const sortOrder = ref<"asc" | "desc">( + (route.query.order as "asc" | "desc") || "asc" +); const tableColumns = [ { key: "id" as keyof ObatData, label: "#", class: "text-dark" }, @@ -74,6 +77,8 @@ const updateQueryParams = () => { query.sortBy = sortBy.value; } + query.order = sortOrder.value; + router.replace({ query }); }; @@ -83,6 +88,7 @@ const fetchData = async () => { take: pagination.pageSize.value.toString(), page: pagination.page.value.toString(), orderBy: sortBy.value, + order: sortOrder.value, ...(searchObat.value && { obat: searchObat.value }), }); @@ -131,6 +137,10 @@ const handleSortChange = (newSortBy: string) => { fetchData(); }; +const toggleSortOrder = () => { + sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc"; +}; + const handlePageSizeChange = (newSize: number) => { pagination.setPageSize(newSize); fetchData(); @@ -160,6 +170,11 @@ watch([() => pagination.page.value], () => { fetchData(); }); +watch(sortOrder, () => { + pagination.reset(); + fetchData(); +}); + watch(searchObat, (newValue, oldValue) => { if (oldValue && !newValue) { pagination.reset(); @@ -174,6 +189,12 @@ onMounted(async () => { if (route.query.sortBy) { sortBy.value = route.query.sortBy as string; } + if (route.query.order) { + const incomingOrder = route.query.order as string; + if (incomingOrder === "asc" || incomingOrder === "desc") { + sortOrder.value = incomingOrder; + } + } await fetchData(); document.title = "Obat - Hospital Log"; @@ -186,25 +207,49 @@ onMounted(async () => {
    -
    -
    - - Tambah Obat - -
    -
    +
    +
    - +
    + + +
    + + Tambah Obat +
    diff --git a/frontend/hospital-log/src/views/dashboard/RekamMedisView.vue b/frontend/hospital-log/src/views/dashboard/RekamMedisView.vue index 4047f37..a9972fc 100644 --- a/frontend/hospital-log/src/views/dashboard/RekamMedisView.vue +++ b/frontend/hospital-log/src/views/dashboard/RekamMedisView.vue @@ -45,6 +45,9 @@ const pagination = usePagination({ initialPage: Number(route.query.page) || 1, initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE, }); +const sortOrder = ref<"asc" | "desc">( + (route.query.order as "asc" | "desc") || "desc" +); const today: string = new Date().toISOString().split("T")[0] || ""; const ageSliderRef = ref(null); const ageRange = ref<[number, number]>([0, 100]); @@ -83,6 +86,8 @@ const updateQueryParams = () => { query.sortBy = sortBy.value; } + query.order = sortOrder.value; + if (filter.value.id_visit) { query.id_visit = filter.value.id_visit; } @@ -169,6 +174,7 @@ const fetchData = async (isFirst?: boolean) => { take: pagination.pageSize.value.toString(), page: pagination.page.value.toString(), orderBy: sortBy.value, + order: sortOrder.value, ...(searchRekamMedis.value && { no_rm: searchRekamMedis.value }), ...(filter.value.id_visit && { id_visit: filter.value.id_visit }), ...(filter.value.nama_pasien && { @@ -250,6 +256,10 @@ const handleSortChange = (newSortBy: string) => { fetchData(); }; +const toggleSortOrder = () => { + sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc"; +}; + const handlePageSizeChange = (newSize: number) => { pagination.setPageSize(newSize); fetchData(); @@ -281,6 +291,11 @@ watch([() => pagination.page.value], () => { fetchData(); }); +watch(sortOrder, () => { + pagination.reset(); + fetchData(); +}); + watch(searchRekamMedis, (newValue, oldValue) => { if (oldValue && !newValue) { pagination.reset(); @@ -295,6 +310,12 @@ onMounted(async () => { if (route.query.sortBy) { sortBy.value = route.query.sortBy as string; } + if (route.query.order) { + const incomingOrder = route.query.order as string; + if (incomingOrder === "asc" || incomingOrder === "desc") { + sortOrder.value = incomingOrder; + } + } await fetchData(true); filter.value.rentang_umur = [ageRange.value[0], ageRange.value[1]]; @@ -540,30 +561,49 @@ onBeforeUnmount(() => {
    -
    -
    - - Tambah Rekam Medis - -
    -
    +
    +
    - +
    + + +
    + + Tambah Rekam Medis +
    diff --git a/frontend/hospital-log/src/views/dashboard/TindakanView.vue b/frontend/hospital-log/src/views/dashboard/TindakanView.vue index e698b59..17a32f2 100644 --- a/frontend/hospital-log/src/views/dashboard/TindakanView.vue +++ b/frontend/hospital-log/src/views/dashboard/TindakanView.vue @@ -38,6 +38,9 @@ const pagination = usePagination({ initialPage: Number(route.query.page) || 1, initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE, }); +const sortOrder = ref<"asc" | "desc">( + (route.query.order as "asc" | "desc") || "asc" +); const filter = ref<{ tindakan: string | null; kategori: string[]; @@ -62,6 +65,8 @@ const updateQueryParams = () => { query.sortBy = sortBy.value; } + query.order = sortOrder.value; + router.replace({ query }); }; @@ -70,6 +75,7 @@ const fetchData = async () => { take: pagination.pageSize.value.toString(), page: pagination.page.value.toString(), orderBy: sortBy.value, + order: sortOrder.value, ...(searchIdVisit.value && { id_visit: searchIdVisit.value }), ...(filter.value.tindakan && { tindakan: filter.value.tindakan }), ...(filter.value.kategori.length > 0 @@ -158,6 +164,10 @@ const handleSortChange = (newSortBy: string) => { fetchData(); }; +const toggleSortOrder = () => { + sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc"; +}; + const handlePageSizeChange = (newSize: number) => { pagination.setPageSize(newSize); fetchData(); @@ -189,6 +199,11 @@ watch([() => pagination.page.value], () => { fetchData(); }); +watch(sortOrder, () => { + pagination.reset(); + fetchData(); +}); + watch(searchIdVisit, (newValue, oldValue) => { if (oldValue && !newValue) { pagination.reset(); @@ -203,6 +218,12 @@ onMounted(async () => { if (route.query.sortBy) { sortBy.value = route.query.sortBy as string; } + if (route.query.order) { + const incomingOrder = route.query.order as string; + if (incomingOrder === "asc" || incomingOrder === "desc") { + sortOrder.value = incomingOrder; + } + } await fetchData(); document.title = "Tindakan Dokter - Hospital Log"; @@ -307,30 +328,49 @@ onMounted(async () => {
    -
    -
    - - Tambah Pemberian Tindakan - -
    -
    +
    +
    - +
    + + +
    + + Tambah Pemberian Tindakan +
    diff --git a/frontend/hospital-log/src/views/dashboard/UsersView.vue b/frontend/hospital-log/src/views/dashboard/UsersView.vue index e0a5aba..e0562e6 100644 --- a/frontend/hospital-log/src/views/dashboard/UsersView.vue +++ b/frontend/hospital-log/src/views/dashboard/UsersView.vue @@ -37,6 +37,9 @@ const pagination = usePagination({ initialPage: Number(route.query.page) || 1, initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE, }); +const sortOrder = ref<"asc" | "desc">( + (route.query.order as "asc" | "desc") || "asc" +); const updateQueryParams = () => { const query: Record = { @@ -52,6 +55,8 @@ const updateQueryParams = () => { query.sortBy = sortBy.value; } + query.order = sortOrder.value; + router.replace({ query }); }; @@ -61,6 +66,7 @@ const fetchData = async () => { take: pagination.pageSize.value.toString(), page: pagination.page.value.toString(), orderBy: sortBy.value, + order: sortOrder.value, ...(searchUsername.value ? { username: searchUsername.value } : {}), }); @@ -106,6 +112,10 @@ const handleSortChange = (newSortBy: string) => { fetchData(); }; +const toggleSortOrder = () => { + sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc"; +}; + const handlePageSizeChange = (newSize: number) => { pagination.setPageSize(newSize); fetchData(); @@ -137,6 +147,11 @@ watch([() => pagination.page.value], () => { fetchData(); }); +watch(sortOrder, () => { + pagination.reset(); + fetchData(); +}); + watch(searchUsername, (newValue, oldValue) => { if (oldValue && !newValue) { pagination.reset(); @@ -145,12 +160,18 @@ watch(searchUsername, (newValue, oldValue) => { }); onMounted(async () => { - if (route.query.search) { - searchUsername.value = route.query.search as string; + if (route.query.username) { + searchUsername.value = route.query.username as string; } if (route.query.sortBy) { sortBy.value = route.query.sortBy as string; } + if (route.query.order) { + const incomingOrder = route.query.order as string; + if (incomingOrder === "asc" || incomingOrder === "desc") { + sortOrder.value = incomingOrder; + } + } await fetchData(); document.title = "Users - Hospital Log"; @@ -164,19 +185,49 @@ onMounted(async () => {
    -
    - - - +
    +
    + +
    + + +
    +
    + + Tambah Pengguna +