clqms-be/.serena/memories/code_style_conventions.md
mahdahar 282c642da6 feat: add OpenSpec workflow, Serena integration, User API, and Specimen delete endpoint
- 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
2026-03-09 07:00:12 +07:00

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]';
}
```