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

115
.paul/PROJECT.md Normal file
View File

@@ -0,0 +1,115 @@
# shopPRO
## What This Is
Autorski silnik sklepu internetowego pisany od podstaw — odpowiednik WooCommerce lub PrestaShop, ale bez zależności od zewnętrznych platform. Składa się z panelu administratora (zarządzanie zamówieniami, produktami, klientami) oraz części frontowej dla klienta końcowego.
## Core Value
Właściciel sklepu internetowego ma pełną kontrolę nad sprzedażą online — produktami, zamówieniami i klientami — w jednym spójnym systemie pisanym od podstaw, bez narzutów zewnętrznych platform.
## Current State
| Attribute | Value |
|-----------|-------|
| Version | 0.333 |
| Status | Production |
| Last Updated | 2026-03-12 |
## Requirements
### Validated (Shipped)
- [x] Panel administratora — zarządzanie produktami, kategoriami, atrybutami
- [x] Panel administratora — zarządzanie zamówieniami
- [x] Panel administratora — zarządzanie klientami
- [x] Część frontowa — przeglądanie i kupowanie produktów
- [x] Koszyk i składanie zamówień
- [x] Integracje płatności i dostaw
- [x] REST API (ordersPRO + Ekomi)
- [x] Redis caching
- [x] Ochrona przed podwójnym składaniem zamówienia
- [x] Domain-Driven Architecture (migracja z legacy zakończona)
### Active (In Progress)
- [ ] [Do zdefiniowania podczas planowania]
### Planned (Next)
- [ ] [Do zdefiniowania podczas planowania]
### Out of Scope
- Multitenancy (wiele sklepów w jednej instancji) — nie planowane
## Target Users
**Primary:** Właściciel/administrator sklepu internetowego
- Zarządza produktami, zamówieniami, klientami przez panel admina
- Potrzebuje niezawodnego, szybkiego narzędzia bez zbędnych zależności
**Secondary:** Klient końcowy sklepu
- Przegląda produkty, dodaje do koszyka, składa zamówienia
## Context
**Technical Context:**
- PHP 7.4+ (produkcja: PHP < 8.0)
- Medoo ORM (`$mdb`), Redis caching
- Domain-Driven Design z Dependency Injection
- PHPUnit 9.6, 810+ testów
- Namespace: `\Domain\`, `\admin\`, `\front\`, `\api\`, `\Shared\`
## Constraints
### Technical Constraints
- PHP < 8.0 na produkcji (brak `match`, named arguments, union types)
- Medoo ORM — prepared statements bez wyjątków
- Redis wymagany dla cache
### Business Constraints
- System wdrażany u klientów jako update package (ZIP)
## Key Decisions
| Decision | Rationale | Date | Status |
|----------|-----------|------|--------|
| DDD + DI zamiast legacy architektury | Testowalność, separacja odpowiedzialności | 2025 | Active |
| PHP < 8.0 kompatybilność | Klienci na starszych serwerach | 2025 | Active |
| Własny silnik zamiast frameworka | Pełna kontrola, brak narzutów | - | Active |
## Success Metrics
| Metric | Target | Current | Status |
|--------|--------|---------|--------|
| Testy | >800 | 810 | On track |
| Pokrycie architektury DDD | 100% | 100% | Achieved |
## Tech Stack
| Layer | Technology | Notes |
|-------|------------|-------|
| Backend | PHP 7.4+ | < 8.0 na produkcji |
| ORM | Medoo | `$mdb` global |
| Cache | Redis | CacheHandler singleton |
| Frontend | HTML/CSS/JS | Własny silnik szablonów (Tpl) |
| Auth | Sesje PHP | CSRF, XSS protection |
| Testy | PHPUnit 9.6 | phpunit.phar |
## Specialized Flows
See: .paul/SPECIAL-FLOWS.md
Quick Reference:
- /feature-dev → Nowe funkcje, większe zmiany (required)
- /koniec-pracy → Release, update package (required)
- /frontend-design → Komponenty UI, szablony widoków
- /code-review → Przegląd kodu przed release
- /simplify → Upraszczanie po implementacji
- /claude-md-improver → Utrzymanie CLAUDE.md
- /zapisz + /wznow → Zapis i wznowienie sesji
---
*PROJECT.md — Updated when requirements or context change*
*Last updated: 2026-03-12*

62
.paul/ROADMAP.md Normal file
View File

@@ -0,0 +1,62 @@
# Roadmap: shopPRO
## Overview
shopPRO to autorski silnik sklepu internetowego rozwijany iteracyjnie. Projekt jest już na produkcji (v0.333) — roadmap obejmuje planowane funkcje i usprawnienia kolejnych wersji.
## Current Milestone
**Security hardening** (v0.33x)
Status: In progress
Phases: 3 of 4 complete
## Phases
| Phase | Name | Plans | Status | Completed |
|-------|------|-------|--------|-----------|
| 1 | Sensitive data logging fix | 1 | Done | 2026-03 |
| 2 | Path traversal + XSS escaping | 1 | Done | 2026-03 (v0.335) |
| 3 | Error handling w krytycznych ścieżkach | 1 | Done | 2026-03 (v0.336) |
| 4 | CSRF protection — admin panel forms | 1 | Applied | 2026-03 (v0.337) |
| 5 | Order bugs fix — duplicate + COD status | 1 | Applied | 2026-03 (v0.338) |
## Next Milestone
**Tech debt — Integrations refactoring**
Status: Planning
| Phase | Name | Plans | Status | Completed |
|-------|------|-------|--------|-----------|
| 6 | IntegrationsRepository split → ApiloRepository | 2 | Done | 2026-03 |
## Phase Details
### Phase 4 — CSRF protection
**Problem:** Brak tokenów CSRF na formularzach panelu admina. State-changing POST endpointy (create/update/delete) są potencjalnie podatne na ataki CSRF.
**Scope:** Dodanie CSRF tokenów do formularzy i walidacji w panelu administracyjnym.
**Reference:** `.paul/codebase/concerns.md` — MEDIUM — Missing CSRF tokens
### Phase 6 — IntegrationsRepository split
**Problem:** `IntegrationsRepository` ma 875 linii — miesza logikę generyczną (settings, logi, product linking) z logiką specyficzną dla Apilo (~650 linii). Narusza zasadę jednej odpowiedzialności.
**Scope:**
- Plan 06-01: Utwórz `ApiloRepository` z metodami apilo* (non-breaking)
- Plan 06-02: Zmigruj konsumentów (IntegrationsController, ShopProductController, OrderAdminService, cron.php), usuń apilo* z IntegrationsRepository
---
### Phase 5 — Order bugs fix
**Problem 1:** Zduplikowane zamówienia — klient widzi błąd i klika złóż zamówienie ponownie. Pierwsze zamówienie trafiło do bazy mimo błędu. Powrót do `/podsumowanie` regeneruje token i pozwala złożyć drugie zamówienie.
**Problem 2:** Zamówienia COD (płatność przy odbiorze) dostają status "Zamówienie złożone" zamiast "Przyjęte do realizacji". Kod sprawdza hardkodowane `payment_id == 3`, które jest inne w tej instancji sklepu.
**Scope:** Guard w `summaryView()`, try-catch w `basketSave()`, kolumna `is_cod` w `pp_shop_payment_methods`, użycie flagi zamiast hardkodowanego ID.
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-03-12*

37
.paul/SPECIAL-FLOWS.md Normal file
View File

@@ -0,0 +1,37 @@
# Specialized Flows: shopPRO
## Project-Level Dependencies
| Work Type | Skill/Command | Priority | Kiedy używać |
|-----------|---------------|----------|--------------|
| Komponenty UI, szablony widoków | /frontend-design | optional | Przy tworzeniu HTML/CSS |
| Nowe funkcje, większe zmiany | /feature-dev | required | Przed implementacją fazy |
| Przegląd kodu | /code-review | optional | Przed release / KONIEC PRACY |
| Upraszczanie po zmianach | /simplify | optional | Po zakończeniu implementacji |
| Utrzymanie CLAUDE.md | /claude-md-improver | optional | Co kilka faz / po dużych zmianach |
| Release, budowanie update package | /koniec-pracy | required | Na koniec każdej sesji roboczej |
| Zapis i wznowienie sesji | /zapisz + /wznow | optional | Na przerwę / powrót do pracy |
## Phase Overrides
Brak — domyślna konfiguracja obowiązuje dla wszystkich faz.
## Templates & Assets
| Asset Type | Location | When Used |
|------------|----------|-----------|
| CLAUDE.md | CLAUDE.md | Konwencje kodu, architektura, stack techniczny |
| Struktura bazy | docs/DATABASE_STRUCTURE.md | Przy zmianach schematu DB |
| Dokumentacja API | api-docs/api-reference.json | Przy zmianach API |
| TODO | docs/TODO.md | Planowanie nowych funkcji |
## Verification (UNIFY)
Podczas UNIFY sprawdź:
- `/feature-dev` — czy był użyty przed implementacją fazy?
- `/koniec-pracy` — czy release został wykonany?
Braki dokumentuj w STATE.md (Deferred Issues), nie blokują UNIFY.
---
*SPECIAL-FLOWS.md — Created: 2026-03-12*

58
.paul/STATE.md Normal file
View File

@@ -0,0 +1,58 @@
# Project State
## Project Reference
See: .paul/PROJECT.md (updated 2026-03-12)
**Core value:** Właściciel sklepu ma pełną kontrolę nad sprzedażą online w jednym systemie pisanym od podstaw, bez narzutów zewnętrznych platform.
**Current focus:** Projekt zainicjalizowany — gotowy do planowania
## Current Position
Milestone: Tech debt — Integrations refactoring
Phase: 6 of ? — IntegrationsRepository split — Complete
Plan: 06-02 complete (phase done)
Status: UNIFY complete, phase 6 finished
Last activity: 2026-03-12 — 06-02 UNIFY complete
Note: Previous milestone (Security hardening) — fazy 4 i 5 UNIFY zakończone.
Milestone Security hardening: COMPLETE.
Progress:
- Milestone (Security hardening): [██████████] 100% (COMPLETE)
- Phase 6: [██████████] 100% (complete)
## Loop Position
Current loop state (phase 6, plan 02):
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Phase 6 complete]
```
Deferred (previous milestone — now closed):
```
Phase 4: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-12]
Phase 5: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-12]
```
## Accumulated Context
### Decisions
None yet.
### Deferred Issues
None yet.
### Blockers/Concerns
None yet.
## Session Continuity
Last session: 2026-03-12
Stopped at: Phase 04 UNIFY complete — wszystkie odroczone pętle zamknięte
Next action: /paul:progress — wybierz kolejny task lub milestone
Resume file: .paul/phases/04-csrf-protection/04-01-SUMMARY.md
---
*STATE.md — Updated after every significant action*

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)

