diff --git a/app/Models/Contact/ContactDetailModel.php b/app/Models/Contact/ContactDetailModel.php index cec0501..a511f28 100755 --- a/app/Models/Contact/ContactDetailModel.php +++ b/app/Models/Contact/ContactDetailModel.php @@ -4,18 +4,20 @@ namespace App\Models\Contact; use App\Models\BaseModel; -class ContactDetailModel extends BaseModel { - protected $table = 'contactdetail'; - protected $primaryKey = 'ContactDetID'; - protected $allowedFields = ['ContactID', 'SiteID', 'ContactCode', 'ContactEmail', 'OccupationID', 'JobTitle', 'Department', 'ContactStartDate', 'ContactEndDate']; - - protected $useTimestamps = true; - protected $createdField = 'ContactStartDate'; - protected $updatedField = ''; - - public function syncDetails(int $ContactID, array $contactDetails) { - try { - $keptSiteIDs = []; +class ContactDetailModel extends BaseModel { + protected $table = 'contactdetail'; + protected $primaryKey = 'ContactDetID'; + protected $allowedFields = ['ContactID', 'SiteID', 'ContactCode', 'ContactEmail', 'OccupationID', 'JobTitle', 'Department', 'ContactStartDate', 'ContactEndDate']; + + protected $useTimestamps = true; + protected $createdField = 'ContactStartDate'; + protected $updatedField = ''; + protected $useSoftDeletes = true; + protected $deletedField = 'ContactEndDate'; + + public function syncDetails(int $ContactID, array $contactDetails) { + try { + $keptSiteIDs = []; foreach ($contactDetails as $detail) { if (empty($detail['SiteID'])) { @@ -44,20 +46,115 @@ class ContactDetailModel extends BaseModel { ->delete(); } else { $this->where('ContactID', $ContactID)->delete(); - } - - return [ - 'status' => 'success', - 'inserted' => count($contactDetails) - count($keptSiteIDs), - 'kept' => count($keptSiteIDs), - ]; - } catch (\Throwable $e) { - log_message('error', 'syncDetails error: ' . $e->getMessage()); + } + + return [ + 'status' => 'success', + 'inserted' => count($contactDetails) - count($keptSiteIDs), + 'kept' => count($keptSiteIDs), + ]; + } catch (\Throwable $e) { + log_message('error', 'syncDetails error: ' . $e->getMessage()); return [ 'status' => 'error', 'message' => $e->getMessage(), - ]; - } -} -} + ]; + } +} + + public function applyDetailOperations(int $contactID, array $operations): array + { + try { + if (!empty($operations['edited']) && !$this->updateDetails($contactID, $operations['edited'])) { + return ['status' => 'error', 'message' => 'Failed to update contact details']; + } + + if (!empty($operations['deleted']) && !$this->softDeleteDetails($contactID, $operations['deleted'])) { + return ['status' => 'error', 'message' => 'Failed to delete contact details']; + } + + if (!empty($operations['created']) && !$this->insertDetailRows($contactID, $operations['created'])) { + return ['status' => 'error', 'message' => 'Failed to create contact details']; + } + + return ['status' => 'success']; + } catch (\Throwable $e) { + log_message('error', 'applyDetailOperations error: ' . $e->getMessage()); + + return [ + 'status' => 'error', + 'message' => $e->getMessage(), + ]; + } + } + + private function insertDetailRows(int $contactID, array $items): bool + { + foreach ($items as $item) { + if (!is_array($item)) { + return false; + } + + $item['ContactID'] = $contactID; + $this->insert($item); + } + + return true; + } + + private function updateDetails(int $contactID, array $items): bool + { + foreach ($items as $detail) { + $detailID = $detail['ContactDetID'] ?? null; + if (!$detailID || !ctype_digit((string) $detailID)) { + return false; + } + + $existing = $this->where('ContactDetID', (int) $detailID) + ->where('ContactID', $contactID) + ->first(); + + if (empty($existing)) { + return false; + } + + $updateData = array_intersect_key($detail, array_flip($this->allowedFields)); + unset($updateData['ContactID']); + + if ($updateData !== []) { + $db = \Config\Database::connect(); + $db->table($this->table) + ->where('ContactDetID', (int) $detailID) + ->where('ContactID', $contactID) + ->where('ContactEndDate', null) + ->update($updateData); + } + } + + return true; + } + + private function softDeleteDetails(int $contactID, array $ids): bool + { + $ids = array_values(array_unique(array_map('intval', $ids))); + if ($ids === []) { + return true; + } + + $existing = $this->select('ContactDetID') + ->whereIn('ContactDetID', $ids) + ->where('ContactID', $contactID) + ->findAll(); + + if (count($existing) !== count($ids)) { + return false; + } + + $this->whereIn('ContactDetID', $ids) + ->where('ContactID', $contactID) + ->delete(); + + return true; + } +} diff --git a/app/Models/Contact/ContactModel.php b/app/Models/Contact/ContactModel.php index 5de4b2a..fbfb76e 100755 --- a/app/Models/Contact/ContactModel.php +++ b/app/Models/Contact/ContactModel.php @@ -56,10 +56,14 @@ class ContactModel extends BaseModel { 'Details' => [] ]; } - if (!empty($row['ContactDetID'])) { - $contact['Details'][] = [ - 'SiteID' => $row['SiteID'] ?? null, - 'ContactDetID' => $row['ContactDetID'], + if (!empty($row['ContactDetID'])) { + if (!empty($row['ContactEndDate'])) { + continue; + } + + $contact['Details'][] = [ + 'SiteID' => $row['SiteID'] ?? null, + 'ContactDetID' => $row['ContactDetID'], 'ContactCode' => $row['ContactCode'] ?? null, 'ContactEmail' => $row['ContactEmail'] ?? null, 'OccupationID' => $row['OccupationID'] ?? null, @@ -95,7 +99,11 @@ class ContactModel extends BaseModel { if (!empty($details)) { $modelDetail = new \App\Models\Contact\ContactDetailModel(); - $result = $modelDetail->syncDetails($contactId, $details); + if ($this->isDetailOperationsPayload($details)) { + $result = $modelDetail->applyDetailOperations($contactId, $details); + } else { + $result = $modelDetail->syncDetails($contactId, $details); + } if ($result['status'] !== 'success') { throw new \RuntimeException('SyncDetails failed: ' . $result['message']); @@ -109,14 +117,18 @@ class ContactModel extends BaseModel { 'ContactID' => $contactId, 'DetailsCount' => count($details), ]; - } catch (\Throwable $e) { - $db->transRollback(); + } catch (\Throwable $e) { + $db->transRollback(); return [ 'status' => 'error', 'message' => $e->getMessage(), - ]; - } - } - -} + ]; + } + } + + private function isDetailOperationsPayload(array $details): bool { + return (bool) array_intersect(array_keys($details), ['created', 'edited', 'deleted']); + } + +} diff --git a/public/api-docs.bundled.yaml b/public/api-docs.bundled.yaml index cf36230..693f213 100755 --- a/public/api-docs.bundled.yaml +++ b/public/api-docs.bundled.yaml @@ -633,6 +633,16 @@ paths: SubSpecialty: type: string description: Sub-specialty code + Details: + description: | + Detail payload supports either a flat array of new rows (legacy format) + or an operations object with `created`, `edited`, and `deleted` arrays. + oneOf: + - $ref: '#/components/schemas/ContactDetailOperations' + - type: array + description: Legacy format for replacing details with new rows only + items: + $ref: '#/components/schemas/ContactDetail' responses: '201': description: Contact updated successfully @@ -8351,6 +8361,68 @@ components: type: string format: date-time description: Occupation display text + ContactDetail: + type: object + properties: + ContactDetID: + type: integer + description: Primary key + ContactID: + type: integer + description: Parent contact ID + SiteID: + type: integer + nullable: true + description: Site identifier + ContactCode: + type: string + nullable: true + description: Contact code at site + ContactEmail: + type: string + nullable: true + description: Contact email address + OccupationID: + type: integer + nullable: true + description: Occupation reference + JobTitle: + type: string + nullable: true + description: Job title + Department: + type: string + nullable: true + description: Department name + ContactStartDate: + type: string + format: date-time + ContactEndDate: + type: string + format: date-time + nullable: true + ContactDetailOperations: + type: object + properties: + created: + type: array + description: New contact details to create + items: + $ref: '#/components/schemas/ContactDetail' + edited: + type: array + description: Existing contact details to update + items: + allOf: + - $ref: '#/components/schemas/ContactDetail' + - type: object + required: + - ContactDetID + deleted: + type: array + description: Contact detail IDs to soft delete + items: + type: integer OrderSpecimen: type: object properties: diff --git a/public/components/schemas/master-data.yaml b/public/components/schemas/master-data.yaml index c0f500f..b78bc72 100755 --- a/public/components/schemas/master-data.yaml +++ b/public/components/schemas/master-data.yaml @@ -34,9 +34,9 @@ Location: format: date-time nullable: true -Contact: - type: object - properties: +Contact: + type: object + properties: ContactID: type: integer description: Primary key @@ -82,13 +82,77 @@ Contact: CreateDate: type: string format: date-time - EndDate: - type: string - format: date-time - description: Occupation display text - -Occupation: - type: object + EndDate: + type: string + format: date-time + description: Occupation display text + +ContactDetail: + type: object + properties: + ContactDetID: + type: integer + description: Primary key + ContactID: + type: integer + description: Parent contact ID + SiteID: + type: integer + nullable: true + description: Site identifier + ContactCode: + type: string + nullable: true + description: Contact code at site + ContactEmail: + type: string + nullable: true + description: Contact email address + OccupationID: + type: integer + nullable: true + description: Occupation reference + JobTitle: + type: string + nullable: true + description: Job title + Department: + type: string + nullable: true + description: Department name + ContactStartDate: + type: string + format: date-time + ContactEndDate: + type: string + format: date-time + nullable: true + +ContactDetailOperations: + type: object + properties: + created: + type: array + description: New contact details to create + items: + $ref: '#/ContactDetail' + edited: + type: array + description: Existing contact details to update + items: + allOf: + - $ref: '#/ContactDetail' + - type: object + required: + - ContactDetID + deleted: + type: array + description: Contact detail IDs to soft delete + items: + type: integer + +Occupation: + type: object properties: OccupationID: type: integer diff --git a/public/paths/contact.yaml b/public/paths/contact.yaml index f3e5348..faa4cda 100755 --- a/public/paths/contact.yaml +++ b/public/paths/contact.yaml @@ -213,6 +213,16 @@ SubSpecialty: type: string description: Sub-specialty code + Details: + description: | + Detail payload supports either a flat array of new rows (legacy format) + or an operations object with `created`, `edited`, and `deleted` arrays. + oneOf: + - $ref: '../components/schemas/master-data.yaml#/ContactDetailOperations' + - type: array + description: Legacy format for replacing details with new rows only + items: + $ref: '../components/schemas/master-data.yaml#/ContactDetail' responses: '201': description: Contact updated successfully diff --git a/tests/feature/Contact/ContactPatchTest.php b/tests/feature/Contact/ContactPatchTest.php index c3ccf3e..541ab55 100755 --- a/tests/feature/Contact/ContactPatchTest.php +++ b/tests/feature/Contact/ContactPatchTest.php @@ -37,9 +37,9 @@ class ContactPatchTest extends CIUnitTestCase private function createContact(array $data = []): array { $payload = array_merge([ - 'ContactCode' => 'CON_' . uniqid(), - 'ContactName' => 'Test Contact ' . uniqid(), - 'ContactType' => 'PERSON', + 'NameFirst' => 'Test', + 'NameLast' => 'Contact ' . uniqid(), + 'Specialty' => 'GP', ], $data); $response = $this->withHeaders($this->authHeaders()) @@ -58,7 +58,7 @@ class ContactPatchTest extends CIUnitTestCase $patch = $this->withHeaders($this->authHeaders()) ->withBodyFormat('json') - ->call('patch', "{$this->endpoint}/{$id}", ['ContactName' => 'Updated Contact']); + ->call('patch', "{$this->endpoint}/{$id}", ['NameFirst' => 'Updated']); $patch->assertStatus(200); $patchData = json_decode($patch->getJSON(), true); @@ -68,15 +68,14 @@ class ContactPatchTest extends CIUnitTestCase $show->assertStatus(200); $showData = json_decode($show->getJSON(), true)['data']; - $this->assertEquals('Updated Contact', $showData['ContactName']); - $this->assertEquals($contact['ContactCode'], $showData['ContactCode']); + $this->assertEquals('Updated', $showData['NameFirst']); } public function testPartialUpdateContactNotFound() { $patch = $this->withHeaders($this->authHeaders()) ->withBodyFormat('json') - ->call('patch', "{$this->endpoint}/999999", ['ContactName' => 'Updated']); + ->call('patch', "{$this->endpoint}/999999", ['NameFirst' => 'Updated']); $patch->assertStatus(404); } @@ -85,7 +84,7 @@ class ContactPatchTest extends CIUnitTestCase { $patch = $this->withHeaders($this->authHeaders()) ->withBodyFormat('json') - ->call('patch', "{$this->endpoint}/invalid", ['ContactName' => 'Updated']); + ->call('patch', "{$this->endpoint}/invalid", ['NameFirst' => 'Updated']); $patch->assertStatus(400); } @@ -109,14 +108,82 @@ class ContactPatchTest extends CIUnitTestCase $patch = $this->withHeaders($this->authHeaders()) ->withBodyFormat('json') - ->call('patch', "{$this->endpoint}/{$id}", ['ContactCode' => 'NEW_' . uniqid()]); + ->call('patch', "{$this->endpoint}/{$id}", ['Specialty' => 'Pathology']); $patch->assertStatus(200); $showData = json_decode($this->withHeaders($this->authHeaders()) ->call('get', "{$this->endpoint}/{$id}") ->getJSON(), true)['data']; - $this->assertNotEquals($contact['ContactCode'], $showData['ContactCode']); - $this->assertEquals($contact['ContactName'], $showData['ContactName']); + $this->assertEquals('Pathology', $showData['Specialty']); + } + + public function testPartialUpdateContactDetailOperations() + { + $contact = $this->createContact([ + 'Details' => [ + [ + 'SiteID' => '1', + 'ContactCode' => 'CODE1', + 'ContactEmail' => 'code1@example.com', + 'OccupationID' => '1', + 'JobTitle' => 'Doctor', + 'Department' => 'General', + ], + [ + 'SiteID' => '2', + 'ContactCode' => 'CODE2', + 'ContactEmail' => 'code2@example.com', + 'OccupationID' => '2', + 'JobTitle' => 'Specialist', + 'Department' => 'Laboratory', + ], + ], + ]); + + $showBefore = $this->withHeaders($this->authHeaders()) + ->call('get', "{$this->endpoint}/{$contact['ContactID']}"); + $showBefore->assertStatus(200); + $beforeData = json_decode($showBefore->getJSON(), true)['data']; + $keepDetail = $beforeData['Details'][0]; + $deleteDetail = $beforeData['Details'][1]; + + $patch = $this->withHeaders($this->authHeaders()) + ->withBodyFormat('json') + ->call('patch', "{$this->endpoint}/{$contact['ContactID']}", [ + 'Details' => [ + 'edited' => [ + [ + 'ContactDetID' => $keepDetail['ContactDetID'], + 'JobTitle' => 'Senior Doctor', + ], + ], + 'created' => [ + [ + 'SiteID' => '3', + 'ContactCode' => 'CODE3', + 'ContactEmail' => 'code3@example.com', + 'OccupationID' => '3', + 'JobTitle' => 'Consultant', + 'Department' => 'Pathology', + ], + ], + 'deleted' => [$deleteDetail['ContactDetID']], + ], + ]); + + $patch->assertStatus(200); + + $showAfter = $this->withHeaders($this->authHeaders()) + ->call('get', "{$this->endpoint}/{$contact['ContactID']}"); + $showAfter->assertStatus(200); + $afterData = json_decode($showAfter->getJSON(), true)['data']; + + $this->assertCount(2, $afterData['Details']); + $detailIds = array_column($afterData['Details'], 'ContactDetID'); + $this->assertContains($keepDetail['ContactDetID'], $detailIds); + + $updatedDetails = array_values(array_filter($afterData['Details'], static fn ($row) => $row['ContactDetID'] === $keepDetail['ContactDetID'])); + $this->assertNotEmpty($updatedDetails); } }