# Coding Rules — Blazma LIMS

## 1. Naming Conventions

### 1.1 Database Tables
- **ALL UPPERCASE** for application tables: `END_USER`, `LAB_CATEGORY_PACKAGE`, `HOSPITAL`
- **lowercase** for Laravel system tables: `migrations`, `jobs`, `cache`, `sessions`
- **snake_case lowercase** for diabetic module tables: `diabetic_patient_visits`
- Column names are **UPPERCASE**: `PROFILE_ID`, `END_USER_ID`, `CREATED_BY`
- Primary keys use **ID** (uppercase): `ID int unsigned auto_increment`
- Timestamps remain lowercase: `created_at`, `updated_at`

### 1.2 Models
- **UPPERCASE class names** matching table names: `ENDUSERLABORDER`, `LABCATEGORYPACKAGE`
- Models must declare `protected $table = 'TABLE_NAME'`
- Models in `App\Models\` (top-level) or `App\Models\Base\` (base classes)
- camelCase relationship method names: `labCategoryPackage()`, `endUserLabOrder()`

```php
// CORRECT model example
class ENDUSERLABORDERPACKAGE extends Model
{
    protected $table = 'END_USER_LAB_ORDER_PACKAGE';
    protected $fillable = ['END_USER_LAB_ORDER_ID', 'LAB_CATEGORY_PACKAGE_ID', 'PRICE'];
    
    public function lABCATEGORYPACKAGE()
    {
        return $this->belongsTo(LABCATEGORYPACKAGE::class, 'LAB_CATEGORY_PACKAGE_ID');
    }
}
```

### 1.3 Controllers
- PascalCase: `LabController`, `InvoiceController`, `PayfortController`
- Located in `app/Http/Controllers/`
- Sub-directories for grouped controllers: `V2/`, `Operations/`

### 1.4 Services
- PascalCase with `Service` suffix: `LabService`, `PaymentService`, `HESNPlusService`
- Located in `app/Services/`
- Sub-directories for grouped services: `Alert/`, `ERP/`, `Metadata/`, `Notification/`, `Zatca/`

### 1.5 Jobs
- PascalCase, descriptive names: `ZatcaInvoice`, `NotificationFired`, `HESNPlusSendData`
- Located in `app/Jobs/`
- Should implement `ShouldQueue`

### 1.6 Routes
- Named routes use kebab-case or dot notation: `name('SampleTracking')`, `name('OrderInfo')`
- URL paths use kebab-case: `/order-checkout`, `/reservation-success`

---

## 2. Controller Responsibilities

Controllers must be **thin**. They:

✅ **MUST**:
- Accept HTTP request
- Validate input via `$request->validate()` or FormRequest classes
- Call one or more Service methods
- Return view or JSON response
- Handle HTTP-specific concerns (response codes, redirects)

❌ **MUST NOT**:
- Contain database queries directly
- Contain business logic
- Perform calculations
- Call external APIs directly

```php
// CORRECT — Thin controller
class InvoiceController extends Controller
{
    public function __construct(private TaxService $taxService) {}

    public function generateInvoice(Request $request)
    {
        $request->validate(['order_id' => 'required|integer']);
        $invoice = $this->taxService->generateInvoice($request->order_id);
        return response()->json(['success' => true, 'invoice' => $invoice]);
    }
}

// WRONG — Fat controller with business logic
class InvoiceController extends Controller
{
    public function generateInvoice(Request $request)
    {
        // NEVER DO THIS — business logic in controller
        $order = END_USER_LAB_ORDER::find($request->order_id);
        $total = $order->packages->sum('PRICE');
        $vat = $total * 0.15;
        // ...
    }
}
```

---

## 3. Service Layer Rules

Services contain ALL business logic. They:

✅ **MUST**:
- Be injected via constructor (never instantiated with `new` inside other classes)
- Scope all queries by `PROFILE_ID` (multi-tenancy)
- Dispatch Jobs for heavy/async operations
- Return data arrays, collections, or model instances
- Throw exceptions for error cases (not return false/null silently)

❌ **MUST NOT**:
- Handle HTTP requests/responses
- Access `$_GET`, `$_POST`, or `request()` directly
- Return views or responses

```php
// CORRECT — Service with PROFILE_ID scoping
class LabService
{
    public function getTestsByProfile(int $profileId): Collection
    {
        return LAB_CATEGORY_PACKAGE::where('PROFILE_ID', $profileId)
            ->where('IS_ACTIVE', 1)
            ->get();
    }
    
