From e99a60fe934a2144c0d272a4efe0b76092d8f1da Mon Sep 17 00:00:00 2001 From: mahdahar <89adham@gmail.com> Date: Mon, 6 Apr 2026 14:21:46 +0700 Subject: [PATCH] feat: support partial patient patch updates Implement true PATCH behavior so omitted fields stay unchanged, while null can explicitly clear nullable nested data. Align patient update tests and OpenAPI schemas/responses with the new 200/400/404 contract. --- app/Controllers/Patient/PatientController.php | 109 +++++++++--- app/Models/Patient/PatientModel.php | 129 +++++++++++++- public/api-docs.bundled.yaml | 157 +++++++++++++++++- public/components/schemas/patient.yaml | 143 +++++++++++++++- public/paths/patients.yaml | 8 +- tests/feature/Patients/PatientUpdateTest.php | 43 +++-- 6 files changed, 545 insertions(+), 44 deletions(-) diff --git a/app/Controllers/Patient/PatientController.php b/app/Controllers/Patient/PatientController.php index 4b503f7..27666c5 100644 --- a/app/Controllers/Patient/PatientController.php +++ b/app/Controllers/Patient/PatientController.php @@ -6,7 +6,7 @@ use CodeIgniter\Controller; use App\Libraries\ValueSet; use App\Models\Patient\PatientModel; -class PatientController extends Controller { +class PatientController extends Controller { use ResponseTrait; protected $db; @@ -110,7 +110,7 @@ class PatientController extends Controller { } public function update($InternalPID = null) { - $input = $this->request->getJSON(true); + $input = $this->request->getJSON(true) ?? []; if (!$InternalPID || !ctype_digit((string) $InternalPID)) { return $this->respond([ @@ -119,30 +119,97 @@ class PatientController extends Controller { ], 400); } - $input['InternalPID'] = (int) $InternalPID; - - // Khusus untuk Override PATIDT - $type = $input['PatIdt']['IdentifierType'] ?? null; - $identifierRulesMap = $this->getPatIdtIdentifierRulesMap(); - if ($type === null || $type === '' || !is_string($type)) { - $identifierRule = 'permit_empty|max_length[255]'; - $this->rules['PatIdt.IdentifierType'] = 'permit_empty'; - $this->rules['PatIdt.Identifier'] = $identifierRule; - } else { - $identifierRule = $identifierRulesMap[$type] ?? 'permit_empty|max_length[255]'; - $this->rules['PatIdt.IdentifierType'] = 'required'; - $this->rules['PatIdt.Identifier'] = $identifierRule; - } - - if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); } - try { - $InternalPID = $this->model->updatePatient($input); - return $this->respondCreated([ 'status' => 'success', 'message' => "data $InternalPID update successfully" ]); + if (!is_array($input) || $input === []) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Patch payload is required.' + ], 400); + } + + if (array_key_exists('PatIdt', $input) && $input['PatIdt'] !== null && !is_array($input['PatIdt'])) { + return $this->failValidationErrors([ + 'PatIdt' => 'PatIdt must be an object or null.' + ]); + } + + $patchRules = $this->buildPatchRules($input); + if ($patchRules !== [] && !$this->validateData($input, $patchRules)) { + return $this->failValidationErrors($this->validator->getErrors()); + } + + try { + $updatedPid = $this->model->updatePatientPartial((int) $InternalPID, $input); + if ($updatedPid === null) { + return $this->respond([ + 'status' => 'failed', + 'message' => "data $InternalPID not found" + ], 404); + } + + return $this->respond([ 'status' => 'success', 'message' => "data $updatedPid update successfully" ], 200); } catch (\Exception $e) { return $this->failServerError('Something went wrong: ' . $e->getMessage()); } } + private function buildPatchRules(array $input): array + { + $rules = []; + + $fieldRules = [ + 'PatientID' => 'permit_empty|regex_match[/^[A-Za-z0-9]+$/]|max_length[30]', + 'AlternatePID' => 'permit_empty|regex_match[/^[A-Za-z0-9]+$/]|max_length[30]', + 'Prefix' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|max_length[10]', + 'Sex' => 'permit_empty', + 'NameFirst' => 'required|regex_match[/^[A-Za-z\'\. ]+$/]|min_length[1]|max_length[60]', + 'NameMiddle' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|min_length[1]|max_length[60]', + 'NameMaiden' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|min_length[1]|max_length[60]', + 'NameLast' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|min_length[1]|max_length[60]', + 'Suffix' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|max_length[10]', + 'PlaceOfBirth' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|max_length[100]', + 'Citizenship' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|max_length[100]', + 'Street_1' => 'permit_empty|regex_match[/^[A-Za-z0-9\'.,\/\- ]+$/]|max_length[255]', + 'Street_2' => 'permit_empty|regex_match[/^[A-Za-z0-9\'.,\/\- ]+$/]|max_length[255]', + 'Street_3' => 'permit_empty|regex_match[/^[A-Za-z0-9\'.,\/\- ]+$/]|max_length[255]', + 'EmailAddress1' => 'permit_empty|valid_email|max_length[100]', + 'EmailAddress2' => 'permit_empty|valid_email|max_length[100]', + 'Birthdate' => 'permit_empty', + 'ZIP' => 'permit_empty|is_natural|max_length[10]', + 'Phone' => 'permit_empty|regex_match[/^\\+?[0-9]{8,15}$/]', + 'MobilePhone' => 'permit_empty|regex_match[/^\\+?[0-9]{8,15}$/]', + 'Country' => 'permit_empty|max_length[10]', + 'Race' => 'permit_empty|max_length[100]', + 'MaritalStatus' => 'permit_empty', + 'Religion' => 'permit_empty|max_length[100]', + 'Ethnic' => 'permit_empty|max_length[100]', + 'isDead' => 'permit_empty', + 'TimeOfDeath' => 'permit_empty', + 'PatCom' => 'permit_empty|string', + 'PatAtt' => 'permit_empty', + 'LinkTo' => 'permit_empty', + 'Custodian' => 'permit_empty', + ]; + + foreach ($fieldRules as $field => $rule) { + if (array_key_exists($field, $input) && $field !== 'PatIdt') { + $rules[$field] = $rule; + } + } + + if (array_key_exists('PatIdt', $input) && $input['PatIdt'] !== null) { + $type = $input['PatIdt']['IdentifierType'] ?? null; + $identifierRulesMap = $this->getPatIdtIdentifierRulesMap(); + $identifierRule = is_string($type) + ? ($identifierRulesMap[$type] ?? 'required|max_length[255]') + : 'required|max_length[255]'; + + $rules['PatIdt.IdentifierType'] = 'required'; + $rules['PatIdt.Identifier'] = $identifierRule; + } + + return $rules; + } + private function getPatIdtIdentifierRulesMap(): array { return [ diff --git a/app/Models/Patient/PatientModel.php b/app/Models/Patient/PatientModel.php index 7bdeceb..02ca5fb 100644 --- a/app/Models/Patient/PatientModel.php +++ b/app/Models/Patient/PatientModel.php @@ -196,7 +196,7 @@ class PatientModel extends BaseModel { } } - public function updatePatient($input) { + public function updatePatient($input) { $db = \Config\Database::connect(); $modelPatIdt = new PatIdtModel(); $modelPatCom = new PatComModel(); @@ -363,6 +363,133 @@ class PatientModel extends BaseModel { return $date->format('j M Y'); } + public function updatePatientPartial(int $InternalPID, array $input): ?int { + $db = \Config\Database::connect(); + $modelPatIdt = new PatIdtModel(); + $modelPatCom = new PatComModel(); + $modelPatAtt = new PatAttModel(); + + $patient = $this->find($InternalPID); + if (!$patient) { + return null; + } + + $hasPatIdt = array_key_exists('PatIdt', $input); + $hasPatCom = array_key_exists('PatCom', $input); + $hasPatAtt = array_key_exists('PatAtt', $input); + + $patIdt = $hasPatIdt ? $input['PatIdt'] : null; + $patCom = $hasPatCom ? $input['PatCom'] : null; + $patAtt = $hasPatAtt ? $input['PatAtt'] : null; + + unset($input['PatIdt'], $input['PatCom'], $input['PatAtt']); + + if (array_key_exists('Custodian', $input)) { + if (is_array($input['Custodian'])) { + $input['Custodian'] = $input['Custodian']['InternalPID'] ?? null; + } + + if ($input['Custodian'] !== null && $input['Custodian'] !== '') { + $input['Custodian'] = (int) $input['Custodian']; + } else { + $input['Custodian'] = null; + } + } + + if (array_key_exists('LinkTo', $input)) { + if (is_array($input['LinkTo'])) { + $internalPids = array_column($input['LinkTo'], 'InternalPID'); + $internalPids = array_filter($internalPids, static fn($pid) => $pid !== null && $pid !== ''); + $input['LinkTo'] = empty($internalPids) ? null : implode(',', $internalPids); + } + } + + $allowedMap = array_flip($this->allowedFields); + $patientUpdate = array_intersect_key($input, $allowedMap); + unset($patientUpdate['CreateDate'], $patientUpdate['DelDate']); + + $db->transBegin(); + + try { + $previousData = $this->find($InternalPID) ?? []; + + if ($patientUpdate !== []) { + $this->where('InternalPID', $InternalPID)->set($patientUpdate)->update(); + $this->checkDbError($db, 'Update patient'); + } + + if ($hasPatIdt) { + if ($patIdt === null || $patIdt === []) { + $modelPatIdt->deletePatIdt((string) $InternalPID); + $this->checkDbError($db, 'Delete patidt'); + } else { + $modelPatIdt->updatePatIdt($patIdt, (string) $InternalPID); + $this->checkDbError($db, 'Update patidt'); + } + } + + if ($hasPatCom) { + if ($patCom === null) { + $modelPatCom->deletePatCom((string) $InternalPID); + $this->checkDbError($db, 'Delete patcom'); + } else { + $modelPatCom->updatePatCom((string) $patCom, (string) $InternalPID); + $this->checkDbError($db, 'Update PatCom'); + } + } + + if ($hasPatAtt) { + if ($patAtt === null) { + $modelPatAtt->deletePatAtt((string) $InternalPID); + $this->checkDbError($db, 'Delete patatt'); + } else { + $modelPatAtt->updatePatAtt((array) $patAtt, (string) $InternalPID); + $this->checkDbError($db, 'Update/Delete patatt'); + } + } + + $afterData = array_merge((array) $previousData, $patientUpdate); + $auditDiff = $this->buildAuditDiff((array) $previousData, $afterData); + $changedFields = array_column($auditDiff, 'field'); + + if ($hasPatIdt) { + $changedFields[] = 'PatIdt'; + } + if ($hasPatCom) { + $changedFields[] = 'PatCom'; + } + if ($hasPatAtt) { + $changedFields[] = 'PatAtt'; + } + + AuditService::logData( + 'PATIENT_DEMOGRAPHICS_UPDATED', + 'UPDATE', + 'patient', + (string) $InternalPID, + 'patient', + null, + null, + null, + 'Patient data updated', + [ + 'diff' => $auditDiff, + 'changed_fields' => array_values(array_unique($changedFields)), + 'validation_profile' => 'patient.patch', + ], + ['entity_version' => 1] + ); + + $db->transCommit(); + + return $InternalPID; + + } catch (\Exception $e) { + $db->transRollback(); + throw $e; + } + } + private function buildAuditDiff(array $before, array $after): array { $diff = []; $fields = array_unique(array_merge(array_keys($before), array_keys($after))); diff --git a/public/api-docs.bundled.yaml b/public/api-docs.bundled.yaml index ee67029..1df05f9 100644 --- a/public/api-docs.bundled.yaml +++ b/public/api-docs.bundled.yaml @@ -2907,7 +2907,7 @@ paths: patch: tags: - Patient - summary: Update patient + summary: Partially update patient security: - bearerAuth: [] parameters: @@ -2922,10 +2922,14 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Patient' + $ref: '#/components/schemas/PatientPatch' responses: '200': description: Patient updated successfully + '400': + description: Validation error + '404': + description: Patient not found /api/report/{orderID}: get: tags: @@ -8322,6 +8326,155 @@ components: CreateDate: type: string format: date-time + PatientPatch: + type: object + description: | + Partial patient update payload. + Omitted fields are left unchanged. Send null explicitly to clear nullable fields. + properties: + PatientID: + type: string + maxLength: 30 + pattern: ^[A-Za-z0-9]+$ + description: Internal patient identifier + AlternatePID: + type: string + maxLength: 30 + pattern: ^[A-Za-z0-9]+$ + Prefix: + type: string + maxLength: 10 + enum: + - Mr + - Mrs + - Ms + - Dr + - Prof + Sex: + type: string + enum: + - '1' + - '2' + description: '1: Female, 2: Male' + NameFirst: + type: string + minLength: 1 + maxLength: 60 + pattern: ^[A-Za-z'\. ]+$ + NameMiddle: + type: string + minLength: 1 + maxLength: 60 + NameMaiden: + type: string + minLength: 1 + maxLength: 60 + NameLast: + type: string + minLength: 1 + maxLength: 60 + Suffix: + type: string + maxLength: 10 + Birthdate: + type: string + format: date-time + description: ISO 8601 UTC datetime + PlaceOfBirth: + type: string + maxLength: 100 + Citizenship: + type: string + maxLength: 100 + Street_1: + type: string + maxLength: 255 + Street_2: + type: string + maxLength: 255 + Street_3: + type: string + maxLength: 255 + ZIP: + type: string + maxLength: 10 + pattern: ^[0-9]+$ + Phone: + type: string + pattern: ^\+?[0-9]{8,15}$ + MobilePhone: + type: string + pattern: ^\+?[0-9]{8,15}$ + EmailAddress1: + type: string + format: email + maxLength: 100 + EmailAddress2: + type: string + format: email + maxLength: 100 + PatIdt: + allOf: + - $ref: '#/components/schemas/PatientIdentifier' + nullable: true + LinkTo: + type: array + description: Array of linked patient references + items: + $ref: '#/components/schemas/LinkedPatient' + Custodian: + allOf: + - $ref: '#/components/schemas/Custodian' + nullable: true + isDead: + type: string + enum: + - '0' + - '1' + description: '0: No (alive), 1: Yes (deceased)' + TimeOfDeath: + type: string + format: date-time + description: ISO 8601 UTC datetime of death + PatCom: + type: string + description: Patient comment/notes + nullable: true + PatAtt: + type: array + description: Patient address entries + nullable: true + items: + $ref: '#/components/schemas/PatAttEntry' + Province: + type: integer + description: Province AreaGeoID (foreign key to areageo table) + City: + type: integer + description: City AreaGeoID (foreign key to areageo table) + Country: + type: string + maxLength: 10 + description: Country ISO 3-letter code (e.g., IDN, USA) + Race: + type: string + maxLength: 100 + MaritalStatus: + type: string + enum: + - A + - B + - D + - M + - S + - W + description: 'A: Annulled, B: Separated, D: Divorced, M: Married, S: Single, W: Widowed' + Religion: + type: string + maxLength: 100 + Ethnic: + type: string + maxLength: 100 TestMapDetail: type: object properties: diff --git a/public/components/schemas/patient.yaml b/public/components/schemas/patient.yaml index dac9d3f..4a426db 100644 --- a/public/components/schemas/patient.yaml +++ b/public/components/schemas/patient.yaml @@ -44,7 +44,7 @@ PatAttEntry: type: string description: Address text -Patient: +Patient: type: object required: - PatientID @@ -180,9 +180,144 @@ Patient: Religion: type: string maxLength: 100 - Ethnic: - type: string - maxLength: 100 + Ethnic: + type: string + maxLength: 100 + +PatientPatch: + type: object + description: | + Partial patient update payload. + Omitted fields are left unchanged. Send null explicitly to clear nullable fields. + properties: + PatientID: + type: string + maxLength: 30 + pattern: '^[A-Za-z0-9]+$' + description: Internal patient identifier + AlternatePID: + type: string + maxLength: 30 + pattern: '^[A-Za-z0-9]+$' + Prefix: + type: string + maxLength: 10 + enum: [Mr, Mrs, Ms, Dr, Prof] + Sex: + type: string + enum: ['1', '2'] + description: '1: Female, 2: Male' + NameFirst: + type: string + minLength: 1 + maxLength: 60 + pattern: "^[A-Za-z'\\. ]+$" + NameMiddle: + type: string + minLength: 1 + maxLength: 60 + NameMaiden: + type: string + minLength: 1 + maxLength: 60 + NameLast: + type: string + minLength: 1 + maxLength: 60 + Suffix: + type: string + maxLength: 10 + Birthdate: + type: string + format: date-time + description: ISO 8601 UTC datetime + PlaceOfBirth: + type: string + maxLength: 100 + Citizenship: + type: string + maxLength: 100 + Street_1: + type: string + maxLength: 255 + Street_2: + type: string + maxLength: 255 + Street_3: + type: string + maxLength: 255 + ZIP: + type: string + maxLength: 10 + pattern: '^[0-9]+$' + Phone: + type: string + pattern: "^\\+?[0-9]{8,15}$" + MobilePhone: + type: string + pattern: "^\\+?[0-9]{8,15}$" + EmailAddress1: + type: string + format: email + maxLength: 100 + EmailAddress2: + type: string + format: email + maxLength: 100 + PatIdt: + allOf: + - $ref: 'patient.yaml#/PatientIdentifier' + nullable: true + LinkTo: + type: array + description: Array of linked patient references + items: + $ref: 'patient.yaml#/LinkedPatient' + Custodian: + allOf: + - $ref: 'patient.yaml#/Custodian' + nullable: true + isDead: + type: string + enum: ['0', '1'] + description: '0: No (alive), 1: Yes (deceased)' + TimeOfDeath: + type: string + format: date-time + description: ISO 8601 UTC datetime of death + PatCom: + type: string + description: Patient comment/notes + nullable: true + PatAtt: + type: array + description: Patient address entries + nullable: true + items: + $ref: 'patient.yaml#/PatAttEntry' + Province: + type: integer + description: Province AreaGeoID (foreign key to areageo table) + City: + type: integer + description: City AreaGeoID (foreign key to areageo table) + Country: + type: string + maxLength: 10 + description: Country ISO 3-letter code (e.g., IDN, USA) + Race: + type: string + maxLength: 100 + MaritalStatus: + type: string + enum: [A, B, D, M, S, W] + description: 'A: Annulled, B: Separated, D: Divorced, M: Married, S: Single, W: Widowed' + Religion: + type: string + maxLength: 100 + Ethnic: + type: string + maxLength: 100 PatientListResponse: type: object diff --git a/public/paths/patients.yaml b/public/paths/patients.yaml index 17d6f9e..67892c1 100644 --- a/public/paths/patients.yaml +++ b/public/paths/patients.yaml @@ -150,7 +150,7 @@ patch: tags: [Patient] - summary: Update patient + summary: Partially update patient security: - bearerAuth: [] parameters: @@ -165,7 +165,11 @@ content: application/json: schema: - $ref: '../components/schemas/patient.yaml#/Patient' + $ref: '../components/schemas/patient.yaml#/PatientPatch' responses: '200': description: Patient updated successfully + '400': + description: Validation error + '404': + description: Patient not found diff --git a/tests/feature/Patients/PatientUpdateTest.php b/tests/feature/Patients/PatientUpdateTest.php index 1d08572..ed16fd2 100644 --- a/tests/feature/Patients/PatientUpdateTest.php +++ b/tests/feature/Patients/PatientUpdateTest.php @@ -56,7 +56,7 @@ class PatientUpdateTest extends CIUnitTestCase $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/999999', $payload); - $result->assertStatus(201); // Update returns success even if no rows found (depending on logic) + $result->assertStatus(404); } /** @@ -99,7 +99,7 @@ class PatientUpdateTest extends CIUnitTestCase } $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload); - $result->assertStatus(201); + $result->assertStatus(200); $json = $result->getJSON(); $data = json_decode($json, true); $this->assertEquals('success', $data['status']); @@ -141,7 +141,7 @@ class PatientUpdateTest extends CIUnitTestCase } $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload); - $result->assertStatus(201); + $result->assertStatus(200); } /** @@ -178,7 +178,7 @@ class PatientUpdateTest extends CIUnitTestCase } $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload); - $result->assertStatus(201); + $result->assertStatus(200); } /** @@ -217,7 +217,7 @@ class PatientUpdateTest extends CIUnitTestCase } $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload); - $result->assertStatus(201); + $result->assertStatus(200); } public function testUpdatePatientWithSimIdentifierSuccess() @@ -242,7 +242,25 @@ class PatientUpdateTest extends CIUnitTestCase ]; $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload); - $result->assertStatus(201); + $result->assertStatus(200); + } + + public function testPatchSingleFieldSuccess() + { + $faker = Factory::create('id_ID'); + + $payload = [ + 'NameFirst' => $faker->firstName, + ]; + + $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload); + $result->assertStatus(200); + } + + public function testPatchEmptyPayloadShouldFail() + { + $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', []); + $result->assertStatus(400); } public function testUpdatePatientWithSimIdentifierInvalidShouldFail() @@ -312,14 +330,11 @@ class PatientUpdateTest extends CIUnitTestCase $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload); - $result->assertStatus(500); - $json = $result->getJSON(); - $data = json_decode($json, true); - if (isset($data['messages']) && is_array($data['messages'])) { - $this->assertArrayHasKey('error', $data['messages']); - } else { - $this->assertArrayHasKey('error', $data); - } + $result->assertStatus(400); + $json = $result->getJSON(); + $data = json_decode($json, true); + $this->assertArrayHasKey('messages', $data); + $this->assertArrayHasKey('PatIdt.IdentifierType', $data['messages']); } }