View File

@@ -0,0 +1,246 @@
---
phase: 04-csrf-protection
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- autoload/Shared/Security/CsrfToken.php
- autoload/admin/Support/Forms/FormRequestHandler.php
- admin/templates/components/form-edit.php
- admin/templates/site/unlogged-layout.php
- admin/templates/users/user-2fa.php
- autoload/admin/App.php
- tests/Unit/Shared/Security/CsrfTokenTest.php
autonomous: true
---
<objective>
## Goal
Dodać ochronę CSRF do wszystkich state-changing POST endpointów panelu administracyjnego.
## Purpose
Brak tokenów CSRF umożliwia atakującemu wymuszenie na zalogowanym adminie wykonania akcji (zapis/usuń/aktualizuj) poprzez spreparowany link lub stronę. Jest to podatność MEDIUM wg concerns.md.
## Output
- Nowa klasa `\Shared\Security\CsrfToken` z generowaniem i walidacją tokenu
- Integracja w `FormRequestHandler` (walidacja) + `form-edit.php` (token w formularzu)
- Integracja w formularzach logowania i 2FA
- Test jednostkowy dla CsrfToken
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
## Source Files
@autoload/admin/Support/Forms/FormRequestHandler.php
@admin/templates/components/form-edit.php
@admin/templates/site/unlogged-layout.php
@admin/templates/users/user-2fa.php
@autoload/admin/App.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| /feature-dev | required | Przed APPLY — nowe klasy, zmiany wielu plików | ○ |
**BLOCKING:** /feature-dev musi być załadowany przed /paul:apply.
## Skill Invocation Checklist
- [ ] /feature-dev loaded (uruchom przed apply)
</skills>
<acceptance_criteria>
## AC-1: Formularz edycji chroni przed CSRF
```gherkin
Given admin jest zalogowany i otwiera dowolny formularz edycji
When formularz jest renderowany
Then zawiera ukryte pole _csrf_token z aktualnym tokenem z sesji
```
## AC-2: Zapis przez formularz bez tokenu jest odrzucany
```gherkin
Given admin endpoint odbiera POST z FormRequestHandler
When żądanie nie zawiera _csrf_token lub token jest nieprawidłowy
Then handleSubmit() zwraca ['success' => false, 'errors' => ['csrf' => '...']]
And żadna operacja na danych nie jest wykonywana
```
## AC-3: Formularz logowania zawiera CSRF token
```gherkin
Given niezalogowany użytkownik otwiera stronę logowania /admin/
When strona jest renderowana
Then formularz logowania zawiera ukryte pole _csrf_token
```
## AC-4: special_actions waliduje CSRF dla user-logon i user-2fa-verify
```gherkin
Given żądanie POST trafia do special_actions()
When s-action to 'user-logon' lub 'user-2fa-verify'
Then token jest walidowany przed przetworzeniem danych
And brak tokenu kończy się przekierowaniem z komunikatem błędu
```
## AC-5: Token jest unikalny per sesja
```gherkin
Given sesja PHP jest aktywna
When CsrfToken::getToken() jest wywołany wielokrotnie
Then zwraca ten sam token w ramach jednej sesji
And token ma co najmniej 64 znaki hex (32 bajty)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Utwórz klasę CsrfToken + test jednostkowy</name>
<files>autoload/Shared/Security/CsrfToken.php, tests/Unit/Shared/Security/CsrfTokenTest.php</files>
<action>
Utwórz `autoload/Shared/Security/CsrfToken.php` z namespace `\Shared\Security`:
```php
class CsrfToken {
const SESSION_KEY = 'csrf_token';
public static function getToken(): string
// Jeśli nie ma tokenu w sesji — generuje bin2hex(random_bytes(32)) i zapisuje
// Zwraca istniejący lub nowy token
public static function validate(string $token): bool
// Pobiera token z sesji, używa hash_equals() dla bezpiecznego porównania
// Zwraca false jeśli sesja nie ma tokenu lub tokeny się różnią
public static function regenerate(): void
// Generuje nowy token i nadpisuje w sesji
// Używać po udanym logowaniu (session fixation prevention)
}
```
Utwórz `tests/Unit/Shared/Security/CsrfTokenTest.php`:
- test getToken() zwraca string długości 64
- test getToken() zwraca ten sam token przy kolejnym wywołaniu (idempotency)
- test validate() zwraca true dla poprawnego tokenu
- test validate() zwraca false dla pustego stringa
- test validate() zwraca false dla błędnego tokenu
- test regenerate() zmienia token
Uwaga PHP < 8.0: brak `match`, brak named arguments, brak union types.
Użyj `isset($_SESSION[...])` zamiast `??` na zmiennych sesji w metodach static (sesja musi być started przed wywołaniem).
</action>
<verify>./test.ps1 tests/Unit/Shared/Security/CsrfTokenTest.php</verify>
<done>AC-5 satisfied: token unikalny, 64 znaki, idempotentny</done>
</task>
<task type="auto">
<name>Task 2: Integracja CSRF w formularzach edycji (form-edit.php + FormRequestHandler)</name>
<files>admin/templates/components/form-edit.php, autoload/admin/Support/Forms/FormRequestHandler.php</files>
<action>
**1. form-edit.php** — dodaj token CSRF jako hidden field zaraz po `_form_id`:
```php
<input type="hidden" name="_csrf_token" value="<?= htmlspecialchars(\Shared\Security\CsrfToken::getToken()) ?>">
```
Dodaj po linii z `_form_id` (linia ~80).
**2. FormRequestHandler::handleSubmit()** — dodaj walidację CSRF jako PIERWSZĄ operację, przed walidacją pól:
```php
$csrfToken = isset($postData['_csrf_token']) ? (string)$postData['_csrf_token'] : '';
if (!\Shared\Security\CsrfToken::validate($csrfToken)) {
return [
'success' => false,
'errors' => ['csrf' => 'Nieprawidłowy token bezpieczeństwa. Odśwież stronę i spróbuj ponownie.'],
'data' => []
];
}
```
Unikaj: modyfikowania logiki walidacji pól — CSRF check to osobny guard przed walidacją.
</action>
<verify>
Ręcznie: sprawdź źródło strony formularza edycji — musi zawierać input[name="_csrf_token"].
Testy: ./test.ps1 (suite nie powinna się zepsuć).
</verify>
<done>AC-1 i AC-2 satisfied</done>
</task>
<task type="auto">
<name>Task 3: CSRF w formularzach logowania i special_actions</name>
<files>admin/templates/site/unlogged-layout.php, admin/templates/users/user-2fa.php, autoload/admin/App.php</files>
<action>
**1. unlogged-layout.php** — dodaj hidden field CSRF do formularza logowania (zaraz po `s-action`):
```php
<input type="hidden" name="_csrf_token" value="<?= htmlspecialchars(\Shared\Security\CsrfToken::getToken()) ?>">
```
**2. user-2fa.php** — sprawdź czy jest formularz POST i dodaj analogicznie token CSRF.
**3. App::special_actions()** — dodaj walidację CSRF na początku, dla akcji które mają konsekwencje:
- `user-logon` — waliduj token, przy błędzie: alert + redirect `/admin/`
- `user-2fa-verify` i `user-2fa-resend` — waliduj token
- Po udanym logowaniu (`user-logon` case 1) — wywołaj `\Shared\Security\CsrfToken::regenerate()` PRZED `self::finalize_admin_login()` (zapobiega session fixation)
Wzorzec walidacji w special_actions (na początku switch lub przed każdym case):
```php
$csrfToken = isset($_POST['_csrf_token']) ? (string)$_POST['_csrf_token'] : '';
if (!\Shared\Security\CsrfToken::validate($csrfToken)) {
\Shared\Helpers\Helpers::alert('Nieprawidłowy token bezpieczeństwa. Spróbuj ponownie.');
header('Location: /admin/');
exit;
}
```
Umieść ten blok PRZED switch ($sa), aby był wspólny dla wszystkich case.
Unikaj: dodawania CSRF do user-logout (to GET link, nie POST — zmiana na POST wykracza poza zakres).
</action>
<verify>
Ręcznie: sprawdź źródło strony logowania — musi zawierać input[name="_csrf_token"].
./test.ps1 (suite nie powinna się zepsuć).
</verify>
<done>AC-3 i AC-4 satisfied</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Logika walidacji pól w `FormValidator` — tylko dodajemy CSRF guard przed walidacją
- Mechanizm sesji w `admin/index.php` — sesja jest już startowana przed wywołaniem kodu
- Routing w `admin\App::route()` — nie zmieniamy routingu
- Jakiekolwiek pliki frontendowe (front/) — CSRF dotyczy tylko admina w tej fazie
- Pliki testów innych niż nowy CsrfTokenTest.php
## SCOPE LIMITS
- Nie zmieniać logout z GET na POST — to osobna zmiana wykraczająca poza zakres
- Nie dodawać CSRF do admin/ajax.php (shop-category, users ajax) — to osobna iteracja
- Nie refaktoryzować FormRequestHandler — tylko dodać CSRF check
- Nie zmieniać struktury sesji poza `csrf_token` key
</boundaries>
<verification>
Przed uznaniem planu za zakończony:
- [ ] ./test.ps1 — wszystkie testy przechodzą (w tym nowe CsrfTokenTest)
- [ ] Strona formularza edycji zawiera hidden input[name="_csrf_token"]
- [ ] Strona logowania /admin/ zawiera hidden input[name="_csrf_token"]
- [ ] POST bez tokenu do FormRequestHandler zwraca error 'csrf'
- [ ] Brak regresji w istniejących testach (810 testów nadal przechodzi)
</verification>
<success_criteria>
- Wszystkie 3 taski wykonane
- CsrfTokenTest przechodzi (min. 6 assertions)
- Pełna suite testów przechodzi bez regresji
- Wszystkie acceptance criteria AC-1 do AC-5 spełnione
- Token regenerowany po udanym logowaniu
</success_criteria>
<output>
Po zakończeniu utwórz `.paul/phases/04-csrf-protection/04-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,119 @@
---
phase: 04-csrf-protection
plan: 01
subsystem: auth
tags: [csrf, security, session, admin]
requires:
- phase: []
provides: []
provides:
- "CsrfToken class — token generation, validation, regeneration"
- "CSRF protection on all admin FormRequestHandler POSTs"
- "CSRF protection on login and 2FA forms"
- "Token regeneration after successful login (session fixation prevention)"
affects: []
tech-stack:
added: []
patterns: ["CSRF guard before field validation in FormRequestHandler", "bin2hex(random_bytes(32)) per-session token"]
key-files:
created:
- autoload/Shared/Security/CsrfToken.php
- tests/Unit/Shared/Security/CsrfTokenTest.php
modified:
- autoload/admin/Support/Forms/FormRequestHandler.php
- admin/templates/components/form-edit.php
- admin/templates/site/unlogged-layout.php
- admin/templates/users/user-2fa.php
- autoload/admin/App.php
key-decisions:
- "Single CSRF validate() call placed before switch($sa) in special_actions() — covers all POST actions uniformly"
- "regenerate() called on successful login AND after 2FA verify — both session fixation points"
patterns-established:
- "CSRF check = first operation in handleSubmit(), before field validation"
- "CsrfToken::getToken() in templates via htmlspecialchars() escape"
duration: ~
started: 2026-03-12T00:00:00Z
completed: 2026-03-12T00:00:00Z
---
# Phase 4 Plan 01: CSRF Protection Summary
**CSRF protection added to entire admin panel — all state-changing POST endpoints now validate a per-session token.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | single session |
| Completed | 2026-03-12 |
| Tasks | 3 completed |
| Files modified | 7 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Formularz edycji zawiera _csrf_token | Pass | form-edit.php linia 81 |
| AC-2: POST bez tokenu odrzucany przez FormRequestHandler | Pass | FormRequestHandler.php linia 3642 |
| AC-3: Formularz logowania zawiera _csrf_token | Pass | unlogged-layout.php linia 46 |
| AC-4: special_actions() waliduje CSRF dla user-logon i 2FA | Pass | App.php linia 4751, przed switch |
| AC-5: Token unikalny per sesja, min. 64 znaki hex | Pass | bin2hex(random_bytes(32)) = 64 znaków |
## Accomplishments
- Nowa klasa `\Shared\Security\CsrfToken` z `getToken()`, `validate()`, `regenerate()`
- Guard w `FormRequestHandler::handleSubmit()` jako pierwsza operacja przed walidacją pól
- Token w szablonach: `form-edit.php`, `unlogged-layout.php`, `user-2fa.php` (oba formularze)
- `regenerate()` wywoływany po udanym logowaniu (linia 96) i po weryfikacji 2FA (linia 140) — zapobiega session fixation
- 6 testów jednostkowych w `CsrfTokenTest.php`
## Task Commits
| Task | Commit | Type | Description |
|------|--------|------|-------------|
| Wszystkie 3 taski | `55988887` | security | faza 4 - ochrona CSRF panelu administracyjnego |
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/Shared/Security/CsrfToken.php` | Created | Token generation, validation, regeneration |
| `tests/Unit/Shared/Security/CsrfTokenTest.php` | Created | 6 unit tests dla CsrfToken |
| `autoload/admin/Support/Forms/FormRequestHandler.php` | Modified | CSRF guard w handleSubmit() |
| `admin/templates/components/form-edit.php` | Modified | Hidden input _csrf_token |
| `admin/templates/site/unlogged-layout.php` | Modified | Token w formularzu logowania |
| `admin/templates/users/user-2fa.php` | Modified | Token w obu formularzach 2FA |
| `autoload/admin/App.php` | Modified | CSRF walidacja w special_actions() + regenerate() |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Jeden blok validate() przed switch($sa) | Pokrywa wszystkie case jednym sprawdzeniem | Prostota, mniej kodu |
| `\Exception` catch (nie `\Throwable`) | PHP 7.4 compat, wystarczy dla typowych wyjątków | Akceptowalny tradeoff |
| Logout poza zakresem (GET link) | Zmiana na POST wykracza poza tę fazę | Zostawione do osobnej iteracji |
## Deviations from Plan
Brak — plan wykonany zgodnie ze specyfikacją.
## Next Phase Readiness
**Ready:**
- Cały admin panel chroniony przed CSRF
- Wzorzec do replikacji: `CsrfToken::getToken()` w szablonie + `validate()` w handlerze
**Concerns:**
- `admin/ajax.php` (shop-category, users ajax) jeszcze nie pokryty — odnotowane w planie jako out-of-scope
**Blockers:** None
---
*Phase: 04-csrf-protection, Plan: 01*
*Completed: 2026-03-12*

View File

@@ -0,0 +1,46 @@
# FIX SUMMARY — 05-01
**Phase:** 05-order-bugs-fix
**Plan:** 05-01-FIX
**Date:** 2026-03-12
**Status:** COMPLETE
## Tasks executed
| # | Task | Status |
|---|------|--------|
| 1 | Guard summaryView() — redirect do istniejącego zamówienia | PASS |
| 2 | try-catch createFromBasket w basketSave() | PASS |
| 3 | Migracja SQL migrations/0.338.sql + DATABASE_STRUCTURE.md | PASS |
| 4 | PaymentMethodRepository — is_cod w normalizacji i forTransport() | PASS |
| 5 | Admin form — switch "Platnosc przy odbiorze" + save | PASS |
| 6 | OrderRepository — is_cod zamiast hardkodowanego payment_id == 3 | PASS |
| 7 | Checkpoint: migracja DB + ustawienie flagi w adminie | DONE |
## Files modified
- `autoload/front/Controllers/ShopBasketController.php`
- `autoload/Domain/Order/OrderRepository.php`
- `autoload/Domain/PaymentMethod/PaymentMethodRepository.php`
- `autoload/admin/Controllers/ShopPaymentMethodController.php`
- `migrations/0.338.sql`
- `docs/DATABASE_STRUCTURE.md`
## Deviations
Brak.
## Post-deploy checklist
- [x] Migracja `migrations/0.338.sql` uruchomiona na produkcji
- [x] Flaga `is_cod = 1` ustawiona na metodzie "Płatność przy odbiorze" w /admin/shop_payment_method/
- [ ] Redis cache zflushowany (lub poczekać na wygaśnięcie 24h TTL)
## AC coverage
| AC | Status |
|----|--------|
| AC-1: Brak duplikatów przy powrocie do /podsumowanie | SATISFIED |
| AC-2: Wyjątki z createFromBasket obsługiwane | SATISFIED |
| AC-3: Admin może ustawić is_cod na metodzie płatności | SATISFIED |
| AC-4: Zamówienie COD dostaje status 4 "Przyjęte do realizacji" | SATISFIED |

View File

@@ -0,0 +1,313 @@
---
phase: 05-order-bugs-fix
plan: 05-01
type: fix
wave: 1
depends_on: []
files_modified:
- autoload/front/Controllers/ShopBasketController.php
- autoload/Domain/Order/OrderRepository.php
- autoload/Domain/PaymentMethod/PaymentMethodRepository.php
- autoload/admin/Controllers/ShopPaymentMethodController.php
- migrations/0.338.sql
- docs/DATABASE_STRUCTURE.md
autonomous: true
---
<objective>
## Goal
Fix 2 production bugs reported by customer: (1) duplicate orders on retry after error, (2) wrong initial status for cash-on-delivery orders.
## Purpose
Production issues affecting real customers. Bug 1 causes double-billed orders. Bug 2 causes wrong order flow for COD payments.
## Output
- `summaryView()` guards against re-submission after successful order
- `basketSave()` handles exceptions from `createFromBasket()` safely
- `is_cod` column added to `pp_shop_payment_methods`
- COD status promotion uses `is_cod` flag instead of hardcoded `payment_id == 3`
- Admin form for payment methods shows `is_cod` switch
</objective>
<context>
@.paul/STATE.md
@.paul/ROADMAP.md
@autoload/front/Controllers/ShopBasketController.php
@autoload/Domain/Order/OrderRepository.php
@autoload/Domain/PaymentMethod/PaymentMethodRepository.php
@autoload/admin/Controllers/ShopPaymentMethodController.php
</context>
<acceptance_criteria>
## AC-1: No duplicate order on retry
Given a customer submits an order and it is created successfully (order_id saved in session),
When the customer navigates back to `/podsumowanie` and tries to submit again,
Then they are redirected to the existing order page — no new order is created.
## AC-2: Exception in createFromBasket does not duplicate order
Given `createFromBasket()` throws an uncaught exception after the INSERT succeeds (partial failure),
When the customer retries submission with the same basket,
Then the exception is caught, an error message is shown, basket session is preserved, and no second order is inserted via normal retry flow (AC-1 guards subsequent summary visit).
## AC-3: COD flag is configurable in admin
Given an admin opens any payment method in `/admin/shop_payment_method/edit/`,
When they toggle "Płatność przy odbiorze" switch and save,
Then the `is_cod` flag is persisted in `pp_shop_payment_methods.is_cod`.
## AC-4: COD order gets correct initial status
Given a customer places an order with a payment method where `is_cod = 1`,
When the order is created,
Then `pp_shop_order_statuses` contains status_id = 4 ("Przyjęte do realizacji") and the old status 0 entry is updated.
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Fix BUG-1: Guard summaryView() against re-submission after successful order</name>
<files>autoload/front/Controllers/ShopBasketController.php</files>
<action>
In `summaryView()`, BEFORE calling `createOrderSubmitToken()`, check if `ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY` is set in session. If it is, look up that order's hash via `$this->orderRepository->findHashById($existingOrderId)`. If the hash exists, redirect to `/zamowienie/{hash}` and exit.
This means the customer who navigates back to the summary page after a successful order is immediately redirected to their order instead of seeing the form again (which would regenerate a token and allow double-submission).
Do NOT call `createOrderSubmitToken()` in this guard path — just redirect.
Current problematic code at the top of `summaryView()`:
```php
$orderSubmitToken = $this->createOrderSubmitToken();
```
Must become:
```php
$existingOrderId = isset($_SESSION[self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY])
? (int)$_SESSION[self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY]
: 0;
if ($existingOrderId > 0) {
$existingOrderHash = $this->orderRepository->findHashById($existingOrderId);
if ($existingOrderHash) {
header('Location: /zamowienie/' . $existingOrderHash);
exit;
}
}
$orderSubmitToken = $this->createOrderSubmitToken();
```
</action>
<verify>
1. Create a test order successfully
2. Navigate back to /podsumowanie in the same browser session
3. Confirm browser redirects to /zamowienie/{hash} without showing the summary form
</verify>
<done>AC-1 satisfied: navigating back to summary after successful order redirects, no form shown</done>
</task>
<task type="auto">
<name>Fix BUG-1: Wrap createFromBasket in try-catch in basketSave()</name>
<files>autoload/front/Controllers/ShopBasketController.php</files>
<action>
In `basketSave()`, wrap the call to `$this->orderRepository->createFromBasket(...)` in a try-catch block. On exception: log with `error_log()`, show user error message via `Helpers::error()`, and redirect to `/koszyk`. Do NOT clear the basket session in the catch block.
Replace the current `if ($order_id = $this->orderRepository->createFromBasket(...))` pattern with:
```php
$order_id = null;
try {
$order_id = $this->orderRepository->createFromBasket(
// ... all current args unchanged ...
);
} catch (\Exception $e) {
error_log('[basketSave] createFromBasket exception: ' . $e->getMessage());
\Shared\Helpers\Helpers::error(\Shared\Helpers\Helpers::lang('zamowienie-zostalo-zlozone-komunikat-blad'));
header('Location: /koszyk');
exit;
}
if ($order_id) {
// ... existing success block unchanged ...
} else {
// ... existing error block unchanged ...
}
```
Use `\Exception` catch (not `\Throwable`) — the project targets PHP 7.4 which supports both, but `\Exception` covers the common cases (DB exceptions, mail exceptions). If there are any `\Error` throws in the chain they won't be caught — acceptable tradeoff for PHP 7.4 compatibility.
</action>
<verify>
Confirm no PHP syntax errors: `php -l autoload/front/Controllers/ShopBasketController.php`
</verify>
<done>AC-2 satisfied: exceptions from createFromBasket are caught and handled gracefully</done>
</task>
<task type="auto">
<name>Fix BUG-2: Add is_cod column migration</name>
<files>migrations/0.338.sql, docs/DATABASE_STRUCTURE.md</files>
<action>
Create the migration file at `migrations/0.338.sql` (kolejna wersja po 0.337):
```sql
ALTER TABLE `pp_shop_payment_methods`
ADD COLUMN `is_cod` TINYINT(1) NOT NULL DEFAULT 0
COMMENT 'Platnosc przy odbiorze (cash on delivery): 1 = tak, 0 = nie';
```
Also update `docs/DATABASE_STRUCTURE.md` — in the `pp_shop_payment_methods` table section, add the new column:
| is_cod | Płatność przy odbiorze: 1 = tak, 0 = nie (TINYINT DEFAULT 0) |
The migration must be run on production DB manually (document this in the plan summary).
</action>
<verify>
File `migrations/0.338.sql` exists and contains valid ALTER TABLE statement.
`docs/DATABASE_STRUCTURE.md` mentions `is_cod` in `pp_shop_payment_methods` section.
</verify>
<done>AC-3 precondition: column definition prepared for migration</done>
</task>
<task type="auto">
<name>Fix BUG-2: Add is_cod to PaymentMethodRepository normalization and queries</name>
<files>autoload/Domain/PaymentMethod/PaymentMethodRepository.php</files>
<action>
1. In `normalizePaymentMethod(array $row)`: add `$row['is_cod'] = (int)($row['is_cod'] ?? 0);`
2. In `findActiveById()`: the method already uses `SELECT *` via Medoo `get('pp_shop_payment_methods', '*', ...)` so `is_cod` will be included automatically once the column exists.
3. In `forTransport()`: the method uses explicit column list in raw SQL. Add `spm.is_cod` to the SELECT list (around line ~241, alongside `spm.apilo_payment_type_id`).
4. In `paymentMethodsByTransport()` (if exists as a separate raw SQL method): similarly add `spm.is_cod` to the SELECT. Search for any other raw SQL selects in this file that list columns explicitly and add `is_cod` to them.
5. In the `allActive()` / `paymentMethodsCached()` path: if `allActive()` uses raw SQL with explicit columns, add `spm.is_cod` there too. If it uses `SELECT *`, nothing needed.
Cache keys that include payment method data (`payment_method{id}`, `payment_methods`) will return stale data until Redis is flushed. The post-deploy step is to flush Redis cache.
</action>
<verify>
`php -l autoload/Domain/PaymentMethod/PaymentMethodRepository.php` — no syntax errors.
All explicit SQL SELECTs in this file now include `is_cod`.
</verify>
<done>AC-3 + AC-4 precondition: repository returns is_cod field</done>
</task>
<task type="auto">
<name>Fix BUG-2: Add is_cod switch to admin payment method form</name>
<files>autoload/admin/Controllers/ShopPaymentMethodController.php</files>
<action>
In `buildFormViewModel()`:
1. Add `'is_cod' => (int)($paymentMethod['is_cod'] ?? 0)` to the `$data` array.
2. Add a switch field after the `status` field:
```php
FormField::switch('is_cod', [
'label' => 'Platnosc przy odbiorze',
'tab' => 'settings',
]),
```
In the `save()` / `update()` method of this controller: ensure `is_cod` is read from POST and included in the DB update data. Find where the other fields (description, status, apilo_payment_type_id, etc.) are read from request and add:
```php
'is_cod' => (int)(\Shared\Helpers\Helpers::get('is_cod') ? 1 : 0),
```
Check if there is a `FormRequestHandler` or similar save mechanism — if so, `is_cod` may need to be added to the allowed fields list. Read the save method to confirm.
</action>
<verify>
`php -l autoload/admin/Controllers/ShopPaymentMethodController.php` — no syntax errors.
Check that `is_cod` appears in both the form field list and the save data array.
</verify>
<done>AC-3 satisfied: admin can set is_cod flag on any payment method</done>
</task>
<task type="auto">
<name>Fix BUG-2: Use is_cod flag instead of hardcoded payment_id == 3 in OrderRepository</name>
<files>autoload/Domain/Order/OrderRepository.php</files>
<action>
In `createFromBasket()`, at lines 817-820, replace the hardcoded check:
```php
// BEFORE:
if ($payment_id == 3) {
$this->updateOrderStatus($order_id, 4);
$this->insertStatusHistory($order_id, 4, 1);
}
```
With:
```php
// AFTER:
if (!empty($payment_method['is_cod'])) {
$this->updateOrderStatus($order_id, 4);
$this->insertStatusHistory($order_id, 4, 1);
}
```
`$payment_method` is already fetched at line 669:
```php
$payment_method = ( new \Domain\PaymentMethod\PaymentMethodRepository( $this->db ) )->findActiveById( (int)$payment_id );
```
So `$payment_method['is_cod']` is available without any additional DB query.
</action>
<verify>
`php -l autoload/Domain/Order/OrderRepository.php` — no syntax errors.
Confirm the old `$payment_id == 3` no longer exists in createFromBasket().
</verify>
<done>AC-4 satisfied: COD status promotion is driven by is_cod flag, not hardcoded ID</done>
</task>
<task type="checkpoint:human-action" gate="blocking">
<action>Run the database migration on production server</action>
<instructions>
Claude has prepared the migration file at `migrations/0.338.sql`.
The SQL is: ALTER TABLE pp_shop_payment_methods ADD COLUMN is_cod TINYINT(1) NOT NULL DEFAULT 0
You need to run this on the production database manually (via phpMyAdmin, SSH, or your DB client).
After running, go to /admin/shop_payment_method/list/ → edit the "Płatność przy odbiorze" payment method → enable the "Płatnosc przy odbiorze" switch → Save.
Also flush Redis cache (or wait for TTL expiry — payment methods cache is 24h).
</instructions>
<verification>
Claude will verify the code changes are in place. The DB migration must be confirmed by you.
</verification>
<resume-signal>Type "done" when migration and admin flag set</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- The CSRF token mechanism (separate from order submit token)
- The basket session structure
- The order submission token logic (ORDER_SUBMIT_TOKEN_SESSION_KEY) — only guard summaryView, don't change how tokens are generated/consumed
- Email sending logic in createFromBasket
- Any other payment method fields or behavior
## SCOPE LIMITS
- Do NOT add database-level unique constraints or idempotency key columns to pp_shop_orders (over-engineering for now)
- Do NOT change the order status values or their meaning
- Do NOT modify test files unless directly testing the changed methods
- Do NOT change the frontend templates
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php -l` passes on all modified PHP files
- [ ] summaryView() guard redirects to existing order when ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY is set
- [ ] createFromBasket call in basketSave() is wrapped in try-catch
- [ ] `is_cod` column exists in migration SQL
- [ ] normalizePaymentMethod() includes is_cod normalization
- [ ] admin form shows is_cod switch
- [ ] admin save includes is_cod in update data
- [ ] OrderRepository uses $payment_method['is_cod'] not $payment_id == 3
- [ ] DATABASE_STRUCTURE.md updated
</verification>
<success_criteria>
- All PHP files lint-clean
- No more duplicate orders when customer navigates back to summary after successful order
- COD payment method (when is_cod=1) automatically promotes order to status 4
- Admin can configure which payment method is COD
</success_criteria>
<output>
After completion, create `.paul/phases/05-order-bugs-fix/05-01-FIX-SUMMARY.md` with:
- List of files changed
- Note that DB migration in `migrations/0.338.sql` must be run on production
- Note that admin must set is_cod=1 on the COD payment method after migration
Then run: `/koniec-pracy`
</output>

