- 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
482 lines
13 KiB
Markdown
482 lines
13 KiB
Markdown
# CLQMS Code Style & Conventions
|
|
|
|
## File Organization
|
|
|
|
### Directory Structure
|
|
```
|
|
app/
|
|
├── Controllers/ # API endpoint handlers
|
|
│ ├── Patient/ # Patient-related controllers
|
|
│ ├── Organization/ # Organization-related controllers
|
|
│ ├── Test/ # Test-related controllers
|
|
│ └── ...
|
|
├── Models/ # Data access layer
|
|
│ ├── BaseModel.php # Base model with UTC normalization
|
|
│ └── ...
|
|
├── Libraries/ # Reusable libraries
|
|
│ ├── ValueSet.php # JSON-based lookup system
|
|
│ └── Data/ # ValueSet JSON files
|
|
├── Database/
|
|
│ └── Migrations/ # Database schema migrations
|
|
└── Config/ # Configuration files
|
|
|
|
tests/
|
|
├── feature/ # Integration/API tests
|
|
├── unit/ # Unit tests
|
|
└── _support/ # Test utilities
|
|
|
|
public/
|
|
├── api-docs.yaml # OpenAPI/Swagger documentation (CRITICAL to update!)
|
|
└── index.php # Front controller
|
|
```
|
|
|
|
## Naming Conventions
|
|
|
|
| Type | Convention | Examples |
|
|
|------|------------|----------|
|
|
| **Classes** | PascalCase | `PatientController`, `PatientModel`, `ValueSet` |
|
|
| **Methods** | camelCase | `getPatient`, `createPatient`, `updatePatient` |
|
|
| **Variables** | camelCase | `$patientId`, `$rows`, `$input` |
|
|
| **Database Fields** | PascalCase with underscores | `PatientID`, `NameFirst`, `Street_1`, `InternalPID` |
|
|
| **Constants** | UPPER_SNAKE_CASE | `MAX_ATTEMPTS`, `DEFAULT_PRIORITY` |
|
|
| **Private Methods** | Prefix with underscore if needed | `_validatePatient` |
|
|
| **Files** | PascalCase | `PatientController.php`, `PatientModel.php` |
|
|
|
|
## Formatting & Style
|
|
|
|
### Indentation & Braces
|
|
- **2-space indentation** for all code
|
|
- **Same-line opening braces**: `public function index() {`
|
|
- No trailing whitespace
|
|
- Closing braces on new line
|
|
|
|
### Imports & Namespaces
|
|
```php
|
|
<?php
|
|
namespace App\Controllers\Patient;
|
|
|
|
use CodeIgniter\API\ResponseTrait;
|
|
use CodeIgniter\Controller;
|
|
use App\Libraries\ValueSet;
|
|
use App\Models\Patient\PatientModel;
|
|
```
|
|
|
|
- Namespace at top: `namespace App\Controllers\...`
|
|
- Organize use statements: Framework first, then App libraries/models
|
|
- Group imports: `use CodeIgniter\...` then `use App\...`
|
|
|
|
### Type Hints (PHP 8.1+)
|
|
```php
|
|
public function getPatient(?int $id): ?array
|
|
{
|
|
// Method body
|
|
}
|
|
```
|
|
- Type hints on method parameters where appropriate
|
|
- Return types required for all methods
|
|
- Use `?type` for nullable types
|
|
- Use `array<string, mixed>` for complex arrays when clear
|
|
|
|
## Controllers Pattern
|
|
|
|
### Standard Controller Structure
|
|
```php
|
|
<?php
|
|
namespace App\Controllers\Patient;
|
|
|
|
use CodeIgniter\API\ResponseTrait;
|
|
use CodeIgniter\Controller;
|
|
use App\Libraries\ValueSet;
|
|
use App\Models\Patient\PatientModel;
|
|
|
|
class PatientController extends Controller {
|
|
use ResponseTrait;
|
|
|
|
protected $db;
|
|
protected $model;
|
|
protected $rules;
|
|
|
|
public function __construct() {
|
|
$this->db = \Config\Database::connect();
|
|
$this->model = new PatientModel();
|
|
$this->rules = [
|
|
'NameFirst' => 'required|regex_match[/^[A-Za-z\'\. ]+$/]|min_length[1]|max_length[60]',
|
|
// More validation rules...
|
|
];
|
|
}
|
|
|
|
public function index() {
|
|
$filters = [
|
|
'InternalPID' => $this->request->getVar('InternalPID'),
|
|
'PatientID' => $this->request->getVar('PatientID'),
|
|
];
|
|
|
|
try {
|
|
$rows = $this->model->getPatients($filters);
|
|
$rows = ValueSet::transformLabels($rows, [
|
|
'Sex' => 'sex',
|
|
]);
|
|
return $this->respond([
|
|
'status' => 'success',
|
|
'message' => 'data fetched successfully',
|
|
'data' => $rows
|
|
], 200);
|
|
} catch (\Exception $e) {
|
|
return $this->failServerError('Exception : ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function create() {
|
|
$input = $this->request->getJSON(true);
|
|
|
|
if (!$this->validateData($input, $this->rules)) {
|
|
return $this->failValidationErrors($this->validator->getErrors());
|
|
}
|
|
|
|
try {
|
|
$InternalPID = $this->model->createPatient($input);
|
|
return $this->respondCreated([
|
|
'status' => 'success',
|
|
'message' => "data $InternalPID created successfully"
|
|
]);
|
|
} catch (\Exception $e) {
|
|
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Controller Rules
|
|
- Extend `BaseController`
|
|
- Use `ResponseTrait`
|
|
- Define `$this->rules` array for validation
|
|
- Return JSON: `['status' => 'success', 'message' => '...', 'data' => ...]`
|
|
- Use try-catch with `$this->failServerError()`
|
|
- Input: `$this->request->getJSON(true)` or `$this->request->getVar()`
|
|
|
|
### Response Formats
|
|
```php
|
|
// Success
|
|
return $this->respond([
|
|
'status' => 'success',
|
|
'message' => 'Operation completed',
|
|
'data' => $data
|
|
], 200);
|
|
|
|
// Created
|
|
return $this->respondCreated([
|
|
'status' => 'success',
|
|
'message' => 'Resource created'
|
|
]);
|
|
|
|
// Validation Error
|
|
return $this->failValidationErrors($errors);
|
|
|
|
// Not Found
|
|
return $this->failNotFound('Resource not found');
|
|
|
|
// Server Error
|
|
return $this->failServerError('Error message');
|
|
```
|
|
|
|
## Models Pattern
|
|
|
|
### Standard Model Structure
|
|
```php
|
|
<?php
|
|
namespace App\Models\Patient;
|
|
|
|
use App\Models\BaseModel;
|
|
use App\Libraries\ValueSet;
|
|
|
|
class PatientModel extends BaseModel {
|
|
protected $table = 'patient';
|
|
protected $primaryKey = 'InternalPID';
|
|
protected $allowedFields = ['PatientID', 'NameFirst', 'NameLast', 'Sex', 'Birthdate', /*...*/];
|
|
|
|
protected $useTimestamps = true;
|
|
protected $createdField = 'CreateDate';
|
|
protected $updatedField = '';
|
|
protected $useSoftDeletes = true;
|
|
protected $deletedField = 'DelDate';
|
|
|
|
public function getPatients(array $filters = []) {
|
|
$this->select('InternalPID, PatientID, NameFirst, NameLast, Sex');
|
|
|
|
if (!empty($filters['PatientID'])) {
|
|
$this->like('PatientID', $filters['PatientID'], 'both');
|
|
}
|
|
|
|
$rows = $this->findAll();
|
|
$rows = ValueSet::transformLabels($rows, [
|
|
'Sex' => 'sex',
|
|
]);
|
|
return $rows;
|
|
}
|
|
|
|
public function createPatient(array $input) {
|
|
$db = \Config\Database::connect();
|
|
|
|
$db->transBegin();
|
|
try {
|
|
$this->insert($input);
|
|
$newInternalPID = $this->getInsertID();
|
|
$this->checkDbError($db, 'Insert patient');
|
|
|
|
// Additional operations...
|
|
|
|
$db->transCommit();
|
|
return $newInternalPID;
|
|
} 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']}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Model Rules
|
|
- Extend `BaseModel` (auto UTC date normalization)
|
|
- Define `$table`, `$primaryKey`, `$allowedFields`
|
|
- Use soft deletes: `$useSoftDeletes = true`, `$deletedField = 'DelDate'`
|
|
- Wrap multi-table operations in transactions
|
|
- Use `$this->checkDbError($db, 'context')` after DB operations
|
|
|
|
## Database Operations
|
|
|
|
### Query Builder (Preferred)
|
|
```php
|
|
// Select with joins
|
|
$this->select('patient.*, patcom.Comment')
|
|
->join('patcom', 'patcom.InternalPID = patient.InternalPID', 'left')
|
|
->where('patient.InternalPID', (int) $InternalPID)
|
|
->findAll();
|
|
|
|
// Insert
|
|
$this->insert($data);
|
|
|
|
// Update
|
|
$this->where('InternalPID', $id)->set($data)->update();
|
|
|
|
// Delete (soft)
|
|
$this->where('InternalPID', $id)->delete(); // Sets DelDate
|
|
```
|
|
|
|
### Escape Inputs
|
|
```php
|
|
// Escape for raw queries (rarely used)
|
|
$this->db->escape($value)
|
|
|
|
// Better: Use parameter binding
|
|
$this->where('PatientID', $patientId)->get();
|
|
```
|
|
|
|
## ValueSet Usage
|
|
|
|
### For Static Lookups
|
|
```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 single label by key
|
|
$label = ValueSet::getLabel('sex', '1'); // Returns 'Female'
|
|
|
|
// Transform database results to add text labels
|
|
$patients = [
|
|
['ID' => 1, 'Sex' => '1'],
|
|
['ID' => 2, 'Sex' => '2'],
|
|
];
|
|
$labeled = ValueSet::transformLabels($patients, [
|
|
'Sex' => 'sex'
|
|
]);
|
|
// Result: [['ID'=>1, 'Sex'=>'1', 'SexLabel'=>'Female'], ...]
|
|
|
|
// Clear cache after modifying valueset JSON files
|
|
ValueSet::clearCache();
|
|
```
|
|
|
|
### ValueSet JSON File Format
|
|
```json
|
|
{
|
|
"name": "sex",
|
|
"description": "Patient gender",
|
|
"values": [
|
|
{"key": "1", "value": "Female"},
|
|
{"key": "2", "value": "Male"},
|
|
{"key": "3", "value": "Unknown"}
|
|
]
|
|
}
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
### Validation
|
|
```php
|
|
if (!$this->validateData($input, $rules)) {
|
|
return $this->failValidationErrors($this->validator->getErrors());
|
|
}
|
|
```
|
|
|
|
### Try-Catch Pattern
|
|
```php
|
|
try {
|
|
$result = $this->model->createPatient($input);
|
|
return $this->respondCreated([
|
|
'status' => 'success',
|
|
'message' => 'Created successfully'
|
|
]);
|
|
} catch (\Exception $e) {
|
|
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
|
}
|
|
```
|
|
|
|
### HTTP Status Codes
|
|
- `200` - Success
|
|
- `201` - Created
|
|
- `400` - Bad Request (validation errors)
|
|
- `401` - Unauthorized
|
|
- `404` - Not Found
|
|
- `500` - Internal Server Error
|
|
|
|
## Testing Pattern
|
|
|
|
### Feature Test
|
|
```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
|
|
$key = getenv('JWT_SECRET') ?: 'my-secret-key';
|
|
$payload = [
|
|
'iss' => 'localhost',
|
|
'aud' => 'localhost',
|
|
'iat' => time(),
|
|
'exp' => time() + 3600,
|
|
'uid' => 1,
|
|
'email' => 'admin@admin.com'
|
|
];
|
|
$this->token = \Firebase\JWT\JWT::encode($payload, $key, 'HS256');
|
|
}
|
|
|
|
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
|
|
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
|
|
```
|
|
|
|
## Special Considerations
|
|
|
|
### UTC Date Handling
|
|
- BaseModel automatically normalizes dates to UTC
|
|
- All dates in database are in UTC format
|
|
- API responses return dates in ISO 8601 format: `Y-m-d\TH:i:s\Z`
|
|
- Date conversions handled automatically via BaseModel hooks
|
|
|
|
### Soft Deletes
|
|
- All transactional tables use soft deletes via `DelDate` field
|
|
- Soft delete sets `DelDate` to current timestamp
|
|
- Query Builder automatically filters out deleted records
|
|
|
|
### Input Validation
|
|
- Use CodeIgniter's validation rules in controllers
|
|
- Custom regex patterns for specific formats (e.g., KTP, Passport)
|
|
- Validate before processing in controllers
|
|
|
|
### API Documentation (CRITICAL)
|
|
- After modifying ANY controller, you MUST update `public/api-docs.yaml`
|
|
- Update OpenAPI schema definitions for new/changed endpoints
|
|
- Update field names, types, and response formats
|
|
- Ensure schemas match actual controller responses
|
|
|
|
### Security
|
|
- Never log or commit secrets (JWT_SECRET, passwords)
|
|
- Escape user inputs before DB operations
|
|
- Use JWT authentication for API endpoints
|
|
- Validate all inputs before processing
|
|
|
|
## Common Patterns
|
|
|
|
### Nested Data Handling
|
|
```php
|
|
// Extract nested data before filtering
|
|
$patIdt = $input['PatIdt'] ?? null;
|
|
$patCom = $input['PatCom'] ?? null;
|
|
|
|
// Remove nested arrays that don't belong to parent table
|
|
unset($input['PatIdt'], $input['PatCom']);
|
|
|
|
// Process nested data separately
|
|
if (!empty($patIdt)) {
|
|
$modelPatIdt->createPatIdt($patIdt, $newInternalPID);
|
|
}
|
|
```
|
|
|
|
### Foreign Key Handling
|
|
```php
|
|
// Handle array-based foreign keys
|
|
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'];
|
|
```
|
|
|
|
### Dynamic Validation Rules
|
|
```php
|
|
// Override validation rules based on input type
|
|
$type = $input['PatIdt']['IdentifierType'] ?? null;
|
|
$identifierRulesMap = [
|
|
'KTP' => 'required|regex_match[/^[0-9]{16}$/]',
|
|
'PASS' => 'required|regex_match[/^[A-Za-z0-9]{1,9}$/]',
|
|
];
|
|
|
|
if ($type) {
|
|
$this->rules['PatIdt.Identifier'] = $identifierRulesMap[$type] ?? 'permit_empty|max_length[255]';
|
|
}
|
|
```
|