This commit is contained in:
Jacek
2026-03-12 13:36:06 +01:00
parent daddb33e3b
commit 5c3374bf32
25 changed files with 2945 additions and 2 deletions

24
.paul/codebase/README.md Normal file
View File

@@ -0,0 +1,24 @@
# Codebase Map — shopPRO
Generated: 2026-03-12
## Documents
| File | Contents |
|------|---------|
| [overview.md](overview.md) | Project summary, size metrics, quick reference |
| [stack.md](stack.md) | Technology stack, libraries, external integrations |
| [architecture.md](architecture.md) | Directory structure, routing, DI, domain modules, request lifecycle |
| [conventions.md](conventions.md) | Naming, Medoo patterns, cache patterns, security patterns |
| [testing.md](testing.md) | PHPUnit setup, test patterns, mocking, coverage |
| [concerns.md](concerns.md) | Security issues, technical debt, dead code, known bugs |
| [dependencies.md](dependencies.md) | Composer, vendored libs, PHP extensions |
## Quick Facts
- **PHP 7.4 <8.0** — no match, union types, str_contains etc.
- **810 tests / 2264 assertions**
- **29 Domain modules**, all with tests
- **Medoo pitfall**: `delete()` takes 2 args, not 3
- **Top concerns**: tpay.txt logging, path traversal in unlink, hardcoded payment seed
- **Largest files**: `ProductRepository.php` (3583 lines), `IntegrationsRepository.php` (875 lines)

View File

