diff --git a/backend/api/backfill-state.json b/backend/api/backfill-state.json new file mode 100644 index 0000000..0ac4125 --- /dev/null +++ b/backend/api/backfill-state.json @@ -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 + } + } +} diff --git a/backend/api/package-lock.json b/backend/api/package-lock.json index 7382657..d682a2a 100644 --- a/backend/api/package-lock.json +++ b/backend/api/package-lock.json @@ -14,17 +14,22 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^11.0.1", "@nestjs/mapped-types": "^2.1.0", "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.8", + "@nestjs/websockets": "^11.1.8", "@prisma/client": "^6.17.1", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cookie": "^1.0.2", "cookie-parser": "^1.4.7", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "snarkjs": "^0.7.5" + "snarkjs": "^0.7.5", + "socket.io": "^4.8.1" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -33,6 +38,7 @@ "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", "@types/bcrypt": "^6.0.0", + "@types/cookie": "^0.6.0", "@types/cookie-parser": "^1.4.9", "@types/express": "^5.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": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.1.tgz", @@ -2541,6 +2560,25 @@ "@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": { "version": "11.0.9", "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": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", @@ -2958,6 +3019,12 @@ "@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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -3104,6 +3171,13 @@ "@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": { "version": "1.4.9", "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", @@ -3121,6 +3195,15 @@ "dev": true, "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": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -4492,6 +4575,15 @@ ], "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": { "version": "2.8.18", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", @@ -5235,12 +5327,12 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" } }, "node_modules/cookie-parser": { @@ -5256,6 +5348,15 @@ "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": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -5616,6 +5717,104 @@ "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": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -6034,6 +6233,12 @@ "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": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6145,6 +6350,15 @@ "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": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", @@ -9069,6 +9283,15 @@ "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": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -10190,6 +10413,141 @@ "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": { "version": "0.7.4", "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_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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/api/package.json b/backend/api/package.json index d7796d9..590d8cb 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -25,17 +25,22 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^11.0.1", "@nestjs/mapped-types": "^2.1.0", "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.8", + "@nestjs/websockets": "^11.1.8", "@prisma/client": "^6.17.1", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cookie": "^1.0.2", "cookie-parser": "^1.4.7", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "snarkjs": "^0.7.5" + "snarkjs": "^0.7.5", + "socket.io": "^4.8.1" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -44,6 +49,7 @@ "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", "@types/bcrypt": "^6.0.0", + "@types/cookie": "^0.6.0", "@types/cookie-parser": "^1.4.9", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", diff --git a/backend/api/src/app.module.ts b/backend/api/src/app.module.ts index 7d75886..8b51da2 100644 --- a/backend/api/src/app.module.ts +++ b/backend/api/src/app.module.ts @@ -12,6 +12,7 @@ import { AuthModule } from './modules/auth/auth.module'; import { FabricModule } from './modules/fabric/fabric.module'; import { AuditModule } from './modules/audit/audit.module'; import { ProofModule } from './modules/proof/proof.module'; +import { EventEmitterModule } from '@nestjs/event-emitter'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { ProofModule } from './modules/proof/proof.module'; FabricModule, AuditModule, ProofModule, + EventEmitterModule.forRoot(), ], 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 abec209..c448dda 100644 --- a/backend/api/src/common/fabric-gateway/index.ts +++ b/backend/api/src/common/fabric-gateway/index.ts @@ -238,7 +238,7 @@ class FabricGateway { `Evaluating getLogWithPagination transaction with pageSize: ${pageSize}, bookmark: ${bookmark}...`, ); const resultBytes = await this.contract.evaluateTransaction( - 'getLogsWithPagination', + 'getLogWithPagination', pageSize.toString(), bookmark, ); diff --git a/backend/api/src/main.ts b/backend/api/src/main.ts index d20201c..6b34265 100644 --- a/backend/api/src/main.ts +++ b/backend/api/src/main.ts @@ -14,7 +14,7 @@ async function bootstrap() { const configService = app.get(ConfigService); app.setGlobalPrefix('api/'); app.enableCors({ - origin: 'http://localhost:5173', + origin: 'https://64spbch3-5173.asse.devtunnels.ms', credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], allowedHeaders: 'Content-Type, Accept, X-CSRF-Token', diff --git a/backend/api/src/modules/audit/audit.controller.ts b/backend/api/src/modules/audit/audit.controller.ts index 1f92570..dd9079f 100644 --- a/backend/api/src/modules/audit/audit.controller.ts +++ b/backend/api/src/modules/audit/audit.controller.ts @@ -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 { AuditService } from './audit.service'; +import { fromEvent, interval, map, Observable } from 'rxjs'; +import { EventEmitter2 } from '@nestjs/event-emitter'; @Controller('audit') export class AuditController { - constructor(private readonly auditService: AuditService) {} + constructor( + private readonly auditService: AuditService, + private eventEmitter: EventEmitter2, + ) {} @Get('/trail') @UseGuards(AuthGuard) async getAuditTrail( + @Query('search') search: string, + @Query('page') page: 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; } - @Post('/trail') + @Post('trail') @UseGuards(AuthGuard) - async createAuditTrail() { - const result = await this.auditService.storeAuditTrail(); - return result; + createAuditTrail() { + this.auditService.storeAuditTrail(); + 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 { + return fromEvent(this.eventEmitter, 'audit.*').pipe( + map((data: any) => { + return new MessageEvent('message', { data: data }); + }), + ); } } diff --git a/backend/api/src/modules/audit/audit.gateway.spec.ts b/backend/api/src/modules/audit/audit.gateway.spec.ts new file mode 100644 index 0000000..ae20edf --- /dev/null +++ b/backend/api/src/modules/audit/audit.gateway.spec.ts @@ -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); + }); + + it('should be defined', () => { + expect(gateway).toBeDefined(); + }); +}); diff --git a/backend/api/src/modules/audit/audit.gateway.ts b/backend/api/src/modules/audit/audit.gateway.ts new file mode 100644 index 0000000..c27a006 --- /dev/null +++ b/backend/api/src/modules/audit/audit.gateway.ts @@ -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); + } +} diff --git a/backend/api/src/modules/audit/audit.module.ts b/backend/api/src/modules/audit/audit.module.ts index fa6af66..b051182 100644 --- a/backend/api/src/modules/audit/audit.module.ts +++ b/backend/api/src/modules/audit/audit.module.ts @@ -7,6 +7,8 @@ import { ObatModule } from '../obat/obat.module'; import { RekamMedisModule } from '../rekammedis/rekammedis.module'; import { TindakanDokterModule } from '../tindakandokter/tindakandokter.module'; import { LogModule } from '../log/log.module'; +import { AuditGateway } from './audit.gateway'; +import { WebsocketGuard } from '../auth/guard/websocket.guard'; @Module({ imports: [ @@ -17,7 +19,7 @@ import { LogModule } from '../log/log.module'; RekamMedisModule, TindakanDokterModule, ], - providers: [AuditService], + providers: [AuditService, AuditGateway, WebsocketGuard], controllers: [AuditController], }) export class AuditModule {} diff --git a/backend/api/src/modules/audit/audit.service.ts b/backend/api/src/modules/audit/audit.service.ts index 3f841b4..05745cc 100644 --- a/backend/api/src/modules/audit/audit.service.ts +++ b/backend/api/src/modules/audit/audit.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { LogService } from '../log/log.service'; import { ObatService } from '../obat/obat.service'; @@ -9,6 +9,7 @@ import type { AuditEvent, resultStatus as ResultStatus, } from '@dist/generated/prisma'; +import { AuditGateway } from './audit.gateway'; type AuditRecordPayload = { id: string; @@ -22,23 +23,62 @@ type AuditRecordPayload = { @Injectable() export class AuditService { + private readonly logger = new Logger(AuditService.name); constructor( private readonly prisma: PrismaService, private readonly logService: LogService, private readonly obatService: ObatService, private readonly rekamMedisService: RekammedisService, 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({ take: pageSize, - skip: bookmark ? 1 : 0, - cursor: bookmark ? { id: bookmark } : undefined, + skip: (page - 1) * pageSize, 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() { @@ -47,6 +87,30 @@ export class AuditService { let bookmark = ''; 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 { while (true) { const pageResults = await this.logService.getLogsWithPagination( @@ -66,11 +130,23 @@ export class AuditService { break; } - const records = ( - await Promise.all( - logs.map((logEntry) => this.buildAuditRecord(logEntry)), - ) - ).filter((record): record is AuditRecordPayload => record !== null); + // const records = ( + // await Promise.all( + // logs.map((logEntry, index) => + // this.buildAuditRecord(logEntry, index), + // ), + // ) + // ).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) { await this.prisma.$transaction( @@ -93,6 +169,9 @@ export class AuditService { } if (nextBookmark === '' || nextBookmark === bookmark) { + const completeData = { status: 'COMPLETED' }; + this.logger.log('Mengirim selesai via WebSocket:', completeData); + this.auditGateway.sendComplete(completeData); break; } @@ -106,6 +185,7 @@ export class AuditService { private async buildAuditRecord( logEntry: any, + index?: number, ): Promise { if (!logEntry?.value) { return null; @@ -128,7 +208,7 @@ export class AuditService { } let dbHash: string | null = null; - + // try { if (logId.startsWith('OBAT_')) { const obatId = this.extractNumericId(logId); @@ -184,6 +264,14 @@ export class AuditService { 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 { id: logId, event: value.event as AuditEvent, diff --git a/backend/api/src/modules/auth/guard/websocket.guard.spec.ts b/backend/api/src/modules/auth/guard/websocket.guard.spec.ts new file mode 100644 index 0000000..12d0896 --- /dev/null +++ b/backend/api/src/modules/auth/guard/websocket.guard.spec.ts @@ -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(); + }); +}); diff --git a/backend/api/src/modules/auth/guard/websocket.guard.ts b/backend/api/src/modules/auth/guard/websocket.guard.ts new file mode 100644 index 0000000..8372fac --- /dev/null +++ b/backend/api/src/modules/auth/guard/websocket.guard.ts @@ -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 { + const client: Socket = context.switchToWs().getClient(); + + 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('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; + } +} diff --git a/backend/api/src/modules/log/dto/store-log.dto.ts b/backend/api/src/modules/log/dto/store-log.dto.ts index 2bebeb3..7acace4 100644 --- a/backend/api/src/modules/log/dto/store-log.dto.ts +++ b/backend/api/src/modules/log/dto/store-log.dto.ts @@ -17,7 +17,7 @@ export class StoreLogDto { @IsEnum( [ 'tindakan_dokter_created', - 'obat_given', + 'obat_created', 'rekam_medis_created', 'tindakan_dokter_updated', 'obat_updated', diff --git a/backend/api/src/modules/log/log.controller.ts b/backend/api/src/modules/log/log.controller.ts index 87b3511..9449674 100644 --- a/backend/api/src/modules/log/log.controller.ts +++ b/backend/api/src/modules/log/log.controller.ts @@ -1,14 +1,16 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; -import { StoreLogDto } from './dto/store-log.dto'; +import { Controller, Post, UseGuards } from '@nestjs/common'; import { LogService } from './log.service'; -import { FabricService } from '../fabric/fabric.service'; +import { AuthGuard } from '../auth/guard/auth.guard'; @Controller('log') export class LogController { - constructor( - private readonly logService: LogService, - private readonly fabricService: FabricService, - ) {} + constructor(private readonly logService: LogService) {} + + @Post('/store-to-blockchain') + @UseGuards(AuthGuard) + async storeLog() { + return this.logService.storeFromDBToBlockchain(); + } // @Post() // storeLog(@Body() dto: StoreLogDto) { diff --git a/backend/api/src/modules/log/log.module.ts b/backend/api/src/modules/log/log.module.ts index 98ec14a..6b79741 100644 --- a/backend/api/src/modules/log/log.module.ts +++ b/backend/api/src/modules/log/log.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { LogController } from './log.controller'; import { LogService } from './log.service'; import { FabricModule } from '../fabric/fabric.module'; +import { PrismaModule } from '../prisma/prisma.module'; @Module({ - imports: [FabricModule], + imports: [FabricModule, PrismaModule], controllers: [LogController], providers: [LogService], exports: [LogService], diff --git a/backend/api/src/modules/log/log.service.spec.ts b/backend/api/src/modules/log/log.service.spec.ts index a2863e8..8bfeec7 100644 --- a/backend/api/src/modules/log/log.service.spec.ts +++ b/backend/api/src/modules/log/log.service.spec.ts @@ -1,12 +1,30 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LogService } from './log.service'; +import { FabricService } from '../fabric/fabric.service'; +import { PrismaService } from '../prisma/prisma.service'; describe('LogService', () => { let service: LogService; 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({ - providers: [LogService], + providers: [ + LogService, + { provide: FabricService, useValue: fabricServiceMock }, + { provide: PrismaService, useValue: prismaServiceMock }, + ], }).compile(); service = module.get(LogService); diff --git a/backend/api/src/modules/log/log.service.ts b/backend/api/src/modules/log/log.service.ts index 504cd1d..905741d 100644 --- a/backend/api/src/modules/log/log.service.ts +++ b/backend/api/src/modules/log/log.service.ts @@ -1,29 +1,389 @@ -import { Injectable } from '@nestjs/common'; -import { StoreLogDto } from './dto/store-log.dto'; +import { Injectable, Logger } from '@nestjs/common'; +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 { 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>; + failures: Record; + 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() 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) { const { id, event, user_id, payload } = dto; 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() { - const countLogs = await this.fabricService.getAllLogs(); - return countLogs.length; + async getLogById(id: string) { + return this.fabricService.getLogById(id); } async getLogsWithPagination(pageSize: number, bookmark: string) { return this.fabricService.getLogsWithPagination(pageSize, bookmark); } + + async storeFromDBToBlockchain() {} + + // async storeFromDBToBlockchain( + // limitPerEntity = 5, + // batchSize = 1, + // ): Promise<{ + // summaries: Record; + // 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; + + // 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 { + return this.syncEntity( + 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 { + return this.syncEntity( + 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 { + return this.syncEntity( + 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( + state: BackfillState, + entity: EntityKey, + limit: number, + batchSize: number, + fetchBatch: (cursor: string | null, take: number) => Promise, + processRecord: (record: T) => Promise, + recordIdentifier: (record: T) => string, + ): Promise { + 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 { + 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); + } + } } diff --git a/backend/api/src/modules/proof/dto/log-proof.dto.ts b/backend/api/src/modules/proof/dto/log-proof.dto.ts index 7cceb63..03be28e 100644 --- a/backend/api/src/modules/proof/dto/log-proof.dto.ts +++ b/backend/api/src/modules/proof/dto/log-proof.dto.ts @@ -1,25 +1,15 @@ import { - ArrayMaxSize, - ArrayMinSize, - IsArray, IsBoolean, - IsDate, - isDateString, - IsIn, IsNotEmpty, IsObject, - IsOptional, IsString, Length, - Matches, - ValidateNested, } from 'class-validator'; -import { Type } from 'class-transformer'; export class LogProofDto { @IsNotEmpty({ message: 'Proof wajib diisi' }) @IsObject({ message: 'Proof harus berupa objek' }) - proofHash: object; + proofHash: any; @IsNotEmpty({ message: 'ID Visit wajib diisi' }) @IsString({ message: 'ID Visit harus berupa string' }) @@ -32,5 +22,5 @@ export class LogProofDto { @IsNotEmpty({ message: 'Timestamp wajib diisi' }) @IsString({ message: 'Timestamp harus berupa string' }) - timestamp: Date; + timestamp: string; } diff --git a/frontend/hospital-log/package-lock.json b/frontend/hospital-log/package-lock.json index 50b2bd2..1fde160 100644 --- a/frontend/hospital-log/package-lock.json +++ b/frontend/hospital-log/package-lock.json @@ -13,6 +13,7 @@ "cally": "^0.8.0", "daisyui": "^5.3.10", "nouislider": "^15.8.1", + "socket.io-client": "^4.8.1", "vee-validate": "^4.15.1", "vue": "^3.5.22", "vue-router": "4", @@ -158,6 +159,12 @@ "dev": true, "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": { "version": "4.1.16", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz", @@ -795,6 +802,23 @@ "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": { "version": "2.1.2", "license": "Apache-2.0", @@ -809,6 +833,28 @@ "dev": true, "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": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -984,6 +1030,12 @@ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "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": { "version": "0.4.1", "dev": true, @@ -1129,6 +1181,34 @@ "version": "1.0.0-beta.41", "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": { "version": "1.2.1", "license": "BSD-3-Clause", @@ -1397,6 +1477,35 @@ "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": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", diff --git a/frontend/hospital-log/package.json b/frontend/hospital-log/package.json index e7aaf86..30fe499 100644 --- a/frontend/hospital-log/package.json +++ b/frontend/hospital-log/package.json @@ -14,6 +14,7 @@ "cally": "^0.8.0", "daisyui": "^5.3.10", "nouislider": "^15.8.1", + "socket.io-client": "^4.8.1", "vee-validate": "^4.15.1", "vue": "^3.5.22", "vue-router": "4", diff --git a/frontend/hospital-log/src/constants/interfaces.ts b/frontend/hospital-log/src/constants/interfaces.ts index a1d8388..29bd119 100644 --- a/frontend/hospital-log/src/constants/interfaces.ts +++ b/frontend/hospital-log/src/constants/interfaces.ts @@ -57,4 +57,23 @@ interface BlockchainLog { 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, +}; diff --git a/frontend/hospital-log/src/constants/pagination.ts b/frontend/hospital-log/src/constants/pagination.ts index 6bea118..967bcfb 100644 --- a/frontend/hospital-log/src/constants/pagination.ts +++ b/frontend/hospital-log/src/constants/pagination.ts @@ -1,4 +1,5 @@ import type { + AuditLogEntry, BlockchainLog, RekamMedis, TindakanDokter, @@ -60,6 +61,18 @@ export const FILTER = { tindakan: "TINDAKAN", 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 = { @@ -227,3 +240,16 @@ export const LOG_TABLE_COLUMNS = [ 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; +}>; diff --git a/frontend/hospital-log/src/views/dashboard/AuditTrailView.vue b/frontend/hospital-log/src/views/dashboard/AuditTrailView.vue index 32c2f9f..049b6e4 100644 --- a/frontend/hospital-log/src/views/dashboard/AuditTrailView.vue +++ b/frontend/hospital-log/src/views/dashboard/AuditTrailView.vue @@ -1,5 +1,5 @@