Files
orderPRO/.paul/codebase/CONVENTIONS.md
Jacek Pyziak 4c3daf69b7 docs: add codebase map to .paul/codebase/
Auto-generated by paul:map-codebase — 4 parallel analysis agents.
Covers stack, architecture, conventions, and concerns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 21:42:24 +01:00

604 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Coding Conventions
**Analysis Date:** 2026-03-12
---
## 1. Documented Rules (AGENTS.md / CLAUDE.md)
These rules are canonical and enforced across the project:
- Code must be readable by a stranger — clear names, no magic
- No logic in views, no copy-paste, no random helpers without coherent design
- Each function/class has **one responsibility**, typically 3050 lines; split if longer
- Max **3 levels of nesting** (if/foreach); move deeper logic to separate methods
- Comments only where they explain **why**, never **what**
- DB: Medoo is mentioned in docs but the codebase uses **raw PDO** with prepared statements — no string-concatenated SQL for user input
- XSS: escape all output in views with the `e()` helper
- CSRF token required on all POST forms, validated via `Csrf::validate()`
- All styles live in `resources/scss/`, never inline in view files
- Never use native `alert()` / `confirm()` — use `window.OrderProAlerts.confirm()`
- Reusable UI blocks go in `resources/views/components/`
---
## 2. Naming Conventions
### PHP
| Element | Convention | Example |
|---|---|---|
| Classes | PascalCase | `OrdersRepository`, `ShipmentProviderRegistry` |
| Methods | camelCase | `findDetails()`, `updateOrderStatus()` |
| Variables | camelCase | `$orderId`, `$statusLabelMap` |
| Constants | UPPER_SNAKE_CASE | `SESSION_KEY` (in `Csrf`) |
| Namespaces | PascalCase, PSR-4 | `App\Modules\Orders`, `App\Core\Http` |
| Interfaces | PascalCase + `Interface` suffix | `ShipmentProviderInterface` |
| Test classes | PascalCase + `Test` suffix | `CronJobTypeTest` |
No abbreviations in names. `$orderId` not `$oid`. Loop variables in short 23 line loops are the only exception.
### Files
- One class per file, filename matches class name exactly
- `PascalCase.php` for all PHP class files
- `kebab-case` or `snake_case` not used in PHP
### CSS / SCSS
- BEM-style class naming: `block__element--modifier`
- Examples: `.sidebar__brand`, `.orders-ref__meta`, `.btn--primary`, `.is-active`
- State modifier classes: `.is-active`, `.is-collapsed`, `.is-open`, `.is-disabled`
- JavaScript hook classes: `.js-` prefix (e.g. `.js-sidebar-collapse`, `.js-filter-toggle-btn`)
---
## 3. PHP Code Style
### Strict Types
Every PHP file starts with:
```php
<?php
declare(strict_types=1);
```
No exceptions observed across the entire `src/` tree.
### Class Declaration
All classes are `final` unless specifically designed for extension (none in this codebase are open for extension):
```php
final class OrdersRepository
{
public function __construct(private readonly PDO $pdo)
{
}
}
```
Constructor property promotion is used throughout:
```php
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly OrdersRepository $orders,
private readonly ?ShipmentPackageRepository $shipmentPackages = null
) {
}
```
### Return Types and Type Hints
All method signatures carry full PHP 8.x type declarations. Return types always declared:
```php
public function findDetails(int $orderId): ?array
public function updateOrderStatus(int $orderId, string $newStatusCode, ...): bool
public function run(int $limit): array
```
PHPDoc `@param` and `@return` annotations are added for complex array shapes:
```php
/**
* @return array{items:array<int, array<string, mixed>>, total:int, page:int, per_page:int, error:string}
*/
public function paginate(array $filters): array
```
### Defensive Casting
All values from external sources (request input, DB rows) are cast explicitly before use:
```php
$orderId = max(0, (int) $request->input('id', 0));
$search = trim((string) ($filters['search'] ?? ''));
$totalPaid = $row['total_paid'] !== null ? (float) $row['total_paid'] : null;
```
---
## 4. Design Patterns
### Service Layer Pattern
Business logic lives in dedicated `*Service` classes, not controllers or repositories:
- `src/Modules/Settings/AllegroOrderImportService.php` — order import logic
- `src/Modules/Settings/ShopproStatusSyncService.php` — status sync logic
- `src/Modules/Settings/ShopproPaymentStatusSyncService.php`
Controllers delegate to services, which delegate to repositories.
### Repository Pattern
All database access goes through `*Repository` classes. No direct PDO calls in controllers or services beyond what repositories expose:
- `src/Modules/Orders/OrdersRepository.php`
- `src/Modules/Settings/AllegroIntegrationRepository.php`
- `src/Modules/Shipments/ShipmentPackageRepository.php`
### Interface + Registry Pattern
Provider integrations use an interface + registry:
```php
// src/Modules/Shipments/ShipmentProviderInterface.php
interface ShipmentProviderInterface
{
public function code(): string;
public function createShipment(int $orderId, array $formData): array;
// ...
}
// src/Modules/Shipments/ShipmentProviderRegistry.php
final class ShipmentProviderRegistry
{
public function get(string $code): ?ShipmentProviderInterface { ... }
public function all(): array { ... }
}
```
Registered at boot time in `routes/web.php`:
```php
$shipmentProviderRegistry = new ShipmentProviderRegistry([
$shipmentService, // AllegroShipmentService
$apaczkaShipmentService,
]);
```
### Cron Handler Pattern
Cron jobs follow a handler contract. Each job type has a dedicated handler class with a `handle(array $payload): array` method:
- `src/Modules/Cron/AllegroOrdersImportHandler.php`
- `src/Modules/Cron/ShopproStatusSyncHandler.php`
- `src/Modules/Cron/AllegroTokenRefreshHandler.php`
Handlers are registered as a string-keyed array in `CronRunner`:
```php
$runner = new CronRunner($repository, $logger, [
'allegro_orders_import' => new AllegroOrdersImportHandler($ordersSyncService),
'shoppro_order_status_sync' => new ShopproStatusSyncHandler($shopproStatusSyncService),
]);
```
### Manual Dependency Injection (No Container)
There is no DI container. All dependencies are wired manually in `routes/web.php` and `Application.php`. The Application class acts as a **Service Locator** for top-level objects (`$app->db()`, `$app->orders()`, `$app->logger()`), but controllers and services receive dependencies through constructor injection.
### Middleware Pipeline
Middleware uses a `handle(Request $request, callable $next): Response` or `__invoke` signature. The router builds a pipeline with `array_reduce`:
```php
// src/Core/Routing/Router.php
$pipeline = array_reduce(
array_reverse($middlewares),
fn (callable $next, callable|object $middleware): callable => $this->wrapMiddleware($middleware, $next),
fn (Request $req): mixed => $handler($req)
);
```
---
## 5. Database Access
### Driver
Raw **PDO** with MySQL (`src/Core/Database/ConnectionFactory.php`). **No ORM**, no query builder (Medoo is referenced in docs but not in actual code). PDO is configured with:
```php
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
```
### Prepared Statements — Mandatory
**All** user-supplied data goes through prepared statements with named parameters:
```php
$stmt = $this->pdo->prepare(
'UPDATE orders SET external_status_id = :status, updated_at = NOW() WHERE id = :id'
);
$stmt->execute(['status' => $newStatusCode, 'id' => $orderId]);
```
For `IN (...)` clauses with a dynamic list, positional `?` placeholders are used and values are bound with `bindValue`:
```php
$placeholders = implode(',', array_fill(0, count($cleanIds), '?'));
$stmt = $this->pdo->prepare('SELECT ... WHERE oi.order_id IN (' . $placeholders . ')');
foreach ($cleanIds as $index => $orderId) {
$stmt->bindValue($index + 1, $orderId, PDO::PARAM_INT);
}
```
Dynamic `ORDER BY` columns are resolved with `match` expressions against a hard-coded allowlist — never interpolated from user input:
```php
$sortColumn = match ($sort) {
'source_order_id' => 'o.source_order_id',
'total_with_tax' => 'o.total_with_tax',
default => $effectiveOrderedAtSql,
};
```
### SQL Style
Multi-line SQL strings use string concatenation only for safe, code-generated SQL fragments (not user data). Named SQL helper methods extract reusable SQL snippets:
```php
private function effectiveStatusSql(string $orderAlias, string $mappingAlias): string
{
return 'CASE WHEN ' . $orderAlias . '.source = "allegro" AND ...';
}
private function effectiveOrderedAtSql(string $orderAlias): string
{
return 'COALESCE(' . $orderAlias . '.ordered_at, ' . $orderAlias . '.source_created_at, ...)';
}
```
### Error Handling in DB Layer
Repository methods catch `Throwable` and return safe defaults (empty array, `null`, or `false`) rather than letting exceptions propagate to the controller:
```php
try {
$rows = $this->pdo->query('SELECT ...')->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable) {
return [];
}
```
---
## 6. Frontend Patterns
### View System
Plain PHP views rendered via `src/Core/View/Template.php`. The `render()` method:
1. Renders the view file with `extract($data)` — data keys become local variables
2. Wraps it in a layout via a second `renderFile()` call, injecting `$content`
```php
$html = $this->template->render('orders/list', $data, 'layouts/app');
```
Two helpers are injected into every view scope:
```php
$e = static fn (mixed $value): string => htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
$t = static fn (string $key, array $replace = []): string => $translator->get($key, $replace);
```
**Always use `$e()` for output.** Use `<?= (string) $rawHtml ?>` (raw echo) only when the value is pre-escaped HTML built in the controller.
### View Structure
```
resources/views/
├── layouts/
│ ├── app.php # Main authenticated layout (sidebar + topbar)
│ └── auth.php # Login layout
├── components/
│ ├── table-list.php # Generic paginated table with filters, sorting, column toggle
│ └── order-status-panel.php
├── orders/
│ ├── list.php
│ └── show.php
├── settings/
│ └── *.php # One view per settings section
└── shipments/
└── prepare.php
```
Views are **data receivers**, not logic writers. All HTML fragment generation for complex cells (status badges, product previews) happens in controller methods and is passed as pre-built HTML strings with `raw: true` in the table config.
### Reusable Components
The `resources/views/components/table-list.php` component accepts a `$tableList` config array and renders a full paginated table with filters, column toggles, and action forms. Include it with:
```php
<?php include $this->resolve('components/table-list') ?>
// or via the view data key 'tableList'
```
The component reads its own config defensively:
```php
$config = is_array($tableList ?? null) ? $tableList : [];
$rows = is_array($config['rows'] ?? null) ? $config['rows'] : [];
```
### SCSS / CSS
Source: `resources/scss/`
Compiled output: `public/assets/css/`
Build command: `npm run build:assets` (uses Dart Sass)
```
resources/scss/
├── app.scss # Main panel stylesheet
├── login.scss # Auth page stylesheet
└── shared/
└── _ui-components.scss # Shared UI primitives (imported with @use)
```
**Never put styles in view files.** All styles go in SCSS. Cache-busting on the compiled CSS is done via `filemtime()` in the layout:
```php
<link rel="stylesheet" href="/assets/css/app.css?ver=<?= filemtime(...) ?: 0 ?>">
```
### JavaScript
No bundler, no TypeScript. Vanilla JavaScript only (ES5 style with `var` in components, ES6 `const/let` allowed in module files).
**Module pattern:** All JS in views is wrapped in an IIFE to avoid global scope pollution:
```js
(function() {
// component code
})();
```
**UI Alerts and Confirmations:** The custom `window.OrderProAlerts` module (built from `resources/modules/jquery-alerts/`) is used for all confirmations. Never use `window.confirm()` directly. The pattern in table-list:
```js
if (window.OrderProAlerts && typeof window.OrderProAlerts.confirm === 'function') {
window.OrderProAlerts.confirm({ title, message, ... }).then(function(accepted) {
if (!accepted) return;
form.setAttribute('data-confirmed', '1');
form.submit();
});
}
```
**DOM targeting:** Use `data-` attributes and `js-` prefixed class names for JS hooks. Never use presentation class names (`.btn--primary`) as JS selectors.
**localStorage:** Filter state, column visibility, and sidebar collapsed state are persisted in `localStorage` with namespaced keys (e.g. `tableList_/orders/list_orders_filters_open`).
---
## 7. Security
### CSRF
All POST forms include a hidden token field:
```html
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
```
Controllers validate it before processing:
```php
$csrfToken = (string) $request->input('_csrf_token', '');
if (!Csrf::validate($csrfToken)) {
$_SESSION['order_flash_error'] = $this->translator->get('auth.errors.csrf_expired');
return Response::redirect('/orders/' . $orderId);
}
```
`Csrf::validate()` uses `hash_equals()` to prevent timing attacks (`src/Core/Security/Csrf.php`).
### XSS
Every dynamic value echoed in a view must go through `$e()`:
```php
<?= $e($buyerName) ?>
<?= $e($t('navigation.orders')) ?>
```
Pre-built HTML fragments (e.g. status badge HTML assembled in controller) are output raw and must already be escaped internally using `htmlspecialchars(..., ENT_QUOTES, 'UTF-8')`.
### Secrets Encryption
Integration credentials (API keys, tokens) are encrypted at rest using AES-256-CBC with HMAC-SHA256 authentication (`src/Modules/Settings/IntegrationSecretCipher.php`). The cipher uses a key derived from `INTEGRATIONS_SECRET` env var. Encrypted values are prefixed with `v1:`.
---
## 8. Error Handling
### Global Handler
Unhandled exceptions are caught in `Application::registerErrorHandlers()`:
```php
set_exception_handler(function (Throwable $exception) use ($debug): void {
$this->logger->error('Unhandled exception', [
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
]);
$message = $debug ? $exception->getMessage() : 'Internal server error';
Response::html($message, 500)->send();
});
```
### Repository Layer
Repositories return safe fallback values on error and never expose raw exception messages to callers:
```php
try {
// ...
} catch (Throwable $exception) {
return [
'items' => [],
'total' => 0,
'error' => $exception->getMessage(), // returned in the data, not thrown
];
}
```
For methods returning `bool`, failure is signaled by returning `false`:
```php
public function updateOrderStatus(...): bool
{
try { /* ... */ return true; }
catch (Throwable) { return false; }
}
```
For methods returning `?array`, failure returns `null`.
### Cron Layer
The `CronRunner` catches per-job exceptions, logs them, marks the job as failed, and continues processing the next job:
```php
} catch (Throwable $exception) {
$this->repository->markJobFailed($jobId, $exception->getMessage(), ...);
$this->logger->error('Cron job failed', [
'job_id' => $jobId,
'job_type' => $jobType,
'error' => $exception->getMessage(),
]);
$failed++;
}
```
### Logger
`src/Core/Support/Logger.php` — custom file logger writing to `storage/logs/`. Format:
```
[2026-03-12 14:22:01] ERROR Cron job failed {"job_id":42,"job_type":"allegro_orders_import","error":"..."}
```
Methods: `$logger->error(string $message, array $context = [])` and `$logger->info(...)`. Context is encoded as JSON on the same line. Log path is configured via `app.log_path` in `config/app.php`.
---
## 9. Testing
### Framework
PHPUnit 11.x. Config: `phpunit.xml` at project root.
```bash
composer test # vendor/bin/phpunit -c phpunit.xml --testdox
```
Settings:
- `failOnWarning="true"` — warnings are test failures
- `failOnRisky="true"` — risky tests fail
- `executionOrder="depends,defects"` — dependency-aware ordering
### Test File Location
Tests live in `tests/` (PSR-4 namespace `Tests\`), mirroring the `src/` structure:
```
tests/
├── bootstrap.php
└── Unit/
├── Cron/
│ └── CronJobTypeTest.php
└── Settings/
└── OrderStatusMappingRepositoryTest.php
```
**Note:** The active `tests/` directory currently contains only `bootstrap.php`. The working test files exist in `archive/2026-03-02_users-only-reset/tests/` — these are the reference patterns to follow when adding new tests.
### Test Class Pattern
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Settings;
use App\Modules\Settings\OrderStatusMappingRepository;
use PDO;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(OrderStatusMappingRepository::class)]
final class OrderStatusMappingRepositoryTest extends TestCase
{
private PDO $pdo;
private OrderStatusMappingRepository $repository;
protected function setUp(): void
{
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->exec('CREATE TABLE ...'); // schema DDL
$this->repository = new OrderStatusMappingRepository($this->pdo);
}
public function testReplaceAndReadMappingsForIntegration(): void
{
$this->repository->replaceForIntegration(10, [...]);
$rows = $this->repository->listByIntegration(10);
self::assertArrayHasKey('new', $rows);
self::assertSame('completed', $rows['paid']['orderpro_status_code']);
}
}
```
Key patterns:
- Use `#[CoversClass(ClassName::class)]` PHP 8 attribute on every test class
- SQLite in-memory database for repository tests (no real DB connection needed)
- Test method names start with `test` and describe behavior: `testReplaceAndReadMappingsForIntegration`
- Use `self::assertSame()` (strict) not `assertEquals()`
- Classes and methods are `final`
- No mocking framework observed — tests use real classes with SQLite in-memory
### What to Test
Repository tests test read/write behavior against an in-memory SQLite DB. Value object / enum tests verify constants and helper methods. No integration tests against the real MySQL database.
---
## 10. Code Quality Tools
### SonarQube
Configured via `sonar-project.properties`:
```
sonar.sources=src,resources/views,routes
sonar.tests=tests
sonar.exclusions=archive/**,node_modules/**,vendor/**,public/assets/**,storage/**
sonar.php.version=8.1
```
Runs against an external SonarQube server at `https://sonar.project-pro.pl`.
### PHPUnit (Coverage Source)
`phpunit.xml` configures `src/` as the source for coverage analysis.
### No Local Static Analysis Config
No `phpstan.neon`, `.phpcs.xml`, or `psalm.xml` are present. SonarQube is the sole static analysis tool. PHP_CS_Fixer and PHPStan are not in `composer.json`.
---
## 11. Import / Use Statement Organization
Use statements are grouped and ordered:
1. Internal `App\Core\*` namespaces
2. Internal `App\Modules\*` namespaces
3. PHP built-in classes (`PDO`, `DateTimeImmutable`, `RuntimeException`, `Throwable`)
Each `use` on its own line. No `use` grouping with `{}`.
---
## 12. Configuration
App config is split by concern in `config/`:
- `config/app.php` — application settings (paths, locale, session, logging, cron)
- `config/database.php` — DB connection parameters
The `Application::config(string $key, mixed $default)` method resolves dot-notation keys:
```php
$app->config('app.integrations.secret', '')
$app->config('database.host')
```
Environment variables are loaded via `src/Core/Support/Env.php` and read in config files.
---
*Convention analysis: 2026-03-12*