@@ -0,0 +1,235 @@
# Architecture & Structure
## Directory Layout
```
shopPRO/
├── autoload/ # Core application code (custom autoloader)
│ ├── Domain/ # Business logic — 29 modules
│ ├── Shared/ # Cross-cutting utilities
│ │ ├── Cache/ # CacheHandler, RedisConnection
│ │ ├── Email/ # Email (PHPMailer wrapper)
│ │ ├── Helpers/ # Static utility methods
│ │ ├── Html/ # HTML escaping/generation
│ │ ├── Image/ # ImageManipulator
│ │ └── Tpl/ # Template engine
│ ├── admin/ # Admin panel layer
│ │ ├── App.php # Router & DI factory
│ │ ├── Controllers/ # 28 DI controllers
│ │ ├── Support/ # Forms, TableListRequestFactory
│ │ ├── Validation/ # FormValidator
│ │ └── ViewModels/ # Forms/, Common/
│ ├── front/ # Frontend layer
│ │ ├── App.php # Router & DI factory
│ │ ├── LayoutEngine.php # Placeholder-based layout engine
│ │ ├── Controllers/ # 8 DI controllers
│ │ └── Views/ # 11 static view classes
│ └── api/ # REST API layer
│ ├── ApiRouter.php # Auth + routing
│ └── Controllers/ # 4 DI controllers
├── admin/
│ ├── index.php # Admin entry point
│ ├── ajax.php # Admin AJAX handler
│ ├── templates/ # Admin view templates
│ └── layout/ # Admin CSS/JS/icons
├── templates/ # Frontend view templates
├── libraries/ # Third-party libraries
├── tests/ # PHPUnit test suite
├── docs/ # Technical documentation
├── index.php # Frontend entry point
├── ajax.php # Frontend AJAX handler
├── api.php # REST API entry point
├── cron.php # Background job processor
└── config.php # DB/Redis config (NOT in repo)
```
## Autoloader
Custom autoloader in each entry point — tries two conventions:
1. `autoload/{namespace}/class.{ClassName}.php` (legacy)
2. `autoload/{namespace}/{ClassName}.php` (PSR-4 style, preferred)
**Namespace → directory mapping (case-sensitive on Linux):**
- `\Domain\``autoload/Domain/`
- `\admin\``autoload/admin/` (**lowercase a** — never `\Admin\`)
- `\front\``autoload/front/`
- `\api\``autoload/api/`
- `\Shared\``autoload/Shared/`
## Dependency Injection
Manual factory pattern in router classes. Each entry point wires dependencies once:
```php
// Example from admin\App::getControllerFactories()
'ShopProduct' => function() {
global $mdb;
return new \admin\Controllers\ShopProductController(
new \Domain\Product\ProductRepository($mdb),
new \Domain\Integrations\IntegrationsRepository($mdb),
new \Domain\Languages\LanguagesRepository($mdb)
);
}
```
DI wiring locations:
- Admin: `autoload/admin/App.php``getControllerFactories()`
- Frontend: `autoload/front/App.php``getControllerFactories()`
- API: `autoload/api/ApiRouter.php``getControllerFactories()`
## Routing
### Admin (`\admin\App`)
- URL: `/admin/?module=shop_product&action=view_list`
- `module` → PascalCase (`shop_product``ShopProduct`) → controller lookup
- `action` → method call on controller
- Auth checked before routing; 2FA supported
### Frontend (`\front\App`)
- Routes stored in `pp_routes` table (regex patterns, cached in Redis as `pp_routes:all`)
- Match URI → extract destination params → merge with `$_GET`
- Special params: `?product=ID`, `?category=ID`, `?article=ID`
- Controller dispatch via `getControllerFactories()`
- Unmatched → static page content
### API (`\api\ApiRouter`)
- URL: `/api.php?endpoint=orders&action=getOrders`
- Stateless — auth via `X-Api-Key` header (`hash_equals()`)
- `endpoint` → controller, `action` → method
## Request Lifecycle (Frontend)
```
HTTP GET /produkt/nazwa-produktu
→ index.php (autoload, init Medoo, session, language)
→ Fetch pp_routes from Redis (or DB)
→ Regex match → extract ?product=123
→ front\LayoutEngine::show()
→ Determine layout (pp_layouts)
→ Replace placeholders [MENU:ID], [BANER_STRONA_GLOWNA], etc.
→ Call view classes / repositories for each placeholder
→ Output HTML (with GTM, meta OG, WebP, lazy loading)
```
## Request Lifecycle (Admin)
```
HTTP GET /admin/?module=shop_order&action=view_list
→ admin/index.php (IP check, session, auth cookie check)
→ admin\App::update() (run pending DB migrations)
→ admin\App::special_actions() (handle s-action=user-logon etc.)
→ admin\App::render()
→ Auth check → if not logged in, show login form
→ admin\App::route()
→ 'shop_order' → ShopOrder → factory()
→ new ShopOrderController(OrderAdminService, ProductRepository)
→ ShopOrderController::viewList()
→ Tpl::view('shop-order/orders-list', [...])
→ Tpl::render('site/main-layout', ['content' => $html])
→ Output admin HTML
```
## Domain Modules (29)
All in `autoload/Domain/{Module}/{Module}Repository.php`:
| Module | Repository | Notes |
|--------|-----------|-------|
| Article | ArticleRepository | Blog/news |
| Attribute | AttributeRepository | Product attributes (color, size) |
| Banner | BannerRepository | Promo banners |
| Basket | (static) | Cart calculations |
| Cache | (utilities) | Cache key constants |
| Category | CategoryRepository | Category tree |
| Client | ClientRepository | Customer accounts |
| Coupon | CouponRepository | Discount codes |
| CronJob | CronJobRepository, CronJobProcessor | Job queue |
| Dashboard | DashboardRepository | Admin stats |
| Dictionaries | DictionariesRepository | Units, enums |
| Integrations | IntegrationsRepository | Apilo, Ekomi (**875 lines — too large**) |
| Languages | LanguagesRepository | i18n translations |
| Layouts | LayoutsRepository | Page templates |
| Newsletter | NewsletterRepository, NewsletterPreviewRenderer | Email campaigns |
| Order | OrderRepository, OrderAdminService | Orders, status |
| Pages | PagesRepository | Static pages |
| PaymentMethod | PaymentMethodRepository | Payment gateways |
| Producer | ProducerRepository | Brands |
| Product | ProductRepository | Core catalog (**3583 lines — too large**) |
| ProductSet | ProductSetRepository | Bundles |
| Promotion | PromotionRepository | Special offers |
| Scontainers | ScontainersRepository | Content blocks |
| Settings | SettingsRepository | Shop config |
| ShopStatus | ShopStatusRepository | Order statuses |
| Transport | TransportRepository | Shipping |
| Update | UpdateRepository | DB migrations |
| User | UserRepository | Admin users, 2FA |
## Admin Controllers (28)
All in `autoload/admin/Controllers/`:
`ArticlesController`, `ArticlesArchiveController`, `BannerController`, `DashboardController`, `DictionariesController`, `FilemanagerController`, `IntegrationsController`, `LanguagesController`, `LayoutsController`, `NewsletterController`, `PagesController`, `ProductArchiveController`, `ScontainersController`, `SettingsController`, `ShopAttributeController`, `ShopCategoryController`, `ShopClientsController`, `ShopCouponController`, `ShopOrderController`, `ShopPaymentMethodController`, `ShopProducerController`, `ShopProductController` (1199 lines), `ShopProductSetsController`, `ShopPromotionController`, `ShopStatusesController`, `ShopTransportController`, `UpdateController`, `UsersController`
## Frontend Controllers (8)
`autoload/front/Controllers/`: `NewsletterController`, `SearchController`, `ShopBasketController`, `ShopClientController`, `ShopCouponController`, `ShopOrderController`, `ShopProducerController`, `ShopProductController`
## Frontend Views (11, static)
`autoload/front/Views/`: `Articles`, `Banners`, `Languages`, `Menu`, `Newsletter`, `Scontainers`, `ShopCategory`, `ShopClient`, `ShopPaymentMethod`, `ShopProduct`, `ShopSearch`
## API Controllers (4)
`autoload/api/Controllers/`: `OrdersApiController`, `ProductsApiController`, `CategoriesApiController`, `DictionariesApiController`
## Template System
### Tpl Engine (`\Shared\Tpl\Tpl`)
```php
// Controller
return \Shared\Tpl\Tpl::view('shop-category/category-edit', [
'category' => $data,
'languages' => $langs,
]);
// Template (templates/shop-category/category-edit.php)
<h1><?= $this->category['name'] ?></h1>
```
Search order: `templates_user/`, `templates/`, `../templates_user/`, `../templates/`
### Frontend Layout Engine (`\front\LayoutEngine`)
Replaces placeholders in layout HTML loaded from `pp_layouts.html`:
- `[MENU:ID]`, `[KONTENER:ID]`, `[LANG:key]`
- `[PROMOWANE_PRODUKTY:limit]`, `[PRODUKTY_TOP:limit]`, `[PRODUKTY_NEW:limit]`
- `[BANER_STRONA_GLOWNA]`, `[BANERY]`, `[COPYRIGHT]`
- `[AKTUALNOSCI:layout_id:limit]`, `[PRODUKTY_KATEGORIA:cat_id:limit]`
## Admin Form System
Universal form system for CRUD views. Full docs: `docs/FORM_EDIT_SYSTEM.md`.
| Component | Class | Location |
|-----------|-------|----------|
| View model | `FormEditViewModel` | `autoload/admin/ViewModels/Forms/` |
| Field definition | `FormField` | same |
| Field type enum | `FormFieldType` | same |
| Tab | `FormTab` | same |
| Action | `FormAction` | same |
| Validation | `FormValidator` | `autoload/admin/Validation/` |
| POST parsing | `FormRequestHandler` | `autoload/admin/Support/Forms/` |
| Rendering | `FormFieldRenderer` | `autoload/admin/Support/Forms/` |
| Template | `form-edit.php` | `admin/templates/components/` |
## Authentication
### Admin
- Session: `$_SESSION['user']` after successful login
- 2FA: 6-digit code sent by email; `twofa_pending` in session during verification
- Remember Me: 14-day HMAC-SHA256 signed cookie
### API
- Stateless; `X-Api-Key` header vs `pp_settings.api_key` via `hash_equals()`
### Frontend
- Customer session in `$_SESSION['client']`
- IP validation on every request (`$_SESSION['ip']` vs `REMOTE_ADDR`)

127
.paul/codebase/concerns.md Normal file
View File

@@ -0,0 +1,127 @@
# Concerns & Technical Debt
> Last updated: 2026-03-12
## Security Issues
### HIGH — Sensitive data logged to public file
**File**: `autoload/front/Controllers/ShopOrderController.php:32`
```php
file_put_contents('tpay.txt', print_r($_POST, true) . print_r($_GET, true), FILE_APPEND);
```
- Logs entire POST/GET (including payment data) to `tpay.txt` likely in webroot
- Possible information disclosure
- **Fix**: Remove log or write to non-public path (e.g., `/logs/`)
### HIGH — Hardcoded payment seed
**File**: `autoload/front/Controllers/ShopOrderController.php:105`
```php
hash("sha256", "ProjectPro1916;" . round($summary_tmp, 2) ...)
```
- Hardcoded secret in source — should be in `config.php`
### MEDIUM — SQL table name interpolated
**File**: `autoload/Domain/Integrations/IntegrationsRepository.php:31`
```php
$stmt = $this->db->query("SELECT * FROM $table");
```
- Technically mitigated by whitelist in `settingsTable()`, but violates "no SQL string concatenation" rule
- **Fix**: Use Medoo's native `select()` method
### MEDIUM — Path traversal in unlink()
**Files**: `autoload/Domain/Product/ProductRepository.php:1605,1617,2129,2163` and `autoload/Domain/Article/ArticleRepository.php:321,340,823,840`
```php
if (file_exists('../' . $row['src'])) {
unlink('../' . $row['src']);
}
```
- Path from DB, no traversal check
- A DB compromise could delete arbitrary files
- **Fix**:
```php
$basePath = realpath('../upload/');
$fullPath = realpath('../' . $row['src']);
if ($fullPath && strpos($fullPath, $basePath) === 0) {
unlink($fullPath);
}
```
### MEDIUM — Unsanitized output in templates
**Files**:
- `templates/articles/article-full.php` — article title and `$_SERVER['SERVER_NAME']` concatenated without escaping
- `templates/articles/article-entry.php``$url` and article titles not escaped
### MEDIUM — Missing CSRF tokens
- No evidence of CSRF tokens on admin panel forms
- State-changing POST endpoints (create/update/delete) are potentially CSRF-vulnerable
---
## Architecture Issues
### IntegrationsRepository too large (875 lines)
**File**: `autoload/Domain/Integrations/IntegrationsRepository.php`
Does too many things: settings CRUD, logging, Apilo OAuth, product sync, webhook handling, ShopPRO import.
**Suggested split**: `ApiloAuthManager`, `ApiloProductSyncService`, `ApiloWebhookHandler`, `IntegrationLogRepository`, `IntegrationSettingsRepository`
### ProductRepository too large (3583 lines)
**File**: `autoload/Domain/Product/ProductRepository.php`
Candidate for extraction of: pricing logic, image handling, cache management, Google feed generation.
### ShopProductController too large (1199 lines)
**File**: `autoload/admin/Controllers/ShopProductController.php`
### Helpers.php too large (1101 lines)
**File**: `autoload/Shared/Helpers/Helpers.php`
Static utility god class. Extract into focused service classes.
### Duplicate email logic
- `\Shared\Helpers\Helpers::send_email()` and `\Shared\Email\Email::send()` both wrap PHPMailer
- Should be unified in `\Shared\Email\Email`
- Documented in `docs/MEMORY.md`
### 47 `global $mdb` usages remain
- DI is complete in Controllers, but some Helpers methods still use `global $mdb`
- Should be gradually eliminated
---
## Dead Code / Unused Files
| File | Issue |
|------|-------|
| `libraries/rb.php` | RedBeanPHP — no references found in autoload, candidate for removal |
| `cron-turstmate.php` (note: typo) | Legacy/questionable cron handler |
| `devel.html` | Development artifact in project root |
| `output.txt` | Artifact file |
| `libraries/filemanager-9.14.1/` + `9.14.2/` | Duplicate versions |
---
## Missing Error Handling
- `IntegrationsRepository.php:163-165` — DB operations after Apilo token refresh lack try-catch
- `ShopOrderController.php:32``file_put_contents()` return value not checked
- `ProductRepository.php:1605``unlink()` without error handling
- `cron.php:2``error_reporting(E_ALL ^ E_NOTICE ^ E_STRICT ^ E_WARNING ^ E_DEPRECATED)` silences all warnings, hiding potential bugs
---
## Known Issues (from docs/TODO.md & docs/MEMORY.md)
| Issue | Location | Status |
|-------|----------|--------|
| Newsletter save/unsubscribe needs testing | `Domain/Newsletter/` | Open |
| Duplicate email sending logic | `Helpers.php` vs `Email.php` | Open |
| `$mdb->delete()` 2-arg pitfall | Documented in MEMORY.md | Known pitfall |
---
## Summary by Priority
| Priority | Count | Key Action |
|----------|-------|-----------|
| **Immediate** (security) | 5 | Remove tpay.txt logging, fix path traversal, move hardcoded secret to config |
| **High** (architecture) | 3 | Split IntegrationsRepository, unify email logic, add CSRF |
| **Medium** (quality) | 4 | Escape template output, add try-catch, remove dead files |
| **Low** (maintenance) | 3 | Remove rb.php, reduce Helpers.php, document helpers usage |

View File

@@ -0,0 +1,198 @@
# Code Conventions
## Naming
| Entity | Convention | Example |
|--------|-----------|---------|
| Classes | PascalCase | `ProductRepository`, `ShopCategoryController` |
| Methods | camelCase | `getQuantity()`, `categoryDetails()` |
| Admin action methods | snake_case | `view_list()`, `category_edit()` |
| Variables | camelCase | `$mockDb`, `$formViewModel`, `$postData` |
| Constants | UPPER_SNAKE_CASE | `MAX_PER_PAGE`, `SORT_TYPES` |
| DB tables | `pp_` prefix + snake_case | `pp_shop_products` |
| DB columns | snake_case | `price_brutto`, `parent_id`, `lang_id` |
| File (new) | `ClassName.php` | `ProductRepository.php` |
| File (legacy) | `class.ClassName.php` | (leave, do not rename) |
| Templates | kebab-case | `shop-category/category-edit.php` |
## Medoo ORM Patterns
```php
// Get single record — returns array or null
$product = $this->db->get('pp_shop_products', '*', ['id' => $id]);
// Get single column value
$qty = $this->db->get('pp_shop_products', 'quantity', ['id' => $id]);
// Select multiple records — always guard against false return
$rows = $this->db->select('pp_shop_categories', '*', [
'parent_id' => $parentId,
'ORDER' => ['o' => 'ASC'],
]);
if (!is_array($rows)) { return []; }
// Count
$count = $this->db->count('pp_shop_products', ['category_id' => $catId]);
// Update
$this->db->update('pp_shop_products', ['quantity' => 10], ['id' => $id]);
// Delete — ALWAYS 2 arguments, never 3!
$this->db->delete('pp_shop_categories', ['id' => $id]);
// Insert, then check ID for success
$this->db->insert('pp_shop_products', $data);
$newId = $this->db->id();
```
**Critical pitfalls:**
- `$mdb->delete()` takes **2 args** — passing 3 causes silent bugs
- `$mdb->get()` returns `null` (not `false`) when no record found
- Always check `!is_array()` on `select()` results before iterating
## Redis Cache Patterns
```php
$cache = new \Shared\Cache\CacheHandler();
// Read (data is serialized)
$raw = $cache->get('shop\\product:' . $id . ':' . $lang . ':' . $hash);
if ($raw) {
return unserialize($raw);
}
// Write
$cache->set(
'shop\\product:' . $id . ':' . $lang . ':' . $hash,
serialize($data),
86400 // TTL in seconds
);
// Delete one key
$cache->delete($key);
// Delete by pattern
$cache->deletePattern("shop\\product:$id:*");
// Clear all product cache variations
\Shared\Helpers\Helpers::clear_product_cache($productId);
```
## Template Rendering
```php
// In controller — always return string
return \Shared\Tpl\Tpl::view('module/template-name', [
'varName' => $value,
]);
// In template — variables available as $this->varName
<h1><?= $this->varName ?></h1>
// XSS escape
<span><?= $tpl->secureHTML($this->userInput) ?></span>
```
## AJAX Response Format
```php
// Standard JSON response
echo json_encode([
'status' => 'ok', // or 'error'
'msg' => 'Zapisano.',
'id' => (int)$savedId,
]);
exit;
```
## Form Handling (Admin)
```php
// Define form
$form = new FormEditViewModel('Category', 'Edit');
$form->addField(FormField::text('name', ['label' => 'Nazwa', 'required' => true]));
$form->addField(FormField::select('status', ['label' => 'Status', 'options' => [...]]));
$form->addTab('General', [$field1, $field2]);
$form->addAction(new FormAction('save', 'Zapisz', FormAction::TYPE_SUBMIT));
// Validate & process POST
$handler = new FormRequestHandler($validator);
$result = $handler->handleSubmit($form, $_POST);
if (!$result['success']) {
// return form with errors
}
// Render form
return Tpl::view('components/form-edit', ['form' => $form]);
```
## Error Handling
```php
// Wrap risky operations — especially external API calls and file operations
try {
$cache->deletePattern("shop\\product:$id:*");
} catch (\Exception $e) {
error_log("Cache clear failed: " . $e->getMessage());
}
// API — always return structured error
if (!$this->authenticate()) {
self::sendError('UNAUTHORIZED', 'Invalid API key', 401);
return;
}
```
## Security
### XSS
```php
// In templates — use secureHTML for user-sourced strings
<?= $tpl->secureHTML($this->categoryName) ?>
// Or use htmlspecialchars directly
<?= htmlspecialchars($value, ENT_QUOTES, 'UTF-8') ?>
```
### SQL Injection
- All queries via Medoo — never concatenate SQL strings
- Use Medoo array syntax or `?` placeholders only
### Session Security
```php
// IP-binding on every request
if ($_SESSION['ip'] !== $_SERVER['REMOTE_ADDR']) {
session_destroy();
header('Location: /');
exit;
}
```
### API Auth
```php
// Timing-safe comparison
return hash_equals($storedKey, $headerKey);
```
## i18n / Translations
- Language stored in `$_SESSION['current-lang']`
- Translations cached in `$_SESSION['lang-{lang_id}']`
- DB table: `pp_langs`, keys fetched via `LanguagesRepository`
- Helper: `\Shared\Helpers\Helpers::lang($key)` returns translation string
## PHP Version Constraints (< 8.0)
```php
// ❌ FORBIDDEN
$result = match($x) { 1 => 'a' };
function foo(int|string $x) {}
str_contains($s, 'needle');
str_starts_with($s, 'pre');
// ✅ USE INSTEAD
$result = $x === 1 ? 'a' : 'b';
function foo($x) {} // + @param int|string in docblock
strpos($s, 'needle') !== false
strncmp($pre, $s, strlen($pre)) === 0
```

View File

@@ -0,0 +1,65 @@
# Dependencies
## Composer (PHP)
**File**: `composer.json`
**PHP requirement**: `>=7.4` (production runs <8.0)
| Package | Version | Purpose |
|---------|---------|---------|
| `phpunit/phpunit` | ^9.5 | Testing framework |
## Vendored Libraries (`libraries/`)
These are NOT managed by Composer — bundled directly.
| Library | Version | Status | Purpose |
|---------|---------|--------|---------|
| `medoo/` | 1.7.10 | Active | Database ORM |
| `phpmailer/` | classic | Active | Email sending |
| `rb.php` | — | **Unused** — remove | RedBeanPHP legacy ORM |
| `ckeditor/` | 4.x | Active | Rich text editor |
| `apexcharts/` | — | Active | Admin charts |
| `bootstrap/` | 4.1.3 + 4.5.2 | Active | CSS framework (two versions present) |
| `fontawesome-5.7.0/` | 5.7.0 | Active | Icons |
| `filemanager-9.14.1/` | 9.14.1 | Active | File manager |
| `filemanager-9.14.2/` | 9.14.2 | Duplicate? | File manager |
| `codemirror/` | — | Active | Code editor in admin |
| `fancyBox/` + `fancybox3/` | 2 + 3 | Active | Lightbox |
| `plupload/` | — | Active | File uploads |
| `grid/` | — | Active | CSS grid system |
## Frontend (JS, served directly)
| Library | Version | Source |
|---------|---------|--------|
| jQuery | 2.1.3 | `libraries/` |
| jQuery Migrate | 1.0.0 | `libraries/` |
| jQuery UI | — | `libraries/` |
| jQuery Autocomplete | 1.4.11 | `libraries/` |
| jQuery Nested Sortable | — | `libraries/` |
| jQuery-confirm | — | `libraries/` |
| Selectize.js | — | `libraries/` |
| Lozad.js | — | `libraries/` |
| Swiper | — | `libraries/` |
| taboverride.min.js | — | `libraries/` |
| validator.js | — | `libraries/` |
## PHP Extensions Required
| Extension | Purpose |
|-----------|---------|
| `redis` | Redis caching |
| `curl` | External API calls (Apilo, image downloads) |
| `pdo` + `pdo_mysql` | Medoo ORM database access |
| `mbstring` | String handling |
| `gd` or `imagick` | Image manipulation (ImageManipulator) |
| `json` | JSON encode/decode |
| `session` | Session management |
## Notes
- **No npm/package.json** — no JS build pipeline
- **SCSS is pre-compiled** — CSS served as static files
- **No Composer autoload at runtime** — custom autoloader in each entry point
- `libraries/rb.php` (RedBeanPHP, 536 KB) — confirmed unused, safe to delete

View File

@@ -0,0 +1,72 @@
# shopPRO — Codebase Overview
> Generated: 2026-03-12
## What is this project?
shopPRO is a PHP e-commerce platform with an admin panel, customer-facing storefront, and REST API. It uses a Domain-Driven Design architecture with Dependency Injection (migration from legacy architecture complete).
## Size & Health
| Metric | Value |
|--------|-------|
| PHP files (autoload/) | ~588 |
| Lines of code (autoload/) | ~71,668 |
| Test suite | **810 tests, 2264 assertions** |
| Domain modules | 29 |
| Admin controllers | 28 |
| Frontend controllers | 8 |
| API controllers | 4 |
| Frontend views (static) | 11 |
## Tech Snapshot
| Layer | Technology |
|-------|-----------|
| Language | PHP 7.47.x (production **< 8.0**) |
| Database ORM | Medoo 1.7.10 + MySQL |
| Caching | Redis via `CacheHandler` |
| Email | PHPMailer (classic) |
| Frontend JS | jQuery 2.1.3 |
| CSS | Bootstrap 4.x (pre-compiled SCSS) |
| HTTP Client | Native cURL |
| Testing | PHPUnit 9.6 via `phpunit.phar` |
| Build tools | **None** |
## Entry Points
| File | Role |
|------|------|
| `index.php` | Frontend storefront |
| `admin/index.php` | Admin panel |
| `ajax.php` | Frontend AJAX |
| `admin/ajax.php` | Admin AJAX |
| `api.php` | REST API (ordersPRO) |
| `cron.php` | Background job processor |
## External Integrations
| Integration | Purpose |
|-------------|---------|
| **Apilo** | ERP/WMS — order sync, inventory, pricing (OAuth 2.0) |
| **Ekomi** | Customer review CSV export |
| **TrustMate** | Review invitation (browser-based, separate cron) |
| **Google XML Feed** | Google Shopping product feed |
| **shopPRO Import** | Import products from another shopPRO instance |
## Key Architecture Decisions
- **DI via manual factories** in `admin\App`, `front\App`, `api\ApiRouter`
- **Repository pattern** — all DB access in `autoload/Domain/{Module}/{Module}Repository.php`
- **Redis caching** for products (TTL 24h), routes, and settings
- **No Composer autoload at runtime** — custom dual-convention autoloader in each entry point
- **Stateless REST API** — auth via `X-Api-Key` header + `hash_equals()`
- **Job queue** — cron jobs stored in `pp_cron_jobs` table, processed by `cron.php`
## Quick Reference
- Full stack details: `stack.md`
- Architecture & routing: `architecture.md`
- Code conventions: `conventions.md`
- Testing patterns: `testing.md`
- Known issues & debt: `concerns.md`

141
.paul/codebase/stack.md Normal file
View File

@@ -0,0 +1,141 @@
# Technology Stack & Integrations
## Languages
| Language | Version | Notes |
|----------|---------|-------|
| PHP | 7.4 <8.0 | Production constraint — no PHP 8.0+ syntax |
| JavaScript | ES5 + jQuery 2.1.3 | No modern framework |
| CSS | Bootstrap 4.x (pre-compiled SCSS) | No build pipeline |
**PHP 8.0+ features explicitly forbidden:**
- `match` expressions → use ternary / if-else
- Named arguments
- Union types (`int|string`) → use single type + docblock
- `str_contains()`, `str_starts_with()`, `str_ends_with()` → use `strpos()`
## Core Libraries
| Library | Version | Location | Purpose |
|---------|---------|----------|---------|
| Medoo | 1.7.10 | `libraries/medoo/medoo.php` | Database ORM |
| PHPMailer | classic | `libraries/phpmailer/` | Email sending |
| RedBeanPHP | — | `libraries/rb.php` | Legacy ORM — **unused, candidate for removal** |
## Frontend Libraries
| Library | Location | Purpose |
|---------|----------|---------|
| jQuery | 2.1.3 | DOM / AJAX |
| jQuery Migrate | 1.0.0 | Backward compat |
| Bootstrap | 4.1.3 / 4.5.2 | `libraries/bootstrap*/` |
| CKEditor | 4.x | `libraries/ckeditor/` | Rich text editor |
| ApexCharts | — | `libraries/apexcharts/` | Admin charts |
| FancyBox | 2 + 3 | `libraries/fancyBox/`, `fancybox3/` | Lightbox |
| Plupload | — | `libraries/plupload/` | File uploads |
| Selectize.js | — | — | Select dropdowns |
| Lozad.js | — | — | Lazy loading |
| Swiper | — | — | Carousel/slider |
| CodeMirror | — | `libraries/codemirror/` | Code editor |
| Font Awesome | 5.7.0 | `libraries/fontawesome-5.7.0/` | Icons |
| File Manager | 9.14.1 & 9.14.2 | `libraries/filemanager-9.14.*/` | File browsing |
## Database
- **ORM**: Medoo 1.7.10 (custom-extended with Redis support)
- **Engine**: MySQL
- **Table prefix**: `pp_`
- **Connection**: `new medoo([...])` in each entry point via credentials from `config.php`
- **Key tables**: `pp_shop_products`, `pp_shop_orders`, `pp_shop_categories`, `pp_shop_clients`
## Caching
- **Technology**: Redis
- **PHP extension**: Native `Redis` class
- **Wrapper**: `\Shared\Cache\CacheHandler` (singleton via `RedisConnection`)
- **Config**: `config.php``$config['redis']['host/port/password']`
- **Serialization**: PHP `serialize()` / `unserialize()`
- **Default TTL**: 86400 seconds (24h)
- **Key patterns**:
- `shop\product:{id}:{lang_id}:{hash}` — product details
- `ProductRepository::getProductPermutationQuantityOptions:v2:{id}:*`
- `pp_routes:all` — URL routing patterns
- `pp_settings_cache` — shop settings
## Email
- **Library**: PHPMailer (classic, not v6)
- **Config**: `config.php` (host, port, login, password)
- **Helpers**:
- `\Shared\Helpers\Helpers::send_email($to, $subject, $text, $reply, $file)`
- `\Shared\Email\Email::send(...)` — newsletter / template-based
- **Issue**: Duplicate PHPMailer logic in both classes — should be unified
## HTTP Client
- **Technology**: Native PHP cURL (`curl_init`, `curl_setopt`, `curl_exec`)
- **No abstraction library** (no Guzzle, Symfony HTTP Client)
- **Used in**: `IntegrationsRepository.php` (Apilo calls), `cron.php` (image downloads)
## Dev & Build Tools
| Tool | Purpose |
|------|---------|
| Composer | PHP dependency management |
| PHPUnit 9.6 | Testing (`phpunit.phar`) |
| PowerShell `test.ps1` | Recommended test runner |
| No webpack/Vite/Gulp | SCSS pre-compiled, assets served as-is |
## External Integrations
### Apilo (ERP/WMS)
- **Auth**: OAuth 2.0 Bearer token (client_id + client_secret from `pp_shop_apilo_settings`)
- **Base URL**: `https://projectpro.apilo.com/rest/api/`
- **Sync operations**: order sending, payment sync, status polling, product qty/price sync, pricelist sync
- **Code**: `autoload/Domain/Integrations/IntegrationsRepository.php`
- **Cron jobs**: `APILO_SEND_ORDER`, `APILO_SYNC_PAYMENT`, `APILO_STATUS_POLL`, `APILO_PRODUCT_SYNC`, `APILO_PRICELIST_SYNC`
- **Logging**: `\Domain\Integrations\ApiloLogger``pp_log` table
### Ekomi (Reviews)
- **Type**: CSV export
- **Code**: `api.php` → generates `/ekomi/ekomi-{date}.csv`
### TrustMate (Review Invitations)
- **Type**: Browser-based (requires JS execution)
- **Code**: `cron.php` (line ~741), `cron-trustmate.php`
- **Config**: `$config['trustmate']['enabled']`
### Google Shopping Feed
- **Type**: XML feed generation
- **Cron job**: `GOOGLE_XML_FEED`
- **Code**: `cron.php``ProductRepository::generateGoogleFeedXml()`
### shopPRO Product Import
- **Type**: Direct MySQL connection to remote shopPRO instance
- **Config**: `pp_shop_shoppro_settings` (domain, db credentials)
- **Code**: `IntegrationsRepository.php` (lines 668850)
- **Logs**: `/logs/shoppro-import-debug.log`
### REST API (ordersPRO — outbound)
- **Auth**: `X-Api-Key` header
- **Endpoints**: orders (list/get/status/paid), products (list/get), dictionaries, categories
- **Code**: `api.php``autoload/api/ApiRouter.php``autoload/api/Controllers/`
## Cron Job System
| Job Type | Purpose |
|----------|---------|
| `APILO_TOKEN_KEEPALIVE` | OAuth token refresh |
| `APILO_SEND_ORDER` | Sync orders to Apilo (priority 40) |
| `APILO_SYNC_PAYMENT` | Sync payment status |
| `APILO_STATUS_POLL` | Poll order status changes |
| `APILO_PRODUCT_SYNC` | Update product qty & prices |
| `APILO_PRICELIST_SYNC` | Update pricelist |
| `PRICE_HISTORY` | Record price history |
| `ORDER_ANALYSIS` | Order/product correlation |
| `TRUSTMATE_INVITATION` | Review invitations |
| `GOOGLE_XML_FEED` | Google Shopping XML |
- **Priority levels**: CRITICAL(10), HIGH(50), NORMAL(100), LOW(200)
- **Backoff**: Exponential on failure (60s → 3600s max)
- **Storage**: `pp_cron_jobs` table