    public function processResult(int $packageId, array $results): void
    {
        // business logic
        SmartReportReady::dispatch($packageId); // async job
    }
}
```

---

## 4. Multi-Tenancy Rules

**CRITICAL**: Every query on business tables MUST be scoped by `PROFILE_ID`.

```php
// ✅ CORRECT
LAB_CATEGORY_PACKAGE::where('PROFILE_ID', $profileId)->get();

// ✅ CORRECT with join
END_USER_LAB_ORDER::where('PROFILE_ID', $profileId)
    ->where('HOSPITAL_ID', $hospitalId)
    ->get();

// ❌ WRONG — returns all tenants' data
LAB_CATEGORY_PACKAGE::all();
LAB_CATEGORY_PACKAGE::where('IS_ACTIVE', 1)->get();
```

The active `PROFILE_ID` is typically retrieved from:
- Environment: `env('PROFILE_ID')` (white-label mode)
- Session/Auth: authenticated user's profile
- Request parameter (for multi-profile users)

---

## 5. Queue / Job Rules

Heavy operations MUST be dispatched as jobs:

```php
// ✅ CORRECT — Heavy operations are async
ZatcaInvoice::dispatch($invoiceId);
SendSMSJob::dispatch($mobile, $message);
HESNPlusSendData::dispatch($orderId);
GenerateAiRecommendationJob::dispatch($userId);

// ❌ WRONG — Synchronous heavy operation in request lifecycle
$this->zatcaService->generateInvoice($invoiceId); // blocks HTTP response
$this->smsService->send($mobile, $message); // blocks HTTP response
```

Job classes must:
- Implement `ShouldQueue`
- Use `implements ShouldQueue` and `use Queueable, InteractsWithQueue, SerializesModels`
- Handle failures gracefully (retry/log)

---

## 6. Validation Patterns

Use inline validation for simple cases, FormRequest for complex:

```php
// Inline validation
$request->validate([
    'PROFILE_ID'  => 'required|integer|exists:PROFILE,ID',
    'HOSPITAL_ID' => 'required|integer|exists:HOSPITAL,ID',
    'NAME_EN'     => 'required|string|max:255',
    'NAME_AR'     => 'nullable|string|max:255',
]);

// FormRequest class (for reuse across endpoints)
class CreateOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'tests'           => 'required|array|min:1',
            'tests.*.id'      => 'required|integer',
            'hospital_id'     => 'required|integer',
            'appointment_date' => 'required|date|after:today',
        ];
    }
}
```

---

## 7. Bilingual Data Pattern

All user-facing content must have both Arabic and English:

```php
// ✅ CORRECT — Always save both languages
$category = LAB_CATEGORY::create([
    'NAME_EN' => $request->name_en,
    'NAME_AR' => $request->name_ar,    // required
    'PROFILE_ID' => $profileId,
]);

// For responses, return both
return [
    'name' => [
        'en' => $category->NAME_EN,
        'ar' => $category->NAME_AR,
    ]
];
```

---

## 8. Feature Flag Pattern

Always check feature flags before executing feature-specific code:

```php
// ✅ CORRECT — Check feature flag first
$profile = PROFILE::find($profileId);

if ($profile->ENABLE_QC) {
    // QC module logic
}

if ($profile->ENABLE_AI_RECOMMENDATION) {
    GenerateAiRecommendationJob::dispatch($orderId);
}

if ($profile->ENABLE_WAREHOUSE) {
    // Deduct reagent from inventory
}
```

---

## 9. Response Format

### API (JSON) responses:
```php
// Success
return response()->json([
    'success' => true,
    'data'    => $result,
    'message' => 'Operation successful',
]);

// Error
return response()->json([
    'success' => false,
    'message' => 'Error description',
    'errors'  => $validationErrors,
], 422);
```

### Web (Blade) responses:
```php
return view('page.name', compact('data', 'profile'));
```

---

## 10. Eloquent Usage Rules

```php
// ✅ CORRECT — Eager loading to prevent N+1
$orders = END_USER_LAB_ORDER::with([
    'endUser',
    'packages.labCategoryPackage',
    'hospital',
])->where('PROFILE_ID', $profileId)->get();

// ✅ CORRECT — Use specific columns
$users = END_USER::select('ID', 'FULL_NAME', 'MOBILE_NUMBER')
    ->where('PROFILE_ID', $profileId)
    ->get();

// ❌ WRONG — N+1 query problem
$orders = END_USER_LAB_ORDER::where('PROFILE_ID', $profileId)->get();
foreach ($orders as $order) {
    $user = $order->endUser; // N+1!
}

