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

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\... then use 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 ?type for 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->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

// 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 - Success
  • 201 - Created
  • 400 - Bad Request (validation errors)
  • 401 - Unauthorized
  • 404 - Not Found
  • 500 - 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 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

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