Skip to content

Commit

Permalink
feat: implement Rekam Medis (#56)
Browse files Browse the repository at this point in the history
* register to fetcher

* add env example entry

* api definition

* add ppk code configuration

* expose config on fetcher and base api

* data compression and encryption

* initial rekam medis data types

* define type using @types/fhir

* update docs

* use fhir v4

* add changeset

* add fhir type docs
  • Loading branch information
mustofa-id authored Oct 19, 2023
1 parent 6fa2b64 commit 13cbb7d
Show file tree
Hide file tree
Showing 12 changed files with 203 additions and 5 deletions.
5 changes: 5 additions & 0 deletions .changeset/calm-boxes-reflect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ssecd/jkn': minor
---

implement Rekam Medis web service
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
JKN_PPK_CODE=
JKN_CONS_ID=
JKN_CONS_SECRET=
JKN_VCLAIM_USER_KEY=
JKN_ANTREAN_USER_KEY=
JKN_APOTEK_USER_KEY=
JKN_PCARE_USER_KEY=
JKN_ICARE_USER_KEY=
JKN_REKAM_MEDIS_USER_KEY=
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -169,14 +179,21 @@ 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
*
* @default process.env.JKN_ICARE_USER_KEY
*/
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"
Expand Down Expand Up @@ -207,6 +224,7 @@ interface Config {
- ✅ Apotek _(experimental)_
- 🧩 PCare _([partial](https://github.com/ssecd/jkn/pull/26))_
- ✅ i-Care
- ✅ Rekam Medis

## Kontribusi

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion src/base.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fetcher, SendOption, Type } from './fetcher.js';
import { Config, Fetcher, SendOption, Type } from './fetcher.js';

export abstract class BaseApi<T extends Type = Type> {
protected abstract readonly type: T;
Expand All @@ -8,6 +8,10 @@ export abstract class BaseApi<T extends Type = Type> {
protected send<R>(option: SendOption) {
return this.fetcher.send<T, R>(this.type, option);
}

protected get config(): Config {
return this.fetcher.configuration;
}
}

type CacheKey = `${Type}${string}`;
Expand Down
31 changes: 29 additions & 2 deletions src/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@ type MaybePromise<T> = T | Promise<T>;

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
*
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -122,6 +137,7 @@ export type SendResponse<T> = {
apotek: CamelResponse<T>;
pcare: CamelResponse<T>;
icare: CamelResponse<T, number>;
rekamMedis: LowerResponse<T>;
};

const api_base_urls: Record<Type, Record<Mode, string>> = {
Expand All @@ -144,6 +160,10 @@ const api_base_urls: Record<Type, Record<Mode, string>> = {
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'
}
};

Expand All @@ -152,13 +172,15 @@ 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 ?? '',
antreanUserKey: process.env.JKN_ANTREAN_USER_KEY ?? '',
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
};

Expand Down Expand Up @@ -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
};
}

Expand Down Expand Up @@ -297,4 +320,8 @@ export class Fetcher {
this.configured = false;
await this.applyConfig();
}

get configuration(): Config {
return this.config;
}
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends object, K extends keyof T> = NonNullable<
Expand Down Expand Up @@ -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<K extends keyof Antrean> = JKNResponseType<Antrean, K>;
Expand Down
64 changes: 64 additions & 0 deletions src/rekam-medis/index.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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}`);
}
}
36 changes: 36 additions & 0 deletions src/rekam-medis/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export interface Bundle<T = fhir4.FhirResource> extends fhir4.Bundle<T> {}

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;
27 changes: 27 additions & 0 deletions src/rekam-medis/utils.ts
Original file line number Diff line number Diff line change
@@ -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<Buffer> {
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;
}
2 changes: 2 additions & 0 deletions words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ diagppk
dinsos
dpho
dpjp
enkripsi
estimasidilayani
faskes
FASKESASAL
faskesasalresep
faskesterdaftar
fdate
FHIR
FKRTL
FKTP
flaginternal
Expand Down

0 comments on commit 13cbb7d

Please sign in to comment.