245
.paul/codebase/testing.md Normal file
View File

@@ -0,0 +1,245 @@
# Testing Patterns
## Overview
| Metric | Value |
|--------|-------|
| Total tests | **810** |
| Total assertions | **2264** |
| Framework | PHPUnit 9.6 (`phpunit.phar`) |
| Bootstrap | `tests/bootstrap.php` |
| Config | `phpunit.xml` |
## Running Tests
```bash
# Full suite (PowerShell — recommended)
./test.ps1
# Specific file
./test.ps1 tests/Unit/Domain/Product/ProductRepositoryTest.php
# Specific test method
./test.ps1 --filter testGetQuantityReturnsCorrectValue
# Alternatives
composer test # standard output
./test.bat # testdox (readable list)
./test-simple.bat # dots
./test-debug.bat # debug output
./test.sh # Git Bash
```
## Test Structure
Tests mirror source structure:
```
tests/Unit/
├── Domain/
│ ├── Product/ProductRepositoryTest.php
│ ├── Category/CategoryRepositoryTest.php
│ ├── Order/OrderRepositoryTest.php
│ └── ... (all 29 modules covered)
├── admin/Controllers/
│ ├── ShopCategoryControllerTest.php
│ └── ...
└── api/
└── ...
```
## Test Class Pattern
```php
namespace Tests\Unit\Domain\Category;
use PHPUnit\Framework\TestCase;
use Domain\Category\CategoryRepository;
class CategoryRepositoryTest extends TestCase
{
private $mockDb;
private CategoryRepository $repository;
protected function setUp(): void
{
$this->mockDb = $this->createMock(\medoo::class);
$this->repository = new CategoryRepository($this->mockDb);
}
// Tests follow below...
}
```
## AAA Pattern (Arrange-Act-Assert)
```php
public function testGetQuantityReturnsCorrectValue(): void
{
// Arrange
$this->mockDb->expects($this->once())
->method('get')
->with(
'pp_shop_products',
'quantity',
['id' => 123]
)
->willReturn(42);
// Act
$result = $this->repository->getQuantity(123);
// Assert
$this->assertSame(42, $result);
}
```
## Mock Patterns
### Simple return value
```php
$this->mockDb->method('get')->willReturn(['id' => 1, 'name' => 'Test']);
```
### Multiple calls with different return values
```php
$this->mockDb->method('get')
->willReturnCallback(function ($table, $columns, $where) {
if ($table === 'pp_shop_categories') {
return ['id' => 15, 'status' => '1'];
}
return null;
});
```
### Verify exact call arguments
```php
$this->mockDb->expects($this->once())
->method('delete')
->with('pp_shop_categories', ['id' => 5]);
```
### Verify method never called
```php
$this->mockDb->expects($this->never())->method('update');
```
### Mock complex PDO statement (for `->query()` calls)
```php
$countStmt = $this->createMock(\PDOStatement::class);
$countStmt->method('fetchAll')->willReturn([[25]]);
$productsStmt = $this->createMock(\PDOStatement::class);
$productsStmt->method('fetchAll')->willReturn([['id' => 301], ['id' => 302]]);
$callIndex = 0;
$this->mockDb->method('query')
->willReturnCallback(function () use (&$callIndex, $countStmt, $productsStmt) {
$callIndex++;
return $callIndex === 1 ? $countStmt : $productsStmt;
});
```
## Controller Test Pattern
```php
class ShopCategoryControllerTest extends TestCase
{
protected function setUp(): void
{
$this->repository = $this->createMock(CategoryRepository::class);
$this->languagesRepository = $this->createMock(LanguagesRepository::class);
$this->controller = new ShopCategoryController(
$this->repository,
$this->languagesRepository
);
}
// Verify constructor signature
public function testConstructorRequiresCorrectRepositories(): void
{
$reflection = new \ReflectionClass(ShopCategoryController::class);
$params = $reflection->getConstructor()->getParameters();
$this->assertCount(2, $params);
$this->assertEquals(
'Domain\\Category\\CategoryRepository',
$params[0]->getType()->getName()
);
}
// Verify action methods return string
public function testViewListReturnsString(): void
{
$this->repository->method('categoriesList')->willReturn([]);
$result = $this->controller->view_list();
$this->assertIsString($result);
}
// Verify expected methods exist
public function testHasExpectedActionMethods(): void
{
$this->assertTrue(method_exists($this->controller, 'view_list'));
$this->assertTrue(method_exists($this->controller, 'category_edit'));
}
}
```
## Test Naming Convention
Pattern: `test{What}{WhenCondition}`
```php
testGetQuantityReturnsCorrectValue()
testGetQuantityReturnsNullWhenProductNotFound()
testCategoryDetailsReturnsDefaultForInvalidId()
testCategoryDeleteReturnsFalseWhenHasChildren()
testCategoryDeleteReturnsTrueWhenDeleted()
testSaveCategoriesOrderReturnsFalseForNonArray()
testPaginatedCategoryProductsClampsPage()
```
## Common Assertions
```php
$this->assertTrue($bool);
$this->assertFalse($bool);
$this->assertEquals($expected, $actual);
$this->assertSame($expected, $actual); // type-strict
$this->assertNull($value);
$this->assertIsArray($value);
$this->assertIsInt($value);
$this->assertIsString($value);
$this->assertEmpty($array);
$this->assertCount(3, $array);
$this->assertArrayHasKey('id', $array);
$this->assertArrayNotHasKey('foo', $array);
$this->assertGreaterThanOrEqual(3, $count);
$this->assertInstanceOf(ClassName::class, $obj);
```
## Available Stubs (`tests/stubs/`)
| Stub | Purpose |
|------|---------|
| `Helpers.php` | `Helpers::seo()`, `::lang()`, `::send_email()`, `::normalize_decimal()` |
| `ShopProduct.php` | Legacy `shop\Product` class stub |
| `RedisConnection` | Redis singleton stub (auto-loaded from bootstrap) |
| `CacheHandler` | Cache stub (no actual Redis needed in tests) |
## What's Covered
- All 29 Domain repositories ✓
- Core business logic (quantity, pricing, category tree) ✓
- Query behavior with mocked Medoo ✓
- Cache patterns ✓
- Controller constructor injection ✓
- `FormValidator` behavior ✓
- API controllers ✓
## What's Lightly Covered
- Full controller action execution (template rendering)
- Session state in tests
- AJAX response integration
- Frontend Views (static classes)