// ❌ WRONG — Select all columns unnecessarily
$users = END_USER::where('PROFILE_ID', $profileId)->get(); // loads 100+ columns
```

---

## 11. File & Asset Storage

```php
// ✅ CORRECT — Use FileService for uploads
$path = $this->fileService->uploadToS3($file, 'reports/');

// ✅ CORRECT — Store path (not full URL) in DB
$record->update(['IMAGE_PATH' => $path]);

// ✅ CORRECT — Generate URL when needed
$url = Storage::disk('s3')->url($record->IMAGE_PATH);
```

---

## 12. Error Logging

```php
// ✅ CORRECT — Use LogService or Laravel logging
\Log::error('HESN Plus API failed', [
    'profile_id' => $profileId,
    'order_id'   => $orderId,
    'error'      => $e->getMessage(),
]);

// Also use system log tables
$this->logService->log('HESN_SEND', $orderId, $response);
```

---

## 13. Migration Rules

```php
// ✅ CORRECT migration naming
// YYYY_MM_DD_HHMMSS_description.php
// 2025_01_15_103000_add_enable_loyalty_to_profile_table.php

Schema::table('PROFILE', function (Blueprint $table) {
    $table->tinyInteger('ENABLE_LOYALTY')->default(0)->after('SUSPENSION_PERIOD');
});

// ✅ CORRECT — Always add down() method
public function down(): void
{
    Schema::table('PROFILE', function (Blueprint $table) {
        $table->dropColumn('ENABLE_LOYALTY');
    });
}
```

### Large tables (END_USER_LAB_ORDER_PACKAGE ~13M rows, END_USER ~2M rows)

A plain `Schema::table()->addColumn()` rewrites the whole table and takes
production down. Two patterns instead:

**A. `ALGORITHM=INSTANT`** — metadata-only, finishes in milliseconds.
Use for `END_USER_LAB_ORDER_PACKAGE` and any InnoDB table **without** a
FULLTEXT index. The column must be NULL or have a constant DEFAULT; no
AUTO_INCREMENT, no index in the same statement, no `AFTER` before MySQL 8.0.29.

```php
public function up(): void
{
    DB::statement('SET SESSION lock_wait_timeout = 5'); // fail fast instead of queueing the app behind us
    DB::statement("
        ALTER TABLE END_USER_LAB_ORDER_PACKAGE
        ADD COLUMN IS_URGENT TINYINT(1) NULL DEFAULT NULL,
        ALGORITHM=INSTANT
    ");
}
```

If MySQL answers `ALGORITHM=INSTANT is not supported`, do **not** drop the
keyword — use pattern B. Each INSTANT ADD counts toward a limit of 64 per
table before a rebuild is required; check
`SELECT TOTAL_ROW_VERSIONS FROM information_schema.INNODB_TABLES WHERE NAME='blazma/END_USER_LAB_ORDER_PACKAGE'`.

**B. `pt-online-schema-change`** — for `END_USER` (its FULLTEXT index
`search` blocks INSTANT) or for adding an index to a large table. Run
`scripts/db/pt-osc.sh` on production **before** deploying, then make the
migration idempotent so `php artisan migrate --force` skips the work:

```bash
scripts/db/pt-osc.sh END_USER "ADD COLUMN IS_VIP TINYINT(1) NULL DEFAULT NULL"            # dry-run
scripts/db/pt-osc.sh END_USER "ADD COLUMN IS_VIP TINYINT(1) NULL DEFAULT NULL" --execute
```

```php
public function up(): void
{
    if (Schema::hasColumn('END_USER', 'IS_VIP')) {
        return; // already applied online with scripts/db/pt-osc.sh
    }
    Schema::table('END_USER', fn (Blueprint $table) => $table->tinyInteger('IS_VIP')->nullable());
}
```

Do not use gh-ost here: `END_USER` has child foreign keys and gh-ost does
not support them; pt-osc handles them via `rebuild_constraints`. The script
already carries the flags production needs (`--recursion-method=none` for
MySQL 8.4, `foreign_key_checks=0` so child FKs rebuild INPLACE, non-strict
`sql_mode` so legacy `0000-00-00` dates copy); see the comments in it before
changing them. Applied this way on production on 2026-09-08 for
`CLIENT_START_DATE` / `CLIENT_EXPITED_DATE`: ~4 min copy, 1 s swap, no downtime.

---

## 14. Documentation Rule

When modifying architecture, modules, or significant logic:
1. Update the relevant file in `/docs/ai/`
2. Update `AI_SYSTEM_MAP.json` if new modules, tables, or services are added
3. Run `php artisan ai:update-docs` after each sprint
