diff --git a/.changeset/calm-boxes-reflect.md b/.changeset/calm-boxes-reflect.md new file mode 100644 index 0000000..0403ec6 --- /dev/null +++ b/.changeset/calm-boxes-reflect.md @@ -0,0 +1,5 @@ +--- +'@ssecd/jkn': minor +--- + +implement Rekam Medis web service diff --git a/.env.example b/.env.example index 43fa3ed..dcd2223 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +JKN_PPK_CODE= JKN_CONS_ID= JKN_CONS_SECRET= JKN_VCLAIM_USER_KEY= @@ -5,3 +6,4 @@ JKN_ANTREAN_USER_KEY= JKN_APOTEK_USER_KEY= JKN_PCARE_USER_KEY= JKN_ICARE_USER_KEY= +JKN_REKAM_MEDIS_USER_KEY= diff --git a/README.md b/README.md index d7aacd2..0f8b3ea 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ npm install @ssecd/jkn Instalasi juga dapat dilakukan menggunakan `PNPM` atau `YARN` +> ⚠ Untuk dukungan *type* pada API Rekam Medis, perlu menambahkan development dependensi `@types/fhir` dengan perintah `npm install --save-dev @types/fhir` atau `pnpm i -D @types/fhir`. + ## Penggunaan Penggunaan paket ini sangatlah sederhana, cukup menginisialisasi global instansi pada sebuah modul atau file seperti berikut: @@ -125,6 +127,14 @@ Konfigurasi mengikuti interface berikut: ```ts interface Config { + /** + * Kode PPK yang diberikan BPJS. + * + * Diperlukan untuk melakukan proses encryption + * pada web service eRekam Medis. + */ + ppkCode: string; + /** * Cons ID dari BPJS * @@ -169,7 +179,7 @@ interface Config { /** * User key i-Care dari BPJS - * + * * Umumnya user key i-Care ini nilai sama dengan user key VClaim * untuk FKRTL dan PCare untuk FKTP * @@ -177,6 +187,13 @@ interface Config { */ icareUserKey: string; + /** + * User key eRekam Medis dari BPJS + * + * @default process.env.JKN_REKAM_MEDIS_USER_KEY + */ + rekamMedisUserKey: string; + /** * Berupa mode "development" dan "production". Secara default akan * membaca nilai environment variable NODE_ENV atau "development" @@ -207,6 +224,7 @@ interface Config { - ✅ Apotek _(experimental)_ - 🧩 PCare _([partial](https://github.com/ssecd/jkn/pull/26))_ - ✅ i-Care +- ✅ Rekam Medis ## Kontribusi diff --git a/package.json b/package.json index 856acd0..dbb9615 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ }, "devDependencies": { "@changesets/cli": "^2.26.2", + "@types/fhir": "^0.0.38", "@types/node": "^20.5.0", "@typescript-eslint/eslint-plugin": "^6.4.0", "@typescript-eslint/parser": "^6.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84aef19..8e7d7e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -13,6 +13,9 @@ devDependencies: '@changesets/cli': specifier: ^2.26.2 version: 2.26.2 + '@types/fhir': + specifier: ^0.0.38 + version: 0.0.38 '@types/node': specifier: ^20.5.0 version: 20.5.0 @@ -582,6 +585,10 @@ packages: resolution: {integrity: sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==} dev: true + /@types/fhir@0.0.38: + resolution: {integrity: sha512-0ia8Ng9OR6yI3J1+TkZxAzp02oarQLfM0mGBUjVf1+7X+hD9UW7TNz7/heRP5QQ9evyRz89Ok/IxSuubx2TzMg==} + dev: true + /@types/is-ci@3.0.0: resolution: {integrity: sha512-Q0Op0hdWbYd1iahB+IFNQcWXFq4O0Q5MwQP7uN0souuQ4rPg1vEYcnIOfr1gY+M+6rc8FGoRaBO1mOOvL29sEQ==} dependencies: diff --git a/src/base.ts b/src/base.ts index c3edbd7..c095ee2 100644 --- a/src/base.ts +++ b/src/base.ts @@ -1,4 +1,4 @@ -import { Fetcher, SendOption, Type } from './fetcher.js'; +import { Config, Fetcher, SendOption, Type } from './fetcher.js'; export abstract class BaseApi { protected abstract readonly type: T; @@ -8,6 +8,10 @@ export abstract class BaseApi { protected send(option: SendOption) { return this.fetcher.send(this.type, option); } + + protected get config(): Config { + return this.fetcher.configuration; + } } type CacheKey = `${Type}${string}`; diff --git a/src/fetcher.ts b/src/fetcher.ts index 6d15c21..3948726 100644 --- a/src/fetcher.ts +++ b/src/fetcher.ts @@ -5,9 +5,17 @@ type MaybePromise = T | Promise; export type Mode = 'development' | 'production'; -export type Type = 'vclaim' | 'antrean' | 'apotek' | 'pcare' | 'icare'; +export type Type = 'vclaim' | 'antrean' | 'apotek' | 'pcare' | 'icare' | 'rekamMedis'; export interface Config { + /** + * Kode PPK yang diberikan BPJS. + * + * Diperlukan untuk melakukan proses encryption + * pada web service eRekam Medis. + */ + ppkCode: string; + /** * Cons ID dari BPJS * @@ -60,6 +68,13 @@ export interface Config { */ icareUserKey: string; + /** + * User key eRekam Medis dari BPJS + * + * @default process.env.JKN_REKAM_MEDIS_USER_KEY + */ + rekamMedisUserKey: string; + /** * Berupa mode "development" dan "production". Secara default akan * membaca nilai environment variable NODE_ENV atau "development" @@ -122,6 +137,7 @@ export type SendResponse = { apotek: CamelResponse; pcare: CamelResponse; icare: CamelResponse; + rekamMedis: LowerResponse; }; const api_base_urls: Record> = { @@ -144,6 +160,10 @@ const api_base_urls: Record> = { icare: { development: 'https://apijkn-dev.bpjs-kesehatan.go.id/ihs_dev', production: 'https://apijkn.bpjs-kesehatan.go.id/wsihs' + }, + rekamMedis: { + development: 'https://apijkn-dev.bpjs-kesehatan.go.id/erekammedis_dev', + production: 'https://apijkn.bpjs-kesehatan.go.id/erekammedis' } }; @@ -152,6 +172,7 @@ export class Fetcher { private config: Config = { mode: process.env.NODE_ENV !== 'production' ? 'development' : process.env.NODE_ENV, + ppkCode: process.env.JKN_PPK_CODE ?? '', consId: process.env.JKN_CONS_ID ?? '', consSecret: process.env.JKN_CONS_SECRET ?? '', vclaimUserKey: process.env.JKN_VCLAIM_USER_KEY ?? '', @@ -159,6 +180,7 @@ export class Fetcher { apotekUserKey: process.env.JKN_APOTEK_USER_KEY ?? '', pcareUserKey: process.env.JKN_PCARE_USER_KEY ?? '', icareUserKey: process.env.JKN_ICARE_USER_KEY ?? '', + rekamMedisUserKey: process.env.JKN_REKAM_MEDIS_USER_KEY ?? '', throw: false }; @@ -193,7 +215,8 @@ export class Fetcher { antrean: this.config.antreanUserKey, apotek: this.config.apotekUserKey, pcare: this.config.pcareUserKey, - icare: this.config.icareUserKey + icare: this.config.icareUserKey, + rekamMedis: this.config.rekamMedisUserKey }; } @@ -297,4 +320,8 @@ export class Fetcher { this.configured = false; await this.applyConfig(); } + + get configuration(): Config { + return this.config; + } } diff --git a/src/index.ts b/src/index.ts index 0904484..c0ae0ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { CachedApi } from './base.js'; import { Fetcher } from './fetcher.js'; import { ICare } from './icare.js'; import { PCare } from './pcare/index.js'; +import { RekamMedis } from './rekam-medis/index.js'; import { VClaim } from './vclaim/index.js'; type JKNResponseType = NonNullable< @@ -37,6 +38,10 @@ export default class JKN extends Fetcher { get icare(): ICare { return this.cache.get('icare', ICare); } + + get rekamMedis(): RekamMedis { + return this.cache.get('rekamMedis', RekamMedis); + } } export type AntreanResponse = JKNResponseType; diff --git a/src/rekam-medis/index.ts b/src/rekam-medis/index.ts new file mode 100644 index 0000000..a90698a --- /dev/null +++ b/src/rekam-medis/index.ts @@ -0,0 +1,64 @@ +import { BaseApi } from '../base.js'; +import { Config } from '../fetcher.js'; +import { RekamMedisFormat } from './types.js'; +import { encrypt, gzip } from './utils.js'; + +export class RekamMedis extends BaseApi<'rekamMedis'> { + protected type = 'rekamMedis' as const; + + async insert(data: { + /** nomor SEP */ + nomorSEP: string; + + /** jenis pelayanan (1 = Rawat Inap) (2 = Rawat Jalan) */ + jenisPelayanan: string; + + /** bulan penerbitan SEP (1 sampai 12) */ + bulan: number; + + /** tahun penerbitan SEP misal 2023 */ + tahun: number; + + /** + * data rekam medis berupa plain object + * + * Proses kompresi dan enkripsi akan dilakukan + * secara otomatis pada method ini + */ + dataRekamMedis: RekamMedisFormat; + }) { + const dataMR = await preprocess(data.dataRekamMedis, this.config); + return this.send({ + path: `/`, + method: 'POST', + skipContentTypeHack: true, + headers: { 'Content-Type': 'text/plain' }, + data: { + request: { + noSep: data.nomorSEP, + jnsPelayanan: data.jenisPelayanan, + bulan: String(data.bulan), + tahun: String(data.tahun), + dataMR + } + } + }); + } +} + +/** + * Pengolahan data berupa compression menggunakan metode GZIP + * and encryption dengan key berupa kombinasi CONS ID, SECRET KEY, + * dan KODE PPK. Ini berdasarkan spesifikasi yang telah ditentukan + * pada halaman TrustMark BPJS Kesehatan. + */ +async function preprocess(data: unknown, config: Config): Promise { + try { + const value = JSON.stringify(data); + const compressed = await gzip(value); + return encrypt(compressed.toString(), config); + } catch (err) { + // TODO: define custom error + throw new Error(`failed to compress or encrypt data. ${err}`); + } +} diff --git a/src/rekam-medis/types.ts b/src/rekam-medis/types.ts new file mode 100644 index 0000000..b66f35a --- /dev/null +++ b/src/rekam-medis/types.ts @@ -0,0 +1,36 @@ +export interface Bundle extends fhir4.Bundle {} + +export interface Composition extends fhir4.Composition {} + +export interface Patient extends fhir4.Patient {} + +export interface Encounter extends fhir4.Encounter { + subject?: fhir4.Encounter['subject'] & { noSep: string }; +} + +export interface MedicationRequest extends fhir4.MedicationRequest {} + +export interface Practitioner extends fhir4.Practitioner {} + +export interface Organization extends fhir4.Organization {} + +export interface Condition extends fhir4.Condition {} + +export interface DiagnosticReport extends fhir4.DiagnosticReport {} + +export interface Procedure extends fhir4.Procedure {} + +export interface Device extends fhir4.Device {} + +export type RekamMedisFormat = + | Bundle + | Composition + | Patient + | Encounter + | MedicationRequest + | Practitioner + | Organization + | Condition + | DiagnosticReport + | Procedure + | Device; diff --git a/src/rekam-medis/utils.ts b/src/rekam-medis/utils.ts new file mode 100644 index 0000000..6505352 --- /dev/null +++ b/src/rekam-medis/utils.ts @@ -0,0 +1,27 @@ +import zlib from 'node:zlib'; +import { Config } from '../fetcher.js'; +import { createCipheriv, createHash } from 'node:crypto'; + +export async function gzip(value: string): Promise { + return new Promise((resolve, reject) => { + zlib.gzip(Buffer.from(value), (err, buf) => { + if (err) reject(err); + else resolve(buf); + }); + }); +} + +export function encrypt(value: string, config: Config): string { + const { consId, consSecret, ppkCode } = config; + if (!consId || !consSecret || !ppkCode) { + throw new Error(`consId, consSecret, or ppkCode are not set`); + } + + const keyPlain = consId + consSecret + ppkCode; + const key = createHash('sha256').update(keyPlain, 'utf8').digest(); + const iv = Uint8Array.from(key.subarray(0, 16)); + const cipher = createCipheriv('aes-256-cbc', key, iv); + let enc = cipher.update(value, 'base64', 'utf8'); + enc += cipher.final('utf8'); + return enc; +} diff --git a/words.txt b/words.txt index ce21b0b..a53c6fc 100644 --- a/words.txt +++ b/words.txt @@ -21,12 +21,14 @@ diagppk dinsos dpho dpjp +enkripsi estimasidilayani faskes FASKESASAL faskesasalresep faskesterdaftar fdate +FHIR FKRTL FKTP flaginternal