- 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
13 KiB
13 KiB
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
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\...thenuse App\...
Type Hints (PHP 8.1+)
public function getPatient(?int $id): ?array
{
// Method body
}
- Type hints on method parameters where appropriate
- Return types required for all methods
- Use
?typefor nullable types - Use
array<string, mixed>for complex arrays when clear
Controllers Pattern
Standard Controller Structure
<?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->rulesarray 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
// 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
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)
// 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
// Escape for raw queries (rarely used)
$this->db->escape($value)
// Better: Use parameter binding
$this->where('PatientID', $patientId)->get();
ValueSet Usage
For Static Lookups
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
{
"name": "sex",
"description": "Patient gender",
"values": [
{"key": "1", "value": "Female"},
{"key": "2", "value": "Male"},
{"key": "3", "value": "Unknown"}
]
}
Error Handling
Validation
if (!$this->validateData($input, $rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
Try-Catch Pattern
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- Success201- Created400- Bad Request (validation errors)401- Unauthorized404- Not Found500- Internal Server Error
Testing Pattern
Feature Test
<?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
# 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
DelDatefield - Soft delete sets
DelDateto 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
// 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
// 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
// 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]';
}