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

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*