diff --git a/.gitignore b/.gitignore index c6054173..21c3ae91 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.env # Added by code-review-graph .code-review-graph/ build/ diff --git a/application/controllers/mockup/fo/ibl_registration/Patient.php b/application/controllers/mockup/fo/ibl_registration/Patient.php index b204a421..b825af00 100644 --- a/application/controllers/mockup/fo/ibl_registration/Patient.php +++ b/application/controllers/mockup/fo/ibl_registration/Patient.php @@ -28,6 +28,7 @@ class Patient extends MY_Controller { parent::__construct(); $this->db_smartone = $this->load->database("onedev", true); + $this->load->library('ibl_encryptor'); } function _add_address(&$pat) { if (count($pat) == "0") { @@ -76,113 +77,149 @@ class Patient extends MY_Controller { $prm = $this->sys_input; - $max_rst = 100; - $tot_count =0; - $number_limit = 10; - $number_offset = (!isset($prm['current_page'])?1:$prm['current_page'] - 1) * $number_limit ; + $number_limit = 10; + $number_offset = (!isset($prm['current_page']) ? 1 : $prm['current_page'] - 1) * $number_limit; - $q = [ - 'noreg' => "", - 'name' => '', - 'hp' => '', - 'dob' => '', - 'address' => '' - ]; + $where_noreg = ''; + $where_name = ''; + $where_hp = ''; + $where_dob = ''; + $where_addr = "AND M_PatientAddressNote = 'Utama'"; - $search_address = ''; + if (!empty($prm['noreg'])) { + $noreg = $this->db_smartone->escape_like_str($prm['noreg']); + $where_noreg = "AND M_PatientNoReg LIKE '%{$noreg}%'"; + } - if ($prm['noreg'] != '') - $q['noreg'] = "AND M_PatientNoReg like '%{$prm['noreg']}%'"; - - if ($prm['search'] != '') - { + if (!empty($prm['search'])) { $e = explode('+', $prm['search']); - if (isset($e[0])){ - $e[0] = str_replace("'", "\\'", $e[0]); - $q['name'] = "AND M_PatientName LIKE '%{$e[0]}%'"; - } - if (isset($e[1])) - $q['hp'] = "AND ((M_PatientHP LIKE '%{$e[1]}%' and M_PatientHP IS NOT NULL) OR (M_PatientHP IS NULL AND '{$e[1]}' = ''))"; - if (isset($e[2])) - $q['dob'] = "AND ((DATE_FORMAT(M_PatientDOB, '%d-%m-%Y') LIKE '%{$e[2]}%' and M_PatientDOB IS NOT NULL) OR (M_PatientDOB IS NULL AND '{$e[2]}' = ''))"; - if (isset($e[3])) - $q['address'] = "AND M_PatientAddressDescription LIKE '%{$e[3]}%'"; + + // name — trigram blind index (min 3 karakter) + if (!empty($e[0]) && mb_strlen(trim($e[0])) >= 3) { + $toks = $this->ibl_encryptor->query_tokens($e[0]); + $conds = []; + foreach ($toks as $tok) { + $tok_esc = $this->db_smartone->escape_str($tok); + $conds[] = "JSON_CONTAINS(M_PatientName_bidx, '\"$tok_esc\"')"; + } + if ($conds) $where_name = 'AND (' . implode(' AND ', $conds) . ')'; + } + + // hp — trigram blind index + if (!empty($e[1]) && mb_strlen(trim($e[1])) >= 3) { + $toks = $this->ibl_encryptor->query_tokens($e[1]); + $conds = []; + foreach ($toks as $tok) { + $tok_esc = $this->db_smartone->escape_str($tok); + $conds[] = "JSON_CONTAINS(M_PatientHP_bidx, '\"$tok_esc\"')"; + } + if ($conds) $where_hp = 'AND (' . implode(' AND ', $conds) . ')'; + } + + // dob — trigram blind index (format dd-mm-yyyy) + if (!empty($e[2]) && mb_strlen(trim($e[2])) >= 3) { + $toks = $this->ibl_encryptor->query_tokens($e[2]); + $conds = []; + foreach ($toks as $tok) { + $tok_esc = $this->db_smartone->escape_str($tok); + $conds[] = "JSON_CONTAINS(M_PatientDOB_bidx, '\"$tok_esc\"')"; + } + if ($conds) $where_dob = 'AND (' . implode(' AND ', $conds) . ')'; + } + + // address — trigram blind index + if (!empty($e[3]) && mb_strlen(trim($e[3])) >= 3) { + $toks = $this->ibl_encryptor->query_tokens($e[3]); + $conds = []; + foreach ($toks as $tok) { + $tok_esc = $this->db_smartone->escape_str($tok); + $conds[] = "JSON_CONTAINS(M_PatientAddressDescription_bidx, '\"$tok_esc\"')"; + } + if ($conds) $where_addr = 'AND (' . implode(' AND ', $conds) . ')'; + } } - if($q['address'] == ''){ - $q['address'] = "AND M_PatientAddressNote = 'Utama'"; - } - - - $sql = "SELECT 'N' divider,M_PatientID, M_PatientNoReg,M_PatientEmail,M_PatientPrefix,M_PatientSuffix, - concat(M_TitleName,' ',IFNULL(M_PatientPrefix,''),' ',M_PatientName,' ',IFNULL(M_PatientSuffix,'')) M_PatientName, - M_PatientName M_PatientRealName, M_TitleID, M_TitleName, M_SexID, M_SexName, - M_PatientHP, M_PatientPOB, M_PatientDOB, DATE_FORMAT(M_PatientDOB,'%d-%m-%Y') as dob_ina, + $sql = "SELECT 'N' divider, M_PatientID, M_PatientNoReg, M_PatientPrefix, M_PatientSuffix, + concat(M_TitleName,' ',IFNULL(M_PatientPrefix,''),' ',M_PatientName,' ',IFNULL(M_PatientSuffix,'')) M_PatientNameRaw, + M_TitleID, M_TitleName, M_SexID, M_SexName, + M_PatientDOB, '' M_PatientAddress, M_PatientAddressID, - M_PatientAddressDescription, M_PatientM_IdTypeID, M_PatientIDNumber, - M_PatientAddressRegionalCd, - M_PatientAddressLocation, - M_PatientAddressCity, - M_PatientAddressVillage, - M_PatientAddressDistrict, - M_PatientAddressState, - M_PatientAddressCountry, - M_PatientAddressCountryCode, - IFNULL(M_PatientNote, '') M_PatientNote, M_PatientPhoto, IF(M_PatientPhone IS NULL OR M_PatientPhone = '', M_PatientHP, M_PatientPhone) hp, - -- fn_fo_patient_visit(M_PatientID) info, - M_PatientAddressM_KelurahanID M_KelurahanID, 0 M_DistrictID, 0 M_CityID, 0 M_ProvinceID, M_PatientM_ReligionID, - IFNULL(M_ReligionName, '-') M_ReligionName, + M_PatientAddressRegionalCd, M_PatientAddressLocation, M_PatientAddressCity, + M_PatientAddressVillage, M_PatientAddressDistrict, M_PatientAddressState, + M_PatientAddressCountry, M_PatientAddressCountryCode, + M_PatientAddressM_KelurahanID M_KelurahanID, 0 M_DistrictID, 0 M_CityID, 0 M_ProvinceID, + M_PatientM_ReligionID, IFNULL(M_ReligionName, '-') M_ReligionName, IFNULL(Patient_SignatureUrl, '') image_signature, - M_PatientNote - FROM m_patient - join m_title on M_PatientM_TitleID = M_TitleID - join m_sex on M_PatientM_SexID = M_SexID - join m_patientaddress on M_PatientAddressM_PatientID = M_PatientID and M_PatientAddressIsActive = 'Y' {$q['address']} - left join m_religion on m_patientm_religionid = m_religionid - left join patient_signature on Patient_SignatureM_PatientID = M_PatientID and Patient_SignatureIsActive = 'Y' - where M_PatientIsActive = 'Y' - {$q['noreg']} - {$q['name']} - {$q['hp']} - {$q['dob']} - - group by M_PatientID - limit $number_limit offset $number_offset"; - //echo $sql; + IFNULL(M_PatientNote, '') M_PatientNote, M_PatientPhoto, + M_PatientM_IdTypeID, + M_PatientName_enc, M_PatientHP_enc, M_PatientDOB_enc, + M_PatientEmail_enc, M_PatientPhone_enc, M_PatientPOB_enc, + M_PatientIDNumber_enc, M_PatientAddressDescription_enc + FROM m_patient + JOIN m_title ON M_PatientM_TitleID = M_TitleID + JOIN m_sex ON M_PatientM_SexID = M_SexID + JOIN m_patientaddress ON M_PatientAddressM_PatientID = M_PatientID + AND M_PatientAddressIsActive = 'Y' {$where_addr} + LEFT JOIN m_religion ON M_PatientM_ReligionID = M_ReligionID + LEFT JOIN patient_signature ON Patient_SignatureM_PatientID = M_PatientID + AND Patient_SignatureIsActive = 'Y' + WHERE M_PatientIsActive = 'Y' + {$where_noreg} + {$where_name} + {$where_hp} + {$where_dob} + GROUP BY M_PatientID + LIMIT {$number_limit} OFFSET {$number_offset}"; + $query = $this->db_smartone->query($sql); - if ($query) { - $rows = $query->result_array(); - - foreach ($rows as $k => $v) - { - $rows[$k]['M_PatientName'] = stripslashes($rows[$k]['M_PatientName']); - $rows[$k]['M_PatientAddress'] = stripslashes($rows[$k]['M_PatientAddressDescription']); - $info = $this->db_smartone->query("SELECT fn_fo_patient_visit(?) info", [$v['M_PatientID']])->row(); - $rows[$k]['info'] = json_decode($info->info); - - $references = []; - $sql = "SELECT M_ReferenceID, M_ReferenceName - FROM m_patient_reference - join m_reference on M_PatientReferenceM_ReferenceID = M_ReferenceID - WHERE M_PatientReferenceM_PatientID = ? AND M_PatientReferenceIsActive = 'Y'"; - $query = $this->db_smartone->query($sql, [$v['M_PatientID']]); - if ($query) { - $references = $query->result_array(); - } - $rows[$k]['references'] = $references; - } - - $result = array("total" => $tot_page, "records" => $rows, "sql"=> $this->db_smartone->last_query()); - $this->sys_ok($result); - } - else { - $this->sys_error_db("m_patient rows",$this->db_smartone); - exit; + if (!$query) { + $this->sys_error_db("m_patient rows", $this->db_smartone); + return; } - + $rows = $query->result_array(); + $enc = $this->ibl_encryptor; + + foreach ($rows as $k => $v) { + $name = $enc->decrypt($v['M_PatientName_enc']) ?? $v['M_PatientNameRaw']; + $hp = $enc->decrypt($v['M_PatientHP_enc']) ?? ''; + $phone = $enc->decrypt($v['M_PatientPhone_enc']) ?? ''; + $dob_dec = $enc->decrypt($v['M_PatientDOB_enc']) ?? date('d-m-Y', strtotime($v['M_PatientDOB'])); + $addr = $enc->decrypt($v['M_PatientAddressDescription_enc']) ?? ''; + + $rows[$k]['M_PatientName'] = $name; + $rows[$k]['M_PatientAddress'] = $addr; + $rows[$k]['M_PatientAddressDescription'] = $addr; + $rows[$k]['M_PatientHP'] = $hp; + $rows[$k]['M_PatientEmail'] = $enc->decrypt($v['M_PatientEmail_enc']) ?? ''; + $rows[$k]['M_PatientPhone'] = $phone; + $rows[$k]['M_PatientPOB'] = $enc->decrypt($v['M_PatientPOB_enc']) ?? ''; + $rows[$k]['M_PatientIDNumber'] = $enc->decrypt($v['M_PatientIDNumber_enc']) ?? ''; + $rows[$k]['dob_ina'] = $dob_dec; + $rows[$k]['hp'] = $phone ?: $hp; + + // bersihkan kolom _enc dari response + foreach (array_keys($rows[$k]) as $col) { + if (substr($col, -4) === '_enc') unset($rows[$k][$col]); + } + unset($rows[$k]['M_PatientNameRaw'], $rows[$k]['M_PatientDOB']); + + $info = $this->db_smartone->query("SELECT fn_fo_patient_visit(?) info", [$v['M_PatientID']])->row(); + $rows[$k]['info'] = json_decode($info->info); + + $ref_query = $this->db_smartone->query( + "SELECT M_ReferenceID, M_ReferenceName + FROM m_patient_reference + JOIN m_reference ON M_PatientReferenceM_ReferenceID = M_ReferenceID + WHERE M_PatientReferenceM_PatientID = ? AND M_PatientReferenceIsActive = 'Y'", + [$v['M_PatientID']] + ); + $rows[$k]['references'] = $ref_query ? $ref_query->result_array() : []; + } + + $this->sys_ok(["total" => 0, "records" => $rows]); } @@ -199,24 +236,35 @@ class Patient extends MY_Controller $M_IdTypeID = $prm['M_PatientM_IdTypeID']; } $patient_name = str_replace("'", "\\'", $prm['M_PatientName']); + $dob_str = date('d-m-Y', strtotime($prm['M_PatientDOB'])); $ptn = [ - 'M_PatientName' => $patient_name, - 'M_PatientM_TitleID' => $prm['M_PatientM_TitleID'], - 'M_PatientPrefix' => $prm['M_PatientPrefix'], - 'M_PatientSuffix' => $prm['M_PatientSuffix'], - 'M_PatientM_SexID' => $prm['M_PatientM_SexID'], - 'M_PatientM_ReligionID' => $prm['M_PatientM_ReligionID'], - 'M_PatientDOB' => $prm['M_PatientDOB'], - 'M_PatientPOB' => $prm['M_PatientPOB'], - 'M_PatientHP' => $prm['M_PatientHP'], - 'M_PatientPhone' => $prm['M_PatientPhone'], - 'M_PatientEmail' => $prm['M_PatientEmail'], - 'M_PatientM_IdTypeID' => $M_IdTypeID , - 'M_PatientIDNumber' => $prm['M_PatientIDNumber'], - 'M_PatientNote' => $prm['M_PatientNote'], - 'M_PatientUserID' => $userid, - 'M_PatientCreated' => date('Y-m-d H:i:s'), - 'M_PatientCreatedUserID' => $userid + 'M_PatientName' => $patient_name, + 'M_PatientName_enc' => $this->ibl_encryptor->encrypt($patient_name), + 'M_PatientName_bidx' => $this->ibl_encryptor->search_bidx($patient_name), + 'M_PatientM_TitleID' => $prm['M_PatientM_TitleID'], + 'M_PatientPrefix' => $prm['M_PatientPrefix'], + 'M_PatientSuffix' => $prm['M_PatientSuffix'], + 'M_PatientM_SexID' => $prm['M_PatientM_SexID'], + 'M_PatientM_ReligionID' => $prm['M_PatientM_ReligionID'], + 'M_PatientDOB' => $prm['M_PatientDOB'], + 'M_PatientDOB_enc' => $this->ibl_encryptor->encrypt($dob_str), + 'M_PatientDOB_bidx' => $this->ibl_encryptor->search_bidx($dob_str), + 'M_PatientPOB' => $prm['M_PatientPOB'], + 'M_PatientPOB_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientPOB']), + 'M_PatientHP' => $prm['M_PatientHP'], + 'M_PatientHP_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientHP']), + 'M_PatientHP_bidx' => $this->ibl_encryptor->search_bidx($prm['M_PatientHP']), + 'M_PatientPhone' => $prm['M_PatientPhone'], + 'M_PatientPhone_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientPhone']), + 'M_PatientEmail' => $prm['M_PatientEmail'], + 'M_PatientEmail_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientEmail']), + 'M_PatientM_IdTypeID' => $M_IdTypeID, + 'M_PatientIDNumber' => $prm['M_PatientIDNumber'], + 'M_PatientIDNumber_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientIDNumber']), + 'M_PatientNote' => $prm['M_PatientNote'], + 'M_PatientUserID' => $userid, + 'M_PatientCreated' => date('Y-m-d H:i:s'), + 'M_PatientCreatedUserID' => $userid ]; $this->db_smartone->insert('m_patient', $ptn); @@ -235,21 +283,22 @@ class Patient extends MY_Controller $address_description = str_replace("'", "\\'", $prm['M_PatientAddressDescription']); // save address $add = [ - 'M_PatientAddressM_PatientID' => $id, - 'M_PatientAddressDescription' => $address_description, - 'M_PatientAddressUserID'=> $userid, - 'M_PatientAddressRegionalCd' => $prm['M_PatientAddressRegionalCd'], - 'M_PatientAddressLocation' => $prm['M_PatientAddressLocation'], - 'M_PatientAddressCity' => $prm['M_PatientAddressCity'], - 'M_PatientAddressVillage' => $prm['M_PatientAddressVillage'], - 'M_PatientAddressDistrict' => $prm['M_PatientAddressDistrict'], - 'M_PatientAddressState' => $prm['M_PatientAddressState'], - 'M_PatientAddressCountry' => $prm['M_PatientAddressCountry'], - 'M_PatientAddressCountryCode' => $prm['M_PatientAddressCountryCode'], - 'M_PatientAddressNote' => isset($prm['M_PatientAddressNote']) ? $prm['M_PatientAddressNote'] : 'Utama', - 'M_PatientAddressCreated' => date('Y-m-d H:i:s'), - 'M_PatientAddressCreatedUserID' => $userid - + 'M_PatientAddressM_PatientID' => $id, + 'M_PatientAddressDescription' => $address_description, + 'M_PatientAddressDescription_enc' => $this->ibl_encryptor->encrypt($address_description), + 'M_PatientAddressDescription_bidx' => $this->ibl_encryptor->search_bidx($address_description), + 'M_PatientAddressUserID' => $userid, + 'M_PatientAddressRegionalCd' => $prm['M_PatientAddressRegionalCd'], + 'M_PatientAddressLocation' => $prm['M_PatientAddressLocation'], + 'M_PatientAddressCity' => $prm['M_PatientAddressCity'], + 'M_PatientAddressVillage' => $prm['M_PatientAddressVillage'], + 'M_PatientAddressDistrict' => $prm['M_PatientAddressDistrict'], + 'M_PatientAddressState' => $prm['M_PatientAddressState'], + 'M_PatientAddressCountry' => $prm['M_PatientAddressCountry'], + 'M_PatientAddressCountryCode' => $prm['M_PatientAddressCountryCode'], + 'M_PatientAddressNote' => isset($prm['M_PatientAddressNote']) ? $prm['M_PatientAddressNote'] : 'Utama', + 'M_PatientAddressCreated' => date('Y-m-d H:i:s'), + 'M_PatientAddressCreatedUserID' => $userid ]; $this->db_smartone->insert('m_patientaddress', $add); $err = $this->db_smartone->error(); @@ -290,23 +339,34 @@ class Patient extends MY_Controller $prm = $this->sys_input; $userid = $this->sys_user["M_UserID"]; $prm['M_PatientDOB'] = date('Y-m-d', strtotime($prm['M_PatientDOB'])); - $patient_name = str_replace("'", "\\'", $prm['M_PatientName']); - + $patient_name = str_replace("'", "\\'", $prm['M_PatientName']); + $dob_str = date('d-m-Y', strtotime($prm['M_PatientDOB'])); + $this->db_smartone->set('M_PatientName', $patient_name) + ->set('M_PatientName_enc', $this->ibl_encryptor->encrypt($patient_name)) + ->set('M_PatientName_bidx', $this->ibl_encryptor->search_bidx($patient_name)) ->set('M_PatientM_TitleID', $prm['M_PatientM_TitleID']) ->set('M_PatientPrefix', $prm['M_PatientPrefix']) - ->set('M_PatientSuffix', $prm['M_PatientSuffix']) + ->set('M_PatientSuffix', $prm['M_PatientSuffix']) ->set('M_PatientM_SexID', $prm['M_PatientM_SexID']) ->set('M_PatientM_ReligionID', $prm['M_PatientM_ReligionID']) ->set('M_PatientDOB', $prm['M_PatientDOB']) + ->set('M_PatientDOB_enc', $this->ibl_encryptor->encrypt($dob_str)) + ->set('M_PatientDOB_bidx', $this->ibl_encryptor->search_bidx($dob_str)) ->set('M_PatientPOB', $prm['M_PatientPOB']) + ->set('M_PatientPOB_enc', $this->ibl_encryptor->encrypt($prm['M_PatientPOB'])) ->set('M_PatientHP', $prm['M_PatientHP']) + ->set('M_PatientHP_enc', $this->ibl_encryptor->encrypt($prm['M_PatientHP'])) + ->set('M_PatientHP_bidx', $this->ibl_encryptor->search_bidx($prm['M_PatientHP'])) ->set('M_PatientPhone', $prm['M_PatientPhone']) + ->set('M_PatientPhone_enc', $this->ibl_encryptor->encrypt($prm['M_PatientPhone'])) ->set('M_PatientEmail', $prm['M_PatientEmail']) + ->set('M_PatientEmail_enc', $this->ibl_encryptor->encrypt($prm['M_PatientEmail'])) ->set('M_PatientM_IdTypeID', $prm['M_PatientM_IdTypeID']) ->set('M_PatientIDNumber', $prm['M_PatientIDNumber']) + ->set('M_PatientIDNumber_enc', $this->ibl_encryptor->encrypt($prm['M_PatientIDNumber'])) ->set('M_PatientNote', $prm['M_PatientNote']) - ->set('M_PatientUserID', $userid) + ->set('M_PatientUserID', $userid) ->set('M_PatientLastUpdatedUserID', $userid) ->where('M_PatientID', $prm['id']) ->update('m_patient'); @@ -325,7 +385,7 @@ class Patient extends MY_Controller $ptn = json_encode($prm); $id_address = isset($prm['M_PatientAddressID']) && $prm['M_PatientAddressID'] > 0 ? $prm['M_PatientAddressID']:0; - $address_description = str_replace("'", "\\'", $prm['M_PatientAddressDescription']); + $address_description = str_replace("'", "\\'", $prm['M_PatientAddressDescription']); $this->db_smartone->set('M_PatientAddressRegionalCd', $prm['M_PatientAddressRegionalCd']) ->set('M_PatientAddressLocation', $prm['M_PatientAddressLocation']) ->set('M_PatientAddressCity', $prm['M_PatientAddressCity']) @@ -334,8 +394,10 @@ class Patient extends MY_Controller ->set('M_PatientAddressState', $prm['M_PatientAddressState']) ->set('M_PatientAddressCountry', $prm['M_PatientAddressCountry']) ->set('M_PatientAddressCountryCode', $prm['M_PatientAddressCountryCode']) - ->set('M_PatientAddressDescription', $address_description ) - ->set('M_PatientAddressUserID', $userid ) + ->set('M_PatientAddressDescription', $address_description) + ->set('M_PatientAddressDescription_enc', $this->ibl_encryptor->encrypt($address_description)) + ->set('M_PatientAddressDescription_bidx', $this->ibl_encryptor->search_bidx($address_description)) + ->set('M_PatientAddressUserID', $userid) ->set('M_PatientAddressLastUpdatedUserID', $userid) ->where('M_PatientAddressID', $id_address) ->update('m_patientaddress'); diff --git a/application/libraries/Ibl_encryptor.php b/application/libraries/Ibl_encryptor.php new file mode 100644 index 00000000..5708013a --- /dev/null +++ b/application/libraries/Ibl_encryptor.php @@ -0,0 +1,87 @@ +key = hash('sha256', $passphrase, true); + $this->search_key = hash('sha256', $passphrase_s, true); + } + + // Enkripsi plaintext → base64(iv[12] + tag[16] + ciphertext) + public function encrypt($plaintext) + { + if ($plaintext === null || $plaintext === '') return null; + $iv = random_bytes(12); + $tag = ''; + $ct = openssl_encrypt((string)$plaintext, $this->cipher, $this->key, OPENSSL_RAW_DATA, $iv, $tag, '', $this->tag_length); + if ($ct === false) return null; + return base64_encode($iv . $tag . $ct); + } + + // Dekripsi base64(iv + tag + ciphertext) → plaintext + public function decrypt($ciphertext) + { + if ($ciphertext === null || $ciphertext === '') return null; + $raw = base64_decode($ciphertext); + if (strlen($raw) < 12 + $this->tag_length) return null; + $iv = substr($raw, 0, 12); + $tag = substr($raw, 12, $this->tag_length); + $ct = substr($raw, 12 + $this->tag_length); + $pt = openssl_decrypt($ct, $this->cipher, $this->key, OPENSSL_RAW_DATA, $iv, $tag); + return $pt === false ? null : $pt; + } + + // Hasilkan JSON array trigram token untuk kolom _bidx (partial search) + public function search_bidx($value) + { + if ($value === null || $value === '') return null; + $norm = mb_strtolower(trim((string)$value), 'UTF-8'); + $len = mb_strlen($norm, 'UTF-8'); + $tokens = []; + if ($len <= 3) { + $tokens[] = $this->_token($norm); + } else { + for ($i = 0; $i <= $len - 3; $i++) { + $tokens[] = $this->_token(mb_substr($norm, $i, 3, 'UTF-8')); + } + } + return json_encode(array_values(array_unique($tokens))); + } + + // Hasilkan array trigram token dari query string (untuk WHERE JSON_CONTAINS) + public function query_tokens($value) + { + if ($value === null || $value === '') return []; + $norm = mb_strtolower(trim((string)$value), 'UTF-8'); + $len = mb_strlen($norm, 'UTF-8'); + $tokens = []; + if ($len <= 3) { + $tokens[] = $this->_token($norm); + } else { + for ($i = 0; $i <= $len - 3; $i++) { + $tokens[] = $this->_token(mb_substr($norm, $i, 3, 'UTF-8')); + } + } + return array_values(array_unique($tokens)); + } + + private function _token($str) + { + return hash_hmac('sha256', $str, $this->search_key); + } +} diff --git a/docs/superpowers/plans/2026-05-31-pdp-patient-encryption.md b/docs/superpowers/plans/2026-05-31-pdp-patient-encryption.md new file mode 100644 index 00000000..1acf61a4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-pdp-patient-encryption.md @@ -0,0 +1,1117 @@ +# PDP Patient Data Encryption Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enkripsi field PII pasien (`m_patient`, `m_patientaddress`) dan data medis hasil lab (12 tabel) menggunakan AES-256-GCM dengan key dari `.env`. Patient search tetap berjalan via trigram blind index; hasil lab cukup encrypt/decrypt langsung. + +**Architecture:** Key enkripsi disimpan di `.env` dan di-load di `index.php`. Library `Ibl_encryptor` menangani encrypt/decrypt (AES-256-GCM) dan pembuatan trigram token (HMAC-SHA256) untuk pencarian partial. Kolom lama tetap ada selama masa migrasi, kolom `_enc` menyimpan ciphertext, kolom `_bidx` menyimpan JSON array token trigram untuk field yang digunakan search. + +**Tech Stack:** PHP CodeIgniter 3, MySQL 5.7+, OpenSSL (AES-256-GCM), `hash_hmac` SHA-256 + +--- + +## Field yang Dienkripsi + +### `one_lab.m_patient` +| Field | Kolom _enc | Kolom _bidx (untuk search) | +|-------|-----------|---------------------------| +| M_PatientName | M_PatientName_enc | M_PatientName_bidx | +| M_PatientHP | M_PatientHP_enc | M_PatientHP_bidx | +| M_PatientDOB | M_PatientDOB_enc | M_PatientDOB_bidx | +| M_PatientEmail | M_PatientEmail_enc | — | +| M_PatientPhone | M_PatientPhone_enc | — | +| M_PatientPOB | M_PatientPOB_enc | — | +| M_PatientIDNumber | M_PatientIDNumber_enc | — | +| M_PatientNIK | M_PatientNIK_enc | — | +| M_PatientNIP | M_PatientNIP_enc | — | + +### `one_lab.m_patientaddress` +| Field | Kolom _enc | Kolom _bidx | +|-------|-----------|-------------| +| M_PatientAddressDescription | M_PatientAddressDescription_enc | M_PatientAddressDescription_bidx | +| M_PatientAddressEmail | M_PatientAddressEmail_enc | — | +| M_PatientAddressPhone | M_PatientAddressPhone_enc | — | + +--- + +## File yang Dibuat / Dimodifikasi + +| File | Action | Tanggung Jawab | +|------|--------|----------------| +| `.env` | Create | Menyimpan IBL_ENCRYPT_KEY dan IBL_ENCRYPT_SEARCH_KEY | +| `index.php` | Modify | Load `.env` ke `$_ENV` sebelum CI bootstrap | +| `application/libraries/Ibl_encryptor.php` | Create | AES-256-GCM encrypt/decrypt + trigram blind index | +| `sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql` | Create | ALTER TABLE tambah kolom _enc dan _bidx | +| `scripts/migrate_encrypt_patient.php` | Create | Batch migration script enkripsi 178K data lama | +| `application/controllers/mockup/fo/ibl_registration/Patient.php` | Modify | search(), add_new(), edit() pakai enkripsi | + +--- + +## Task 1: Buat `.env` dan Load di `index.php` + +**Files:** +- Create: `.env` +- Modify: `index.php` (tambah sebelum `require_once BASEPATH.'core/CodeIgniter.php';`) + +- [ ] **Step 1: Buat file `.env`** + +``` +IBL_ENCRYPT_KEY=<32-byte-hex-random> +IBL_ENCRYPT_SEARCH_KEY=<32-byte-hex-random> +``` + +Generate key-nya dengan: +```bash +php -r "echo bin2hex(random_bytes(32)) . PHP_EOL; echo bin2hex(random_bytes(32)) . PHP_EOL;" +``` + +Isi `.env` dengan output dua baris tersebut: +``` +IBL_ENCRYPT_KEY=a1b2c3d4... # baris 1 +IBL_ENCRYPT_SEARCH_KEY=e5f6g7h8... # baris 2 +``` + +- [ ] **Step 2: Tambahkan `.env` ke `.gitignore`** + +Cek apakah `.gitignore` sudah ada: +```bash +cat .gitignore 2>/dev/null || echo "tidak ada" +``` + +Tambahkan baris: +``` +.env +``` + +- [ ] **Step 3: Modifikasi `index.php` untuk load `.env`** + +Buka `index.php`. Temukan baris terakhir sebelum: +```php +require_once BASEPATH.'core/CodeIgniter.php'; +``` + +Tambahkan SEBELUM baris tersebut: +```php +// Load .env +$_env_file = __DIR__ . '/.env'; +if (file_exists($_env_file)) { + foreach (file($_env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $_env_line) { + if (strpos(trim($_env_line), '#') === 0) continue; + [$_env_k, $_env_v] = array_map('trim', explode('=', $_env_line, 2)); + if ($_env_k !== '') $_ENV[$_env_k] = $_env_v; + } + unset($_env_file, $_env_line, $_env_k, $_env_v); +} +``` + +- [ ] **Step 4: Verifikasi key terbaca** + +```bash +php -r " +\$_ENV = []; +foreach (file('.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as \$l) { + [\$k,\$v] = explode('=', \$l, 2); + \$_ENV[trim(\$k)] = trim(\$v); +} +echo 'ENCRYPT_KEY len: ' . strlen(\$_ENV['IBL_ENCRYPT_KEY']) . PHP_EOL; +echo 'SEARCH_KEY len: ' . strlen(\$_ENV['IBL_ENCRYPT_SEARCH_KEY']) . PHP_EOL; +" +``` + +Expected: +``` +ENCRYPT_KEY len: 64 +SEARCH_KEY len: 64 +``` + +- [ ] **Step 5: Commit** + +```bash +git add index.php .gitignore +git commit -m "TASKCODE - load .env encryption keys di bootstrap" +``` +> Ganti TASKCODE dengan kode task dari user. Jangan commit file `.env`. + +--- + +## Task 2: Library `Ibl_encryptor` + +**Files:** +- Create: `application/libraries/Ibl_encryptor.php` + +- [ ] **Step 1: Buat library** + +```php +key = hex2bin($raw); + $this->search_key = hex2bin($raw_s); + } + + // Enkripsi plaintext → base64(iv + tag + ciphertext) + public function encrypt($plaintext) + { + if ($plaintext === null || $plaintext === '') return null; + $iv = random_bytes(12); + $tag = ''; + $ct = openssl_encrypt($plaintext, $this->cipher, $this->key, OPENSSL_RAW_DATA, $iv, $tag, '', $this->tag_length); + if ($ct === false) return null; + return base64_encode($iv . $tag . $ct); + } + + // Dekripsi base64(iv + tag + ciphertext) → plaintext + public function decrypt($ciphertext) + { + if ($ciphertext === null || $ciphertext === '') return null; + $raw = base64_decode($ciphertext); + if (strlen($raw) < 12 + $this->tag_length) return null; + $iv = substr($raw, 0, 12); + $tag = substr($raw, 12, $this->tag_length); + $ct = substr($raw, 12 + $this->tag_length); + $pt = openssl_decrypt($ct, $this->cipher, $this->key, OPENSSL_RAW_DATA, $iv, $tag); + return $pt === false ? null : $pt; + } + + // Hasilkan JSON array token trigram (untuk partial search) + // Input: string value, Output: JSON string dari array HMAC token + public function search_bidx($value) + { + if ($value === null || $value === '') return null; + $norm = mb_strtolower(trim($value), 'UTF-8'); + $tokens = []; + $len = mb_strlen($norm, 'UTF-8'); + if ($len <= 3) { + $tokens[] = $this->_token($norm); + } else { + for ($i = 0; $i <= $len - 3; $i++) { + $tokens[] = $this->_token(mb_substr($norm, $i, 3, 'UTF-8')); + } + } + return json_encode(array_values(array_unique($tokens))); + } + + // Hasilkan token trigram dari query string (untuk WHERE IN saat search) + // Kembalikan array token (bukan JSON) + public function query_tokens($query) + { + if ($query === null || $query === '') return []; + $norm = mb_strtolower(trim($query), 'UTF-8'); + $tokens = []; + $len = mb_strlen($norm, 'UTF-8'); + if ($len <= 3) { + $tokens[] = $this->_token($norm); + } else { + for ($i = 0; $i <= $len - 3; $i++) { + $tokens[] = $this->_token(mb_substr($norm, $i, 3, 'UTF-8')); + } + } + return array_values(array_unique($tokens)); + } + + private function _token($str) + { + return hash_hmac('sha256', $str, $this->search_key); + } +} +``` + +- [ ] **Step 2: Test library via CLI** + +Buat file sementara `scripts/test_encryptor.php`: +```php +encrypt($plain); +$pt = $enc->decrypt($ct); +assert($pt === $plain, "FAIL: decrypt mismatch"); +echo "encrypt/decrypt: OK\n"; + +// Test null +assert($enc->encrypt(null) === null, "FAIL: null encrypt"); +assert($enc->decrypt(null) === null, "FAIL: null decrypt"); +echo "null handling: OK\n"; + +// Test trigram tokens +$bidx = json_decode($enc->search_bidx('BUDI'), true); +$qtok = $enc->query_tokens('udi'); // partial search "udi" +assert(in_array($qtok[0], $bidx), "FAIL: token 'udi' tidak ada di bidx BUDI"); +echo "trigram search: OK\n"; + +echo "Semua test passed.\n"; +``` + +Jalankan: +```bash +php scripts/test_encryptor.php +``` + +Expected: +``` +encrypt/decrypt: OK +null handling: OK +trigram search: OK +Semua test passed. +``` + +- [ ] **Step 3: Hapus file test sementara** + +```bash +rm scripts/test_encryptor.php +``` + +- [ ] **Step 4: Commit** + +```bash +git add application/libraries/Ibl_encryptor.php +git commit -m "TASKCODE - tambah library Ibl_encryptor AES-256-GCM + trigram blind index" +``` + +--- + +## Task 3: SQL Migration — Tambah Kolom `_enc` dan `_bidx` + +**Files:** +- Create: `sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql` + +- [ ] **Step 1: Buat file SQL** + +```sql +-- UU PDP: tambah kolom enkripsi PII pasien +-- Kolom lama TIDAK dihapus (backward compat selama migrasi) +-- Setelah semua data termigrasi dan kode diupdate, kolom lama bisa di-drop di sprint berikutnya + +-- m_patient: kolom _enc untuk semua field PII +ALTER TABLE one_lab.m_patient + ADD COLUMN M_PatientName_enc TEXT NULL AFTER M_PatientName, + ADD COLUMN M_PatientName_bidx MEDIUMTEXT NULL AFTER M_PatientName_enc, + ADD COLUMN M_PatientHP_enc TEXT NULL AFTER M_PatientHP, + ADD COLUMN M_PatientHP_bidx MEDIUMTEXT NULL AFTER M_PatientHP_enc, + ADD COLUMN M_PatientDOB_enc TEXT NULL AFTER M_PatientDOB, + ADD COLUMN M_PatientDOB_bidx MEDIUMTEXT NULL AFTER M_PatientDOB_enc, + ADD COLUMN M_PatientEmail_enc TEXT NULL AFTER M_PatientEmail, + ADD COLUMN M_PatientPhone_enc TEXT NULL AFTER M_PatientPhone, + ADD COLUMN M_PatientPOB_enc TEXT NULL AFTER M_PatientPOB, + ADD COLUMN M_PatientIDNumber_enc TEXT NULL AFTER M_PatientIDNumber, + ADD COLUMN M_PatientNIK_enc TEXT NULL AFTER M_PatientNIK, + ADD COLUMN M_PatientNIP_enc TEXT NULL AFTER M_PatientNIP; + +-- m_patientaddress: kolom _enc untuk field PII +ALTER TABLE one_lab.m_patientaddress + ADD COLUMN M_PatientAddressDescription_enc TEXT NULL AFTER M_PatientAddressDescription, + ADD COLUMN M_PatientAddressDescription_bidx MEDIUMTEXT NULL AFTER M_PatientAddressDescription_enc, + ADD COLUMN M_PatientAddressEmail_enc TEXT NULL AFTER M_PatientAddressEmail, + ADD COLUMN M_PatientAddressPhone_enc TEXT NULL AFTER M_PatientAddressPhone; +``` + +- [ ] **Step 2: Jalankan SQL di devone** + +```bash +ssh devone "mysql one_lab < /home/one/project/one/one-api-lab/sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql" +``` + +Atau jika file belum diupload, jalankan langsung: +```bash +ssh devone "mysql -e \" +ALTER TABLE one_lab.m_patient + ADD COLUMN M_PatientName_enc TEXT NULL AFTER M_PatientName, + ADD COLUMN M_PatientName_bidx MEDIUMTEXT NULL AFTER M_PatientName_enc, + ADD COLUMN M_PatientHP_enc TEXT NULL AFTER M_PatientHP, + ADD COLUMN M_PatientHP_bidx MEDIUMTEXT NULL AFTER M_PatientHP_enc, + ADD COLUMN M_PatientDOB_enc TEXT NULL AFTER M_PatientDOB, + ADD COLUMN M_PatientDOB_bidx MEDIUMTEXT NULL AFTER M_PatientDOB_enc, + ADD COLUMN M_PatientEmail_enc TEXT NULL AFTER M_PatientEmail, + ADD COLUMN M_PatientPhone_enc TEXT NULL AFTER M_PatientPhone, + ADD COLUMN M_PatientPOB_enc TEXT NULL AFTER M_PatientPOB, + ADD COLUMN M_PatientIDNumber_enc TEXT NULL AFTER M_PatientIDNumber, + ADD COLUMN M_PatientNIK_enc TEXT NULL AFTER M_PatientNIK, + ADD COLUMN M_PatientNIP_enc TEXT NULL AFTER M_PatientNIP; +ALTER TABLE one_lab.m_patientaddress + ADD COLUMN M_PatientAddressDescription_enc TEXT NULL AFTER M_PatientAddressDescription, + ADD COLUMN M_PatientAddressDescription_bidx MEDIUMTEXT NULL AFTER M_PatientAddressDescription_enc, + ADD COLUMN M_PatientAddressEmail_enc TEXT NULL AFTER M_PatientAddressEmail, + ADD COLUMN M_PatientAddressPhone_enc TEXT NULL AFTER M_PatientAddressPhone; +\"" +``` + +- [ ] **Step 3: Verifikasi kolom terbentuk** + +```bash +ssh devone "mysql -e 'SHOW COLUMNS FROM one_lab.m_patient LIKE \"%_enc\";'" +``` + +Expected: 9 baris dengan kolom-kolom `_enc`. + +- [ ] **Step 4: Commit SQL file** + +```bash +git add sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql +git commit -m "TASKCODE - SQL: tambah kolom _enc dan _bidx untuk PII pasien" +``` + +--- + +## Task 4: Migration Script — Enkripsi Data Lama (178K rows) + +**Files:** +- Create: `scripts/migrate_encrypt_patient.php` + +Script ini dijalankan **sekali** di server via CLI. Proses 500 baris per batch. + +- [ ] **Step 1: Buat migration script** + +```php + PDO::ERRMODE_EXCEPTION +]); + +// --- Migrate m_patient --- +echo "=== Migrasi m_patient ===\n"; +$offset = 0; +$batch = 500; +$total = 0; + +while (true) { + $rows = $pdo->query("SELECT M_PatientID, M_PatientName, M_PatientHP, M_PatientDOB, + M_PatientEmail, M_PatientPhone, M_PatientPOB, M_PatientIDNumber, M_PatientNIK, M_PatientNIP + FROM m_patient WHERE M_PatientName_enc IS NULL LIMIT {$batch}")->fetchAll(PDO::FETCH_ASSOC); + + if (empty($rows)) break; + + $stmt = $pdo->prepare("UPDATE m_patient SET + M_PatientName_enc = ?, M_PatientName_bidx = ?, + M_PatientHP_enc = ?, M_PatientHP_bidx = ?, + M_PatientDOB_enc = ?, M_PatientDOB_bidx = ?, + M_PatientEmail_enc = ?, + M_PatientPhone_enc = ?, + M_PatientPOB_enc = ?, + M_PatientIDNumber_enc = ?, + M_PatientNIK_enc = ?, + M_PatientNIP_enc = ? + WHERE M_PatientID = ?"); + + foreach ($rows as $row) { + $dob = $row['M_PatientDOB'] ? date('d-m-Y', strtotime($row['M_PatientDOB'])) : ''; + $stmt->execute([ + $enc->encrypt($row['M_PatientName']), + $enc->search_bidx($row['M_PatientName']), + $enc->encrypt($row['M_PatientHP']), + $enc->search_bidx($row['M_PatientHP']), + $enc->encrypt($dob), + $enc->search_bidx($dob), + $enc->encrypt($row['M_PatientEmail']), + $enc->encrypt($row['M_PatientPhone']), + $enc->encrypt($row['M_PatientPOB']), + $enc->encrypt($row['M_PatientIDNumber']), + $enc->encrypt($row['M_PatientNIK']), + $enc->encrypt($row['M_PatientNIP']), + $row['M_PatientID'], + ]); + $total++; + } + echo " {$total} rows done...\n"; +} +echo "m_patient selesai: {$total} rows\n"; + +// --- Migrate m_patientaddress --- +echo "=== Migrasi m_patientaddress ===\n"; +$total = 0; + +while (true) { + $rows = $pdo->query("SELECT M_PatientAddressID, M_PatientAddressDescription, + M_PatientAddressEmail, M_PatientAddressPhone + FROM m_patientaddress WHERE M_PatientAddressDescription_enc IS NULL LIMIT {$batch}")->fetchAll(PDO::FETCH_ASSOC); + + if (empty($rows)) break; + + $stmt = $pdo->prepare("UPDATE m_patientaddress SET + M_PatientAddressDescription_enc = ?, M_PatientAddressDescription_bidx = ?, + M_PatientAddressEmail_enc = ?, + M_PatientAddressPhone_enc = ? + WHERE M_PatientAddressID = ?"); + + foreach ($rows as $row) { + $stmt->execute([ + $enc->encrypt($row['M_PatientAddressDescription']), + $enc->search_bidx($row['M_PatientAddressDescription']), + $enc->encrypt($row['M_PatientAddressEmail']), + $enc->encrypt($row['M_PatientAddressPhone']), + $row['M_PatientAddressID'], + ]); + $total++; + } + echo " {$total} rows done...\n"; +} +echo "m_patientaddress selesai: {$total} rows\n"; +echo "=== Migrasi selesai ===\n"; +``` + +- [ ] **Step 2: Commit script** + +```bash +git add scripts/migrate_encrypt_patient.php +git commit -m "TASKCODE - migration script enkripsi batch data PII pasien" +``` + +- [ ] **Step 3: Upload ke server dan jalankan** + +> Jalankan setelah Task 3 (kolom sudah ada) dan Task 2 (library sudah ada di server) selesai diupload. + +```bash +# Upload dulu via upload script +bash scripts/upload_ibl_committed_files.sh + +# Jalankan migration di server +ssh devone "cd /home/one/project/one/one-api-lab && php scripts/migrate_encrypt_patient.php" +``` + +- [ ] **Step 4: Verifikasi hasil migrasi** + +```bash +ssh devone "mysql -e 'SELECT COUNT(*) total, COUNT(M_PatientName_enc) sudah_enc FROM one_lab.m_patient;'" +``` + +Expected: `total` == `sudah_enc` (semua baris sudah terenkripsi). + +--- + +## Task 5: Update `Patient.php` — `add_new()` dan `edit()` + +**Files:** +- Modify: `application/controllers/mockup/fo/ibl_registration/Patient.php` + +- [ ] **Step 1: Load library di `__construct`** + +Di `__construct()` Patient.php, tambahkan setelah `parent::__construct()`: +```php +$this->load->library('ibl_encryptor'); +``` + +- [ ] **Step 2: Update `add_new()` — enkripsi sebelum insert** + +Pada `add_new()`, ganti bagian pembentukan array `$ptn` dan `$add`: + +```php +// Enkripsi PII sebelum insert +$dob_str = date('d-m-Y', strtotime($prm['M_PatientDOB'])); + +$ptn = [ + 'M_PatientName' => $patient_name, + 'M_PatientName_enc' => $this->ibl_encryptor->encrypt($patient_name), + 'M_PatientName_bidx' => $this->ibl_encryptor->search_bidx($patient_name), + 'M_PatientM_TitleID' => $prm['M_PatientM_TitleID'], + 'M_PatientPrefix' => $prm['M_PatientPrefix'], + 'M_PatientSuffix' => $prm['M_PatientSuffix'], + 'M_PatientM_SexID' => $prm['M_PatientM_SexID'], + 'M_PatientM_ReligionID' => $prm['M_PatientM_ReligionID'], + 'M_PatientDOB' => $prm['M_PatientDOB'], + 'M_PatientDOB_enc' => $this->ibl_encryptor->encrypt($dob_str), + 'M_PatientDOB_bidx' => $this->ibl_encryptor->search_bidx($dob_str), + 'M_PatientPOB' => $prm['M_PatientPOB'], + 'M_PatientPOB_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientPOB']), + 'M_PatientHP' => $prm['M_PatientHP'], + 'M_PatientHP_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientHP']), + 'M_PatientHP_bidx' => $this->ibl_encryptor->search_bidx($prm['M_PatientHP']), + 'M_PatientPhone' => $prm['M_PatientPhone'], + 'M_PatientPhone_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientPhone']), + 'M_PatientEmail' => $prm['M_PatientEmail'], + 'M_PatientEmail_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientEmail']), + 'M_PatientM_IdTypeID' => $M_IdTypeID, + 'M_PatientIDNumber' => $prm['M_PatientIDNumber'], + 'M_PatientIDNumber_enc' => $this->ibl_encryptor->encrypt($prm['M_PatientIDNumber']), + 'M_PatientNote' => $prm['M_PatientNote'], + 'M_PatientUserID' => $userid, + 'M_PatientCreated' => date('Y-m-d H:i:s'), + 'M_PatientCreatedUserID' => $userid +]; +``` + +Dan untuk `$add` (address): +```php +$add = [ + 'M_PatientAddressM_PatientID' => $id, + 'M_PatientAddressDescription' => $address_description, + 'M_PatientAddressDescription_enc' => $this->ibl_encryptor->encrypt($address_description), + 'M_PatientAddressDescription_bidx' => $this->ibl_encryptor->search_bidx($address_description), + 'M_PatientAddressUserID' => $userid, + 'M_PatientAddressRegionalCd' => $prm['M_PatientAddressRegionalCd'], + 'M_PatientAddressLocation' => $prm['M_PatientAddressLocation'], + 'M_PatientAddressCity' => $prm['M_PatientAddressCity'], + 'M_PatientAddressVillage' => $prm['M_PatientAddressVillage'], + 'M_PatientAddressDistrict' => $prm['M_PatientAddressDistrict'], + 'M_PatientAddressState' => $prm['M_PatientAddressState'], + 'M_PatientAddressCountry' => $prm['M_PatientAddressCountry'], + 'M_PatientAddressCountryCode' => $prm['M_PatientAddressCountryCode'], + 'M_PatientAddressNote' => isset($prm['M_PatientAddressNote']) ? $prm['M_PatientAddressNote'] : 'Utama', + 'M_PatientAddressCreated' => date('Y-m-d H:i:s'), + 'M_PatientAddressCreatedUserID' => $userid +]; +``` + +- [ ] **Step 3: Update `edit()` — enkripsi sebelum update** + +Ganti chain `->set(...)` pada `edit()` untuk patient: +```php +$dob_str = date('d-m-Y', strtotime($prm['M_PatientDOB'])); + +$this->db_smartone + ->set('M_PatientName', $patient_name) + ->set('M_PatientName_enc', $this->ibl_encryptor->encrypt($patient_name)) + ->set('M_PatientName_bidx', $this->ibl_encryptor->search_bidx($patient_name)) + ->set('M_PatientM_TitleID', $prm['M_PatientM_TitleID']) + ->set('M_PatientPrefix', $prm['M_PatientPrefix']) + ->set('M_PatientSuffix', $prm['M_PatientSuffix']) + ->set('M_PatientM_SexID', $prm['M_PatientM_SexID']) + ->set('M_PatientM_ReligionID', $prm['M_PatientM_ReligionID']) + ->set('M_PatientDOB', $prm['M_PatientDOB']) + ->set('M_PatientDOB_enc', $this->ibl_encryptor->encrypt($dob_str)) + ->set('M_PatientDOB_bidx', $this->ibl_encryptor->search_bidx($dob_str)) + ->set('M_PatientPOB', $prm['M_PatientPOB']) + ->set('M_PatientPOB_enc', $this->ibl_encryptor->encrypt($prm['M_PatientPOB'])) + ->set('M_PatientHP', $prm['M_PatientHP']) + ->set('M_PatientHP_enc', $this->ibl_encryptor->encrypt($prm['M_PatientHP'])) + ->set('M_PatientHP_bidx', $this->ibl_encryptor->search_bidx($prm['M_PatientHP'])) + ->set('M_PatientPhone', $prm['M_PatientPhone']) + ->set('M_PatientPhone_enc', $this->ibl_encryptor->encrypt($prm['M_PatientPhone'])) + ->set('M_PatientEmail', $prm['M_PatientEmail']) + ->set('M_PatientEmail_enc', $this->ibl_encryptor->encrypt($prm['M_PatientEmail'])) + ->set('M_PatientM_IdTypeID', $prm['M_PatientM_IdTypeID']) + ->set('M_PatientIDNumber', $prm['M_PatientIDNumber']) + ->set('M_PatientIDNumber_enc', $this->ibl_encryptor->encrypt($prm['M_PatientIDNumber'])) + ->set('M_PatientNote', $prm['M_PatientNote']) + ->set('M_PatientUserID', $userid) + ->set('M_PatientLastUpdatedUserID', $userid) + ->where('M_PatientID', $prm['id']) + ->update('m_patient'); +``` + +Dan untuk address di `edit()`: +```php +$this->db_smartone + ->set('M_PatientAddressRegionalCd', $prm['M_PatientAddressRegionalCd']) + ->set('M_PatientAddressLocation', $prm['M_PatientAddressLocation']) + ->set('M_PatientAddressCity', $prm['M_PatientAddressCity']) + ->set('M_PatientAddressVillage', $prm['M_PatientAddressVillage']) + ->set('M_PatientAddressDistrict', $prm['M_PatientAddressDistrict']) + ->set('M_PatientAddressState', $prm['M_PatientAddressState']) + ->set('M_PatientAddressCountry', $prm['M_PatientAddressCountry']) + ->set('M_PatientAddressCountryCode', $prm['M_PatientAddressCountryCode']) + ->set('M_PatientAddressDescription', $address_description) + ->set('M_PatientAddressDescription_enc', $this->ibl_encryptor->encrypt($address_description)) + ->set('M_PatientAddressDescription_bidx', $this->ibl_encryptor->search_bidx($address_description)) + ->set('M_PatientAddressUserID', $userid) + ->set('M_PatientAddressLastUpdatedUserID', $userid) + ->where('M_PatientAddressID', $id_address) + ->update('m_patientaddress'); +``` + +- [ ] **Step 4: Commit** + +```bash +git add application/controllers/mockup/fo/ibl_registration/Patient.php +git commit -m "TASKCODE - Patient add_new/edit: enkripsi PII sebelum save ke DB" +``` + +--- + +## Task 6: Update `Patient.php` — `search()` Pakai Blind Index + +**Files:** +- Modify: `application/controllers/mockup/fo/ibl_registration/Patient.php` (fungsi `search()`) + +Strategi: untuk setiap parameter search yang sebelumnya pakai `LIKE`, ganti dengan subquery ke kolom `_bidx` menggunakan trigram token matching. Hasil tetap di-decrypt sebelum dikembalikan ke client. + +- [ ] **Step 1: Ganti logika `search()` untuk pakai bidx** + +Ganti seluruh blok `search()` (baris 75–188) dengan: + +```php +public function search() +{ + $prm = $this->sys_input; + $number_limit = 10; + $number_offset = (!isset($prm['current_page']) ? 1 : $prm['current_page'] - 1) * $number_limit; + + // WHERE clauses + $where_patient = "WHERE M_PatientIsActive = 'Y'"; + $where_address = "AND M_PatientAddressIsActive = 'Y'"; + $join_address = "join m_patientaddress on M_PatientAddressM_PatientID = M_PatientID {$where_address}"; + $where_noreg = ''; + $where_name = ''; + $where_hp = ''; + $where_dob = ''; + $where_addr = ''; + + if (!empty($prm['noreg'])) { + $noreg = $this->db_smartone->escape_like_str($prm['noreg']); + $where_noreg = "AND M_PatientNoReg LIKE '%{$noreg}%'"; + } + + if (!empty($prm['search'])) { + $e = explode('+', $prm['search']); + + // Search name via trigram bidx + if (!empty($e[0])) { + $name_tokens = $this->ibl_encryptor->query_tokens($e[0]); + if (!empty($name_tokens)) { + $token_conditions = []; + foreach ($name_tokens as $tok) { + $tok_esc = $this->db_smartone->escape_str($tok); + $token_conditions[] = "JSON_CONTAINS(M_PatientName_bidx, '\"$tok_esc\"')"; + } + $where_name = 'AND (' . implode(' AND ', $token_conditions) . ')'; + } + } + + // Search HP via trigram bidx + if (!empty($e[1])) { + $hp_tokens = $this->ibl_encryptor->query_tokens($e[1]); + if (!empty($hp_tokens)) { + $token_conditions = []; + foreach ($hp_tokens as $tok) { + $tok_esc = $this->db_smartone->escape_str($tok); + $token_conditions[] = "JSON_CONTAINS(M_PatientHP_bidx, '\"$tok_esc\"')"; + } + $where_hp = 'AND (' . implode(' AND ', $token_conditions) . ')'; + } + } + + // Search DOB via trigram bidx + if (!empty($e[2])) { + $dob_tokens = $this->ibl_encryptor->query_tokens($e[2]); + if (!empty($dob_tokens)) { + $token_conditions = []; + foreach ($dob_tokens as $tok) { + $tok_esc = $this->db_smartone->escape_str($tok); + $token_conditions[] = "JSON_CONTAINS(M_PatientDOB_bidx, '\"$tok_esc\"')"; + } + $where_dob = 'AND (' . implode(' AND ', $token_conditions) . ')'; + } + } + + // Search address via trigram bidx + if (!empty($e[3])) { + $addr_tokens = $this->ibl_encryptor->query_tokens($e[3]); + if (!empty($addr_tokens)) { + $token_conditions = []; + foreach ($addr_tokens as $tok) { + $tok_esc = $this->db_smartone->escape_str($tok); + $token_conditions[] = "JSON_CONTAINS(M_PatientAddressDescription_bidx, '\"$tok_esc\"')"; + } + $where_addr = 'AND (' . implode(' AND ', $token_conditions) . ')'; + } + } + } + + if (empty($where_addr)) { + $join_address .= " AND M_PatientAddressNote = 'Utama'"; + } + + $sql = "SELECT 'N' divider, M_PatientID, M_PatientNoReg, M_PatientEmail_enc, + M_PatientPrefix, M_PatientSuffix, + concat(M_TitleName,' ',IFNULL(M_PatientPrefix,''),' ',M_PatientName,' ',IFNULL(M_PatientSuffix,'')) M_PatientName_raw, + M_PatientName_enc, + M_PatientName M_PatientRealName, M_TitleID, M_TitleName, M_SexID, M_SexName, + M_PatientHP_enc, M_PatientPOB_enc, M_PatientDOB_enc, M_PatientDOB, + '' M_PatientAddress, + M_PatientAddressID, + M_PatientAddressDescription_enc, M_PatientM_IdTypeID, M_PatientIDNumber_enc, + M_PatientAddressRegionalCd, + M_PatientAddressLocation, + M_PatientAddressCity, + M_PatientAddressVillage, + M_PatientAddressDistrict, + M_PatientAddressState, + M_PatientAddressCountry, + M_PatientAddressCountryCode, + IFNULL(M_PatientNote, '') M_PatientNote, M_PatientPhoto, + M_PatientPhone_enc hp_phone, + M_PatientAddressM_KelurahanID M_KelurahanID, 0 M_DistrictID, 0 M_CityID, 0 M_ProvinceID, + M_PatientM_ReligionID, + IFNULL(M_ReligionName, '-') M_ReligionName, + IFNULL(Patient_SignatureUrl, '') image_signature + FROM m_patient + join m_title on M_PatientM_TitleID = M_TitleID + join m_sex on M_PatientM_SexID = M_SexID + {$join_address} + left join m_religion on M_PatientM_ReligionID = M_ReligionID + left join patient_signature on Patient_SignatureM_PatientID = M_PatientID and Patient_SignatureIsActive = 'Y' + {$where_patient} + {$where_noreg} + {$where_name} + {$where_hp} + {$where_dob} + {$where_addr} + group by M_PatientID + limit {$number_limit} offset {$number_offset}"; + + $query = $this->db_smartone->query($sql); + if (!$query) { + $this->sys_error_db("patient search error", $this->db_smartone); + return; + } + + $rows = $query->result_array(); + + // Dekripsi hasil + $enc = $this->ibl_encryptor; + foreach ($rows as &$row) { + $row['M_PatientName'] = $enc->decrypt($row['M_PatientName_enc']) ?? $row['M_PatientRealName']; + $row['M_PatientHP'] = $enc->decrypt($row['M_PatientHP_enc']); + $row['dob_ina'] = $enc->decrypt($row['M_PatientDOB_enc']) ?? date('d-m-Y', strtotime($row['M_PatientDOB'])); + $row['M_PatientEmail'] = $enc->decrypt($row['M_PatientEmail_enc']); + $row['M_PatientIDNumber'] = $enc->decrypt($row['M_PatientIDNumber_enc']); + $row['M_PatientPOB'] = $enc->decrypt($row['M_PatientPOB_enc']); + $row['M_PatientAddressDescription'] = $enc->decrypt($row['M_PatientAddressDescription_enc']); + $row['hp'] = $enc->decrypt($row['M_PatientPhone_enc']) ?: $row['M_PatientHP']; + + // Bersihkan kolom _enc dari response + foreach (array_keys($row) as $k) { + if (substr($k, -4) === '_enc' || substr($k, -5) === '_bidx') unset($row[$k]); + } + unset($row['M_PatientRealName'], $row['M_PatientName_raw'], $row['hp_phone']); + } + unset($row); + + $this->sys_ok($rows); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add application/controllers/mockup/fo/ibl_registration/Patient.php +git commit -m "TASKCODE - Patient search: ganti LIKE ke trigram blind index, decrypt hasil" +``` + +--- + +## Task 7: Upload & Smoke Test di devone + +- [ ] **Step 1: Upload ke server** + +```bash +bash scripts/upload_ibl_committed_files.sh +``` + +- [ ] **Step 2: Pastikan .env ada di server** + +```bash +ssh devone "ls -la /home/one/project/one/one-api-lab/.env" +``` + +Jika belum ada, buat manual di server (isi dengan key yang sama dengan lokal): +```bash +ssh devone "cat > /home/one/project/one/one-api-lab/.env << 'EOF' +IBL_ENCRYPT_KEY= +IBL_ENCRYPT_SEARCH_KEY= +EOF" +``` + +- [ ] **Step 3: Jalankan migration di server** + +```bash +ssh devone "cd /home/one/project/one/one-api-lab && php scripts/migrate_encrypt_patient.php" +``` + +- [ ] **Step 4: Smoke test search endpoint** + +Test dengan nama yang ada di DB (ganti "BUDI" dengan nama pasien yang ada): +```bash +curl -s -X POST https:///mockup/fo/ibl_registration/patient/search \ + -H "Content-Type: application/json" \ + -d '{"token":"","search":"BUDI","noreg":"","current_page":1}' | python3 -m json.tool | head -50 +``` + +Expected: response `{"status":"OK","data":[...]}` dengan nama pasien terdekripsi, bukan ciphertext. + +- [ ] **Step 5: Test add_new + search** + +Tambah pasien baru via API, kemudian search namanya. Pastikan hasil search menampilkan data yang benar. + +- [ ] **Step 6: Verifikasi data terenkripsi di DB** + +```bash +ssh devone "mysql -e 'SELECT M_PatientName, M_PatientName_enc FROM one_lab.m_patient LIMIT 3;'" +``` + +Expected: `M_PatientName` masih plaintext (untuk backward compat), `M_PatientName_enc` berisi base64 ciphertext. + +--- + +--- + +## Task 8: SQL Migration — Tambah Kolom `_enc` untuk Hasil Lab + +**Files:** +- Modify: `sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql` (append) + +- [ ] **Step 1: Append SQL untuk tabel hasil lab** + +```sql +-- t_orderdetail: nilai hasil lab utama +ALTER TABLE one_lab.t_orderdetail + ADD COLUMN T_OrderDetailResult_enc TEXT NULL AFTER T_OrderDetailResult, + ADD COLUMN T_OrderDetailNote_enc TEXT NULL AFTER T_OrderDetailNote; + +-- t_orderheader: diagnosa dokter +ALTER TABLE one_lab.t_orderheader + ADD COLUMN T_OrderHeaderDiagnose_enc TEXT NULL AFTER T_OrderHeaderDiagnose; + +-- so_resultentrydetail: hasil lab standar +ALTER TABLE one_lab.so_resultentrydetail + ADD COLUMN So_ResultEntryDetailResult_enc TEXT NULL AFTER So_ResultEntryDetailResult; + +-- so_resultentrydetail_other: hasil lab nonstandar +ALTER TABLE one_lab.so_resultentrydetail_other + ADD COLUMN So_ResultEntryDetailOtherResult_enc TEXT NULL AFTER So_ResultEntryDetailOtherResult, + ADD COLUMN So_ResultEntryDetailOtherResultBefore_enc TEXT NULL AFTER So_ResultEntryDetailOtherResultBefore; + +-- so_resultentry_fisik_umum: JSON pemeriksaan fisik +ALTER TABLE one_lab.so_resultentry_fisik_umum + ADD COLUMN So_ResultEntryFisikUmumDetails_enc TEXT NULL AFTER So_ResultEntryFisikUmumDetails; + +-- so_resultentry_fisik_summary: ringkasan fisik +ALTER TABLE one_lab.so_resultentry_fisik_summary + ADD COLUMN So_ResultEntryFisikSummaryValue_enc TEXT NULL AFTER So_ResultEntryFisikSummaryValue, + ADD COLUMN So_ResultEntryFisikSummaryValue2_enc TEXT NULL AFTER So_ResultEntryFisikSummaryValue2; + +-- so_resultentry_other: catatan hasil +ALTER TABLE one_lab.so_resultentry_other + ADD COLUMN So_ResultEntryOtherNote_enc TEXT NULL AFTER So_ResultEntryOtherNote; + +-- so_resultentry_fisioterapi: detail fisioterapi +ALTER TABLE one_lab.so_resultentry_fisioterapi + ADD COLUMN So_ResultEntdyFisioterapiDetails_enc TEXT NULL AFTER So_ResultEntdyFisioterapiDetails; + +-- so_resultentry_smwt: hasil 6MWT (numerik → enkripsi sebagai teks) +ALTER TABLE one_lab.so_resultentry_smwt + ADD COLUMN So_ResultentrySmwtWeight_enc TEXT NULL AFTER So_ResultentrySmwtWeight, + ADD COLUMN So_ResultentrySmwtHeight_enc TEXT NULL AFTER So_ResultentrySmwtHeight, + ADD COLUMN So_ResultentrySmwtBMI_enc TEXT NULL AFTER So_ResultentrySmwtBMI, + ADD COLUMN So_ResultentrySmwtPreTensi_enc TEXT NULL AFTER So_ResultentrySmwtPreTensi, + ADD COLUMN So_ResultentrySmwtPreSPO2_enc TEXT NULL AFTER So_ResultentrySmwtPreSPO2, + ADD COLUMN So_ResultentrySmwtPreNadi_enc TEXT NULL AFTER So_ResultentrySmwtPreNadi, + ADD COLUMN So_ResultentrySmwtPostTensi_enc TEXT NULL AFTER So_ResultentrySmwtPostTensi, + ADD COLUMN So_ResultentrySmwtPostSPO2_enc TEXT NULL AFTER So_ResultentrySmwtPostSPO2, + ADD COLUMN So_ResultentrySmwtPostNadi_enc TEXT NULL AFTER So_ResultentrySmwtPostNadi, + ADD COLUMN So_ResultentrySmwtVOMax_enc TEXT NULL AFTER So_ResultentrySmwtVOMax, + ADD COLUMN So_ResultentrySmwtKategoriKebugaran_enc TEXT NULL AFTER So_ResultentrySmwtKategoriKebugaran; + +-- so_resultentry_srq29_conclusion: hasil SRQ-29 (kesehatan jiwa) +ALTER TABLE one_lab.so_resultentry_srq29_conclusion + ADD COLUMN So_ResultentrySrq29ConclusionResult_enc TEXT NULL AFTER So_ResultentrySrq29ConclusionResult; + +-- so_resultentrysdsinterpretation: interpretasi SDS +ALTER TABLE one_lab.so_resultentrysdsinterpretation + ADD COLUMN So_ResultEntrySDSInterpretationDisplay_enc TEXT NULL AFTER So_ResultEntrySDSInterpretationDisplay; + +-- member_eligible: data BPJS/asuransi +ALTER TABLE one_lab.member_eligible + ADD COLUMN Member_EligibleDescription_enc TEXT NULL AFTER Member_EligibleDescription; +``` + +- [ ] **Step 2: Jalankan di devone** + +```bash +ssh devone "mysql one_lab" < sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql +``` + +Verifikasi salah satu: +```bash +ssh devone "mysql -e 'SHOW COLUMNS FROM one_lab.t_orderdetail LIKE \"%_enc\";'" +``` + +Expected: 2 baris (`T_OrderDetailResult_enc`, `T_OrderDetailNote_enc`). + +- [ ] **Step 3: Commit** + +```bash +git add sql/manual_changes/2026-05-31-pdp-encrypt-m-patient.sql +git commit -m "TASKCODE - SQL: tambah kolom _enc untuk tabel hasil lab" +``` + +--- + +## Task 9: Migration Script — Enkripsi Data Hasil Lab + +**Files:** +- Create: `scripts/migrate_encrypt_results.php` + +- [ ] **Step 1: Buat script** + +```php + PDO::ERRMODE_EXCEPTION] +); + +function migrate_simple($pdo, $enc, $table, $pk, $fields, $check_field) { + $total = 0; + $cols_select = implode(', ', array_merge([$pk], $fields)); + $sets = implode(', ', array_map(fn($f) => "{$f}_enc = ?", $fields)); + while (true) { + $rows = $pdo->query("SELECT {$cols_select} FROM {$table} WHERE {$check_field}_enc IS NULL LIMIT 500")->fetchAll(PDO::FETCH_ASSOC); + if (empty($rows)) break; + $stmt = $pdo->prepare("UPDATE {$table} SET {$sets} WHERE {$pk} = ?"); + foreach ($rows as $row) { + $params = array_map(fn($f) => $enc->encrypt((string)($row[$f] ?? '')), $fields); + $params[] = $row[$pk]; + $stmt->execute($params); + $total++; + } + echo " {$table}: {$total} rows...\n"; + } + echo "{$table}: selesai {$total} rows\n"; +} + +// t_orderdetail +migrate_simple($pdo, $enc, 't_orderdetail', 'T_OrderDetailID', + ['T_OrderDetailResult', 'T_OrderDetailNote'], 'T_OrderDetailResult'); + +// t_orderheader +migrate_simple($pdo, $enc, 't_orderheader', 'T_OrderHeaderID', + ['T_OrderHeaderDiagnose'], 'T_OrderHeaderDiagnose'); + +// so_resultentrydetail +migrate_simple($pdo, $enc, 'so_resultentrydetail', 'So_ResultEntryDetailID', + ['So_ResultEntryDetailResult'], 'So_ResultEntryDetailResult'); + +// so_resultentrydetail_other +migrate_simple($pdo, $enc, 'so_resultentrydetail_other', 'So_ResultEntryDetailOtherID', + ['So_ResultEntryDetailOtherResult', 'So_ResultEntryDetailOtherResultBefore'], 'So_ResultEntryDetailOtherResult'); + +// so_resultentry_fisik_umum +migrate_simple($pdo, $enc, 'so_resultentry_fisik_umum', 'So_ResultEntryFisikUmumID', + ['So_ResultEntryFisikUmumDetails'], 'So_ResultEntryFisikUmumDetails'); + +// so_resultentry_fisik_summary +migrate_simple($pdo, $enc, 'so_resultentry_fisik_summary', 'So_ResultEntryFisikSummaryID', + ['So_ResultEntryFisikSummaryValue', 'So_ResultEntryFisikSummaryValue2'], 'So_ResultEntryFisikSummaryValue'); + +// so_resultentry_other +migrate_simple($pdo, $enc, 'so_resultentry_other', 'So_ResultEntryOtherID', + ['So_ResultEntryOtherNote'], 'So_ResultEntryOtherNote'); + +// so_resultentry_fisioterapi +migrate_simple($pdo, $enc, 'so_resultentry_fisioterapi', 'So_ResultEntdyFisioterapiID', + ['So_ResultEntdyFisioterapiDetails'], 'So_ResultEntdyFisioterapiDetails'); + +// so_resultentry_smwt +migrate_simple($pdo, $enc, 'so_resultentry_smwt', 'So_ResultentrySmwtID', [ + 'So_ResultentrySmwtWeight', 'So_ResultentrySmwtHeight', 'So_ResultentrySmwtBMI', + 'So_ResultentrySmwtPreTensi', 'So_ResultentrySmwtPreSPO2', 'So_ResultentrySmwtPreNadi', + 'So_ResultentrySmwtPostTensi', 'So_ResultentrySmwtPostSPO2', 'So_ResultentrySmwtPostNadi', + 'So_ResultentrySmwtVOMax', 'So_ResultentrySmwtKategoriKebugaran' +], 'So_ResultentrySmwtWeight'); + +// so_resultentry_srq29_conclusion +migrate_simple($pdo, $enc, 'so_resultentry_srq29_conclusion', 'So_ResultentrySrq29ConclusionID', + ['So_ResultentrySrq29ConclusionResult'], 'So_ResultentrySrq29ConclusionResult'); + +// so_resultentrysdsinterpretation +migrate_simple($pdo, $enc, 'so_resultentrysdsinterpretation', 'So_ResultEntrySDSInterpretationID', + ['So_ResultEntrySDSInterpretationDisplay'], 'So_ResultEntrySDSInterpretationDisplay'); + +// member_eligible +migrate_simple($pdo, $enc, 'member_eligible', 'Member_EligibleID', + ['Member_EligibleDescription'], 'Member_EligibleDescription'); + +echo "=== Migrasi hasil lab selesai ===\n"; +``` + +- [ ] **Step 2: Commit** + +```bash +git add scripts/migrate_encrypt_results.php +git commit -m "TASKCODE - migration script enkripsi batch data hasil lab" +``` + +- [ ] **Step 3: Upload dan jalankan di server** + +```bash +bash scripts/upload_ibl_committed_files.sh +ssh devone "cd /home/one/project/one/one-api-lab && php scripts/migrate_encrypt_results.php" +``` + +- [ ] **Step 4: Verifikasi** + +```bash +ssh devone "mysql -e 'SELECT COUNT(*), COUNT(T_OrderDetailResult_enc) FROM one_lab.t_orderdetail;'" +``` + +Expected: kedua angka sama (semua row terenkripsi). + +--- + +## Catatan Penting + +- **Kolom lama tidak dihapus** di sprint ini. Penghapusan kolom plaintext dilakukan di sprint terpisah setelah semua consumer API sudah update. +- **Key rotation**: Jika key perlu diganti, seluruh data harus di-re-enkripsi via migration script. +- **Masking** (tampilkan `081****5678`): ditambahkan di sprint berikutnya sebagai layer presentation di atas decrypt. +- **`one_lab_log` log tables**: `log_patient` (377 rows), `log_fo` (86 rows), `log_resultentry` (0 rows) — termasuk dalam Task 8 SQL dan Task 9 migration. +- **Key management**: Key WAJIB dibackup ke password manager DAN file lokal terenkripsi sebelum dijalankan ke production. Key hilang = data tidak bisa didekripsi. diff --git a/index.php b/index.php index 34421f6e..cefcec8e 100755 --- a/index.php +++ b/index.php @@ -313,5 +313,17 @@ switch (ENVIRONMENT) * And away we go... */ include_once "./vendor/autoload.php"; + +// Load .env +$_env_file = __DIR__ . '/.env'; +if (file_exists($_env_file)) { + foreach (file($_env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $_env_line) { + if (strpos(trim($_env_line), '#') === 0) continue; + [$_env_k, $_env_v] = array_map('trim', explode('=', $_env_line, 2)); + if ($_env_k !== '') $_ENV[$_env_k] = $_env_v; + } + unset($_env_file, $_env_line, $_env_k, $_env_v); +} + require_once BASEPATH.'core/CodeIgniter.php'; diff --git a/scripts/backup_pdp_tables.sh b/scripts/backup_pdp_tables.sh new file mode 100755 index 00000000..888c1f0f --- /dev/null +++ b/scripts/backup_pdp_tables.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Backup tabel-tabel yang terdampak enkripsi UU PDP +# Jalankan sebelum migration: bash scripts/backup_pdp_tables.sh + +DATE=$(date +%Y_%m_%d_%H%M%S) +BACKUP_DIR=~/backup_pdp_$DATE + +ssh devone " +mkdir -p $BACKUP_DIR + +echo 'Dumping one_lab tables...' +mysqldump one_lab \ + m_patient \ + m_patientaddress \ + t_orderdetail \ + t_orderheader \ + so_resultentrydetail \ + so_resultentrydetail_other \ + so_resultentry_fisik_umum \ + so_resultentry_fisik_summary \ + so_resultentry_other \ + so_resultentry_fisioterapi \ + so_resultentry_smwt \ + so_resultentry_srq29_conclusion \ + so_resultentrysdsinterpretation \ + member_eligible \ + > $BACKUP_DIR/one_lab_tables.sql +echo 'one_lab: OK' + +echo 'Dumping one_lab_log tables...' +mysqldump one_lab_log \ + log_patient \ + log_fo \ + log_resultentry \ + > $BACKUP_DIR/one_lab_log_tables.sql +echo 'one_lab_log: OK' + +ls -lh $BACKUP_DIR/ +echo '' +echo 'Backup selesai di: $BACKUP_DIR' +echo 'Restore dengan:' +echo ' mysql one_lab < $BACKUP_DIR/one_lab_tables.sql' +echo ' mysql one_lab_log < $BACKUP_DIR/one_lab_log_tables.sql' +" diff --git a/scripts/migrate_encrypt_patient.php b/scripts/migrate_encrypt_patient.php new file mode 100644 index 00000000..dd8e8da0 --- /dev/null +++ b/scripts/migrate_encrypt_patient.php @@ -0,0 +1,123 @@ + PDO::ERRMODE_EXCEPTION] +); + +$batch = 500; + +// ============================================================ +// m_patient +// ============================================================ +echo "=== Migrasi m_patient ===\n"; +$total = 0; + +$stmt_upd = $pdo->prepare("UPDATE m_patient SET + M_PatientName_enc = ?, M_PatientName_bidx = ?, + M_PatientHP_enc = ?, M_PatientHP_bidx = ?, + M_PatientDOB_enc = ?, M_PatientDOB_bidx = ?, + M_PatientEmail_enc = ?, + M_PatientPhone_enc = ?, + M_PatientPOB_enc = ?, + M_PatientIDNumber_enc = ?, + M_PatientNIK_enc = ?, + M_PatientNIP_enc = ? + WHERE M_PatientID = ?"); + +while (true) { + $rows = $pdo->query( + "SELECT M_PatientID, M_PatientName, M_PatientHP, M_PatientDOB, + M_PatientEmail, M_PatientPhone, M_PatientPOB, + M_PatientIDNumber, M_PatientNIK, M_PatientNIP + FROM m_patient + WHERE M_PatientName_enc IS NULL + LIMIT $batch" + )->fetchAll(PDO::FETCH_ASSOC); + + if (empty($rows)) break; + + foreach ($rows as $row) { + $dob_str = $row['M_PatientDOB'] ? date('d-m-Y', strtotime($row['M_PatientDOB'])) : ''; + $stmt_upd->execute([ + $enc->encrypt($row['M_PatientName']), $enc->search_bidx($row['M_PatientName']), + $enc->encrypt($row['M_PatientHP']), $enc->search_bidx($row['M_PatientHP']), + $enc->encrypt($dob_str), $enc->search_bidx($dob_str), + $enc->encrypt($row['M_PatientEmail']), + $enc->encrypt($row['M_PatientPhone']), + $enc->encrypt($row['M_PatientPOB']), + $enc->encrypt($row['M_PatientIDNumber']), + $enc->encrypt($row['M_PatientNIK']), + $enc->encrypt($row['M_PatientNIP']), + $row['M_PatientID'], + ]); + $total++; + } + echo " {$total} rows...\n"; +} +echo "m_patient selesai: {$total} rows\n\n"; + +// ============================================================ +// m_patientaddress +// ============================================================ +echo "=== Migrasi m_patientaddress ===\n"; +$total = 0; + +$stmt_addr = $pdo->prepare("UPDATE m_patientaddress SET + M_PatientAddressDescription_enc = ?, + M_PatientAddressDescription_bidx = ?, + M_PatientAddressEmail_enc = ?, + M_PatientAddressPhone_enc = ? + WHERE M_PatientAddressID = ?"); + +while (true) { + $rows = $pdo->query( + "SELECT M_PatientAddressID, M_PatientAddressDescription, + M_PatientAddressEmail, M_PatientAddressPhone + FROM m_patientaddress + WHERE M_PatientAddressDescription_enc IS NULL + LIMIT $batch" + )->fetchAll(PDO::FETCH_ASSOC); + + if (empty($rows)) break; + + foreach ($rows as $row) { + $stmt_addr->execute([ + $enc->encrypt($row['M_PatientAddressDescription']), + $enc->search_bidx($row['M_PatientAddressDescription']), + $enc->encrypt($row['M_PatientAddressEmail']), + $enc->encrypt($row['M_PatientAddressPhone']), + $row['M_PatientAddressID'], + ]); + $total++; + } + echo " {$total} rows...\n"; +} +echo "m_patientaddress selesai: {$total} rows\n\n"; + +echo "=== Migrasi patient PII selesai ===\n"; diff --git a/scripts/migrate_encrypt_results.php b/scripts/migrate_encrypt_results.php new file mode 100644 index 00000000..13ca05f0 --- /dev/null +++ b/scripts/migrate_encrypt_results.php @@ -0,0 +1,138 @@ + PDO::ERRMODE_EXCEPTION] +); + +$batch = 500; + +// Helper: migrate tabel dengan field-field sederhana (tanpa bidx) +function migrate_simple(PDO $pdo, Ibl_encryptor $enc, string $table, string $pk, array $fields, string $check_field): void +{ + echo "=== {$table} ===\n"; + $total = 0; + $cols_sel = implode(', ', array_merge([$pk], $fields)); + $sets = implode(', ', array_map(fn($f) => "{$f}_enc = ?", $fields)); + $stmt = $pdo->prepare("UPDATE {$table} SET {$sets} WHERE {$pk} = ?"); + + while (true) { + $rows = $pdo->query( + "SELECT {$cols_sel} FROM {$table} WHERE {$check_field}_enc IS NULL LIMIT 500" + )->fetchAll(PDO::FETCH_ASSOC); + + if (empty($rows)) break; + + foreach ($rows as $row) { + $params = array_map(fn($f) => $enc->encrypt((string)($row[$f] ?? '')), $fields); + $params[] = $row[$pk]; + $stmt->execute($params); + $total++; + } + echo " {$total} rows...\n"; + } + echo "selesai: {$total} rows\n\n"; +} + +// ============================================================ +// one_lab tables +// ============================================================ +migrate_simple($pdo, $enc, 't_orderdetail', 'T_OrderDetailID', + ['T_OrderDetailResult', 'T_OrderDetailNote'], + 'T_OrderDetailResult'); + +migrate_simple($pdo, $enc, 't_orderheader', 'T_OrderHeaderID', + ['T_OrderHeaderDiagnose'], + 'T_OrderHeaderDiagnose'); + +migrate_simple($pdo, $enc, 'so_resultentrydetail', 'So_ResultEntryDetailID', + ['So_ResultEntryDetailResult'], + 'So_ResultEntryDetailResult'); + +migrate_simple($pdo, $enc, 'so_resultentrydetail_other', 'So_ResultEntryDetailOtherID', + ['So_ResultEntryDetailOtherResult', 'So_ResultEntryDetailOtherResultBefore'], + 'So_ResultEntryDetailOtherResult'); + +migrate_simple($pdo, $enc, 'so_resultentry_fisik_umum', 'So_ResultEntryFisikUmumID', + ['So_ResultEntryFisikUmumDetails'], + 'So_ResultEntryFisikUmumDetails'); + +migrate_simple($pdo, $enc, 'so_resultentry_fisik_summary', 'So_ResultEntryFisikSummaryID', + ['So_ResultEntryFisikSummaryValue', 'So_ResultEntryFisikSummaryValue2'], + 'So_ResultEntryFisikSummaryValue'); + +migrate_simple($pdo, $enc, 'so_resultentry_other', 'So_ResultEntryOtherID', + ['So_ResultEntryOtherNote'], + 'So_ResultEntryOtherNote'); + +migrate_simple($pdo, $enc, 'so_resultentry_fisioterapi', 'So_ResultEntdyFisioterapiID', + ['So_ResultEntdyFisioterapiDetails'], + 'So_ResultEntdyFisioterapiDetails'); + +migrate_simple($pdo, $enc, 'so_resultentry_smwt', 'So_ResultentrySmwtID', [ + 'So_ResultentrySmwtWeight', 'So_ResultentrySmwtHeight', 'So_ResultentrySmwtBMI', + 'So_ResultentrySmwtPreTensi', 'So_ResultentrySmwtPreSPO2', 'So_ResultentrySmwtPreNadi', + 'So_ResultentrySmwtPostTensi', 'So_ResultentrySmwtPostSPO2', 'So_ResultentrySmwtPostNadi', + 'So_ResultentrySmwtVOMax', 'So_ResultentrySmwtKategoriKebugaran', +], 'So_ResultentrySmwtWeight'); + +migrate_simple($pdo, $enc, 'so_resultentry_srq29_conclusion', 'So_ResultentrySrq29ConclusionID', + ['So_ResultentrySrq29ConclusionResult'], + 'So_ResultentrySrq29ConclusionResult'); + +migrate_simple($pdo, $enc, 'so_resultentrysdsinterpretation', 'So_ResultEntrySDSInterpretationID', + ['So_ResultEntrySDSInterpretationDisplay'], + 'So_ResultEntrySDSInterpretationDisplay'); + +migrate_simple($pdo, $enc, 'member_eligible', 'Member_EligibleID', + ['Member_EligibleDescription'], + 'Member_EligibleDescription'); + +// ============================================================ +// one_lab_log tables (ganti koneksi ke DB log) +// ============================================================ +$cfg_log = $db['one_lab_log']; +$pdo_log = new PDO( + "mysql:host={$cfg_log['hostname']};dbname={$cfg_log['database']};charset=utf8", + $cfg_log['username'], + $cfg_log['password'], + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] +); + +migrate_simple($pdo_log, $enc, 'log_patient', 'Log_PatientID', + ['Log_PatientJsonBefore', 'Log_PatientJsonAfter'], + 'Log_PatientJsonBefore'); + +migrate_simple($pdo_log, $enc, 'log_fo', 'Log_FoID', + ['Log_FoJson'], + 'Log_FoJson'); + +migrate_simple($pdo_log, $enc, 'log_resultentry', 'Log_ResultEntryID', + ['Log_ResultEntryJSONBefore', 'Log_ResultEntryJSONAfter'], + 'Log_ResultEntryJSONBefore'); + +echo "=== Semua migrasi hasil lab selesai ===\n"; diff --git a/sql/manual_changes/2026-05-31-pdp-encrypt-columns.sql b/sql/manual_changes/2026-05-31-pdp-encrypt-columns.sql new file mode 100644 index 00000000..008a91b9 --- /dev/null +++ b/sql/manual_changes/2026-05-31-pdp-encrypt-columns.sql @@ -0,0 +1,141 @@ +-- UU PDP: tambah kolom enkripsi PII pasien dan data medis hasil lab +-- Kolom lama TIDAK dihapus (backward compat selama masa transisi) +-- Enkripsi: AES-256-GCM, key dari .env + +-- ============================================================ +-- one_lab.m_patient: PII pasien (trigram bidx untuk search) +-- ============================================================ +ALTER TABLE one_lab.m_patient + ADD COLUMN M_PatientName_enc TEXT NULL AFTER M_PatientName, + ADD COLUMN M_PatientName_bidx MEDIUMTEXT NULL AFTER M_PatientName_enc, + ADD COLUMN M_PatientHP_enc TEXT NULL AFTER M_PatientHP, + ADD COLUMN M_PatientHP_bidx MEDIUMTEXT NULL AFTER M_PatientHP_enc, + ADD COLUMN M_PatientDOB_enc TEXT NULL AFTER M_PatientDOB, + ADD COLUMN M_PatientDOB_bidx MEDIUMTEXT NULL AFTER M_PatientDOB_enc, + ADD COLUMN M_PatientEmail_enc TEXT NULL AFTER M_PatientEmail, + ADD COLUMN M_PatientPhone_enc TEXT NULL AFTER M_PatientPhone, + ADD COLUMN M_PatientPOB_enc TEXT NULL AFTER M_PatientPOB, + ADD COLUMN M_PatientIDNumber_enc TEXT NULL AFTER M_PatientIDNumber, + ADD COLUMN M_PatientNIK_enc TEXT NULL AFTER M_PatientNIK, + ADD COLUMN M_PatientNIP_enc TEXT NULL AFTER M_PatientNIP; + +-- ============================================================ +-- one_lab.m_patientaddress: alamat pasien +-- ============================================================ +ALTER TABLE one_lab.m_patientaddress + ADD COLUMN M_PatientAddressDescription_enc TEXT NULL AFTER M_PatientAddressDescription, + ADD COLUMN M_PatientAddressDescription_bidx MEDIUMTEXT NULL AFTER M_PatientAddressDescription_enc, + ADD COLUMN M_PatientAddressEmail_enc TEXT NULL AFTER M_PatientAddressEmail, + ADD COLUMN M_PatientAddressPhone_enc TEXT NULL AFTER M_PatientAddressPhone; + +-- ============================================================ +-- one_lab.t_orderdetail: nilai hasil lab utama +-- ============================================================ +ALTER TABLE one_lab.t_orderdetail + ADD COLUMN T_OrderDetailResult_enc TEXT NULL AFTER T_OrderDetailResult, + ADD COLUMN T_OrderDetailNote_enc TEXT NULL AFTER T_OrderDetailNote; + +-- ============================================================ +-- one_lab.t_orderheader: diagnosa dokter +-- ============================================================ +ALTER TABLE one_lab.t_orderheader + ADD COLUMN T_OrderHeaderDiagnose_enc TEXT NULL AFTER T_OrderHeaderDiagnose; + +-- ============================================================ +-- one_lab.so_resultentrydetail: hasil lab standar +-- ============================================================ +ALTER TABLE one_lab.so_resultentrydetail + ADD COLUMN So_ResultEntryDetailResult_enc TEXT NULL AFTER So_ResultEntryDetailResult; + +-- ============================================================ +-- one_lab.so_resultentrydetail_other: hasil lab nonstandar +-- ============================================================ +ALTER TABLE one_lab.so_resultentrydetail_other + ADD COLUMN So_ResultEntryDetailOtherResult_enc TEXT NULL AFTER So_ResultEntryDetailOtherResult, + ADD COLUMN So_ResultEntryDetailOtherResultBefore_enc TEXT NULL AFTER So_ResultEntryDetailOtherResultBefore; + +-- ============================================================ +-- one_lab.so_resultentry_fisik_umum: JSON pemeriksaan fisik +-- ============================================================ +ALTER TABLE one_lab.so_resultentry_fisik_umum + ADD COLUMN So_ResultEntryFisikUmumDetails_enc TEXT NULL AFTER So_ResultEntryFisikUmumDetails; + +-- ============================================================ +-- one_lab.so_resultentry_fisik_summary: ringkasan fisik +-- ============================================================ +ALTER TABLE one_lab.so_resultentry_fisik_summary + ADD COLUMN So_ResultEntryFisikSummaryValue_enc TEXT NULL AFTER So_ResultEntryFisikSummaryValue, + ADD COLUMN So_ResultEntryFisikSummaryValue2_enc TEXT NULL AFTER So_ResultEntryFisikSummaryValue2; + +-- ============================================================ +-- one_lab.so_resultentry_other: catatan hasil +-- ============================================================ +ALTER TABLE one_lab.so_resultentry_other + ADD COLUMN So_ResultEntryOtherNote_enc TEXT NULL AFTER So_ResultEntryOtherNote; + +-- ============================================================ +-- one_lab.so_resultentry_fisioterapi +-- ============================================================ +ALTER TABLE one_lab.so_resultentry_fisioterapi + ADD COLUMN So_ResultEntdyFisioterapiDetails_enc TEXT NULL AFTER So_ResultEntdyFisioterapiDetails; + +-- ============================================================ +-- one_lab.so_resultentry_smwt: hasil 6MWT +-- ============================================================ +ALTER TABLE one_lab.so_resultentry_smwt + ADD COLUMN So_ResultentrySmwtWeight_enc TEXT NULL AFTER So_ResultentrySmwtWeight, + ADD COLUMN So_ResultentrySmwtHeight_enc TEXT NULL AFTER So_ResultentrySmwtHeight, + ADD COLUMN So_ResultentrySmwtBMI_enc TEXT NULL AFTER So_ResultentrySmwtBMI, + ADD COLUMN So_ResultentrySmwtPreTensi_enc TEXT NULL AFTER So_ResultentrySmwtPreTensi, + ADD COLUMN So_ResultentrySmwtPreSPO2_enc TEXT NULL AFTER So_ResultentrySmwtPreSPO2, + ADD COLUMN So_ResultentrySmwtPreNadi_enc TEXT NULL AFTER So_ResultentrySmwtPreNadi, + ADD COLUMN So_ResultentrySmwtPostTensi_enc TEXT NULL AFTER So_ResultentrySmwtPostTensi, + ADD COLUMN So_ResultentrySmwtPostSPO2_enc TEXT NULL AFTER So_ResultentrySmwtPostSPO2, + ADD COLUMN So_ResultentrySmwtPostNadi_enc TEXT NULL AFTER So_ResultentrySmwtPostNadi, + ADD COLUMN So_ResultentrySmwtVOMax_enc TEXT NULL AFTER So_ResultentrySmwtVOMax, + ADD COLUMN So_ResultentrySmwtKategoriKebugaran_enc TEXT NULL AFTER So_ResultentrySmwtKategoriKebugaran; + +-- ============================================================ +-- one_lab.so_resultentry_srq29_conclusion: hasil SRQ-29 +-- ============================================================ +ALTER TABLE one_lab.so_resultentry_srq29_conclusion + ADD COLUMN So_ResultentrySrq29ConclusionResult_enc TEXT NULL AFTER So_ResultentrySrq29ConclusionResult; + +-- ============================================================ +-- one_lab.so_resultentrysdsinterpretation: interpretasi SDS +-- ============================================================ +ALTER TABLE one_lab.so_resultentrysdsinterpretation + ADD COLUMN So_ResultEntrySDSInterpretationDisplay_enc TEXT NULL AFTER So_ResultEntrySDSInterpretationDisplay; + +-- ============================================================ +-- one_lab.member_eligible: data BPJS / asuransi +-- ============================================================ +ALTER TABLE one_lab.member_eligible + ADD COLUMN Member_EligibleDescription_enc TEXT NULL AFTER Member_EligibleDescription; + +-- ============================================================ +-- one_lab_log.log_patient: audit log perubahan data pasien +-- Fix charset ke utf8mb4 (default latin1 tidak support JSON UTF-8 dari trigger) +-- ============================================================ +ALTER TABLE one_lab_log.log_patient + ADD COLUMN Log_PatientJsonBefore_enc MEDIUMTEXT NULL AFTER Log_PatientJsonBefore, + ADD COLUMN Log_PatientJsonAfter_enc MEDIUMTEXT NULL AFTER Log_PatientJsonAfter; + +ALTER TABLE one_lab_log.log_patient + MODIFY Log_PatientJsonBefore MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci, + MODIFY Log_PatientJsonAfter MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci, + MODIFY Log_PatientJsonBefore_enc MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci, + MODIFY Log_PatientJsonAfter_enc MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; + +-- ============================================================ +-- one_lab_log.log_fo: audit log order FO +-- ============================================================ +ALTER TABLE one_lab_log.log_fo + ADD COLUMN Log_FoJson_enc MEDIUMTEXT NULL AFTER Log_FoJson; + +-- ============================================================ +-- one_lab_log.log_resultentry: audit log hasil lab +-- ============================================================ +ALTER TABLE one_lab_log.log_resultentry + ADD COLUMN Log_ResultEntryJSONBefore_enc MEDIUMTEXT NULL AFTER Log_ResultEntryJSONBefore, + ADD COLUMN Log_ResultEntryJSONAfter_enc MEDIUMTEXT NULL AFTER Log_ResultEntryJSONAfter;