docs: publish contact detail op payloads
This commit is contained in:
parent
5a7b9b257e
commit
577ceb3d54
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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']);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user