- Add OpenSpec experimental workflow with commands (opsx-apply, opsx-archive, opsx-explore, opsx-propose) - Add Serena memory system for project context - Implement User API (UserController, UserModel, routes) - Add Specimen delete endpoint - Update Test definitions and Routes - Sync API documentation (OpenAPI) - Archive completed 2026-03-08-backend-specs change
670 lines
18 KiB
Markdown
670 lines
18 KiB
Markdown
# CLQMS Important Patterns & Special Considerations
|
|
|
|
## JWT Authentication Pattern
|
|
|
|
### How Authentication Works
|
|
1. User logs in via `/api/v2/auth/login` or `/api/login`
|
|
2. Server generates JWT token with payload containing user info
|
|
3. Token stored in HTTP-only cookie named `token`
|
|
4. `AuthFilter` checks for token on protected routes
|
|
5. Token decoded using `JWT_SECRET` from environment
|
|
6. If invalid/missing, returns 401 Unauthorized
|
|
|
|
### AuthFilter Location
|
|
- File: `app/Filters/AuthFilter.php`
|
|
- Registered in: `app/Config/Filters.php` as `'auth'` alias
|
|
|
|
### Protected Routes
|
|
Routes are protected by adding `'filter' => 'auth'` to route group:
|
|
```php
|
|
$routes->group('api', ['filter' => 'auth'], function ($routes) {
|
|
$routes->get('patient', 'Patient\PatientController::index');
|
|
$routes->post('patient', 'Patient\PatientController::create');
|
|
});
|
|
```
|
|
|
|
### JWT Token Structure
|
|
```php
|
|
$payload = [
|
|
'iss' => 'localhost', // Issuer
|
|
'aud' => 'localhost', // Audience
|
|
'iat' => time(), // Issued at
|
|
'nbf' => time(), // Not before
|
|
'exp' => time() + 3600, // Expiration (1 hour)
|
|
'uid' => 1, // User ID
|
|
'email' => 'admin@admin.com' // User email
|
|
];
|
|
```
|
|
|
|
### Generating JWT Token (for Tests)
|
|
```php
|
|
$key = getenv('JWT_SECRET') ?: 'my-secret-key';
|
|
$payload = [
|
|
'iss' => 'localhost',
|
|
'aud' => 'localhost',
|
|
'iat' => time(),
|
|
'exp' => time() + 3600,
|
|
'uid' => 1,
|
|
'email' => 'admin@admin.com'
|
|
];
|
|
$token = \Firebase\JWT\JWT::encode($payload, $key, 'HS256');
|
|
```
|
|
|
|
### Injecting Token in Tests
|
|
```php
|
|
protected $token;
|
|
|
|
protected function setUp(): void {
|
|
parent::setUp();
|
|
// Generate token as shown above
|
|
$this->token = $encodedToken;
|
|
}
|
|
|
|
public function testEndpoint() {
|
|
$this->withHeaders(['Cookie' => 'token=' . $this->token])
|
|
->get('api/patient')
|
|
->assertStatus(200);
|
|
}
|
|
```
|
|
|
|
### Public Routes (No Authentication)
|
|
```php
|
|
$routes->group('api', function ($routes) {
|
|
$routes->post('login', 'AuthController::login'); // No auth filter
|
|
});
|
|
|
|
$routes->group('api/demo', function ($routes) {
|
|
$routes->post('order', 'Test\DemoOrderController::createDemoOrder'); // No auth
|
|
});
|
|
```
|
|
|
|
## UTC Date Handling Pattern
|
|
|
|
### BaseModel Date Normalization
|
|
`BaseModel` automatically handles UTC date conversion:
|
|
|
|
**Before Insert/Update:**
|
|
```php
|
|
protected $beforeInsert = ['normalizeDatesToUTC'];
|
|
protected $beforeUpdate = ['normalizeDatesToUTC'];
|
|
```
|
|
- Converts local dates to UTC before database operations
|
|
- Uses helper: `convert_array_to_utc($data)`
|
|
|
|
**After Find/Insert/Update:**
|
|
```php
|
|
protected $afterFind = ['convertDatesToUTCISO'];
|
|
protected $afterInsert = ['convertDatesToUTCISO'];
|
|
protected $afterUpdate = ['convertDatesToUTCISO'];
|
|
```
|
|
- Converts UTC dates to ISO 8601 format for API responses
|
|
- Uses helper: `convert_array_to_utc_iso($data)`
|
|
|
|
### Date Formats
|
|
|
|
**Database Format (UTC):**
|
|
- Format: `Y-m-d H:i:s`
|
|
- Timezone: UTC
|
|
- Example: `2026-02-11 23:55:08`
|
|
|
|
**API Response Format (ISO 8601):**
|
|
- Format: `Y-m-d\TH:i:s\Z`
|
|
- Example: `2026-02-11T23:55:08Z`
|
|
|
|
### Manual Date Conversion
|
|
```php
|
|
// Convert to UTC for storage
|
|
$birthdate = new \DateTime('1990-05-15', new \DateTimeZone('Asia/Jakarta'));
|
|
$utcDate = $birthdate->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
|
|
|
|
// Format for display (ISO 8601)
|
|
$displayDate = $utcDate->format('Y-m-d\TH:i:s\Z');
|
|
```
|
|
|
|
### Date Fields in Database
|
|
- `CreateDate` - Record creation timestamp
|
|
- `DelDate` - Soft delete timestamp (null if not deleted)
|
|
- `TimeOfDeath` - Death timestamp (patient)
|
|
- Other date fields vary by table
|
|
|
|
## Soft Delete Pattern
|
|
|
|
### How Soft Deletes Work
|
|
- All transactional tables use `DelDate` field for soft deletes
|
|
- Setting `DelDate` to current timestamp marks record as deleted
|
|
- Queries automatically exclude records where `DelDate` is not null
|
|
- Data remains in database for audit trail
|
|
|
|
### BaseModel Soft Delete Configuration
|
|
```php
|
|
protected $useSoftDeletes = true;
|
|
protected $deletedField = 'DelDate';
|
|
```
|
|
|
|
### Manual Soft Delete
|
|
```php
|
|
// In controller
|
|
$this->db->table('patient')
|
|
->where('InternalPID', $InternalPID)
|
|
->update(['DelDate' => date('Y-m-d H:i:s')]);
|
|
|
|
// Or using model
|
|
$this->where('InternalPID', $id)->delete(); // BaseModel handles this
|
|
```
|
|
|
|
### Querying Deleted Records
|
|
If you need to include soft-deleted records:
|
|
```php
|
|
$this->withDeleted()->findAll();
|
|
```
|
|
|
|
### Including Deleted Records in Join
|
|
When joining with tables that might have soft-deleted records:
|
|
```php
|
|
->join('patatt', 'patatt.InternalPID = patient.InternalPID and patatt.DelDate is null', 'left')
|
|
```
|
|
|
|
## ValueSet Pattern
|
|
|
|
### ValueSet Library Location
|
|
- File: `app/Libraries/ValueSet.php`
|
|
- Data directory: `app/Libraries/Data/valuesets/`
|
|
|
|
### Getting ValueSet Data
|
|
```php
|
|
use App\Libraries\ValueSet;
|
|
|
|
// Get all values for a lookup (formatted for dropdowns)
|
|
$gender = ValueSet::get('sex');
|
|
// Returns: [{"value"=>"1","label":"Female"},{"value"=>"2","label":"Male"},...]
|
|
|
|
// Get raw data without formatting
|
|
$raw = ValueSet::getRaw('sex');
|
|
// Returns: [{"key":"1","value":"Female"},{"key":"2","value":"Male"},...]
|
|
|
|
// Get single label by key
|
|
$label = ValueSet::getLabel('sex', '1'); // Returns 'Female'
|
|
|
|
// Get key/value pairs for select inputs
|
|
$options = ValueSet::getOptions('sex');
|
|
// Returns: [["key"=>"1","value"=>"Female"],...]
|
|
```
|
|
|
|
### Transforming Database Results
|
|
```php
|
|
$patients = $this->model->getPatients();
|
|
$patients = ValueSet::transformLabels($patients, [
|
|
'Sex' => 'sex',
|
|
'Priority' => 'order_priority',
|
|
'MaritalStatus' => 'marital_status',
|
|
]);
|
|
// Adds fields: SexLabel, PriorityLabel, MaritalStatusLabel
|
|
```
|
|
|
|
### ValueSet JSON File Format
|
|
```json
|
|
{
|
|
"name": "sex",
|
|
"description": "Patient gender",
|
|
"values": [
|
|
{"key": "1", "value": "Female"},
|
|
{"key": "2", "value": "Male"},
|
|
{"key": "3", "value": "Unknown"}
|
|
]
|
|
}
|
|
```
|
|
|
|
### Clearing ValueSet Cache
|
|
After modifying ValueSet JSON files:
|
|
```php
|
|
ValueSet::clearCache();
|
|
```
|
|
|
|
### Common ValueSets
|
|
| Name | Description | Example Values |
|
|
|------|-------------|----------------|
|
|
| `sex` | Patient gender | Female, Male, Unknown |
|
|
| `marital_status` | Marital status | Single, Married, Divorced |
|
|
| `race` | Ethnicity | Jawa, Sunda, Batak, etc. |
|
|
| `order_priority` | Order priority | Stat, ASAP, Routine, Preop |
|
|
| `order_status` | Order lifecycle | STC, SCtd, SArrv, SRcvd |
|
|
| `specimen_type` | Specimen types | BLD, SER, PLAS, UR, CSF |
|
|
| `specimen_status` | Specimen status | Ordered, Collected, Received |
|
|
| `result_status` | Result validation | Preliminary, Final, Corrected |
|
|
| `test_type` | Test definition types | TEST, PARAM, CALC, GROUP |
|
|
|
|
## Error Handling Pattern
|
|
|
|
### Controller Error Handling
|
|
```php
|
|
public function create() {
|
|
$input = $this->request->getJSON(true);
|
|
|
|
if (!$this->validateData($input, $this->rules)) {
|
|
return $this->failValidationErrors($this->validator->getErrors());
|
|
}
|
|
|
|
try {
|
|
$result = $this->model->createPatient($input);
|
|
return $this->respondCreated([
|
|
'status' => 'success',
|
|
'message' => "data {$result} created successfully"
|
|
]);
|
|
} catch (\Exception $e) {
|
|
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
|
}
|
|
}
|
|
```
|
|
|
|
### Model Error Handling
|
|
```php
|
|
public function createPatient(array $input) {
|
|
$db = \Config\Database::connect();
|
|
$db->transBegin();
|
|
|
|
try {
|
|
$this->insert($input);
|
|
$newId = $this->getInsertID();
|
|
$this->checkDbError($db, 'Insert patient');
|
|
|
|
$db->transCommit();
|
|
return $newId;
|
|
} catch (\Exception $e) {
|
|
$db->transRollback();
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
private function checkDbError($db, string $context) {
|
|
$error = $db->error();
|
|
if (!empty($error['code'])) {
|
|
throw new \Exception(
|
|
"{$context} failed: {$error['code']} - {$error['message']}"
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### ResponseTrait Methods
|
|
```php
|
|
// Success (200)
|
|
return $this->respond(['status' => 'success', 'data' => $data], 200);
|
|
|
|
// Created (201)
|
|
return $this->respondCreated(['status' => 'success', 'message' => 'Created']);
|
|
|
|
// Validation Error (400)
|
|
return $this->failValidationErrors($errors);
|
|
|
|
// Not Found (404)
|
|
return $this->failNotFound('Resource not found');
|
|
|
|
// Server Error (500)
|
|
return $this->failServerError('Error message');
|
|
|
|
// Unauthorized (401) - use in AuthFilter
|
|
return Services::response()
|
|
->setStatusCode(401)
|
|
->setJSON(['status' => 'failed', 'message' => 'Unauthorized']);
|
|
```
|
|
|
|
## Database Transaction Pattern
|
|
|
|
### Standard Transaction Pattern
|
|
```php
|
|
$db = \Config\Database::connect();
|
|
$db->transBegin();
|
|
|
|
try {
|
|
// Insert/Update main record
|
|
$this->insert($data);
|
|
$id = $this->getInsertID();
|
|
$this->checkDbError($db, 'Insert main');
|
|
|
|
// Insert related records
|
|
$relatedModel->insert($relatedData);
|
|
$this->checkDbError($db, 'Insert related');
|
|
|
|
$db->transCommit();
|
|
return $id;
|
|
} catch (\Exception $e) {
|
|
$db->transRollback();
|
|
throw $e;
|
|
}
|
|
```
|
|
|
|
### When to Use Transactions
|
|
- Multi-table operations (main record + related records)
|
|
- Operations that must be atomic
|
|
- When you need to rollback all changes if any operation fails
|
|
|
|
## Nested Data Handling Pattern
|
|
|
|
### Extracting Nested Data
|
|
```php
|
|
// Extract nested data before filtering
|
|
$patIdt = $input['PatIdt'] ?? null;
|
|
$patCom = $input['PatCom'] ?? null;
|
|
$patAtt = $input['PatAtt'] ?? null;
|
|
|
|
// Remove nested arrays that don't belong to parent table
|
|
unset($input['PatIdt'], $input['PatCom'], $input['PatAtt']);
|
|
|
|
// Now $input only contains fields for main table
|
|
```
|
|
|
|
### Processing Nested Data
|
|
```php
|
|
// Insert main record
|
|
$this->insert($input);
|
|
$mainId = $this->getInsertID();
|
|
|
|
// Process related records
|
|
if (!empty($patIdt)) {
|
|
$modelPatIdt->createPatIdt($patIdt, $mainId);
|
|
}
|
|
|
|
if (!empty($patCom)) {
|
|
$modelPatCom->createPatCom($patCom, $mainId);
|
|
}
|
|
|
|
if (!empty($patAtt) && is_array($patAtt)) {
|
|
foreach ($patAtt as $address) {
|
|
$modelPatAtt->createPatAtt($address, $mainId);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Foreign Key Handling Pattern
|
|
|
|
### Array-Based Foreign Keys
|
|
```php
|
|
// Handle array of related records
|
|
if (!empty($input['LinkTo']) && is_array($input['LinkTo'])) {
|
|
$internalPids = array_column($input['LinkTo'], 'InternalPID');
|
|
$input['LinkTo'] = implode(',', $internalPids);
|
|
}
|
|
$input['LinkTo'] = empty($input['LinkTo']) ? null : $input['LinkTo'];
|
|
```
|
|
|
|
### Single Record Foreign Key
|
|
```php
|
|
// Handle single related record
|
|
if (!empty($input['Custodian']) && is_array($input['Custodian'])) {
|
|
$input['Custodian'] = $input['Custodian']['InternalPID'] ?? null;
|
|
if ($input['Custodian'] !== null) {
|
|
$input['Custodian'] = (int) $input['Custodian'];
|
|
}
|
|
}
|
|
```
|
|
|
|
## Validation Rules Pattern
|
|
|
|
### Dynamic Validation Rules
|
|
```php
|
|
// Override validation rules based on input type
|
|
$type = $input['PatIdt']['IdentifierType'] ?? null;
|
|
$identifierRulesMap = [
|
|
'KTP' => 'required|regex_match[/^[0-9]{16}$/]', // 16 digits
|
|
'PASS' => 'required|regex_match[/^[A-Za-z0-9]{1,9}$/]', // alphanumeric max 9
|
|
'SSN' => 'required|regex_match[/^[0-9]{9}$/]', // numeric 9 digits
|
|
'SIM' => 'required|regex_match[/^[0-9]{19,20}$/]', // numeric 19-20 digits
|
|
'KTAS' => 'required|regex_match[/^[0-9]{11}$/]', // numeric 11 digits
|
|
];
|
|
|
|
if ($type && is_string($type)) {
|
|
$identifierRule = $identifierRulesMap[$type] ?? 'permit_empty|max_length[255]';
|
|
$this->rules['PatIdt.IdentifierType'] = 'required';
|
|
$this->rules['PatIdt.Identifier'] = $identifierRule;
|
|
} else {
|
|
$this->rules['PatIdt.IdentifierType'] = 'permit_empty';
|
|
$this->rules['PatIdt.Identifier'] = 'permit_empty|max_length[255]';
|
|
}
|
|
```
|
|
|
|
### Common Validation Rules
|
|
```php
|
|
// Required field
|
|
'NameFirst' => 'required'
|
|
|
|
// String with letters, apostrophes, spaces
|
|
'NameFirst' => 'regex_match[/^[A-Za-z\'\. ]+$/]'
|
|
|
|
// Alphanumeric
|
|
'PatientID' => 'regex_match[/^[A-Za-z0-9]+$/]'
|
|
|
|
// Numeric
|
|
'InternalPID' => 'is_natural'
|
|
|
|
// Email
|
|
'EmailAddress1' => 'valid_email'
|
|
|
|
// Phone number with optional + and 8-15 digits
|
|
'Phone' => 'regex_match[/^\\+?[0-9]{8,15}$/]'
|
|
|
|
// Date
|
|
'Birthdate' => 'required'
|
|
|
|
// Permit empty
|
|
'AlternatePID' => 'permit_empty'
|
|
```
|
|
|
|
## Edge API Pattern
|
|
|
|
### Edge API Purpose
|
|
Integration with laboratory instruments via `tiny-edge` middleware for:
|
|
- Receiving instrument results
|
|
- Sending pending orders to instruments
|
|
- Acknowledging order delivery
|
|
- Logging instrument status
|
|
|
|
### Edge API Endpoints
|
|
```php
|
|
// Receive instrument results
|
|
POST /api/edge/results
|
|
|
|
// Fetch pending orders for instrument
|
|
GET /api/edge/orders?instrument=coulter_counter
|
|
|
|
// Acknowledge order delivery
|
|
POST /api/edge/orders/:id/ack
|
|
|
|
// Log instrument status
|
|
POST /api/edge/status
|
|
```
|
|
|
|
### Edge API Workflow
|
|
```
|
|
Instrument → tiny-edge → POST /api/edge/results → edgeres table
|
|
↓
|
|
[Manual/Auto Processing]
|
|
↓
|
|
patres table (patient results)
|
|
```
|
|
|
|
### Staging Table Pattern
|
|
- Raw results stored in `edgeres` table first
|
|
- Allows validation before processing to main tables
|
|
- Rerun handling via `AspCnt` field (attempt count)
|
|
|
|
## Security Considerations
|
|
|
|
### Environment Variables (NEVER COMMIT)
|
|
- `JWT_SECRET` - JWT signing key
|
|
- Database credentials (username, password)
|
|
- API keys for external services
|
|
|
|
### Input Validation
|
|
- Always validate user input with `$this->validateData($input, $rules)`
|
|
- Use CodeIgniter validation rules
|
|
- Custom regex patterns for specific formats
|
|
|
|
### SQL Injection Prevention
|
|
- Use Query Builder (parameter binding)
|
|
- Never concatenate user input into raw SQL
|
|
- If using raw SQL, escape inputs: `$this->db->escape($value)`
|
|
|
|
### Output Escaping
|
|
- ResponseTrait automatically handles JSON encoding
|
|
- For HTML output (if needed), use `esc()` helper
|
|
|
|
## API Documentation Pattern
|
|
|
|
### Critical Requirement
|
|
After modifying ANY controller, **MUST** update `public/api-docs.yaml`:
|
|
- Add new endpoints
|
|
- Update existing endpoint schemas
|
|
- Document request/response formats
|
|
- Include field names, types, and validation rules
|
|
- Add example requests/responses
|
|
|
|
### API Documentation Format
|
|
```yaml
|
|
paths:
|
|
/api/patient:
|
|
get:
|
|
summary: List patients
|
|
parameters:
|
|
- name: InternalPID
|
|
in: query
|
|
schema:
|
|
type: integer
|
|
responses:
|
|
'200':
|
|
description: Success
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
message:
|
|
type: string
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: '#/components/schemas/Patient'
|
|
```
|
|
|
|
## Testing Pattern
|
|
|
|
### Feature Test Structure
|
|
```php
|
|
<?php
|
|
namespace Tests\Feature;
|
|
|
|
use CodeIgniter\Test\FeatureTestTrait;
|
|
use CodeIgniter\Test\CIUnitTestCase;
|
|
|
|
class PatientCreateTest extends CIUnitTestCase
|
|
{
|
|
use FeatureTestTrait;
|
|
|
|
protected $token;
|
|
|
|
protected function setUp(): void {
|
|
parent::setUp();
|
|
// Generate JWT token
|
|
$this->token = $this->generateToken();
|
|
}
|
|
|
|
public function testCanCreatePatient() {
|
|
$this->withHeaders(['Cookie' => 'token=' . $this->token])
|
|
->post('api/patient', [
|
|
'PatientID' => 'PT001',
|
|
'NameFirst' => 'John',
|
|
'NameLast' => 'Doe',
|
|
'Sex' => '2',
|
|
'Birthdate' => '1990-05-15'
|
|
])
|
|
->assertStatus(200)
|
|
->assertJSONFragment(['status' => 'success']);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Test Commands
|
|
```bash
|
|
# Run all tests
|
|
vendor/bin/phpunit
|
|
|
|
# Run specific test file (IMPORTANT for debugging)
|
|
vendor/bin/phpunit tests/feature/Patient/PatientCreateTest.php
|
|
|
|
# Run with coverage
|
|
vendor/bin/phpunit --coverage-text=tests/coverage.txt --coverage-html=tests/coverage/ -d memory_limit=1024m
|
|
```
|
|
|
|
## Common Pitfalls
|
|
|
|
### Forgetting to Clear ValueSet Cache
|
|
**Problem:** Modified ValueSet JSON file but changes not reflected
|
|
**Solution:** Call `ValueSet::clearCache()` after modifications
|
|
|
|
### Not Updating api-docs.yaml
|
|
**Problem:** API documentation out of sync with implementation
|
|
**Solution:** Always update `public/api-docs.yaml` after controller changes
|
|
|
|
### Missing Soft Delete Filter in Joins
|
|
**Problem:** Query returns soft-deleted records from joined tables
|
|
**Solution:** Add `and table.DelDate is null` to join condition
|
|
|
|
### Incorrect Date Format
|
|
**Problem:** Dates not in UTC format causing issues
|
|
**Solution:** BaseModel handles this, but manual dates must be in `Y-m-d H:i:s` UTC
|
|
|
|
### Validation Rule Not Applied
|
|
**Problem:** Input not validated, invalid data inserted
|
|
**Solution:** Always call `$this->validateData($input, $rules)` before processing
|
|
|
|
### Transaction Not Rolled Back on Error
|
|
**Problem:** Partial data left in database on error
|
|
**Solution:** Always use try-catch with `$db->transRollback()`
|
|
|
|
### Not Using BaseModel for Date Handling
|
|
**Problem:** Dates not normalized to UTC
|
|
**Solution:** All models must extend `BaseModel`
|
|
|
|
## Debugging Tips
|
|
|
|
### Enable Detailed Error Messages
|
|
In development environment (`.env`):
|
|
```env
|
|
CI_ENVIRONMENT = development
|
|
```
|
|
|
|
### Log SQL Queries
|
|
Add to database config or temporarily enable:
|
|
```php
|
|
$db->setQueryLog(true);
|
|
$log = $db->getQueryLog();
|
|
```
|
|
|
|
### Check Application Logs
|
|
```bash
|
|
# Windows
|
|
type writable\logs\log-*.log
|
|
|
|
# Unix/Linux
|
|
tail -f writable/logs/log-*.log
|
|
```
|
|
|
|
### Add Temporary Debug Output
|
|
```php
|
|
var_dump($variable); die();
|
|
// or
|
|
log_message('debug', 'Debug info: ' . json_encode($data));
|
|
```
|
|
|
|
### Run Specific Test for Debugging
|
|
```bash
|
|
vendor/bin/phpunit tests/feature/SpecificTest.php --filter testMethodName
|
|
```
|
|
|
|
### Check Database State
|
|
```bash
|
|
php spark db:table patient
|
|
# or use MySQL Workbench, phpMyAdmin, etc.
|
|
```
|