feat: done filter and search audit, done audit trail function, done webservice for proof

This commit is contained in:
yosaphatprs 2025-11-17 11:14:13 +07:00
parent 9f6d861ea0
commit bd7f93e826
24 changed files with 1404 additions and 171 deletions

View File

@ -0,0 +1,28 @@
{
"cursors": {
"pemberian_obat": "5",
"rekam_medis": "100079",
"pemberian_tindakan": "5"
},
"failures": {},
"metadata": {
"pemberian_obat": {
"lastRunAt": "2025-11-14T03:35:57.788Z",
"processed": 5,
"success": 5,
"failed": 0
},
"rekam_medis": {
"lastRunAt": "2025-11-14T03:35:57.788Z",
"processed": 5,
"success": 5,
"failed": 0
},
"pemberian_tindakan": {
"lastRunAt": "2025-11-14T03:35:57.788Z",
"processed": 5,
"success": 5,
"failed": 0
}
}
}

View File

@ -14,17 +14,22 @@
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "^11.0.1", "@nestjs/jwt": "^11.0.1",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.8",
"@nestjs/websockets": "^11.1.8",
"@prisma/client": "^6.17.1", "@prisma/client": "^6.17.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"snarkjs": "^0.7.5" "snarkjs": "^0.7.5",
"socket.io": "^4.8.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@ -33,6 +38,7 @@
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.9", "@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
@ -2487,6 +2493,19 @@
} }
} }
}, },
"node_modules/@nestjs/event-emitter": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.1.tgz",
"integrity": "sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==",
"license": "MIT",
"dependencies": {
"eventemitter2": "6.4.9"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/jwt": { "node_modules/@nestjs/jwt": {
"version": "11.0.1", "version": "11.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.1.tgz", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.1.tgz",
@ -2541,6 +2560,25 @@
"@nestjs/core": "^11.0.0" "@nestjs/core": "^11.0.0"
} }
}, },
"node_modules/@nestjs/platform-socket.io": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.8.tgz",
"integrity": "sha512-nMUvwcdztso8BjN9czRl4sm0Ewc5xrCcgLvy+QPt6VAnTdu06KcZqtA6Cl3MKxViSQsQ8NBN5foKvZehNt/tug==",
"license": "MIT",
"dependencies": {
"socket.io": "4.8.1",
"tslib": "2.8.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nest"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/websockets": "^11.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/schematics": { "node_modules/@nestjs/schematics": {
"version": "11.0.9", "version": "11.0.9",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.9.tgz", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.9.tgz",
@ -2667,6 +2705,29 @@
} }
} }
}, },
"node_modules/@nestjs/websockets": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.8.tgz",
"integrity": "sha512-RXo2336p/vyAwJ0qPInglzNSQ//qz+JTLr2LE1vlbmN5WcyB7zV6+gY06YgNdsr3oy/cXRh7fnC3Ph/VAu1EVg==",
"license": "MIT",
"dependencies": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
"tslib": "2.8.1"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-socket.io": "^11.0.0",
"reflect-metadata": "^0.1.12 || ^0.2.0",
"rxjs": "^7.1.0"
},
"peerDependenciesMeta": {
"@nestjs/platform-socket.io": {
"optional": true
}
}
},
"node_modules/@noble/curves": { "node_modules/@noble/curves": {
"version": "1.9.7", "version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
@ -2958,6 +3019,12 @@
"@sinonjs/commons": "^3.0.1" "@sinonjs/commons": "^3.0.1"
} }
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@ -3104,6 +3171,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cookie-parser": { "node_modules/@types/cookie-parser": {
"version": "1.4.9", "version": "1.4.9",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz",
@ -3121,6 +3195,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/eslint": { "node_modules/@types/eslint": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@ -4492,6 +4575,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.18", "version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
@ -5235,12 +5327,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.7.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">=18"
} }
}, },
"node_modules/cookie-parser": { "node_modules/cookie-parser": {
@ -5256,6 +5348,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": { "node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@ -5616,6 +5717,104 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/engine.io": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
"integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.3", "version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@ -6034,6 +6233,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter2": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
"license": "MIT"
},
"node_modules/events": { "node_modules/events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@ -6145,6 +6350,15 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/exsolve": { "node_modules/exsolve": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
@ -9069,6 +9283,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -10190,6 +10413,141 @@
"snarkjs": "build/cli.cjs" "snarkjs": "build/cli.cjs"
} }
}, },
"node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
"integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
"license": "MIT",
"dependencies": {
"debug": "~4.3.4",
"ws": "~8.17.1"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.7.4", "version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
@ -11712,6 +12070,27 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0" "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
} }
}, },
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -25,17 +25,22 @@
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "^11.0.1", "@nestjs/jwt": "^11.0.1",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.8",
"@nestjs/websockets": "^11.1.8",
"@prisma/client": "^6.17.1", "@prisma/client": "^6.17.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"snarkjs": "^0.7.5" "snarkjs": "^0.7.5",
"socket.io": "^4.8.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@ -44,6 +49,7 @@
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.9", "@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",

View File