View File

@@ -0,0 +1,188 @@
---
phase: 06-integrations-refactoring
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- autoload/Domain/Integrations/ApiloRepository.php
- tests/Unit/Domain/Integrations/ApiloRepositoryTest.php
autonomous: true
---
<objective>
## Goal
Wyekstrahować wszystkie metody Apilo z `IntegrationsRepository` do nowej klasy `ApiloRepository` — non-breaking (IntegrationsRepository pozostaje bez zmian do planu 06-02).
## Purpose
`IntegrationsRepository` ma 875 linii z czego ~650 to logika Apilo (OAuth, keepalive, fetchList, produkty). Po ekstrakcji każda klasa będzie mieć jedną odpowiedzialność, zgodnie z zasadami projektu (jedna klasa = jedna odpowiedzialność, max ~50 linii na metodę).
## Output
- Nowy plik: `autoload/Domain/Integrations/ApiloRepository.php` (~650 linii)
- Nowy plik testów: `tests/Unit/Domain/Integrations/ApiloRepositoryTest.php`
- `IntegrationsRepository` bez zmian (backward compatible)
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@autoload/Domain/Integrations/IntegrationsRepository.php
@tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php
</context>
<acceptance_criteria>
## AC-1: ApiloRepository zawiera wszystkie metody Apilo
```gherkin
Given plik autoload/Domain/Integrations/ApiloRepository.php istnieje
When przeglądamy jego publiczne metody
Then klasa ma: apiloAuthorize, apiloGetAccessToken, apiloKeepalive,
apiloIntegrationStatus, apiloFetchList, apiloFetchListResult,
apiloProductSearch, apiloCreateProduct
```
## AC-2: ApiloRepository ma własny dostęp do DB (DI przez konstruktor)
```gherkin
Given ApiloRepository(db: $mdb) jest tworzona
When wywoływana jest dowolna metoda apilo*
Then używa $db do zapytań bez zależności od IntegrationsRepository
```
## AC-3: IntegrationsRepository nie zmieniona (backward compatible)
```gherkin
Given istniejące testy IntegrationsRepositoryTest przechodzą
When uruchamiane jest ./test.ps1
Then wszystkie 817+ testów green, brak nowych błędów
```
## AC-4: Testy ApiloRepository pokrywają kluczowe metody
```gherkin
Given nowy plik ApiloRepositoryTest.php
When uruchamiane jest ./test.ps1
Then testy dla: apiloGetAccessToken, apiloKeepalive, apiloIntegrationStatus,
apiloFetchListResult, apiloFetchList (invalid type), prywatnych helperów przechodzą
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Utwórz ApiloRepository — ekstrakcja metod Apilo</name>
<files>autoload/Domain/Integrations/ApiloRepository.php</files>
<action>
Utwórz nowy plik `autoload/Domain/Integrations/ApiloRepository.php`.
Namespace: `Domain\Integrations`
Klasa ma:
- `private $db;`
- `private const SETTINGS_TABLE = 'pp_shop_apilo_settings';`
- Konstruktor: `public function __construct($db)`
Przenieś (skopiuj) z IntegrationsRepository **bez modyfikacji logiki**:
- Metody publiczne: `apiloAuthorize`, `apiloGetAccessToken`, `apiloKeepalive`,
`apiloIntegrationStatus`, `apiloFetchList`, `apiloFetchListResult`,
`apiloProductSearch`, `apiloCreateProduct`
- Metody prywatne: `refreshApiloAccessToken`, `shouldRefreshAccessToken`,
`isFutureDate`, `normalizeApiloMapList`, `isMapListShape`, `extractApiloErrorMessage`
Dostosowania niezbędne po przeniesieniu:
- Wszędzie gdzie metody apilo* wewnętrznie wołają `$this->getSettings('apilo')`
zamień na `$this->db->select(self::SETTINGS_TABLE, ['name', 'value'])` i mapuj
na `[$row['name'] => $row['value']]` (ta sama logika co w IntegrationsRepository::getSettings)
- Wszędzie gdzie wołają `$this->saveSetting('apilo', ...)` — zamień na bezpośrednie
`$this->db->update(self::SETTINGS_TABLE, ['value' => $value], ['name' => $name])`
i `$this->db->insert(self::SETTINGS_TABLE, ['name' => $name, 'value' => $value])`
z `count()` przed jak w saveSetting (dokładna kopia logiki)
Unikaj: dziedziczenia z IntegrationsRepository, jakichkolwiek zależności poza $db.
PHP < 8.0: brak match, named args, union types, str_contains.
</action>
<verify>
php -l autoload/Domain/Integrations/ApiloRepository.php zwraca "No syntax errors"
Klasa ma dokładnie 8 publicznych metod apilo* + 6 prywatnych helperów.
</verify>
<done>AC-1 i AC-2 spełnione</done>
</task>
<task type="auto">
<name>Task 2: Utwórz ApiloRepositoryTest</name>
<files>tests/Unit/Domain/Integrations/ApiloRepositoryTest.php</files>
<action>
Utwórz `tests/Unit/Domain/Integrations/ApiloRepositoryTest.php`.
Namespace: `Tests\Unit\Domain\Integrations`
Klasa extends `PHPUnit\Framework\TestCase`
Przenieś (skopiuj) z IntegrationsRepositoryTest wszystkie testy dotyczące metod Apilo:
- `testApiloGetAccessTokenReturnsNullWithoutSettings`
- `testShouldRefreshAccessTokenReturnsFalseForFarFutureDate`
- `testShouldRefreshAccessTokenReturnsTrueForNearExpiryDate`
- `testApiloFetchListThrowsForInvalidType`
- `testApiloFetchListResultReturnsDetailedErrorWhenConfigMissing`
- `testApiloIntegrationStatusReturnsMissingConfigMessage`
- `testNormalizeApiloMapListRejectsErrorPayload`
- `testNormalizeApiloMapListAcceptsIdNameList`
Dostosuj w skopiowanych testach:
- Zmień `new IntegrationsRepository($this->mockDb)``new ApiloRepository($this->mockDb)`
- Use statement: `use Domain\Integrations\ApiloRepository;`
- setUp: `$this->repository = new ApiloRepository($this->mockDb);`
Uwaga: w testach mockujących `select` z `pp_shop_apilo_settings` — sprawdź czy
ApiloRepository używa dokładnie tej samej tabeli i struktury zapytania co IntegrationsRepository.
Jeśli zmieniło się wywołanie (np. bezpośrednie select zamiast przez getSettings),
dostosuj expect() w testach.
Nie usuwaj tych testów z IntegrationsRepositoryTest — zostają tam do planu 06-02.
</action>
<verify>
./test.ps1 tests/Unit/Domain/Integrations/ApiloRepositoryTest.php — wszystkie testy green
./test.ps1 — pełna suite green (817+ testów, brak regresji)
</verify>
<done>AC-3 i AC-4 spełnione</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `autoload/Domain/Integrations/IntegrationsRepository.php` — bez żadnych zmian w tym planie
- `tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php` — tylko dodajemy, nie usuwamy
- Żadne kontrolery, App.php, cron.php — migracja konsumentów to plan 06-02
- Żadne zmiany logiki biznesowej — czysta ekstrakcja, zero refaktoringu logiki
## SCOPE LIMITS
- Ten plan tworzy tylko nową klasę + testy. Konsumenci nadal używają IntegrationsRepository.
- Nie zmieniamy nazw metod, sygnatur, zachowania.
- Nie optymalizujemy kodu Apilo podczas przenoszenia.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] php -l autoload/Domain/Integrations/ApiloRepository.php — no syntax errors
- [ ] ApiloRepository ma 8 publicznych metod: apiloAuthorize, apiloGetAccessToken,
apiloKeepalive, apiloIntegrationStatus, apiloFetchList, apiloFetchListResult,
apiloProductSearch, apiloCreateProduct
- [ ] ./test.ps1 tests/Unit/Domain/Integrations/ApiloRepositoryTest.php — all green
- [ ] ./test.ps1 — full suite green, żadna regresja w IntegrationsRepositoryTest
- [ ] IntegrationsRepository.php nie został zmodyfikowany
</verification>
<success_criteria>
- ApiloRepository.php istnieje z pełnym zestawem metod Apilo
- ApiloRepositoryTest.php istnieje z testami dla kluczowych metod
- Pełna suite testów green (817+ testów)
- IntegrationsRepository niezmieniony (backward compatible)
</success_criteria>
<output>
After completion, create `.paul/phases/06-integrations-refactoring/06-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,104 @@
---
phase: 06-integrations-refactoring
plan: 01
subsystem: domain
tags: [apilo, integrations, refactoring, repository]
requires: []
provides:
- "ApiloRepository — klasa z 8 pub metodami Apilo (OAuth, keepalive, fetchList, products)"
- "ApiloRepositoryTest — 9 testów jednostkowych"
affects: [06-02-consumers-migration]
tech-stack:
added: []
patterns:
- "ApiloRepository: własna stała SETTINGS_TABLE, prywatne getApiloSettings/saveApiloSetting zamiast delegacji do IntegrationsRepository"
key-files:
created:
- autoload/Domain/Integrations/ApiloRepository.php
- tests/Unit/Domain/Integrations/ApiloRepositoryTest.php
modified: []
key-decisions:
- "ApiloRepository nie dziedziczy z IntegrationsRepository — własny $db, własna const SETTINGS_TABLE"
- "Non-breaking: IntegrationsRepository zachowany bez zmian do planu 06-02"
- "saveApiloSetting/getApiloSettings jako prywatne — nie duplikują interfejsu publicznego"
patterns-established:
- "Ekstrakcja domenowej podklasy: nowa klasa z własnym $db, prywatnym dostępem do settings swojej tabeli"
duration: ~15min
started: 2026-03-12T00:00:00Z
completed: 2026-03-12T00:00:00Z
---
# Phase 6 Plan 01: IntegrationsRepository split — ApiloRepository Summary
**Wyekstrahowano 8 metod Apilo (~330 linii) z IntegrationsRepository do nowego ApiloRepository — non-breaking, 826/826 testów green.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15 min |
| Completed | 2026-03-12 |
| Tasks | 2 / 2 |
| Files created | 2 |
| Files modified | 0 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: ApiloRepository zawiera wszystkie metody Apilo | Pass | 8 pub metod + 6 priv helperów |
| AC-2: Własny DI przez konstruktor ($db) | Pass | brak zależności od IntegrationsRepository |
| AC-3: IntegrationsRepository niezmieniony (backward compatible) | Pass | plik nie był modyfikowany |
| AC-4: Testy ApiloRepository przechodzą | Pass | 9/9 testów, 826/826 full suite |
## Accomplishments
- `ApiloRepository.php` — 330 linii: OAuth (authorize, getAccessToken, keepalive, refresh), integracja status, fetchList/fetchListResult, productSearch, createProduct
- `ApiloRepositoryTest.php` — 9 testów: getAccessToken, shouldRefreshAccessToken (×2), fetchList invalid type, fetchListResult config missing, integrationStatus missing config, normalizeApiloMapList (×2), allPublicMethodsExist
- Full suite wzrosła z 817 do 826 testów (zero regresji)
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/Domain/Integrations/ApiloRepository.php` | Created | Klasa Apilo: OAuth, keepalive, fetchList, produkty |
| `tests/Unit/Domain/Integrations/ApiloRepositoryTest.php` | Created | Testy jednostkowe ApiloRepository |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Prywatne `getApiloSettings()` / `saveApiloSetting()` zamiast dziedziczenia | Unika coupling z IntegrationsRepository, czysta encapsulacja | 06-02 nie potrzebuje IntegrationsRepository w ApiloRepository |
| Zachowanie `APILO_ENDPOINTS` i `APILO_SETTINGS_KEYS` jako class constants | Były private const w IntegrationsRepository — logicznie należą do ApiloRepository | Stałe są prywatne, nie wymuszają zmian w konsumentach |
| Non-breaking w 06-01 | Migracja konsumentów w 06-02 — mniejsze ryzyko, łatwiejszy review | IntegrationsRepository nadal działa dla wszystkich konsumentów |
## Deviations from Plan
Brak — plan wykonany dokładnie jak napisano.
## Issues Encountered
Brak.
## Next Phase Readiness
**Ready:**
- `ApiloRepository` gotowy do użycia przez konsumentów
- Interfejs publiczny identyczny z metodami `apilo*` w IntegrationsRepository
- Testy stanowią baseline dla weryfikacji po migracji konsumentów
**Concerns:**
- `IntegrationsController` używa zarówno metod Apilo jak i Settings/ShopPRO — po 06-02 będzie potrzebować obu repozytoriów w konstruktorze
- `OrderAdminService` tworzy `new IntegrationsRepository($db)` lokalnie w 5 miejscach — po 06-02 trzeba zmienić na `new ApiloRepository($db)`
**Blockers:** Brak
---
*Phase: 06-integrations-refactoring, Plan: 01*
*Completed: 2026-03-12*

View File

@@ -0,0 +1,296 @@
---
phase: 06-integrations-refactoring
plan: 02
type: execute
wave: 1
depends_on: ["06-01"]
files_modified:
- autoload/admin/Controllers/IntegrationsController.php
- autoload/admin/App.php
- autoload/Domain/Order/OrderAdminService.php
- cron.php
- autoload/Domain/Integrations/IntegrationsRepository.php
- tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php
autonomous: true
---
<objective>
## Goal
Zmigrować wszystkich konsumentów metod `apilo*` z `IntegrationsRepository` na nowy `ApiloRepository`, a następnie usunąć metody Apilo z `IntegrationsRepository` (cleanup).
## Purpose
Po tym planie `IntegrationsRepository` będzie lean (~225 linii): tylko settings, logi, product linking, ShopPRO import. `ApiloRepository` jest jedynym miejscem logiki Apilo.
## Output
- IntegrationsController: używa obu repozytoriów (IntegrationsRepository dla settings/logi, ApiloRepository dla apilo*)
- OrderAdminService: 3 metody używają ApiloRepository dla apiloGetAccessToken
- cron.php: apilo* wywołania przez $apiloRepository
- IntegrationsRepository: usunięte metody apilo* (~650 linii mniej)
- IntegrationsRepositoryTest: oczyszczony z duplikatów testów apilo*
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/STATE.md
## Prior Work
@.paul/phases/06-integrations-refactoring/06-01-SUMMARY.md
## Source Files
@autoload/admin/Controllers/IntegrationsController.php
@autoload/admin/App.php
@autoload/Domain/Order/OrderAdminService.php
@autoload/Domain/Integrations/IntegrationsRepository.php
@tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php
</context>
<acceptance_criteria>
## AC-1: IntegrationsController używa ApiloRepository dla apilo*
```gherkin
Given IntegrationsController ma dwa repozytoria: $repository i $apiloRepository
When wywoływana jest dowolna metoda apilo* (apilo_settings, apilo_authorization, itp.)
Then używa $this->apiloRepository->apilo*() a nie $this->repository->apilo*()
```
## AC-2: OrderAdminService i cron.php używają ApiloRepository dla apiloGetAccessToken
```gherkin
Given OrderAdminService::resendToApilo, syncApiloPayment, syncApiloStatus
oraz cron.php potrzebują access tokenu
When wywoływana jest metoda apiloGetAccessToken()
Then używają new ApiloRepository($db) lub $apiloRepository, nie IntegrationsRepository
```
## AC-3: IntegrationsRepository nie zawiera metod apilo*
```gherkin
Given plik IntegrationsRepository.php po cleanup
When sprawdzamy publiczne metody klasy
Then metody apilo* NIE ISTNIEJĄ, pozostają tylko:
getSettings, getSetting, saveSetting,
getLogs, deleteLog, clearLogs,
linkProduct, unlinkProduct, getProductSku,
shopproImportProduct
```
## AC-4: Pełna suite testów green
```gherkin
Given wszystkie zmiany wprowadzone
When uruchamiane jest php phpunit.phar
Then wszystkie testy green (826+ testów, zero regresji)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Zaktualizuj IntegrationsController i App.php</name>
<files>autoload/admin/Controllers/IntegrationsController.php, autoload/admin/App.php</files>
<action>
**IntegrationsController.php:**
1. Dodaj import: `use Domain\Integrations\ApiloRepository;`
2. Dodaj property: `private ApiloRepository $apiloRepository;`
3. Zmień konstruktor na:
```php
public function __construct( IntegrationsRepository $repository, ApiloRepository $apiloRepository )
{
$this->repository = $repository;
$this->apiloRepository = $apiloRepository;
}
```
4. Zamień wszystkie wywołania `$this->repository->apilo*()` na `$this->apiloRepository->apilo*()`:
- linia ~128: `$this->repository->apiloIntegrationStatus()` → `$this->apiloRepository->apiloIntegrationStatus()`
- linia ~150: `$this->repository->apiloAuthorize(...)` → `$this->apiloRepository->apiloAuthorize(...)`
- linia ~159: `$this->repository->apiloIntegrationStatus()` → `$this->apiloRepository->apiloIntegrationStatus()`
- linia ~194: `$this->repository->apiloCreateProduct(...)` → `$this->apiloRepository->apiloCreateProduct(...)`
- linia ~211: `$this->repository->apiloProductSearch(...)` → `$this->apiloRepository->apiloProductSearch(...)`
- linia ~270: `$this->repository->apiloFetchListResult(...)` → `$this->apiloRepository->apiloFetchListResult(...)`
Pozostaw bez zmian: getLogs, clearLogs, getSettings, saveSetting, getProductSku,
linkProduct, unlinkProduct, getSettings('shoppro'), saveSetting('shoppro'), shopproImportProduct
— wszystkie przez `$this->repository`.
**App.php:**
W fabryce 'Integrations' (linia ~384) zmień:
```php
return new \admin\Controllers\IntegrationsController(
new \Domain\Integrations\IntegrationsRepository( $mdb )
);
```
na:
```php
return new \admin\Controllers\IntegrationsController(
new \Domain\Integrations\IntegrationsRepository( $mdb ),
new \Domain\Integrations\ApiloRepository( $mdb )
);
```
</action>
<verify>
php -l autoload/admin/Controllers/IntegrationsController.php — no syntax errors
php -l autoload/admin/App.php — no syntax errors
grep "apiloRepository" autoload/admin/Controllers/IntegrationsController.php — pokazuje 6+ wystąpień
</verify>
<done>AC-1 spełnione</done>
</task>
<task type="auto">
<name>Task 2: Zaktualizuj OrderAdminService i cron.php</name>
<files>autoload/Domain/Order/OrderAdminService.php, cron.php</files>
<action>
**OrderAdminService.php** — 3 metody tworzą IntegrationsRepository i wołają apiloGetAccessToken().
Zmień tylko te 3 miejsca (linie ~422, ~678, ~751):
```php
// PRZED (w każdym z 3 miejsc):
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db);
// lub: new \Domain\Integrations\IntegrationsRepository( $mdb );
$accessToken = $integrationsRepository->apiloGetAccessToken();
// PO (w każdym z 3 miejsc):
$apiloRepository = new \Domain\Integrations\ApiloRepository($db);
// lub z $mdb gdzie używano $mdb
$accessToken = $apiloRepository->apiloGetAccessToken();
```
POZOSTAW BEZ ZMIAN (linie ~579, ~628) — te tworzą IntegrationsRepository
i wołają tylko getSettings('apilo') — to metoda generyczna, zostaje w IntegrationsRepository.
**cron.php** — linia ~133:
Po linii `$integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb );`
dodaj:
```php
$apiloRepository = new \Domain\Integrations\ApiloRepository( $mdb );
```
Zamień wywołania apilo* przez `$integrationsRepository` na `$apiloRepository`:
- linia ~191: `$integrationsRepository->apiloKeepalive(300)` → `$apiloRepository->apiloKeepalive(300)`
- linia ~279: `$integrationsRepository->apiloGetAccessToken()` → `$apiloRepository->apiloGetAccessToken()`
- linia ~560: `$integrationsRepository->apiloGetAccessToken()` → `$apiloRepository->apiloGetAccessToken()`
- linia ~589: `$integrationsRepository->apiloGetAccessToken()` → `$apiloRepository->apiloGetAccessToken()`
- linia ~642: `$integrationsRepository->apiloGetAccessToken()` → `$apiloRepository->apiloGetAccessToken()`
POZOSTAW BEZ ZMIAN w cron.php:
- `$integrationsRepository->getSettings('apilo')` (linie ~188, ~198, ~553, ~586, ~632)
- `$integrationsRepository->saveSetting('apilo', ...)` (linia ~625)
</action>
<verify>
php -l autoload/Domain/Order/OrderAdminService.php — no syntax errors
php -l cron.php — no syntax errors
grep "integrationsRepository->apilo" cron.php — brak wyników (wszystkie apilo przeniesione)
grep "integrationsRepository->apilo" autoload/Domain/Order/OrderAdminService.php — brak wyników
</verify>
<done>AC-2 spełnione</done>
</task>
<task type="auto">
<name>Task 3: Usuń metody apilo* z IntegrationsRepository + cleanup testów</name>
<files>autoload/Domain/Integrations/IntegrationsRepository.php, tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php</files>
<action>
**IntegrationsRepository.php:**
Usuń następujące bloki (cały kod między komentarzami sekcji a kolejną sekcją):
1. Sekcję "// ── Apilo OAuth" z metodami:
- `apiloAuthorize()`
- `apiloGetAccessToken()`
- `apiloKeepalive()`
- `refreshApiloAccessToken()` (private)
- `shouldRefreshAccessToken()` (private)
- `isFutureDate()` (private)
2. Stałe klasy:
- `private const APILO_ENDPOINTS = [...]`
- `private const APILO_SETTINGS_KEYS = [...]`
3. Sekcję "// ── Apilo API fetch lists" z metodami:
- `apiloFetchList()`
- `apiloFetchListResult()`
- `normalizeApiloMapList()` (private)
- `isMapListShape()` (private)
- `extractApiloErrorMessage()` (private)
4. Z sekcji "// ── Apilo product operations" usuń tylko:
- `apiloProductSearch()`
- `apiloCreateProduct()`
(ZACHOWAJ `getProductSku()` — jest generyczna, używana też przez ShopProductController)
Po usunięciu IntegrationsRepository powinna zawierać:
- settings (settingsTable, getSettings, getSetting, saveSetting)
- logs (getLogs, deleteLog, clearLogs)
- product linking (linkProduct, unlinkProduct, getProductSku)
- ShopPRO import (shopproImportProduct, missingShopproSetting, shopproDb)
**IntegrationsRepositoryTest.php:**
Usuń następujące metody testowe (zostały już przeniesione do ApiloRepositoryTest):
- `testApiloGetAccessTokenReturnsNullWithoutSettings()`
- `testShouldRefreshAccessTokenReturnsFalseForFarFutureDate()`
- `testShouldRefreshAccessTokenReturnsTrueForNearExpiryDate()`
- `testApiloFetchListThrowsForInvalidType()`
- `testApiloFetchListResultReturnsDetailedErrorWhenConfigMissing()`
- `testApiloIntegrationStatusReturnsMissingConfigMessage()`
- `testNormalizeApiloMapListRejectsErrorPayload()`
- `testNormalizeApiloMapListAcceptsIdNameList()`
W metodzie `testAllPublicMethodsExist()` usuń z tablicy `$expectedMethods` wpisy apilo*:
- `'apiloAuthorize'`, `'apiloGetAccessToken'`, `'apiloKeepalive'`, `'apiloIntegrationStatus'`
- `'apiloFetchList'`, `'apiloFetchListResult'`, `'apiloProductSearch'`, `'apiloCreateProduct'`
(Pozostaw: `'getSettings'`, `'getSetting'`, `'saveSetting'`, `'linkProduct'`, `'unlinkProduct'`,
`'getProductSku'`, `'shopproImportProduct'`, `'getLogs'`, `'deleteLog'`, `'clearLogs'`)
Usuń też `testSettingsTableMapping()` i `testShopproProviderWorks()` tylko jeśli są duplikatami
(sprawdź przed usunięciem — jeśli nie mają odpowiedników, zostaw).
</action>
<verify>
php -l autoload/Domain/Integrations/IntegrationsRepository.php — no syntax errors
grep "apilo" autoload/Domain/Integrations/IntegrationsRepository.php — brak wyników (lub tylko komentarze)
php phpunit.phar — wszystkie testy green (826+, zero regresji)
php phpunit.phar tests/Unit/Domain/Integrations/ — oba pliki testów green
</verify>
<done>AC-3 i AC-4 spełnione</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `autoload/Domain/Integrations/ApiloRepository.php` — gotowy, nie modyfikować
- `tests/Unit/Domain/Integrations/ApiloRepositoryTest.php` — gotowy, nie modyfikować
- `autoload/admin/Controllers/ShopProductController.php` — używa tylko getSetting(), nie apilo*
- `autoload/admin/Controllers/ShopStatusesController.php` — używa tylko getSetting(), nie apilo*
- `autoload/admin/Controllers/ShopTransportController.php` — używa tylko getSetting(), nie apilo*
- `autoload/admin/Controllers/ShopPaymentMethodController.php` — używa tylko getSetting(), nie apilo*
- Logika biznesowa nie zmienia się — czysta migracja wywołań
## SCOPE LIMITS
- Nie refaktoryzujemy OrderAdminService poza zmianą 3 instancji na ApiloRepository
- Nie zmieniamy sygnatury metod ani logiki
- Nie przenosimy ShopPRO import do osobnej klasy (to nie ten plan)
</boundaries>
<verification>
Before declaring plan complete:
- [ ] php -l na wszystkich zmodyfikowanych plikach — no syntax errors
- [ ] grep "apiloRepository->apilo" w IntegrationsController — 6 wystąpień (apilo metody)
- [ ] grep "this->repository->apilo" w IntegrationsController — brak wyników
- [ ] grep "integrationsRepository->apilo" w cron.php — brak wyników
- [ ] grep "integrationsRepository->apilo" w OrderAdminService — brak wyników
- [ ] grep "public function apilo" w IntegrationsRepository — brak wyników
- [ ] php phpunit.phar — 826+ testów green
</verification>
<success_criteria>
- IntegrationsController używa ApiloRepository dla wszystkich metod apilo*
- OrderAdminService i cron.php używają ApiloRepository dla apiloGetAccessToken
- IntegrationsRepository nie zawiera żadnych metod apilo*
- Pełna suite testów green bez regresji
</success_criteria>
<output>
After completion, create `.paul/phases/06-integrations-refactoring/06-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,99 @@
---
phase: 06-integrations-refactoring
plan: 02
subsystem: domain
tags: [apilo, integrations, refactoring, migration]
requires:
- phase: 06-01
provides: ApiloRepository class with all apilo* methods
provides:
- "Wszyscy konsumenci apilo* używają ApiloRepository"
- "IntegrationsRepository lean (~225 linii): settings, logi, product linking, ShopPRO"
affects: []
tech-stack:
added: []
patterns:
- "IntegrationsController z dwoma repozytoriami: IntegrationsRepository + ApiloRepository"
key-files:
created: []
modified:
- autoload/admin/Controllers/IntegrationsController.php
- autoload/admin/App.php
- autoload/Domain/Order/OrderAdminService.php
- cron.php
- autoload/Domain/Integrations/IntegrationsRepository.php
- tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php
- tests/Unit/admin/Controllers/IntegrationsControllerTest.php
key-decisions:
- "IntegrationsController dostał ApiloRepository jako drugi argument konstruktora"
- "OrderAdminService: tylko 3 z 5 instancji zmienione na ApiloRepository (2 używają getSettings — zostają)"
- "cron.php: $apiloRepository obok $integrationsRepository (oba potrzebne)"
patterns-established:
- "Kontroler używający dwóch repozytoriów: każde do swojej domeny"
duration: ~20min
started: 2026-03-12T00:00:00Z
completed: 2026-03-12T00:00:00Z
---
# Phase 6 Plan 02: Migracja konsumentów + cleanup IntegrationsRepository
**Wszyscy konsumenci apilo* zmigrowano na ApiloRepository; IntegrationsRepository oczyszczono do ~225 linii; 818/818 testów green.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~20 min |
| Completed | 2026-03-12 |
| Tasks | 3 / 3 |
| Files modified | 7 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: IntegrationsController używa ApiloRepository dla apilo* | Pass | 6 wywołań przeniesione |
| AC-2: OrderAdminService i cron.php używają ApiloRepository | Pass | 3 metody + 5 wywołań w cron |
| AC-3: IntegrationsRepository nie zawiera metod apilo* | Pass | 0 wystąpień apilo* |
| AC-4: Pełna suite green | Pass | 818/818 testów |
## Accomplishments
- IntegrationsRepository: ~650 linii usunięte, zostały settings + logi + product linking + ShopPRO
- IntegrationsController: nowy konstruktor `(IntegrationsRepository, ApiloRepository)`
- OrderAdminService: 3 metody (resendToApilo, syncApiloPayment, syncApiloStatus) używają ApiloRepository
- cron.php: `$apiloRepository` dla 5 wywołań apilo*; `$integrationsRepository` dla getSettings/saveSetting
- IntegrationsRepositoryTest: oczyszczony z 8 duplikatów apilo testów + przywrócone 3 testy generyczne
- IntegrationsControllerTest: zaktualizowany do nowego 2-arg konstruktora
## Files Modified
| File | Zmiana |
|------|--------|
| `autoload/admin/Controllers/IntegrationsController.php` | +ApiloRepository dependency, 6 apilo* calls rerouted |
| `autoload/admin/App.php` | Inject ApiloRepository do IntegrationsController |
| `autoload/Domain/Order/OrderAdminService.php` | 3× IntegrationsRepository → ApiloRepository |
| `cron.php` | +$apiloRepository, 5 apilo* calls rerouted |
| `autoload/Domain/Integrations/IntegrationsRepository.php` | Usunięto ~650 linii apilo* |
| `tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php` | Cleanup + przywrócone testy generyczne |
| `tests/Unit/admin/Controllers/IntegrationsControllerTest.php` | Zaktualizowany do 2-arg konstruktora |
## Deviations from Plan
- IntegrationsControllerTest wymagał aktualizacji (nie był w planie) — auto-fix podczas weryfikacji
- 3 testy przypadkowo usunięte przez regex (testAllPublicMethodsExist, testSettingsTableMapping, testShopproProviderWorks) — przywrócone
## Next Phase Readiness
**Ready:** Refaktoring fazy 6 kompletny. IntegrationsRepository lean, ApiloRepository izolowany.
**Blockers:** Brak
---
*Phase: 06-integrations-refactoring, Plan: 02*
*Completed: 2026-03-12*

File diff suppressed because one or more lines are too long

1
.vscode/ftp-kr.diff.ver_0.338.2.zip vendored Normal file
View File

@@ -0,0 +1 @@
c:\visual studio code\projekty\shopPRO\updates\0.30\ver_0.338.zip

1
.vscode/ftp-kr.diff.ver_0.338.zip vendored Normal file
View File

@@ -0,0 +1 @@
c:\visual studio code\projekty\shopPRO\updates\0.30\ver_0.338.zip

3
.vscode/ftp-kr.json vendored
View File

@@ -18,6 +18,7 @@
"/.serena",
"/.claude",
"/docs",
"/tests"
"/tests",
"/.paul"
]
}

150
docs/PAUL_WORKFLOW.md Normal file
View File

@@ -0,0 +1,150 @@
# PAUL — Workflow dla shopPRO
[PAUL](https://github.com/ChristopherKahler/paul) to zestaw komend dla Claude Code, który strukturyzuje pracę nad projektem: planowanie → implementacja → weryfikacja. Działa przez slash-komendy w czacie z Claude.
---
## Jednorazowa inicjalizacja
```
/paul:init
```
Uruchom raz — tworzy plik `.paul/` z konfiguracją projektu (milestones, fazy). Jeśli już zainicjalizowany, pomijasz ten krok.
---
## Nowa funkcja (feature)
### 1. Omów wizję
```
/paul:discuss
```
Claude zadaje pytania, żeby doprecyzować, co dokładnie chcesz zbudować — wynik to jasna definicja przed planowaniem.
### 2. Zbadaj opcje techniczne (opcjonalnie)
```
/paul:discover
```
Przydatne gdy nie jesteś pewny jak zaimplementować coś technicznie — Claude przegląda kod i proponuje podejścia.
### 3. Zaplanuj implementację
```
/paul:plan
```
Tworzy szczegółowy plan z krokami, plikami do zmiany, kolejnością. Zatwierdź plan zanim ruszysz dalej.
### 4. Wykonaj plan
```
/paul:apply
```
Claude implementuje plan krok po kroku, z commitami na każdą fazę.
### 5. Zweryfikuj (UAT)
```
/paul:verify
```
Claude generuje checklistę testów manualnych — punkt po punkcie sprawdzasz czy funkcja działa.
---
## Poprawka błędu (bug fix)
```
/paul:plan-fix
```
Dedykowany flow dla bugów: opisujesz problem → Claude analizuje kod → tworzy plan naprawy → `/paul:apply` wykonuje.
Krótszy wariant dla prostych bugów — możesz też po prostu opisać błąd i użyć `/paul:plan``/paul:apply`.
---
## Zarządzanie sesjami
| Komenda | Kiedy |
|---------|-------|
| `/paul:progress` | Sprawdź gdzie jesteś, co dalej |
| `/paul:pause` | Kończysz sesję — tworzy plik handoff |
| `/paul:resume` | Zaczynasz nową sesję — wczytuje kontekst |
| `/paul:handoff` | Generuje dokument przekazania pracy |
---
## Milestones (większe wdrożenia)
Gdy planujesz większą wersję (np. nowy moduł, refactor):
```
/paul:milestone # Tworzysz nowy milestone
/paul:add-phase # Dodajesz fazę do milestone
/paul:complete-milestone # Zamykasz po wdrożeniu
```
---
## Czy PAUL może przejrzeć projekt pod kątem błędów?
Tak, częściowo. Najlepsze podejście:
### Mapowanie kodu
```
/paul:map-codebase
```
Generuje analizę architektury — wyłapuje niespójności, martwy kod, problematyczne zależności.
### Research konkretnego obszaru
```
/paul:research
```
Np. _"Sprawdź czy walidacja zamówień w OrderRepository jest kompletna"_ — Claude przegląda kod i raportuje problemy.
### Ograniczenia
PAUL nie jest narzędziem do statycznej analizy kodu (jak PHPStan czy psalm). Do systematycznego przeglądu błędów lepiej połączyć:
- **PAUL** (`/paul:map-codebase` + `/paul:research`) — problemy architektoniczne, logika biznesowa
- **Testy** (`./test.ps1`) — regresje, złe zachowanie metod
- **PHPStan** (jeśli dodany do projektu) — typy, niezdefiniowane zmienne
---
## Typowy dzień pracy w shopPRO
```
# Rano — wznów kontekst
/paul:resume
# W trakcie — sprawdź co dalej
/paul:progress
# Nowa funkcja lub bug
/paul:discuss → /paul:plan → /paul:apply → /paul:verify
# Na koniec dnia
/paul:pause
```
---
## Dobre praktyki
- Zawsze zatwierdzaj plan (`/paul:plan`) zanim uruchomisz `/paul:apply` — łatwiej zmienić plan niż cofnąć kod
- Po każdym `/paul:apply` odpal testy: `./test.ps1`
- Używaj `/paul:verify` przed mergem — generuje checklistę zamiast zgadywać co przetestować
- Przy bugach najpierw opisz symptom dokładnie, Claude lepiej planuje naprawę mając konkretny przypadek