docs: publish contact detail op payloads

This commit is contained in:
mahdahar 2026-04-15 14:00:43 +07:00
parent 5a7b9b257e
commit 577ceb3d54
6 changed files with 380 additions and 58 deletions

View File

@ -12,6 +12,8 @@ class ContactDetailModel extends BaseModel {
protected $useTimestamps = true; protected $useTimestamps = true;
protected $createdField = 'ContactStartDate'; protected $createdField = 'ContactStartDate';
protected $updatedField = ''; protected $updatedField = '';
protected $useSoftDeletes = true;
protected $deletedField = 'ContactEndDate';
public function syncDetails(int $ContactID, array $contactDetails) { public function syncDetails(int $ContactID, array $contactDetails) {
try { try {
@ -60,4 +62,99 @@ class ContactDetailModel extends BaseModel {
]; ];
} }
} }
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;
}
} }

View File

@ -57,6 +57,10 @@ class ContactModel extends BaseModel {
]; ];
} }
if (!empty($row['ContactDetID'])) { if (!empty($row['ContactDetID'])) {
if (!empty($row['ContactEndDate'])) {
continue;
}
$contact['Details'][] = [ $contact['Details'][] = [
'SiteID' => $row['SiteID'] ?? null, 'SiteID' => $row['SiteID'] ?? null,
'ContactDetID' => $row['ContactDetID'], 'ContactDetID' => $row['ContactDetID'],
@ -95,7 +99,11 @@ class ContactModel extends BaseModel {
if (!empty($details)) { if (!empty($details)) {
$modelDetail = new \App\Models\Contact\ContactDetailModel(); $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') { if ($result['status'] !== 'success') {
throw new \RuntimeException('SyncDetails failed: ' . $result['message']); throw new \RuntimeException('SyncDetails failed: ' . $result['message']);
@ -109,14 +117,18 @@ class ContactModel extends BaseModel {
'ContactID' => $contactId, 'ContactID' => $contactId,
'DetailsCount' => count($details), 'DetailsCount' => count($details),
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
$db->transRollback(); $db->transRollback();
return [ return [
'status' => 'error', 'status' => 'error',
'message' => $e->getMessage(), 'message' => $e->getMessage(),
]; ];
} }
}
private function isDetailOperationsPayload(array $details): bool {
return (bool) array_intersect(array_keys($details), ['created', 'edited', 'deleted']);
} }
} }

View File

@ -633,6 +633,16 @@ paths:
SubSpecialty: SubSpecialty:
type: string type: string
description: Sub-specialty code 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: responses:
'201': '201':
description: Contact updated successfully description: Contact updated successfully
@ -8351,6 +8361,68 @@ components:
type: string type: string
format: date-time format: date-time
description: Occupation display text 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: OrderSpecimen:
type: object type: object
properties: properties:

View File

@ -87,6 +87,70 @@ Contact:
format: date-time format: date-time
description: Occupation display text 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: Occupation:
type: object type: object
properties: properties:

View File

@ -213,6 +213,16 @@
SubSpecialty: SubSpecialty:
type: string type: string
description: Sub-specialty code 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: responses:
'201': '201':
description: Contact updated successfully description: Contact updated successfully

View File

@ -37,9 +37,9 @@ class ContactPatchTest extends CIUnitTestCase
private function createContact(array $data = []): array private function createContact(array $data = []): array
{ {
$payload = array_merge([ $payload = array_merge([
'ContactCode' => 'CON_' . uniqid(), 'NameFirst' => 'Test',
'ContactName' => 'Test Contact ' . uniqid(), 'NameLast' => 'Contact ' . uniqid(),
'ContactType' => 'PERSON', 'Specialty' => 'GP',
], $data); ], $data);
$response = $this->withHeaders($this->authHeaders()) $response = $this->withHeaders($this->authHeaders())
@ -58,7 +58,7 @@ class ContactPatchTest extends CIUnitTestCase
$patch = $this->withHeaders($this->authHeaders()) $patch = $this->withHeaders($this->authHeaders())
->withBodyFormat('json') ->withBodyFormat('json')
->call('patch', "{$this->endpoint}/{$id}", ['ContactName' => 'Updated Contact']); ->call('patch', "{$this->endpoint}/{$id}", ['NameFirst' => 'Updated']);
$patch->assertStatus(200); $patch->assertStatus(200);
$patchData = json_decode($patch->getJSON(), true); $patchData = json_decode($patch->getJSON(), true);
@ -68,15 +68,14 @@ class ContactPatchTest extends CIUnitTestCase
$show->assertStatus(200); $show->assertStatus(200);
$showData = json_decode($show->getJSON(), true)['data']; $showData = json_decode($show->getJSON(), true)['data'];
$this->assertEquals('Updated Contact', $showData['ContactName']); $this->assertEquals('Updated', $showData['NameFirst']);
$this->assertEquals($contact['ContactCode'], $showData['ContactCode']);
} }
public function testPartialUpdateContactNotFound() public function testPartialUpdateContactNotFound()
{ {
$patch = $this->withHeaders($this->authHeaders()) $patch = $this->withHeaders($this->authHeaders())
->withBodyFormat('json') ->withBodyFormat('json')
->call('patch', "{$this->endpoint}/999999", ['ContactName' => 'Updated']); ->call('patch', "{$this->endpoint}/999999", ['NameFirst' => 'Updated']);
$patch->assertStatus(404); $patch->assertStatus(404);
} }
@ -85,7 +84,7 @@ class ContactPatchTest extends CIUnitTestCase
{ {
$patch = $this->withHeaders($this->authHeaders()) $patch = $this->withHeaders($this->authHeaders())
->withBodyFormat('json') ->withBodyFormat('json')
->call('patch', "{$this->endpoint}/invalid", ['ContactName' => 'Updated']); ->call('patch', "{$this->endpoint}/invalid", ['NameFirst' => 'Updated']);
$patch->assertStatus(400); $patch->assertStatus(400);
} }
@ -109,14 +108,82 @@ class ContactPatchTest extends CIUnitTestCase
$patch = $this->withHeaders($this->authHeaders()) $patch = $this->withHeaders($this->authHeaders())
->withBodyFormat('json') ->withBodyFormat('json')
->call('patch', "{$this->endpoint}/{$id}", ['ContactCode' => 'NEW_' . uniqid()]); ->call('patch', "{$this->endpoint}/{$id}", ['Specialty' => 'Pathology']);
$patch->assertStatus(200); $patch->assertStatus(200);
$showData = json_decode($this->withHeaders($this->authHeaders()) $showData = json_decode($this->withHeaders($this->authHeaders())
->call('get', "{$this->endpoint}/{$id}") ->call('get', "{$this->endpoint}/{$id}")
->getJSON(), true)['data']; ->getJSON(), true)['data'];
$this->assertNotEquals($contact['ContactCode'], $showData['ContactCode']); $this->assertEquals('Pathology', $showData['Specialty']);
$this->assertEquals($contact['ContactName'], $showData['ContactName']); }
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);
} }
} }