@ -12,6 +12,7 @@ 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'; import { ProofModule } from './modules/proof/proof.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
@Module({ @Module({
imports: [ imports: [
@ -28,6 +29,7 @@ import { ProofModule } from './modules/proof/proof.module';
FabricModule, FabricModule,
AuditModule, AuditModule,
ProofModule, ProofModule,
EventEmitterModule.forRoot(),
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@ -238,7 +238,7 @@ class FabricGateway {
`Evaluating getLogWithPagination transaction with pageSize: ${pageSize}, bookmark: ${bookmark}...`, `Evaluating getLogWithPagination transaction with pageSize: ${pageSize}, bookmark: ${bookmark}...`,
); );
const resultBytes = await this.contract.evaluateTransaction( const resultBytes = await this.contract.evaluateTransaction(
'getLogsWithPagination', 'getLogWithPagination',
pageSize.toString(), pageSize.toString(),
bookmark, bookmark,
); );

View File

@ -14,7 +14,7 @@ async function bootstrap() {
const configService = app.get(ConfigService); const configService = app.get(ConfigService);
app.setGlobalPrefix('api/'); app.setGlobalPrefix('api/');
app.enableCors({ app.enableCors({
origin: 'http://localhost:5173', origin: 'https://64spbch3-5173.asse.devtunnels.ms',
credentials: true, credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: 'Content-Type, Accept, X-CSRF-Token', allowedHeaders: 'Content-Type, Accept, X-CSRF-Token',

View File

@ -1,25 +1,63 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; import {
Body,
Controller,
Get,
Post,
Query,
Sse,
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';
import { fromEvent, interval, map, Observable } from 'rxjs';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Controller('audit') @Controller('audit')
export class AuditController { export class AuditController {
constructor(private readonly auditService: AuditService) {} constructor(
private readonly auditService: AuditService,
private eventEmitter: EventEmitter2,
) {}
@Get('/trail') @Get('/trail')
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
async getAuditTrail( async getAuditTrail(
@Query('search') search: string,
@Query('page') page: number,
@Query('pageSize') pageSize: number, @Query('pageSize') pageSize: number,
@Query('bookmark') bookmark: string, @Query('type') type: string,
@Query('tampered') tampered: string,
) { ) {
const result = await this.auditService.getAuditTrails(pageSize, bookmark); const result = await this.auditService.getAuditTrails(
search,
page,
pageSize,
type,
tampered,
);
return result; return result;
} }
@Post('/trail') @Post('trail')
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
async createAuditTrail() { createAuditTrail() {
const result = await this.auditService.storeAuditTrail(); this.auditService.storeAuditTrail();
return result; return { message: 'Proses audit trail dijalankan', status: 'STARTED' };
// return interval(1000).pipe(
// map(
// (_) =>
// ({ data: { message: 'Audit trail in progress...' } }) as MessageEvent,
// ),
// );
}
@Sse('stream')
@UseGuards(AuthGuard)
auditStream(): Observable<MessageEvent> {
return fromEvent(this.eventEmitter, 'audit.*').pipe(
map((data: any) => {
return new MessageEvent('message', { data: data });
}),
);
} }
} }

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuditGateway } from './audit.gateway';
describe('AuditGateway', () => {
let gateway: AuditGateway;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AuditGateway],
}).compile();
gateway = module.get<AuditGateway>(AuditGateway);
});
it('should be defined', () => {
expect(gateway).toBeDefined();
});
});

View File

@ -0,0 +1,38 @@
import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { UseGuards, Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { WebsocketGuard } from '../auth/guard/websocket.guard';
@WebSocketGateway({
cors: {
origin: 'https://64spbch3-5173.asse.devtunnels.ms',
credentials: true,
},
})
export class AuditGateway {
@WebSocketServer()
server: Server;
private logger = new Logger('AuditGateway');
@UseGuards(WebsocketGuard)
handleConnection(client: Socket) {
this.logger.log(`Klien terhubung: ${client.id}`);
}
handleDisconnect(client: Socket) {
this.logger.log(`Klien terputus: ${client.id}`);
}
sendProgress(progressData: any) {
this.server.emit('audit.progress', progressData);
}
sendComplete(completeData: any) {
this.server.emit('audit.complete', completeData);
}
sendError(errorData: any) {
this.server.emit('audit.error', errorData);
}
}

View File

@ -7,6 +7,8 @@ import { ObatModule } from '../obat/obat.module';
import { RekamMedisModule } from '../rekammedis/rekammedis.module'; import { RekamMedisModule } from '../rekammedis/rekammedis.module';
import { TindakanDokterModule } from '../tindakandokter/tindakandokter.module'; import { TindakanDokterModule } from '../tindakandokter/tindakandokter.module';
import { LogModule } from '../log/log.module'; import { LogModule } from '../log/log.module';
import { AuditGateway } from './audit.gateway';
import { WebsocketGuard } from '../auth/guard/websocket.guard';
@Module({ @Module({
imports: [ imports: [
@ -17,7 +19,7 @@ import { LogModule } from '../log/log.module';
RekamMedisModule, RekamMedisModule,
TindakanDokterModule, TindakanDokterModule,
], ],
providers: [AuditService], providers: [AuditService, AuditGateway, WebsocketGuard],
controllers: [AuditController], controllers: [AuditController],
}) })
export class AuditModule {} export class AuditModule {}

View File

@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { LogService } from '../log/log.service'; import { LogService } from '../log/log.service';
import { ObatService } from '../obat/obat.service'; import { ObatService } from '../obat/obat.service';
@ -9,6 +9,7 @@ import type {
AuditEvent, AuditEvent,
resultStatus as ResultStatus, resultStatus as ResultStatus,
} from '@dist/generated/prisma'; } from '@dist/generated/prisma';
import { AuditGateway } from './audit.gateway';
type AuditRecordPayload = { type AuditRecordPayload = {
id: string; id: string;
@ -22,23 +23,62 @@ type AuditRecordPayload = {
@Injectable() @Injectable()
export class AuditService { export class AuditService {
private readonly logger = new Logger(AuditService.name);
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,
private readonly tindakanDokterService: TindakanDokterService, private readonly tindakanDokterService: TindakanDokterService,
private auditGateway: AuditGateway,
) {} ) {}
async getAuditTrails(pageSize: number, bookmark: string) { async getAuditTrails(
search: string,
page: number,
pageSize: number,
type?: string,
tampered?: string,
) {
if (type === 'all' || type === 'initial') {
type = undefined;
} else if (type === 'rekam_medis') {
type = 'REKAM';
} else if (type === 'tindakan') {
type = 'TINDAKAN';
} else if (type === 'obat') {
type = 'OBAT';
}
if (tampered === 'all' || tampered === 'initial' || !tampered) {
tampered = undefined;
}
console.log(type, tampered);
const auditLogs = await this.prisma.audit.findMany({ const auditLogs = await this.prisma.audit.findMany({
take: pageSize, take: pageSize,
skip: bookmark ? 1 : 0, skip: (page - 1) * pageSize,
cursor: bookmark ? { id: bookmark } : undefined,
orderBy: { timestamp: 'asc' }, orderBy: { timestamp: 'asc' },
where: {
id: type && type !== 'all' ? { startsWith: type } : undefined,
result: tampered ? (tampered as ResultStatus) : undefined,
OR: search ? [{ id: { contains: search } }] : undefined,
},
}); });
return auditLogs; console.log(auditLogs);
const count = await this.prisma.audit.count({
where: {
id: type && type !== 'all' ? { startsWith: type } : undefined,
result: tampered ? (tampered as ResultStatus) : undefined,
},
});
return {
...auditLogs,
totalCount: count,
};
} }
async storeAuditTrail() { async storeAuditTrail() {
@ -47,6 +87,30 @@ export class AuditService {
let bookmark = ''; let bookmark = '';
let processedCount = 0; let processedCount = 0;
// try {
// const intervalId = setInterval(() => {
// processedCount++;
// const progressData = {
// status: 'RUNNING',
// progress_count: processedCount,
// };
// this.logger.log('Mengirim progres via WebSocket:', progressData);
// // PANGGIL FUNGSI GATEWAY
// this.auditGateway.sendProgress(progressData);
// if (processedCount >= BATCH_SIZE) {
// clearInterval(intervalId);
// const completeData = { status: 'COMPLETED' };
// this.logger.log('Mengirim selesai via WebSocket:', completeData);
// // PANGGIL FUNGSI GATEWAY
// this.auditGateway.sendComplete(completeData);
// }
// }, 500);
// } catch (error) {
// console.error('Tes streaming GAGAL', error);
// }
try { try {
while (true) { while (true) {
const pageResults = await this.logService.getLogsWithPagination( const pageResults = await this.logService.getLogsWithPagination(
@ -66,11 +130,23 @@ export class AuditService {
break; break;
} }
const records = ( // const records = (
await Promise.all( // await Promise.all(
logs.map((logEntry) => this.buildAuditRecord(logEntry)), // logs.map((logEntry, index) =>
) // this.buildAuditRecord(logEntry, index),
).filter((record): record is AuditRecordPayload => record !== null); // ),
// )
// ).filter((record): record is AuditRecordPayload => record !== null);
const records: AuditRecordPayload[] = [];
for (let index = 0; index < logs.length; index++) {
const record = await this.buildAuditRecord(logs[index], index);
if (record !== null) {
records.push(record);
}
// Wait 1 second before processing next item
await new Promise((resolve) => setTimeout(resolve, 250));
}
if (records.length > 0) { if (records.length > 0) {
await this.prisma.$transaction( await this.prisma.$transaction(
@ -93,6 +169,9 @@ export class AuditService {
} }
if (nextBookmark === '' || nextBookmark === bookmark) { if (nextBookmark === '' || nextBookmark === bookmark) {
const completeData = { status: 'COMPLETED' };
this.logger.log('Mengirim selesai via WebSocket:', completeData);
this.auditGateway.sendComplete(completeData);
break; break;
} }
@ -106,6 +185,7 @@ export class AuditService {
private async buildAuditRecord( private async buildAuditRecord(
logEntry: any, logEntry: any,
index?: number,
): Promise<AuditRecordPayload | null> { ): Promise<AuditRecordPayload | null> {
if (!logEntry?.value) { if (!logEntry?.value) {
return null; return null;
@ -128,7 +208,7 @@ export class AuditService {
} }
let dbHash: string | null = null; let dbHash: string | null = null;
//
try { try {
if (logId.startsWith('OBAT_')) { if (logId.startsWith('OBAT_')) {
const obatId = this.extractNumericId(logId); const obatId = this.extractNumericId(logId);
@ -184,6 +264,14 @@ export class AuditService {
const result: ResultStatus = isNotTampered ? 'non_tampered' : 'tampered'; const result: ResultStatus = isNotTampered ? 'non_tampered' : 'tampered';
const progressData = {
status: 'RUNNING',
progress_count: index ?? 0,
};
this.logger.log('Mengirim progres via WebSocket:', progressData);
this.auditGateway.sendProgress(progressData);
return { return {
id: logId, id: logId,
event: value.event as AuditEvent, event: value.event as AuditEvent,

View File

@ -0,0 +1,11 @@
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { WebsocketGuard } from './websocket.guard';
describe('WebsocketGuard', () => {
it('should be defined', () => {
expect(
new WebsocketGuard(new JwtService(), new ConfigService()),
).toBeDefined();
});
});

View File

@ -0,0 +1,60 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Socket } from 'socket.io';
import * as cookie from 'cookie';
interface AuthPayload {
sub: number;
username: string;
role: string;
}
interface SocketWithAuth extends Socket {
user: AuthPayload;
}
@Injectable()
export class WebsocketGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const client: Socket = context.switchToWs().getClient<Socket>();
const token = this.extractTokenFromCookie(client.handshake.headers.cookie);
if (!token) {
client.disconnect();
throw new UnauthorizedException('No token provided');
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('JWT_SECRET'),
});
client.data.user = payload as AuthPayload;
} catch {
client.disconnect();
throw new UnauthorizedException('Invalid token');
}
return true;
}
private extractTokenFromCookie(
cookieHeader: string | undefined,
): string | undefined {
if (!cookieHeader) return undefined;
const cookies = cookie.parse(cookieHeader);
return cookies.access_token;
}
}

View File

@ -17,7 +17,7 @@ export class StoreLogDto {
@IsEnum( @IsEnum(
[ [
'tindakan_dokter_created', 'tindakan_dokter_created',
'obat_given', 'obat_created',
'rekam_medis_created', 'rekam_medis_created',
'tindakan_dokter_updated', 'tindakan_dokter_updated',
'obat_updated', 'obat_updated',

View File

@ -1,14 +1,16 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { Controller, Post, UseGuards } from '@nestjs/common';
import { StoreLogDto } from './dto/store-log.dto';
import { LogService } from './log.service'; import { LogService } from './log.service';
import { FabricService } from '../fabric/fabric.service'; import { AuthGuard } from '../auth/guard/auth.guard';
@Controller('log') @Controller('log')
export class LogController { export class LogController {
constructor( constructor(private readonly logService: LogService) {}
private readonly logService: LogService,
private readonly fabricService: FabricService, @Post('/store-to-blockchain')
) {} @UseGuards(AuthGuard)
async storeLog() {
return this.logService.storeFromDBToBlockchain();
}
// @Post() // @Post()
// storeLog(@Body() dto: StoreLogDto) { // storeLog(@Body() dto: StoreLogDto) {

View File

@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { LogController } from './log.controller'; import { LogController } from './log.controller';
import { LogService } from './log.service'; import { LogService } from './log.service';
import { FabricModule } from '../fabric/fabric.module'; import { FabricModule } from '../fabric/fabric.module';
import { PrismaModule } from '../prisma/prisma.module';
@Module({ @Module({
imports: [FabricModule], imports: [FabricModule, PrismaModule],
controllers: [LogController], controllers: [LogController],
providers: [LogService], providers: [LogService],
exports: [LogService], exports: [LogService],

View File

@ -1,12 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { LogService } from './log.service'; import { LogService } from './log.service';
import { FabricService } from '../fabric/fabric.service';
import { PrismaService } from '../prisma/prisma.service';
describe('LogService', () => { describe('LogService', () => {
let service: LogService; let service: LogService;
beforeEach(async () => { beforeEach(async () => {
const fabricServiceMock = {
storeLog: jest.fn(),
getLogById: jest.fn(),
getLogsWithPagination: jest.fn(),
} as unknown as FabricService;
const prismaServiceMock = {
pemberian_obat: { findMany: jest.fn() },
rekam_medis: { findMany: jest.fn() },
pemberian_tindakan: { findMany: jest.fn() },
} as unknown as PrismaService;
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [LogService], providers: [
LogService,
{ provide: FabricService, useValue: fabricServiceMock },
{ provide: PrismaService, useValue: prismaServiceMock },
],
}).compile(); }).compile();
service = module.get<LogService>(LogService); service = module.get<LogService>(LogService);

View File

@ -1,29 +1,389 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { StoreLogDto } from './dto/store-log.dto'; import { promises as fs } from 'node:fs';
import path from 'node:path';
import { sha256 } from '@api/common/crypto/hash';
import { PrismaService } from '../prisma/prisma.service';
import { FabricService } from '../fabric/fabric.service'; import { FabricService } from '../fabric/fabric.service';
import { StoreLogDto } from './dto/store-log.dto';
import type {
pemberian_obat as PemberianObat,
pemberian_tindakan as PemberianTindakan,
rekam_medis as RekamMedis,
} from '@dist/generated/prisma';
export interface BackfillFailure {
entity: EntityKey;
id: string;
reason: string;
timestamp: string;
}
interface BackfillState {
cursors: Partial<Record<EntityKey, string>>;
failures: Record<string, BackfillFailure>;
metadata?: Partial<
Record<
EntityKey,
{
lastRunAt: string;
processed: number;
success: number;
failed: number;
}
>
>;
}
export interface BackfillSummary {
processed: number;
success: number;
failed: number;
lastCursor: string | null;
failures: BackfillFailure[];
}
export type EntityKey = 'pemberian_obat' | 'rekam_medis' | 'pemberian_tindakan';
@Injectable() @Injectable()
export class LogService { export class LogService {
constructor(private readonly fabricService: FabricService) {} private readonly logger = new Logger(LogService.name);
private readonly statePath = path.resolve(
process.cwd(),
'backfill-state.json',
);
private readonly backfillUserId = process.env.BACKFILL_USER_ID ?? '9';
constructor(
private readonly fabricService: FabricService,
private readonly prisma: PrismaService,
) {}
async storeLog(dto: StoreLogDto) { async storeLog(dto: StoreLogDto) {
const { id, event, user_id, payload } = dto; const { id, event, user_id, payload } = dto;
return this.fabricService.storeLog(id, event, user_id.toString(), payload); return this.fabricService.storeLog(id, event, user_id.toString(), payload);
} }
async getLogById(id: string) {
const result = await this.fabricService.getLogById(id);
return result;
}
async getAllLogs() {
return this.fabricService.getAllLogs();
}
async countLogs() { async getLogById(id: string) {
const countLogs = await this.fabricService.getAllLogs(); return this.fabricService.getLogById(id);
return countLogs.length;
} }
async getLogsWithPagination(pageSize: number, bookmark: string) { async getLogsWithPagination(pageSize: number, bookmark: string) {
return this.fabricService.getLogsWithPagination(pageSize, bookmark); return this.fabricService.getLogsWithPagination(pageSize, bookmark);
} }
async storeFromDBToBlockchain() {}
// async storeFromDBToBlockchain(
// limitPerEntity = 5,
// batchSize = 1,
// ): Promise<{
// summaries: Record<string, BackfillSummary>;
// checkpointFile: string;
// }> {
// const state = await this.loadState();
// const summaries = {
// pemberian_obat: await this.syncPemberianObat(
// state,
// limitPerEntity,
// batchSize,
// ),
// rekam_medis: await this.syncRekamMedis(state, limitPerEntity, batchSize),
// pemberian_tindakan: await this.syncPemberianTindakan(
// state,
// limitPerEntity,
// batchSize,
// ),
// } as Record<EntityKey, BackfillSummary>;
// const timestamp = new Date().toISOString();
// await this.persistState({
// ...state,
// metadata: {
// ...(state.metadata ?? {}),
// pemberian_obat: {
// lastRunAt: timestamp,
// processed: summaries.pemberian_obat.processed,
// success: summaries.pemberian_obat.success,
// failed: summaries.pemberian_obat.failed,
// },
// rekam_medis: {
// lastRunAt: timestamp,
// processed: summaries.rekam_medis.processed,
// success: summaries.rekam_medis.success,
// failed: summaries.rekam_medis.failed,
// },
// pemberian_tindakan: {
// lastRunAt: timestamp,
// processed: summaries.pemberian_tindakan.processed,
// success: summaries.pemberian_tindakan.success,
// failed: summaries.pemberian_tindakan.failed,
// },
// },
// });
// return {
// summaries,
// checkpointFile: this.statePath,
// };
// }
private async syncPemberianObat(
state: BackfillState,
limit: number,
batchSize: number,
): Promise<BackfillSummary> {
return this.syncEntity<PemberianObat>(
state,
'pemberian_obat',
limit,
batchSize,
async (cursor, take) => {
const query: any = {
orderBy: { id: 'asc' },
take,
};
if (cursor) {
query.cursor = { id: Number(cursor) };
query.skip = 1;
}
return this.prisma.pemberian_obat.findMany(query);
},
async (record) => {
const payload = {
obat: record.obat,
jumlah_obat: record.jumlah_obat,
aturan_pakai: record.aturan_pakai,
};
const payloadHash = sha256(JSON.stringify(payload));
await this.fabricService.storeLog(
`OBAT_${record.id}`,
'obat_created',
this.backfillUserId,
payloadHash,
);
return `${record.id}`;
},
(record) => `${record.id}`,
);
}
private async syncRekamMedis(
state: BackfillState,
limit: number,
batchSize: number,
): Promise<BackfillSummary> {
return this.syncEntity<RekamMedis>(
state,
'rekam_medis',
limit,
batchSize,
async (cursor, take) => {
const query: any = {
orderBy: { id_visit: 'asc' },
take,
};
if (cursor) {
query.cursor = { id_visit: cursor };
query.skip = 1;
}
return this.prisma.rekam_medis.findMany(query);
},
async (record) => {
const payload = {
dokter_id: 123,
visit_id: record.id_visit,
anamnese: record.anamnese ?? '',
jenis_kasus: record.jenis_kasus ?? '',
tindak_lanjut: record.tindak_lanjut ?? '',
};
const payloadHash = sha256(JSON.stringify(payload));
await this.fabricService.storeLog(
`REKAM_${record.id_visit}`,
'rekam_medis_created',
this.backfillUserId,
payloadHash,
);
return record.id_visit;
},
(record) => record.id_visit,
);
}
private async syncPemberianTindakan(
state: BackfillState,
limit: number,
batchSize: number,
): Promise<BackfillSummary> {
return this.syncEntity<PemberianTindakan>(
state,
'pemberian_tindakan',
limit,
batchSize,
async (cursor, take) => {
const query: any = {
orderBy: { id: 'asc' },
take,
};
if (cursor) {
query.cursor = { id: Number(cursor) };
query.skip = 1;
}
return this.prisma.pemberian_tindakan.findMany(query);
},
async (record) => {
const payload = {
id_visit: record.id_visit,
tindakan: record.tindakan,
kategori_tindakan: record.kategori_tindakan ?? null,
kelompok_tindakan: record.kelompok_tindakan ?? null,
};
const payloadHash = sha256(JSON.stringify(payload));
await this.fabricService.storeLog(
`TINDAKAN_${record.id}`,
'tindakan_dokter_created',
this.backfillUserId,
payloadHash,
);
return `${record.id}`;
},
(record) => `${record.id}`,
);
}
private async syncEntity<T>(
state: BackfillState,
entity: EntityKey,
limit: number,
batchSize: number,
fetchBatch: (cursor: string | null, take: number) => Promise<T[]>,
processRecord: (record: T) => Promise<string>,
recordIdentifier: (record: T) => string,
): Promise<BackfillSummary> {
let cursor = state.cursors[entity] ?? null;
let processed = 0;
let success = 0;
let failed = 0;
while (processed < limit) {
const remaining = limit - processed;
if (remaining <= 0) {
break;
}
const take = Math.min(batchSize, remaining);
const records = await fetchBatch(cursor, take);
if (!records || records.length === 0) {
break;
}
const results = await Promise.allSettled(
records.map(async (record) => processRecord(record)),
);
results.forEach((result, index) => {
const id = recordIdentifier(records[index]);
const key = this.failureKey(entity, id);
if (result.status === 'fulfilled') {
success += 1;
delete state.failures[key];
} else {
failed += 1;
const failure = {
entity,
id,
reason: this.serializeError(result.reason),
timestamp: new Date().toISOString(),
} satisfies BackfillFailure;
state.failures[key] = failure;
this.logger.warn(
`Failed to backfill ${entity} ${id}: ${failure.reason}`,
);
}
});
processed += records.length;
cursor = recordIdentifier(records[records.length - 1]);
state.cursors[entity] = cursor;
await this.persistState(state);
if (records.length < take) {
break;
}
}
return {
processed,
success,
failed,
lastCursor: cursor,
failures: this.collectFailures(entity, state),
};
}
private async loadState(): Promise<BackfillState> {
try {
const raw = await fs.readFile(this.statePath, 'utf8');
const parsed = JSON.parse(raw);
return {
cursors: parsed.cursors ?? {},
failures: parsed.failures ?? {},
metadata: parsed.metadata ?? {},
} satisfies BackfillState;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
return {
cursors: {},
failures: {},
metadata: {},
};
}
throw error;
}
}
private async persistState(state: BackfillState) {
const serializable = {
cursors: state.cursors,
failures: state.failures,
metadata: state.metadata ?? {},
};
await fs.mkdir(path.dirname(this.statePath), { recursive: true });
await fs.writeFile(
this.statePath,
JSON.stringify(serializable, null, 2),
'utf8',
);
}
private collectFailures(
entity: EntityKey,
state: BackfillState,
): BackfillFailure[] {
return Object.values(state.failures).filter(
(entry) => entry.entity === entity,
);
}
private failureKey(entity: EntityKey, id: string) {
return `${entity}:${id}`;
}
private serializeError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
} }

View File

@ -1,25 +1,15 @@
import { import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsBoolean, IsBoolean,
IsDate,
isDateString,
IsIn,
IsNotEmpty, IsNotEmpty,
IsObject, IsObject,
IsOptional,
IsString, IsString,
Length, Length,
Matches,
ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer';
export class LogProofDto { export class LogProofDto {
@IsNotEmpty({ message: 'Proof wajib diisi' }) @IsNotEmpty({ message: 'Proof wajib diisi' })
@IsObject({ message: 'Proof harus berupa objek' }) @IsObject({ message: 'Proof harus berupa objek' })
proofHash: object; proofHash: any;
@IsNotEmpty({ message: 'ID Visit wajib diisi' }) @IsNotEmpty({ message: 'ID Visit wajib diisi' })
@IsString({ message: 'ID Visit harus berupa string' }) @IsString({ message: 'ID Visit harus berupa string' })
@ -32,5 +22,5 @@ export class LogProofDto {
@IsNotEmpty({ message: 'Timestamp wajib diisi' }) @IsNotEmpty({ message: 'Timestamp wajib diisi' })
@IsString({ message: 'Timestamp harus berupa string' }) @IsString({ message: 'Timestamp harus berupa string' })
timestamp: Date; timestamp: string;
} }

View File

@ -13,6 +13,7 @@
"cally": "^0.8.0", "cally": "^0.8.0",
"daisyui": "^5.3.10", "daisyui": "^5.3.10",
"nouislider": "^15.8.1", "nouislider": "^15.8.1",
"socket.io-client": "^4.8.1",
"vee-validate": "^4.15.1", "vee-validate": "^4.15.1",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "4", "vue-router": "4",
@ -158,6 +159,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.1.16", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
@ -795,6 +802,23 @@
"url": "https://github.com/saadeghi/daisyui?sponsor=1" "url": "https://github.com/saadeghi/daisyui?sponsor=1"
} }
}, },
"node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"license": "Apache-2.0", "license": "Apache-2.0",
@ -809,6 +833,28 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.3", "version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@ -984,6 +1030,12 @@
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/muggle-string": { "node_modules/muggle-string": {
"version": "0.4.1", "version": "0.4.1",
"dev": true, "dev": true,
@ -1129,6 +1181,34 @@
"version": "1.0.0-beta.41", "version": "1.0.0-beta.41",
"license": "MIT" "license": "MIT"
}, },
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
@ -1397,6 +1477,35 @@
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
}, },
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",

View File

@ -14,6 +14,7 @@
"cally": "^0.8.0", "cally": "^0.8.0",
"daisyui": "^5.3.10", "daisyui": "^5.3.10",
"nouislider": "^15.8.1", "nouislider": "^15.8.1",
"socket.io-client": "^4.8.1",
"vee-validate": "^4.15.1", "vee-validate": "^4.15.1",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "4", "vue-router": "4",

View File

@ -57,4 +57,23 @@ interface BlockchainLog {
status: string; status: string;
} }
export type { RekamMedis, Users, TindakanDokter, BlockchainLog, Obat }; type AuditLogType = "obat" | "rekam_medis" | "tindakan" | "proof" | "unknown";
interface AuditLogEntry extends BlockchainLog {
type: AuditLogType;
typeLabel: string;
tamperedLabel: string;
last_sync: string;
isTampered: boolean;
txId?: string;
}
export type {
RekamMedis,
Users,
TindakanDokter,
BlockchainLog,
Obat,
AuditLogType,
AuditLogEntry,
};

View File

@ -1,4 +1,5 @@
import type { import type {
AuditLogEntry,
BlockchainLog, BlockchainLog,
RekamMedis, RekamMedis,
TindakanDokter, TindakanDokter,
@ -60,6 +61,18 @@ export const FILTER = {
tindakan: "TINDAKAN", tindakan: "TINDAKAN",
laboratorium: "LABORATORIUM", laboratorium: "LABORATORIUM",
}, },
AUDIT_TYPE: {
all: "Semua Tipe",
rekam_medis: "Rekam Medis",
tindakan: "Tindakan",
obat: "Obat",
proof: "Proof",
},
AUDIT_TAMPERED: {
all: "Semua Data",
tampered: "Termanipulasi",
clean: "Tidak Termanipulasi",
},
}; };
export const SORT_OPTIONS = { export const SORT_OPTIONS = {
@ -227,3 +240,16 @@ export const LOG_TABLE_COLUMNS = [
class: "text-dark", class: "text-dark",
}, },
]; ];
export const AUDIT_TABLE_COLUMNS = [
{ key: "id", label: "ID Log", class: "text-dark" },
{ key: "typeLabel", label: "Tipe Data", class: "text-dark" },
{ key: "event", label: "Event", class: "text-dark" },
{ key: "last_sync", label: "Last Sync", class: "text-dark" },
{ key: "userId", label: "User ID", class: "text-dark" },
{ key: "status", label: "Status Data", class: "text-dark" },
] satisfies Array<{
key: keyof AuditLogEntry;
label: string;
class?: string;
}>;

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from "vue"; import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import Sidebar from "../../components/dashboard/Sidebar.vue"; import Sidebar from "../../components/dashboard/Sidebar.vue";
import Footer from "../../components/dashboard/Footer.vue"; import Footer from "../../components/dashboard/Footer.vue";
@ -13,21 +13,13 @@ import {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
DEBOUNCE_DELAY, DEBOUNCE_DELAY,
ITEMS_PER_PAGE_OPTIONS, ITEMS_PER_PAGE_OPTIONS,
AUDIT_TABLE_COLUMNS,
} from "../../constants/pagination"; } from "../../constants/pagination";
import type { BlockchainLog } from "../../constants/interfaces";
import ButtonDark from "../../components/dashboard/ButtonDark.vue"; import ButtonDark from "../../components/dashboard/ButtonDark.vue";
import DialogConfirm from "../../components/DialogConfirm.vue"; import DialogConfirm from "../../components/DialogConfirm.vue";
import PaginationControls from "../../components/dashboard/PaginationControls.vue"; import PaginationControls from "../../components/dashboard/PaginationControls.vue";
import type { AuditLogEntry, AuditLogType } from "../../constants/interfaces";
type AuditLogType = "obat" | "rekam_medis" | "tindakan" | "unknown"; import { io, Socket } from "socket.io-client";
interface AuditLogEntry extends BlockchainLog {
type: AuditLogType;
typeLabel: string;
tamperedLabel: string;
isTampered: boolean;
txId?: string;
}
interface AuditLogResponse { interface AuditLogResponse {
data: AuditLogEntry[]; data: AuditLogEntry[];
@ -48,36 +40,10 @@ const pageSize = ref<number>(Number(route.query.take) || DEFAULT_PAGE_SIZE);
const logs = ref<AuditLogEntry[]>([]); const logs = ref<AuditLogEntry[]>([]);
const searchId = ref(""); const searchId = ref("");
const filters = ref({ const filters = ref({
type: (route.query.type as string) || "all", type: (route.query.type as string) || "initial",
tampered: (route.query.tampered as string) || "all", tampered: (route.query.tampered as string) || "initial",
}); });
const TYPE_OPTIONS = [
{ value: "all", label: "Semua Tipe" },
{ value: "rekam_medis", label: "Rekam Medis" },
{ value: "tindakan", label: "Tindakan" },
{ value: "obat", label: "Obat" },
];
const TAMPER_OPTIONS = [
{ value: "all", label: "Semua Data" },
{ value: "tampered", label: "Termanipulasi" },
{ value: "clean", label: "Tidak Termanipulasi" },
];
const AUDIT_TABLE_COLUMNS = [
{ key: "id", label: "ID Log", class: "text-dark" },
{ key: "typeLabel", label: "Tipe Data", class: "text-dark" },
{ key: "event", label: "Event", class: "text-dark" },
{ key: "timestamp", label: "Timestamp", class: "text-dark" },
{ key: "userId", label: "User ID", class: "text-dark" },
{ key: "status", label: "Status Data", class: "text-dark" },
] satisfies Array<{
key: keyof AuditLogEntry;
label: string;
class?: string;
}>;
const formatTimestamp = (rawValue?: string) => { const formatTimestamp = (rawValue?: string) => {
if (!rawValue) { if (!rawValue) {
return "-"; return "-";
@ -102,13 +68,18 @@ const formatTimestamp = (rawValue?: string) => {
return date.toLocaleString("id-ID", options).replace(/\./g, ":"); return date.toLocaleString("id-ID", options).replace(/\./g, ":");
}; };
const progres = ref(0);
const anomali = ref(0);
const status = ref("IDLE");
const runAuditTrail = async () => { const runAuditTrail = async () => {
console.log("Running audit trail..."); console.log("Running audit trail...");
status.value = "STARTING";
progres.value = 0;
anomali.value = 0;
try { try {
const result = await api.post("/audit/trail", {}); const result = await api.post("/audit/trail", {});
console.log(result);
console.log("Audit trail run result:", result);
} catch (error) { } catch (error) {
// console.error("Error running audit trail:", error); // console.error("Error running audit trail:", error);
} }
@ -151,6 +122,7 @@ const typeLabelMap: Record<AuditLogType, string> = {
rekam_medis: "Rekam Medis", rekam_medis: "Rekam Medis",
tindakan: "Tindakan", tindakan: "Tindakan",
obat: "Obat", obat: "Obat",
proof: "Proof",
unknown: "Tidak Diketahui", unknown: "Tidak Diketahui",
}; };
@ -183,6 +155,7 @@ const normalizeEntry = (entry: any): AuditLogEntry => {
txId: flattened.txId ?? entry.txId ?? "", txId: flattened.txId ?? entry.txId ?? "",
event: flattened.event ?? entry.event ?? "-", event: flattened.event ?? entry.event ?? "-",
timestamp: formatTimestamp(flattened.timestamp ?? entry.timestamp), timestamp: formatTimestamp(flattened.timestamp ?? entry.timestamp),
last_sync: formatTimestamp(flattened.last_sync ?? entry.last_sync),
hash: String(flattened.hash ?? flattened.payload ?? entry.hash ?? "-"), hash: String(flattened.hash ?? flattened.payload ?? entry.hash ?? "-"),
userId: Number.isFinite(numericUserId) ? numericUserId : 0, userId: Number.isFinite(numericUserId) ? numericUserId : 0,
status: statusLabel, status: statusLabel,
@ -200,6 +173,7 @@ const showAuditModal = () => {
const updateQueryParams = () => { const updateQueryParams = () => {
const query: Record<string, string> = { const query: Record<string, string> = {
page: pagination.page.value.toString(),
pageSize: pageSize.value.toString(), pageSize: pageSize.value.toString(),
}; };
@ -218,9 +192,10 @@ const updateQueryParams = () => {
router.replace({ query }); router.replace({ query });
}; };
const fetchData = async (bookmarkParam?: string, isInitial?: boolean) => { const fetchData = async () => {
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: pagination.page.value.toString(),
pageSize: pageSize.value.toString(), pageSize: pageSize.value.toString(),
}); });
@ -228,41 +203,36 @@ const fetchData = async (bookmarkParam?: string, isInitial?: boolean) => {
params.append("search", searchId.value.trim()); params.append("search", searchId.value.trim());
} }
if (bookmarkParam) {
params.append("bookmark", bookmarkParam);
}
if (filters.value.type !== "all") { if (filters.value.type !== "all") {
params.append("type", filters.value.type); params.append("type", filters.value.type);
} }
if (filters.value.tampered !== "all") { if (filters.value.tampered !== "all") {
params.append( params.append("tampered", filters.value.tampered);
"tampered",
filters.value.tampered === "tampered" ? "true" : "false"
);
} }
const response = await api.get<AuditLogResponse | AuditLogEntry[]>( const response = await api.get<AuditLogResponse | AuditLogEntry[]>(
`/audit/trail${params.toString() ? `?${params.toString()}` : ""}` `/audit/trail${params.toString() ? `?${params.toString()}` : ""}`
); );
console.log(response); const apiResponse = response as any;
pagination.totalCount.value = apiResponse.totalCount;
const payload = Array.isArray(response) const dataArray: AuditLogEntry[] = [];
? { data: response, totalCount: response.length } Object.keys(apiResponse).forEach((key) => {
: response; if (key !== "totalCount") {
const item = apiResponse[Number(key)];
if (item) {
dataArray.push(item);
}
}
});
const normalized = Array.isArray(payload.data) logs.value = dataArray.map((item) => normalizeEntry(item));
? payload.data.map((item) => normalizeEntry(item))
: [];
logs.value = normalized; // if (!isInitial) {
pagination.totalCount.value = payload.totalCount ?? normalized.length; updateQueryParams();
// }
if (!isInitial) {
updateQueryParams();
}
} catch (error) { } catch (error) {
console.error("Error fetching audit logs:", error); console.error("Error fetching audit logs:", error);
logs.value = []; logs.value = [];
@ -289,15 +259,6 @@ const handleResetFilters = () => {
pagination.reset(); pagination.reset();
fetchData(); fetchData();
}; };
// const filteredTamperedLabel = computed(() =>
// filters.value.tampered === "tampered"
// ? "Termanipulasi"
// : filters.value.tampered === "clean"
// ? "Tidak Termanipulasi"
// : "Semua Data"
// );
watch( watch(
() => pagination.page.value, () => pagination.page.value,
() => { () => {
@ -328,14 +289,53 @@ watch(searchId, (newValue, oldValue) => {
} }
}); });
let socket: Socket;
onMounted(async () => { onMounted(async () => {
if (route.query.search) { if (route.query.search) {
searchId.value = route.query.search as string; searchId.value = route.query.search as string;
} }
await fetchData("", true); const csrfToken = localStorage.getItem("csrf_token") || "";
socket = io("https://64spbch3-1323.asse.devtunnels.ms", {
withCredentials: true,
extraHeaders: {
"X-CSRF-TOKEN": csrfToken,
},
});
socket.on("connect", () => {
console.log("Berhasil terhubung ke WebSocket:", socket.id);
});
socket.on("audit.progress", (data) => {
console.log("Menerima progres:", data);
status.value = "RUNNING";
progres.value = data.progress_count;
});
socket.on("audit.complete", (data) => {
console.log("Menerima selesai:", data);
fetchData();
status.value = "COMPLETED";
});
socket.on("audit.error", (data) => {
console.log("Menerima error:", data);
status.value = "ERROR";
socket.disconnect();
});
await fetchData();
document.title = "Audit Trail - Hospital Log"; document.title = "Audit Trail - Hospital Log";
}); });
onBeforeUnmount(() => {
if (socket) {
socket.disconnect();
}
});
</script> </script>
<template> <template>
@ -354,38 +354,43 @@ onMounted(async () => {
Filter Filter
</div> </div>
<div class="collapse-content text-sm flex flex-col gap-4"> <div class="collapse-content text-sm flex flex-col gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="flex gap-x-4">
<label class="form-control w-full"> <div class="flex gap-x-4 items-end">
<span class="label-text font-semibold">Tipe Data</span> <div class="h-full">
<select <label for="jenis_kelamin" class="font-bold">Tipe Data</label>
v-model="filters.type" <select
class="select select-bordered bg-white" v-model="filters.type"
> class="select bg-white border border-gray-300 mt-1"
<option
v-for="option in TYPE_OPTIONS"
:key="option.value"
:value="option.value"
> >
{{ option.label }} <option disabled selected value="initial">
</option> Pilih Tipe Data
</select> </option>
</label> <option value="rekam_medis">Rekam Medis</option>
<option value="tindakan">Tindakan</option>
<label class="form-control w-full"> <option value="obat">Obat</option>
<span class="label-text font-semibold">Status Manipulasi</span> <option value="proof">Proof</option>
<select <option value="all">Semua Tipe</option>
v-model="filters.tampered" </select>
class="select select-bordered bg-white" </div>
> </div>
<option <div class="flex gap-x-4 items-end">
v-for="option in TAMPER_OPTIONS" <div class="h-full">
:key="option.value" <label for="jenis_kelamin" class="font-bold"
:value="option.value" >Status Manipulasi</label
> >
{{ option.label }} <select
</option> v-model="filters.tampered"
</select> class="select bg-white border border-gray-300 mt-1"
</label> >
<option disabled selected value="initial">
Pilih Status Manipulasi
</option>
<option value="tampered">Tampered</option>
<option value="non_tampered">Non-tampered</option>
<option value="all">Semua</option>
</select>
</div>
</div>
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
@ -417,10 +422,38 @@ onMounted(async () => {
cancel-text="Batal" cancel-text="Batal"
@confirm="runAuditTrail" @confirm="runAuditTrail"
/> />
<ButtonDark text="Lakukan Audit" @click="showAuditModal" /> <ButtonDark
:disabled="
(progres != 0 && status != 'COMPLETED') ||
status === 'STARTING' ||
status === 'RUNNING'
"
text="Lakukan Audit"
@click="showAuditModal"
/>
</div>
<div
v-if="
(progres != 0 && status != 'COMPLETED') ||
status === 'STARTING' ||
status === 'RUNNING'
"
class="p-4"
>
<div class="h-48 flex justify-center items-center flex-col gap-4">
<span class="loading loading-ring loading-xl"></span>
<div class="text-center">
<p class="font-semibold mb-2">
Proses audit sedang berjalan...
</p>
<p class="text-sm text-gray-600">
{{ progres + 1 }} data telah diperiksa
</p>
</div>
</div>
</div> </div>
<DataTable <DataTable
v-if="status === 'COMPLETED' || status === 'IDLE'"
:data="logs" :data="logs"
:columns="AUDIT_TABLE_COLUMNS" :columns="AUDIT_TABLE_COLUMNS"
:is-loading="api.isLoading.value" :is-loading="api.isLoading.value"
@ -428,7 +461,11 @@ onMounted(async () => {
:is-aksi="false" :is-aksi="false"
/> />
<PaginationControls <PaginationControls
v-if="!api.isLoading.value && logs.length > 0" v-if="
!api.isLoading.value &&
logs.length > 0 &&
(status === 'COMPLETED' || status === 'IDLE')
"
:page="pagination.page" :page="pagination.page"
:page-size="pagination.pageSize" :page-size="pagination.pageSize"
:total-count="pagination.totalCount" :total-count="pagination.totalCount"