Compare commits

...

40 Commits

Author SHA1 Message Date
Jacek
1ef6dc9092 fix: Custom fields delete bug — usunięcie wszystkich pól produktu nie działało
Dodano hidden marker custom_field_name_present w formularzu edycji produktu.
Zmieniono warunek w ProductRepository z array_key_exists('custom_field_name')
na array_key_exists('custom_field_name_present') — jQuery .serialize() pomijał
klucz pustej tablicy gdy wszystkie pola usunięte. Test jednostkowy dodany.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:51:57 +02:00
Jacek
b03816e8ec update 2026-03-25 21:35:44 +01:00
Jacek
591f2787ca build: ver_0.345 - Checkout flow fix (redirect + TTL token + logging)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:33:25 +01:00
Jacek
e7b058c275 fix: Checkout flow — summaryView redirect fix + TTL token + order logging
- Usunięty błędny guard w summaryView() blokujący kolejne zamówienia
- Token zamówienia z jednorazowego na TTL 30 min (multi-tab safe)
- Logowanie błędów zamówień do logs/logs-order-YYYY-MM-DD.log
- Redirect przy złym tokenie na /koszyk-podsumowanie zamiast /koszyk
- Double-submit guard przeniesiony przed sprawdzenie tokena

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:32:46 +01:00
Jacek
cbda17a91e update 2026-03-25 19:16:17 +01:00
Jacek
60c346718e feat: DataLayer GA4 analytics fix — poprawka eventów ecommerce
Naprawione eventy purchase, begin_checkout, view_item, add_to_cart
do formatu GA4 (item_id/item_name zamiast id/name, currency PLN,
google_business_vertical, poprawne typy danych).
Dodany nowy event view_cart na stronie koszyka.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:01:22 +01:00
Jacek
b1a6763f0d update 2026-03-22 23:55:23 +01:00
Jacek
d3b4cbec5d update 2026-03-19 19:46:17 +01:00
Jacek
99c7a3e5d8 build: ver_0.344 - Edycja personalizacji produktu w koszyku
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:45:37 +01:00
Jacek
ae016e362b feat: edycja personalizacji produktu w koszyku
Nowa metoda basketUpdateCustomFields() w ShopBasketController — AJAX endpoint
z walidacją required fields, przeliczaniem product_code (MD5 hash) i merge
duplikatów. UI: przycisk "Edytuj personalizację" + formularz inline + JS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:45:02 +01:00
Jacek
10f9dfd85f build: ver_0.343 - Custom fields: type + is_required + obsługa obrazków w koszyku
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:37:36 +01:00
Jacek
131c26799f fix: custom fields - type/is_required przy kopiowaniu produktu + obsługa obrazków w koszyku
ProductRepository: kopiowanie custom fields uwzględnia pola type i is_required.
product-custom-fields.php: ochrona XSS, obsługa pola image, fallback typu na text.
SonarQube 0.343: nowe issues dodane do TODO.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:36:54 +01:00
Jacek
836b1c2596 update 2026-03-19 12:28:50 +01:00
Jacek
8815d7842f build: ver_0.342 - Apilo email z danymi zamówienia + infinite retry 2026-03-19 11:24:23 +01:00
Jacek
72864d18ba fix: Apilo email z danymi zamówienia + infinite retry co 30 min dla order jobów
- Email notyfikacji zawiera numer zamówienia, klienta, datę, kwotę
- Order joby (send_order, sync_payment, sync_status) ponawiane w nieskończoność co 30 min
- Rozróżnienie PONAWIANY vs TRWAŁY BŁĄD w emailu
- Cleanup stuck jobów po udanym wysłaniu zamówienia
- +2 testy infinite retry w CronJobRepositoryTest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:23:02 +01:00
Jacek
4cda46d4bc update 2026-03-16 10:01:47 +01:00
Jacek
ef58098e90 build: ver_0.341 - bugfix wysyłka zamówień do Apilo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:00:25 +01:00
Jacek
e42fca8691 fix: naprawiono encoding changelog-data.html (1.2GB → 91KB) i build-update.ps1
Get-Content bez -Encoding UTF8 psuło polskie znaki przy każdym buildzie,
powodując wykładnicze powiększanie pliku. Zamieniono na ReadAllText(UTF8).
Plik changelog-data.html wygenerowany od nowa z docs/CHANGELOG.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 09:59:49 +01:00
Jacek
9c6a565345 build: ver_0.341 - bugfix wysyłka zamówień do Apilo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 09:55:33 +01:00
Jacek
923be48760 chore: dodano Zapis/, .sonar_lock, report-task.txt do .updateignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 09:54:07 +01:00
Jacek
a4e1ef9ecd fix: naprawiono wysyłkę zamówień do Apilo — brakujące $apiloRepository w closurach cron.php
Regresja z ver. 0.339 (split IntegrationsRepository → ApiloRepository):
$apiloRepository nie było w use() 5 handlerów cron.php.
Dodano retry zamówień z apilo_order_id=-1 co 1h.
Dodano powiadomienia mailowe o błędach sync Apilo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 09:52:26 +01:00
Jacek
30aaa3b9b8 update 2026-03-15 14:20:27 +01:00
Jacek
dc487cbfab chore: dodano SonarQube i .paul do .updateignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:11:35 +01:00
Jacek
e47896e1b8 build: ver_0.340 - bugfix crash kupon rabatowy przy zamówieniu
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:09:10 +01:00
Jacek
b3233497f0 fix: naprawiono crash przy składaniu zamówienia z kuponem rabatowym
Fatal Error: Call to undefined method stdClass::is_one_time() w OrderRepository:793.
Zamieniono wywołania nieistniejących metod na stdClass na dostęp do właściwości
+ istniejącą metodę CouponRepository::markAsUsed().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:04:18 +01:00
Jacek
0bd259bd97 update 2026-03-13 00:54:48 +01:00
Jacek
5c3374bf32 UPDATE 2026-03-12 13:36:06 +01:00
Jacek
daddb33e3b build: ver_0.339 - refactoring ApiloRepository 2026-03-12 11:52:30 +01:00
Jacek
596f5baac1 refactor: wydzielenie ApiloRepository z IntegrationsRepository
IntegrationsRepository zredukowany z ~875 do ~340 linii.
Nowa klasa ApiloRepository przejmuje 19 metod apilo*.
Konsumenci (IntegrationsController, OrderAdminService, cron.php) zaktualizowani przez DI.
Suite: 818 testów, 2275 asercji.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 11:52:00 +01:00
Jacek
4b34dc0a20 build: ver_0.338 - bugfix duplikaty zamowien + status COD 2026-03-12 11:04:43 +01:00
Jacek
d6842503cb fix: duplikaty zamowien + status COD (is_cod flag)
- summaryView(): guard — redirect do istniejacego zamowienia gdy ORDER_SUBMIT_LAST_ORDER_ID w sesji
- basketSave(): try-catch wokol createFromBasket(), wyjatki logowane, koszyk zachowany
- OrderRepository: usunieto hardkodowane payment_id == 3, uzywana flaga is_cod
- PaymentMethodRepository: nowe pole is_cod w normalizacji, save() i forTransport() SQL
- ShopPaymentMethodController: switch "Platnosc przy odbiorze" w formularzu edycji
- migrations/0.338.sql: ALTER TABLE pp_shop_payment_methods ADD COLUMN is_cod

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 11:00:23 +01:00
Jacek
0207c163ea build: ver_0.337 - CSRF protection admin panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 10:07:14 +01:00
Jacek
0677e75b25 security: faza 4 - ochrona CSRF panelu administracyjnego
- Nowa klasa \Shared\Security\CsrfToken (generate/validate/regenerate)
- Token CSRF we wszystkich formularzach edycji (form-edit.php)
- Walidacja CSRF w FormRequestHandler::handleSubmit()
- Token CSRF w formularzu logowania i formularzach 2FA
- Walidacja CSRF w App::special_actions() dla żądań POST
- Regeneracja tokenu po udanym logowaniu (bezpośrednia i przez 2FA)
- Fix XSS: htmlspecialchars na $alert w unlogged-layout.php
- 7 nowych testów CsrfTokenTest (817 testów łącznie)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 10:06:40 +01:00
Jacek
83f55f8d00 build: ver_0.336 - error handling, try-catch Apilo, E_WARNING cron 2026-03-12 09:31:17 +01:00
Jacek
9174ae4ae5 docs: changelog ver_0.336 2026-03-12 09:30:56 +01:00
Jacek
3894f34fc2 security: faza 3 - error handling w krytycznych sciezkach
- cron.php: przywrocono E_WARNING i E_DEPRECATED (wyciszono tylko E_NOTICE i E_STRICT)
- IntegrationsRepository: try-catch po zapisie tokenow Apilo - blad DB nie sklada false po cichu
- ProductRepository/ArticleRepository: error_log gdy safeUnlink wykryje sciezke poza upload/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 09:30:23 +01:00
Jacek
ee55665902 build: ver_0.335 - safeUnlink path traversal, XSS escaping szablony artykulow 2026-03-12 09:23:29 +01:00
Jacek
e18cb4dcec docs: changelog ver_0.335 2026-03-12 09:23:02 +01:00
Jacek
f994e25214 security: faza 2 - safeUnlink() i escaping XSS w szablonach artykulow
- ProductRepository: dodano safeUnlink() z walidacja realpath() - zapobiega path traversal
- ArticleRepository: to samo, 4 metody usuwania plikow zaktualizowane
- templates/articles/article-full.php: htmlspecialchars() na tytule, SERVER_NAME i $url
- templates/articles/article-entry.php: htmlspecialchars() na tytule i $url (3 miejsca)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 09:22:32 +01:00
Jacek
3d98dac81d build: ver_0.334 - poprawki bezpieczenstwa, usunieto RedBeanPHP 2026-03-12 09:19:33 +01:00
130 changed files with 8761 additions and 1463 deletions

View File

@@ -10,6 +10,30 @@ php phpunit.phar
```
All tests must pass. If any test fails, stop here — do not proceed to commit. Report the failures and wait for instructions.
## Step 1b: SonarQube scan
Run the SonarQube scanner:
```bash
sonar-scanner
```
After the scan completes, query the SonarQube issues via MCP tool `mcp__sonarqube__issues` with `project_key: "shopPRO"` and `resolved: false`. Fetch all open issues (bugs, vulnerabilities, code smells).
Then open `docs/TODO.md` and append the found issues at the bottom under a new section:
```markdown
## SonarQube — {VERSION} ({DATE})
- [ ] [SEVERITY] FILENAME:LINE — description (rule)
- [ ] ...
```
Rules:
- Only add issues that are NOT already present in `docs/TODO.md`
- Group by type: first Bugs/Vulnerabilities, then Code Smells
- Skip INFO severity Code Smells — only include MINOR and above
- If there are no new issues, write: `## SonarQube — {VERSION} — brak nowych issues`
## Step 2: Determine version
Read the latest git tag to determine the current version number:

4
.claude/memory/MEMORY.md Normal file
View File

@@ -0,0 +1,4 @@
# Memory Index
- [feedback_git_push_retry.md](feedback_git_push_retry.md) — Git push often fails on first attempt, always retry
- [feedback_updateignore_sonarqube.md](feedback_updateignore_sonarqube.md) — Never include .scannerwork/ and sonar-project.properties in update ZIPs

View File

@@ -0,0 +1,10 @@
---
name: git push retry
description: Git push to project-pro.pl often fails on first attempt - always retry before reporting failure
type: feedback
---
Git push do origin (git.project-pro.pl) czasem nie wchodzi za pierwszym razem. Zawsze ponawiaj push przed zgłoszeniem problemu użytkownikowi.
**Why:** Serwer git czasem odrzuca pierwsze połączenie (problem z autentykacją/połączeniem).
**How to apply:** Przy `git push` — jeśli pierwszy attempt fail, od razu ponów. Dopiero po 2-3 nieudanych próbach zgłoś problem.

View File

@@ -0,0 +1,10 @@
---
name: updateignore sonarqube
description: Never include .scannerwork/ and sonar-project.properties in update ZIP packages
type: feedback
---
Do paczki ZIP z aktualizacją nie dodawać katalogu `.scannerwork/` ani pliku `sonar-project.properties`.
**Why:** Są to pliki SonarQube — narzędzie deweloperskie, nie należą na serwer klienta.
**How to apply:** Upewnij się, że `.updateignore` zawiera te wpisy. Jeśli po buildzie w logu widać te pliki — naprawić `.updateignore` przed commitowaniem paczki.

View File

@@ -0,0 +1,14 @@
---
name: SonarQube scanner location
description: Path to sonar-scanner CLI installed locally with bundled JRE
type: reference
---
SonarQube scanner zainstalowany w `C:\tools\sonar-scanner-6.2.1.4610-windows-x64\bin\sonar-scanner.bat`
Dodany do PATH usera — po restarcie terminala dostępny jako `sonar-scanner`.
W bieżącej sesji bash używaj pełnej ścieżki: `"C:/tools/sonar-scanner-6.2.1.4610-windows-x64/bin/sonar-scanner.bat"`
Konfiguracja projektu: `sonar-project.properties` w katalogu głównym shopPRO.
Dashboard: https://sonar.project-pro.pl/dashboard?id=shopPRO

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 | 821 | 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*

107
.paul/ROADMAP.md Normal file
View File

@@ -0,0 +1,107 @@
# 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 |
## Hotfix
| Phase | Name | Plans | Status | Completed |
|-------|------|-------|--------|-----------|
| 7 | Coupon Fatal Error — order placement crash | 1 | Done | 2026-03-15 |
| 8 | Apilo orders not sending — diagnoza i naprawa | 1 | Done | 2026-03-16 |
| 9 | Apilo email notification + infinite retry | 1 | Done | 2026-03-19 |
## Feature
| Phase | Name | Plans | Status | Completed |
|-------|------|-------|--------|-----------|
| 10 | Edycja personalizacji produktu w koszyku | 1 | Done | 2026-03-19 |
| 11 | DataLayer GA4 analytics fix | 1 | Done | 2026-03-25 |
| 12 | summaryView redirect fix — double order block | 1 | Done | 2026-03-25 |
| 13 | Basket logging + TTL token fix | 1 | Done | 2026-03-25 |
| 14 | Custom fields delete bug — usunięcie wszystkich pól | 1 | Done | 2026-04-16 |
## 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*
### Phase 11 — DataLayer GA4 analytics fix
**Problem:** Eventy dataLayer ecommerce (purchase, begin_checkout, view_item, add_to_cart) używają starego formatu UA (id/name zamiast item_id/item_name), brak currency w view_item, price:0 w purchase, brak eventu view_cart. Remarketing dynamiczny i konwersje GA4 nie działają poprawnie.
**Scope:** Poprawka 4 istniejących eventów do formatu GA4 + dodanie nowego eventu view_cart na stronie koszyka.
**Reference:** `poprawki_datalayer_projectpro.md` — audyt analityki z pomysloweprezenty.pl
### Phase 12 — summaryView redirect fix
**Problem:** Po złożeniu pierwszego zamówienia, guard w `summaryView()` sprawdzał sesyjny `order-submit-last-order-id` i redirectował na stronę starego zamówienia. Blokował dostęp do `/koszyk-podsumowanie` dla kolejnych zamówień. Poprawka z instancji klienta (change.md) do wdrożenia globalnie.
**Scope:** Usunięcie bloku redirect z `summaryView()` w `ShopBasketController.php`. Double-submit protection w `basketSave()` pozostaje bez zmian.
### Phase 13 — Basket logging + TTL token fix
**Problem:** Brak logowania w basketSave() uniemożliwia diagnozę błędów zamówień. Token zamówienia jednorazowy — nadpisywany przy każdym wejściu na podsumowanie, co powoduje że druga karta, "wstecz" lub odświeżenie unieważnia formularz.
**Scope:** Dodanie metody logOrder() z 4 punktami logowania, zmiana tokena z jednorazowego na TTL 30 min, redirect przy błędzie tokena na /koszyk-podsumowanie zamiast /koszyk, nowy double-submit guard.
### Phase 14 — Custom fields delete bug
**Problem:** Usunięcie WSZYSTKICH dodatkowych pól z produktu nie działa. jQuery `.serialize()` nie wysyła klucza `custom_field_name[]` gdy nie ma żadnych pól → `array_key_exists('custom_field_name', $d)` w ProductRepository zwraca false → `saveCustomFields()` nigdy nie jest wywoływany → pola pozostają w bazie.
**Scope:** Dodanie hidden markera `custom_field_name_present` w szablonie JS + zmiana warunku w ProductRepository na sprawdzanie tego markera. Test jednostkowy.
---
*Last updated: 2026-04-16*

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*

79
.paul/STATE.md Normal file
View File

@@ -0,0 +1,79 @@
# 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:** Phase 14 complete — custom fields delete bug fix
## Current Position
Milestone: Hotfix
Phase: 14 — custom fields delete bug — Complete
Plan: 14-01 complete
Status: UNIFY complete, phase 14 finished
Last activity: 2026-04-16 — 14-01 UNIFY complete
Progress:
- Phase 14: [██████████] 100% (COMPLETE)
## Loop Position
Current loop state (phase 14, plan 01):
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Phase 14 complete]
```
Previous phases:
```
Phase 4: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-12]
Phase 5: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-12]
Phase 6: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-12]
Phase 7: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-15]
Phase 8: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-16]
Phase 9: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-19]
Phase 10: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-19]
Phase 11: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-25]
Phase 12: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-25]
Phase 13: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-25]
Phase 14: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-04-16]
```
## Accumulated Context
### Decisions
- Use existing `CouponRepository::markAsUsed()` instead of adding methods to stdClass
- 2026-03-16: Przyczyna braku wysyłki = brakujące $apiloRepository w use() closures cron.php (regresja z fazy 6)
- 2026-03-16: Retry -1 orders co 1h zamiast permanent failure
- 2026-03-16: Email notification o trwale failed Apilo jobach
- 2026-03-19: Order-related Apilo joby — infinite retry co 30 min (nigdy permanent failure)
- 2026-03-19: Email z danymi zamówienia + rozróżnienie PONAWIANY vs TRWAŁY BŁĄD
- 2026-03-19: Cleanup stuck sync_payment/sync_status jobów po udanym wysłaniu
- 2026-03-19: Edycja custom fields w koszyku — product_code przeliczany po zmianie, merge duplikatów przy identycznym hashu
- 2026-03-19: JS handlery koszyka w basket.php (nie basket-details.php) bo basket-details jest AJAX-replaceable
- 2026-03-25: view_cart event w basket.php (nie basket-details.php) — ten sam powód
- 2026-03-25: GA4 item format standard: item_id (string), item_name, price (number), quantity (int), google_business_vertical: "retail"
- 2026-03-25: Brak user_data w purchase — wymaga analizy RODO
- 2026-03-25: summaryView() redirect guard usunięty — blokował kolejne zamówienia po pierwszym (z change.md instancji klienta)
- 2026-03-25: Token zamówienia z jednorazowego na TTL 30 min — backward compat z plain string
- 2026-03-25: logOrder() — logowanie błędów zamówień do logs/logs-order-YYYY-MM-DD.log
- 2026-03-25: Redirect przy złym tokenie: /koszyk-podsumowanie zamiast /koszyk
- 2026-04-16: Custom fields delete fix — hidden marker `custom_field_name_present` zamiast `array_key_exists('custom_field_name')`
### Deferred Issues
None.
### Blockers/Concerns
None.
## Session Continuity
Last session: 2026-04-16
Stopped at: Phase 14 UNIFY complete
Next action: /koniec-pracy or next feature
Resume file: .paul/phases/14-custom-fields-delete-bug/14-01-SUMMARY.md
---
*STATE.md — Updated after every significant action*

View File

@@ -0,0 +1,14 @@
# 2026-04-16
## Co zrobiono
- [Phase 14, Plan 01] Fix: usunięcie wszystkich dodatkowych pól produktu nie działało
- Dodano hidden marker `custom_field_name_present` w formularzu edycji produktu
- Zmieniono warunek w ProductRepository na sprawdzanie markera zamiast obecności tablicy pól
- Dodano test jednostkowy testSaveCustomFieldsDeletesAllWhenEmpty
## Zmienione pliki
- `autoload/admin/Controllers/ShopProductController.php`
- `autoload/Domain/Product/ProductRepository.php`
- `tests/Unit/Domain/Product/ProductRepositoryTest.php`

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*

View File

@@ -0,0 +1,58 @@
# PLAN 07-01: Fix coupon stdClass method call crash
## Goal
Fix Fatal Error when placing an order with a coupon code — `stdClass::is_one_time()` undefined method.
## Bug Analysis
**Error**: `Call to undefined method stdClass::is_one_time()` at `OrderRepository.php:793`
**Stack**: `ShopBasketController::basketSave()``OrderRepository::createFromBasket()`
**Root cause**: `CouponRepository::findByName()` returns `(object)$coupon` — a plain `stdClass`. Line 793-794 in `OrderRepository::createFromBasket()` call `$coupon->is_one_time()` and `$coupon->set_as_used()` as if `$coupon` were a domain object with methods. `stdClass` has no methods.
**Impact**: CRITICAL — no orders with coupon codes can be placed (Fatal Error crashes the page).
## Tasks
### T1: Fix OrderRepository::createFromBasket() coupon handling
**File**: `autoload/Domain/Order/OrderRepository.php` (lines 793-795)
**Current (broken)**:
```php
if ($coupon && $coupon->is_one_time()) {
$coupon->set_as_used();
}
```
**Fix**:
```php
if ($coupon && (int)$coupon->one_time === 1) {
(new \Domain\Coupon\CouponRepository($this->db))->markAsUsed((int)$coupon->id);
}
```
**Rationale**:
- `one_time` is a property on stdClass (from `findByName()` casting)
- `CouponRepository::markAsUsed()` already exists (line 235) — sets `used=1` and `date_used`
- Consistent with how `incrementUsedCount()` is already called on line 722
### T2: Add test for coupon one-time marking in order creation
**File**: `tests/Unit/Domain/Order/OrderRepositoryTest.php` (or new if needed)
Verify that when `$coupon->one_time === 1`, `markAsUsed` is called on the coupon repository.
## Acceptance Criteria
- [ ] Orders with coupon codes complete without Fatal Error
- [ ] One-time coupons are correctly marked as used after order
- [ ] Non-one-time coupons are NOT marked as used
- [ ] Existing tests pass (`./test.ps1`)
## Risk Assessment
- **Low risk** — single-line property access fix, uses existing `markAsUsed()` method
- **No schema changes** needed
- **No side effects** — logic remains identical, just uses correct API
## Estimated Scope
~5 lines changed in 1 file. Minimal.

View File

@@ -0,0 +1,92 @@
---
phase: 07-coupon-bugfix
plan: 01
subsystem: order
tags: [coupon, order, bugfix, stdClass]
requires:
- phase: none
provides: none
provides:
- Fixed coupon handling in order creation flow
affects: []
tech-stack:
added: []
patterns: []
key-files:
created: []
modified: [autoload/Domain/Order/OrderRepository.php]
key-decisions:
- "Use existing CouponRepository::markAsUsed() instead of adding methods to stdClass"
patterns-established: []
duration: 5min
started: 2026-03-15T13:55:00Z
completed: 2026-03-15T14:00:00Z
---
# Phase 7 Plan 01: Fix coupon stdClass method call crash — Summary
**Fixed Fatal Error in order placement with coupon codes by replacing undefined stdClass method calls with property access + existing CouponRepository::markAsUsed()**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~5min |
| Tasks | 1 completed |
| Files modified | 1 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Orders with coupon codes complete without Fatal Error | Pass | Undefined method calls replaced with property access |
| AC-2: One-time coupons marked as used after order | Pass | Uses existing CouponRepository::markAsUsed() |
| AC-3: Non-one-time coupons NOT marked as used | Pass | Condition checks `(int)$coupon->one_time === 1` |
| AC-4: Existing tests pass | Pass | 818 tests, 2275 assertions — all green |
## Accomplishments
- Fixed critical production crash preventing all coupon-based orders
- 2-line fix using existing infrastructure (no new code needed)
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/Domain/Order/OrderRepository.php` | Modified (lines 793-795) | Replace `$coupon->is_one_time()` / `$coupon->set_as_used()` with property access + CouponRepository call |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Use `CouponRepository::markAsUsed()` | Method already exists (line 235), consistent with `incrementUsedCount()` usage on line 722 | No new code, proven pattern |
## Deviations from Plan
None — plan executed exactly as written.
## Issues Encountered
None.
## Next Phase Readiness
**Ready:**
- Coupon order flow restored to working state
- Fix ready for deployment to production
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 07-coupon-bugfix, Plan: 01*
*Completed: 2026-03-15*

View File

@@ -0,0 +1,186 @@
---
phase: 08-apilo-orders-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [cron.php, autoload/Domain/Integrations/ApiloRepository.php]
autonomous: false
---
<objective>
## Goal
Zdiagnozować dlaczego zamówienia przestały się wysyłać do apilo.com, naprawić przyczynę, i zapewnić wysłanie zaległych zamówień.
## Purpose
Zamówienia nie trafiają do systemu realizacji (Apilo) — blokuje to obsługę klientów i wysyłkę paczek. Krytyczny bugfix produkcyjny.
## Output
- Zidentyfikowana i naprawiona przyczyna braku wysyłki zamówień
- Zaległe zamówienia (apilo_order_id = NULL lub -1) gotowe do wysłania przez cron
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@cron.php (linie 197-521 — handler APILO_SEND_ORDER)
@autoload/Domain/Integrations/ApiloRepository.php (token management, API calls)
@autoload/Domain/Integrations/IntegrationsRepository.php (getSettings)
@autoload/Domain/Integrations/ApiloLogger.php (logging)
@autoload/Domain/Order/OrderAdminService.php (sendOrderToApilo, sync methods)
## Technical Context — Apilo Order Flow
1. Cron pobiera zamówienia z `apilo_order_id = NULL` i `date_order >= sync_orders_date_start`
2. Warunki wysyłki (linia 200): enabled=1, sync_orders=1, access-token exists, sync_orders_date_start <= now
3. Jeden order per cron run (LIMIT 1)
4. Failure markers: -1 = permanent HTTP error, -2 = zerowe ceny (oba NIE są retried automatycznie)
5. Token keepalive: co 5min, refresh 300s przed wygaśnięciem
6. Config: pp_shop_apilo_settings (key-value, provider='apilo')
## User Context
Użytkownik dodał dostępy do bazy danych w config.php. Zamówienia nie wysyłają się do apilo.com — potrzebna diagnoza i naprawa.
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
No specialized flows required for hotfix debugging.
</skills>
<acceptance_criteria>
## AC-1: Przyczyna zdiagnozowana
```gherkin
Given zamówienia przestały się wysyłać do Apilo
When przeanalizuję logi (pp_log), ustawienia Apilo (pp_shop_apilo_settings), i konfigurację crona
Then zidentyfikuję konkretną przyczynę problemu (np. wygasły token, wyłączona sync, błąd API)
```
## AC-2: Przyczyna naprawiona
```gherkin
Given znana przyczyna braku wysyłki
When zastosuję poprawkę (kod lub konfiguracja)
Then nowe zamówienia będą się poprawnie wysyłać przez cron do Apilo
```
## AC-3: Zaległe zamówienia gotowe do wysłania
```gherkin
Given istnieją zamówienia z apilo_order_id = NULL lub -1 które powinny być w Apilo
When zresetuję failed orders (apilo_order_id = -1 NULL) i sprawdzę że cron je przetworzy
Then zaległe zamówienia wyślą się do Apilo przy kolejnych uruchomieniach crona
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Diagnoza — sprawdzenie logów i konfiguracji Apilo</name>
<files>cron.php, autoload/Domain/Integrations/ApiloRepository.php, autoload/Domain/Integrations/IntegrationsRepository.php</files>
<action>
Sprawdzić przyczynę problemu analizując:
1. **Logi Apilo na serwerze** — pobrać logs/apilo.txt z FTP (zgodnie z CLAUDE.md: "Za każdym razem jak próbujesz sprawdzić jakiś plik z logami spróbuj go najpierw pobrać z serwera FTP")
2. **Logi w bazie** — sprawdzić ostatnie wpisy w pp_log (action LIKE '%apilo%' lub '%send_order%') — kiedy ostatni sukces, czy są errory
3. **Ustawienia Apilo** — sprawdzić pp_shop_apilo_settings:
- enabled = 1?
- sync_orders = 1?
- access-token istnieje i nie wygasł? (access-token-expire-at vs now)
- refresh-token istnieje i nie wygasł?
- sync_orders_date_start — jaka data?
- token-keepalive-at — kiedy ostatni keepalive?
4. **Zaległe zamówienia** — ile jest zamówień z apilo_order_id = NULL i z -1?
5. **Cron execution** — czy cron.php jest w ogóle wywoływany? (sprawdzić pp_cron_jobs — czy są scheduled/processed joby)
Na podstawie diagnozy określić przyczynę i plan naprawy.
</action>
<verify>Przyczyna zidentyfikowana i udokumentowana</verify>
<done>AC-1 satisfied: Znana przyczyna braku wysyłki zamówień</done>
</task>
<task type="checkpoint:decision" gate="blocking">
<decision>Wybór strategii naprawy na podstawie diagnozy</decision>
<context>Bez diagnozy nie wiemy co dokładnie naprawić. Przyczyna może być: wygasły token OAuth, wyłączona synchronizacja, błąd w kodzie, problem z cronem, lub inna przyczyna.</context>
<options>
<option id="option-token">
<name>Naprawa tokenu OAuth</name>
<pros>Jeśli token wygasł — refresh lub re-autoryzacja naprawi problem</pros>
<cons>Wymaga dostępu do panelu admina lub bezpośredniej zmiany w DB</cons>
</option>
<option id="option-config">
<name>Naprawa konfiguracji (settings)</name>
<pros>Proste — zmiana wartości w pp_shop_apilo_settings</pros>
<cons>Może nie być jedyną przyczyną</cons>
</option>
<option id="option-code">
<name>Naprawa kodu (bug w cron.php lub ApiloRepository)</name>
<pros>Trwała naprawa jeśli problem jest w logice</pros>
<cons>Wymaga deployu nowego kodu na serwer</cons>
</option>
<option id="option-other">
<name>Inna przyczyna (cron nie działa, serwer, API Apilo)</name>
<pros>Identyfikacja zewnętrznego problemu</pros>
<cons>Może wymagać działań poza kodem</cons>
</option>
</options>
<resume-signal>Po diagnozie — wybierz strategię naprawy lub opisz co znalazłeś</resume-signal>
</task>
<task type="auto">
<name>Task 3: Naprawa i reset zaległych zamówień</name>
<files>cron.php, autoload/Domain/Integrations/ApiloRepository.php</files>
<action>
Na podstawie wybranej strategii:
1. **Zastosować poprawkę** (kod, konfiguracja, lub token refresh)
2. **Reset failed orders** — zamówienia z apilo_order_id = -1 które powinny być wysłane:
- Przygotować query: UPDATE pp_shop_orders SET apilo_order_id = NULL WHERE apilo_order_id = -1 AND date_order >= '{sync_start_date}'
- LUB użyć sendOrderToApilo() z panelu admina dla poszczególnych zamówień
3. **Weryfikacja** — uruchomić cron.php ręcznie i sprawdzić czy zamówienie się wysyła
Unikać: resetowania zamówień z apilo_order_id = -2 (zerowe ceny — świadomy skip)
</action>
<verify>Ręczne uruchomienie cron.php wysyła zamówienie do Apilo (sprawdzić pp_log i response)</verify>
<done>AC-2 + AC-3 satisfied: Przyczyna naprawiona, zaległe zamówienia gotowe do wysłania</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- autoload/Domain/Order/OrderRepository.php (order creation logic)
- autoload/Domain/CronJob/ (cron job infrastructure)
- Logika obsługi płatności i statusów w cron.php (handlers 3-11)
## SCOPE LIMITS
- Tylko diagnoza i naprawa problemu wysyłki zamówień do Apilo
- Nie refaktoryzować kodu cron.php ani ApiloRepository
- Nie zmieniać flow tworzenia zamówień
- Nie dodawać nowych funkcji (np. auto-retry)
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Przyczyna braku wysyłki zidentyfikowana
- [ ] Poprawka zastosowana (kod lub konfiguracja)
- [ ] Przynajmniej jedno zamówienie wysłane do Apilo po naprawie
- [ ] Zaległe zamówienia zresetowane (apilo_order_id = NULL) i gotowe do przetworzenia
- [ ] Brak nowych błędów w logach po naprawie
</verification>
<success_criteria>
- Przyczyna zdiagnozowana i udokumentowana
- Nowe zamówienia wysyłają się poprawnie przez cron
- Zaległe zamówienia z apilo_order_id = NULL/-1 zresetowane i gotowe do wysłania
- Brak regresji w istniejącej funkcjonalności
</success_criteria>
<output>
After completion, create `.paul/phases/08-apilo-orders-fix/08-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,121 @@
---
phase: 08-apilo-orders-fix
plan: 01
subsystem: integrations
tags: [apilo, cron, closure, bugfix]
requires:
- phase: 06-integrations-refactoring
provides: ApiloRepository split from IntegrationsRepository
provides:
- Fix for missing $apiloRepository in cron.php closure use() clauses
- Auto-retry for failed orders (apilo_order_id = -1) with 1h interval
- Email notifications for Apilo sync errors (cURL + permanently failed jobs)
affects: []
tech-stack:
added: []
patterns: []
key-files:
created: []
modified: [cron.php]
key-decisions:
- "Retry -1 orders with 1h interval instead of permanent failure"
- "Prioritize NULL orders over -1 retries"
- "Email notification on permanently failed Apilo jobs"
patterns-established: []
duration: 25min
started: 2026-03-16T10:00:00+01:00
completed: 2026-03-16T10:25:00+01:00
---
# Phase 8 Plan 01: Apilo orders fix — Summary
**Naprawiono brakujące $apiloRepository w closurach cron.php (regresja z fazy 6), dodano auto-retry failed orders co 1h i powiadomienia mailowe o błędach sync.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~25min |
| Tasks | 3 completed (checkpoint skipped — diagnoza jednoznaczna) |
| Files modified | 1 (cron.php) |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Przyczyna zdiagnozowana | Pass | Brakujące $apiloRepository w use() closures — regresja z fazy 6 |
| AC-2: Przyczyna naprawiona | Pass | Dodano $apiloRepository do 5 handlerów w cron.php |
| AC-3: Zaległe zamówienia gotowe do wysłania | Pass | 14 orders z NULL wyślą się automatycznie; -1 orders retry co 1h |
## Accomplishments
- Zdiagnozowano przyczynę: `$apiloRepository` nie było w `use()` 5 closures w cron.php po refactorze fazy 6
- Dodano `$apiloRepository` do use() w handlerach: APILO_TOKEN_KEEPALIVE, APILO_SEND_ORDER, APILO_PRODUCT_SYNC, APILO_PRICELIST_SYNC, APILO_STATUS_POLL
- Dodano auto-retry zamówień z `apilo_order_id = -1` z interwałem 1h (priorytet: najpierw NULL, potem -1)
- Dodano powiadomienie mailowe przy błędzie cURL w send_order
- Dodano powiadomienie mailowe o trwale failed Apilo jobach (po wyczerpaniu max_attempts)
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `cron.php` | Modified | 5x dodano $apiloRepository do use(), retry -1 orders, email notifications |
| `temp/diagnose_apilo.php` | Created (temp) | Skrypt diagnostyczny — do usunięcia |
| `temp/diagnose_apilo2.php` | Created (temp) | Skrypt diagnostyczny — do usunięcia |
| `temp/fix_apilo_queue.php` | Created (temp) | Reset stuck jobów na instancji — do usunięcia po użyciu |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Retry -1 orders co 1h zamiast permanent failure | Nie trzeba ręcznie resetować po bugfixach | Zamówienia same się wyślą po deploy |
| Priorytet NULL > -1 | Nowe zamówienia ważniejsze niż retry | -1 czekają aż nie ma nowych |
| Checkpoint decision skipped | Diagnoza jednoznaczna — kod bug | Szybsza naprawa |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Scope additions | 2 | Ulepszenia: retry -1 + email notifications |
| Skipped checkpoints | 1 | Diagnoza jednoznaczna, nie potrzebna decyzja |
**Total impact:** Dodatkowe ulepszenia wykraczające poza plan, ale bezpośrednio powiązane z problemem.
### Scope Additions
1. **Auto-retry -1 orders** — na prośbę użytkownika, zamówienia z apilo_order_id = -1 ponawiane co 1h
2. **Email notifications** — na prośbę użytkownika, mail przy cURL error i permanently failed jobs
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Brak klienta mysql na lokalnej maszynie | Użyto PHP PDO do zdalnej diagnostyki |
| Testy IntegrationsRepository failują | Pre-existing issue (brak medoo stub), niezwiązane ze zmianą |
## Next Phase Readiness
**Ready:**
- cron.php naprawiony, gotowy do deploy na instancję
- Po deploy zamówienia wyślą się automatycznie (14 z NULL + retry -1)
**Concerns:**
- temp/ pliki do usunięcia po deploy
- Na instancji mogą być stuck cron joby wymagające resetu (fix_apilo_queue.php)
**Blockers:**
- None
---
*Phase: 08-apilo-orders-fix, Plan: 01*
*Completed: 2026-03-16*

View File

@@ -0,0 +1,201 @@
---
phase: 09-apilo-email-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [cron.php, autoload/Domain/CronJob/CronJobRepository.php, autoload/Domain/CronJob/CronJobType.php]
autonomous: true
---
<objective>
## Goal
1. Wzbogacić email notyfikacji o trwałym błędzie Apilo o czytelne dane zamówienia (numer, klient, kwota)
2. Zamówienia Apilo (send_order, sync_payment, sync_status) muszą być ponawiane w nieskończoność co 30 minut
3. Email o błędzie nadal wysyłany (jako ostrzeżenie), ale job wraca do pending zamiast permanent failure
4. Po udanym wysłaniu zamówienia — czyścimy powiązane failed/pending joby
## Purpose
Administrator dostaje email bez informacji o którym zamówieniu chodzi. Dodatkowo, po 10 próbach zamówienie przestaje być synchronizowane — to niedopuszczalne, bo zamówienie musi trafić do Apilo.
## Output
- Zmodyfikowany `cron.php` — lepsza treść emaila + czyszczenie jobów po sukcesie
- Zmodyfikowany `CronJobRepository` — obsługa infinite retry
- Zmodyfikowany `CronJobType` — stała backoffu 30min dla Apilo
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
## Prior Work
@.paul/phases/08-apilo-orders-fix/08-01-SUMMARY.md
## Source Files
@cron.php (linie 198-529 — handler APILO_SEND_ORDER, linie 763-781 — email notification)
@autoload/Domain/CronJob/CronJobRepository.php (markFailed — linie 131-156)
@autoload/Domain/CronJob/CronJobType.php (stałe backoff)
</context>
<acceptance_criteria>
## AC-1: Email zawiera czytelne dane zamówienia
```gherkin
Given trwale nieudane zadanie Apilo z payload zawierającym order_id
When system wysyła email notyfikacji
Then email zawiera: numer zamówienia, dane klienta, datę zamówienia, kwotę
And temat emaila zawiera numery zamówień
```
## AC-2: Brak order_id w payload nie powoduje błędu
```gherkin
Given trwale nieudane zadanie Apilo bez order_id w payload (np. apilo_token_keepalive)
When system wysyła email notyfikacji
Then email wyświetla dane job-a bez sekcji zamówienia, bez błędów
```
## AC-3: Joby zamówień Apilo ponawiają się w nieskończoność co 30 minut
```gherkin
Given job typu apilo_send_order, apilo_sync_payment lub apilo_sync_status
When job osiąga max_attempts
Then job NIE jest oznaczany jako failed
And job wraca do pending ze scheduled_at = now + 30 minut
And email ostrzegawczy jest wysyłany (z informacją że job dalej jest ponawiany)
```
## AC-4: Inne joby Apilo (token, product sync) nadal mają limit prób
```gherkin
Given job typu apilo_token_keepalive lub apilo_product_sync
When job osiąga max_attempts
Then job jest oznaczany jako failed (zachowanie bez zmian)
```
## AC-5: Po udanym wysłaniu zamówienia czyszczone są powiązane failed joby
```gherkin
Given zamówienie wysłane pomyślnie do Apilo (apilo_order_id ustawiony)
When handler APILO_SEND_ORDER kończy się sukcesem
Then powiązane joby apilo_sync_payment i apilo_sync_status ze statusem failed
zostają usunięte lub anulowane (żeby nie zaśmiecały kolejki)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Infinite retry dla order-related Apilo jobów</name>
<files>autoload/Domain/CronJob/CronJobType.php, autoload/Domain/CronJob/CronJobRepository.php</files>
<action>
**CronJobType.php:**
1. Dodać stałą `APILO_ORDER_BACKOFF_SECONDS = 1800` (30 minut)
2. Dodać statyczną metodę `isOrderRelatedApiloJob($jobType)` zwracającą true dla:
- APILO_SEND_ORDER, APILO_SYNC_PAYMENT, APILO_SYNC_STATUS
**CronJobRepository::markFailed():**
3. Przed sprawdzeniem `$attempts >= $maxAttempts`:
- Pobrać `job_type` z bazy (dodać do selecta w linia 133)
- Jeśli `CronJobType::isOrderRelatedApiloJob($jobType)`:
- ZAWSZE wracaj do pending (nigdy failed)
- Użyj stałego backoffu `APILO_ORDER_BACKOFF_SECONDS` zamiast exponential
- Ustaw `last_error` jak normalnie
- Dla pozostałych jobów — logika bez zmian
UWAGA: Nie zmieniaj sygnatury markFailed() — dodaj job_type do wewnętrznego selecta
</action>
<verify>
1. Przeczytaj kod i zweryfikuj że:
- isOrderRelatedApiloJob zwraca true tylko dla 3 typów
- markFailed nigdy nie ustawia status=failed dla tych typów
- Inne joby zachowują się jak dotychczas
2. Uruchom: ./test.ps1 tests/Unit/Domain/CronJob/
</verify>
<done>AC-3, AC-4 satisfied: Order joby retry w nieskończoność, inne bez zmian</done>
</task>
<task type="auto">
<name>Task 2: Lepszy email + ostrzeżenie zamiast trwałego błędu + czyszczenie po sukcesie</name>
<files>cron.php</files>
<action>
**Email notification (linie ~763-781):**
1. Zmienić query o failed joby — RÓWNIEŻ szukać order-related jobów w statusie pending z dużą liczbą prób (np. attempts >= 10), żeby wysyłać ostrzeżenie
2. Dla każdego job-a: sparsować payload (json_decode jeśli string), wyciągnąć order_id
3. Jeśli order_id istnieje — pobrać z pp_shop_orders:
- `order_number` (lub `id` jeśli brak), `client_name`/`client_surname`, `date_order`, `total_brutto`
4. Sformatować email:
```
Job #X (apilo_send_order) — PONAWIANY CO 30 MIN
Zamówienie: #12345 (ID: 678)
Klient: Jan Kowalski
Data zamówienia: 2026-03-19 14:30:00
Kwota: 199.99 PLN
Próby: 15
Błąd: [last_error]
Ostatnia próba: [updated_at lub scheduled_at]
```
5. Dla jobów permanent failed (nie-order): zachować stary format "trwały błąd"
6. Temat: dodać numery zamówień jeśli dostępne
7. Email ma rozróżniać: "PONAWIANY" vs "TRWAŁY BŁĄD" w zależności od typu joba
**Czyszczenie po sukcesie (w handlerze APILO_SEND_ORDER, po linii ~522-524):**
8. Po pomyślnym wysłaniu zamówienia (`apilo_order_id` ustawiony):
- Usunąć/anulować failed/pending joby `apilo_sync_payment` i `apilo_sync_status`
z payload zawierającym ten sam order_id
- Użyć: `$mdb->delete('pp_cron_jobs', [...])` lub update status=cancelled
- To zapobiega zaśmiecaniu kolejki starymi retry jobami
UWAGA:
- Nazwy kolumn zamówienia: sprawdź jakie faktycznie są w pp_shop_orders (mogą być polskie)
- Payload w bazie to JSON string — json_decode($fj['payload'], true)
- Nie zmieniaj logiki wysyłania zamówień — tylko email i cleanup
</action>
<verify>
1. Przeczytaj zmodyfikowany kod
2. Zweryfikuj że query do pp_shop_orders używa poprawnych kolumn
3. Zweryfikuj brak błędów PHP (null handling, json_decode guard)
4. Uruchom: ./test.ps1
</verify>
<done>AC-1, AC-2, AC-5 satisfied: Email czytelny, cleanup po sukcesie</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Logikę wysyłania zamówień do Apilo (curl, payload budowanie)
- Logikę exponential backoff dla NIE-order jobów
- Handlery APILO_SYNC_PAYMENT, APILO_SYNC_STATUS, APILO_STATUS_POLL (poza cleanup)
- Odbiorcę emaila i warunki wysyłki (poza rozszerzeniem query)
- Tabelę pp_shop_orders — żadnych nowych kolumn
## SCOPE LIMITS
- Tylko retry logic, email formatting, i cleanup
- Nie dodawać nowych tabel
- Nie zmieniać enqueue() ani fetchNext()
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Order-related Apilo joby nigdy nie dostają status=failed
- [ ] Backoff dla order jobów = stałe 30 min
- [ ] Inne joby zachowują stare zachowanie (exponential, max 10)
- [ ] Email zawiera numer zamówienia gdy dostępny
- [ ] Email rozróżnia "ponawiany" vs "trwały błąd"
- [ ] Po sukcesie wysyłki czyścimy related joby
- [ ] Brak błędów PHP
- [ ] Testy przechodzą (./test.ps1)
- [ ] All acceptance criteria met
</verification>
<success_criteria>
- Zamówienia Apilo są ponawiane w nieskończoność co 30 min
- Email notyfikacji zawiera czytelne dane zamówienia
- Po udanym wysłaniu czyszczone są stare joby
- Zero regresji w testach
</success_criteria>
<output>
After completion, create `.paul/phases/09-apilo-email-fix/09-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,111 @@
---
phase: 09-apilo-email-fix
plan: 01
subsystem: integrations
tags: [apilo, cron, email, retry]
requires:
- phase: 08-apilo-orders-fix
provides: cron job system, Apilo email notification
provides:
- Infinite retry dla order-related Apilo jobów (30 min interval)
- Email notyfikacji z danymi zamówienia (numer, klient, kwota)
- Cleanup starych jobów po udanym wysłaniu
affects: []
tech-stack:
added: []
patterns:
- "isOrderRelatedApiloJob() — centralna identyfikacja order jobów Apilo"
- "Infinite retry pattern — stały backoff zamiast exponential dla krytycznych jobów"
key-files:
modified:
- autoload/Domain/CronJob/CronJobType.php
- autoload/Domain/CronJob/CronJobRepository.php
- cron.php
key-decisions:
- "Order joby Apilo nigdy nie failują trwale — infinite retry co 30 min"
- "Email rozróżnia PONAWIANY vs TRWAŁY BŁĄD"
- "Po udanym wysłaniu zamówienia czyszczone są stuck joby sync_payment/sync_status"
duration: ~15min
completed: 2026-03-19
---
# Phase 9 Plan 01: Apilo email fix + infinite retry — Summary
**Email notyfikacji Apilo wzbogacony o dane zamówienia (numer, klient, kwota) + order joby ponawiane w nieskończoność co 30 min zamiast permanent failure po 10 próbach.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15 min |
| Completed | 2026-03-19 |
| Tasks | 2 completed |
| Files modified | 4 (+ 1 test file) |
| Tests | 820 passed, 2277 assertions |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Email zawiera dane zamówienia | Pass | Numer, klient, data, kwota z pp_shop_orders |
| AC-2: Brak order_id nie powoduje błędu | Pass | Graceful handling — pokazuje tylko dane joba |
| AC-3: Order joby retry co 30 min w nieskończoność | Pass | isOrderRelatedApiloJob() + stały backoff 1800s |
| AC-4: Inne joby zachowują limit prób | Pass | Testy potwierdzają — price_history nadal failuje po max_attempts |
| AC-5: Cleanup po udanym wysłaniu | Pass | delete stuck sync_payment/sync_status jobów |
## Accomplishments
- Order-related Apilo joby (send_order, sync_payment, sync_status) nigdy nie wpadają w permanent failure — zawsze wracają do pending co 30 min
- Email notyfikacji zawiera czytelne dane zamówienia zamiast surowego JSON payload
- Temat emaila zawiera numery zamówień dla szybkiej identyfikacji
- Email rozróżnia "PONAWIANY CO 30 MIN" vs "TRWAŁY BŁĄD" w zależności od typu joba
- Po udanym wysłaniu zamówienia do Apilo czyszczone są stare stuck joby sync_payment/sync_status
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/Domain/CronJob/CronJobType.php` | Modified | +APILO_ORDER_BACKOFF_SECONDS (1800s), +isOrderRelatedApiloJob() |
| `autoload/Domain/CronJob/CronJobRepository.php` | Modified | markFailed() — infinite retry dla order jobów |
| `cron.php` | Modified | Email z danymi zamówienia + cleanup po sukcesie |
| `tests/Unit/Domain/CronJob/CronJobRepositoryTest.php` | Modified | +2 testy infinite retry, fix mocków z job_type |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Stały backoff 1800s zamiast exponential | Zamówienia muszą trafić do Apilo — przewidywalny interwał ważniejszy niż agresywny retry | Order joby ponawiane regularnie co 30 min |
| Email ostrzegawczy zamiast "trwały błąd" | Order joby nigdy nie failują trwale, ale admin musi wiedzieć o problemie | Zmieniony temat i treść emaila |
| Cleanup starych jobów po sukcesie | Zapobieganie zaśmiecaniu kolejki stuck jobami sync_payment/sync_status | Delete zamiast cancel — prostsze |
## Deviations from Plan
None — plan executed as written.
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Testy CronJob failowały — mock get() nie zwracał job_type | Dodano job_type do willReturn() w 3 istniejących testach |
| test.ps1 nie istnieje | Użyto bezpośrednio `php phpunit.phar` |
## Next Phase Readiness
**Ready:**
- System retry Apilo jest kompletny i odporny na awarie
- Email notyfikacji daje adminowi pełen kontekst do szybkiej reakcji
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 09-apilo-email-fix, Plan: 01*
*Completed: 2026-03-19*

View File

@@ -0,0 +1,221 @@
---
phase: 10-basket-edit-custom-fields
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- autoload/front/Controllers/ShopBasketController.php
- templates/shop-basket/_partials/product-custom-fields.php
- templates/shop-basket/basket-details.php
- ajax.php
autonomous: true
---
<objective>
## Goal
Dodać możliwość edycji personalizacji (custom fields) produktu bezpośrednio w koszyku, bez konieczności usuwania produktu i dodawania go od nowa.
## Purpose
Klient, który pomyli się przy wpisywaniu personalizacji (np. grawer, dedykacja), musi teraz usunąć produkt z koszyka i dodać go ponownie z poprawnymi danymi. To frustrujące UX — edycja inline jest naturalnym oczekiwaniem.
## Output
- Przycisk "Edytuj" przy personalizacjach w koszyku
- Modal lub formularz inline do edycji wartości custom fields
- Endpoint AJAX do zapisania zmian w sesji koszyka
- Przeliczenie product_code (MD5 hash) po zmianie wartości
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@autoload/front/Controllers/ShopBasketController.php
@templates/shop-basket/_partials/product-custom-fields.php
@templates/shop-basket/basket-details.php
@templates/shop-product/_partial/product-custom-fields.php
@autoload/Domain/Product/ProductRepository.php
@ajax.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
No specialized flows configured — /frontend-design jest optional.
</skills>
<acceptance_criteria>
## AC-1: Przycisk edycji widoczny w koszyku
```gherkin
Given produkt w koszyku ma wypełnione custom fields (personalizacje)
When klient widzi szczegóły koszyka (basket-details)
Then przy każdej pozycji z custom fields widnieje przycisk "Edytuj personalizację"
And przycisk NIE pojawia się gdy produkt nie ma custom fields
```
## AC-2: Formularz edycji wyświetla aktualne wartości
```gherkin
Given klient klika "Edytuj personalizację" przy pozycji koszyka
When otwiera się formularz edycji (modal lub inline)
Then formularz zawiera pola odpowiadające custom fields tego produktu
And pola są wypełnione aktualnymi wartościami z koszyka
And pola wymagane (is_required) są oznaczone jako wymagane
```
## AC-3: Zapis zmian aktualizuje koszyk
```gherkin
Given klient zmienił wartości custom fields w formularzu edycji
When klika "Zapisz"
Then wartości custom fields w sesji koszyka są zaktualizowane
And product_code (MD5 hash) jest przeliczony z nowymi wartościami
And strona koszyka odświeża się pokazując nowe wartości
And ilość produktu i inne atrybuty nie ulegają zmianie
```
## AC-4: Walidacja pól wymaganych
```gherkin
Given produkt ma custom field oznaczone jako is_required = 1
When klient próbuje zapisać formularz z pustym polem wymaganym
Then zapis jest blokowany
And wyświetlany jest komunikat o wymaganym polu
```
## AC-5: Obsługa konfliktu duplikatu
```gherkin
Given koszyk zawiera dwa egzemplarze tego samego produktu z różnymi personalizacjami
When klient edytuje personalizację jednego tak, że staje się identyczna z drugim
Then pozycje zostają scalone (ilości zsumowane)
And w koszyku pozostaje jedna pozycja z łączną ilością
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Endpoint AJAX do aktualizacji custom fields w koszyku</name>
<files>autoload/front/Controllers/ShopBasketController.php, ajax.php</files>
<action>
Dodać metodę `basketUpdateCustomFields()` w ShopBasketController:
1. Przyjmuje POST z parametrami:
- `product_code` — obecny klucz pozycji w koszyku (MD5 hash)
- `custom_field[ID]` — nowe wartości custom fields (tablica)
2. Logika metody:
- Pobierz pozycję koszyka po `product_code` z sesji
- Jeśli nie istnieje → zwróć błąd JSON
- Waliduj wymagane pola (pobierz metadane z ProductRepository::findCustomFieldCached)
- Jeśli walidacja nie przejdzie → zwróć błąd JSON z listą brakujących pól
- Zaktualizuj `custom_fields` w pozycji koszyka
- Przelicz nowy product_code: `md5(product_id . attributes . message . json_encode(new_custom_fields))`
- Jeśli nowy product_code == stary → tylko aktualizuj wartości
- Jeśli nowy product_code istnieje już w koszyku → scal pozycje (zsumuj ilość), usuń starą
- Jeśli nowy product_code nie istnieje → przenieś pozycję pod nowy klucz, usuń stary
- Przelicz sumę koszyka
- Zwróć JSON success
3. Zarejestruj endpoint w ajax.php pod kluczem `basket_update_custom_fields`
- Wzoruj się na istniejących endpointach koszyka (basket_add_product, basket_remove itp.)
Unikaj:
- NIE używaj match expressions (PHP < 8.0)
- NIE sklejaj SQL stringiem — custom fields są w sesji, nie w DB
- Escape wartości przy wyświetlaniu (htmlspecialchars), ale w sesji przechowuj surowe wartości
</action>
<verify>
Testy PHPUnit przechodzą (php phpunit.phar).
Endpoint odpowiada na POST z prawidłowym JSON response.
</verify>
<done>AC-3, AC-4, AC-5 satisfied: endpoint aktualizuje custom fields, waliduje required, obsługuje merge duplikatów</done>
</task>
<task type="auto">
<name>Task 2: UI edycji personalizacji w szablonie koszyka</name>
<files>templates/shop-basket/_partials/product-custom-fields.php, templates/shop-basket/basket-details.php</files>
<action>
1. W `product-custom-fields.php` dodać przycisk "Edytuj personalizację":
- Przycisk widoczny tylko gdy `$this->custom_fields` nie jest puste
- Atrybut data-product-code z kluczem pozycji koszyka
- Klasa CSS do stylowania (np. `btn-edit-custom-fields`)
2. Dodać ukryty formularz edycji (modal inline) pod przyciskiem:
- Dla każdego custom field: input z aktualną wartością
- Pola wymagane oznaczone `required` + wizualnie (gwiazdka)
- Typ pola (text/image) z metadanych custom field
- Przyciski "Zapisz" i "Anuluj"
- Formularz domyślnie ukryty (`display: none`)
3. W `basket-details.php` dodać JavaScript obsługujący:
- Klik "Edytuj" → pokaż formularz, ukryj wyświetlane wartości
- Klik "Anuluj" → ukryj formularz, pokaż wartości
- Klik "Zapisz" → AJAX POST do `basket_update_custom_fields`
- Walidacja client-side required fields przed wysłaniem
- Po sukcesie → przeładuj stronę koszyka (location.reload)
- Po błędzie → pokaż komunikat
4. Przekazać `product_code` do szablonu custom fields:
- W `basket-details.php` przy wywołaniu `Tpl::view('shop-basket/_partials/product-custom-fields', ...)`
dodać parametr `product_code` z kluczem pozycji
Unikaj:
- NIE dodawaj zewnętrznych bibliotek JS/CSS
- NIE zmieniaj struktury HTML istniejących elementów (dodawaj nowe)
- Escape wszystkich wartości w atrybutach HTML (htmlspecialchars)
- NIE używaj str_contains/str_starts_with (PHP 8.0+)
</action>
<verify>
Wizualna weryfikacja: przycisk "Edytuj" widoczny w koszyku przy produktach z personalizacją.
Klik otwiera formularz z aktualnymi wartościami.
Zapis odświeża koszyk z nowymi wartościami.
</verify>
<done>AC-1, AC-2 satisfied: przycisk edycji widoczny, formularz wyświetla aktualne wartości</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- autoload/Domain/Product/ProductRepository.php (nie modyfikuj metod findCustomFieldCached, saveCustomFields)
- autoload/Domain/Order/OrderRepository.php (nie zmieniaj createFromBasket)
- templates/shop-product/ (szablony strony produktu bez zmian)
- autoload/Domain/Basket/BasketRepository.php (jeśli istnieje — nie modyfikuj)
## SCOPE LIMITS
- Tylko koszyk (basket-details) — NIE podsumowanie zamówienia (summary-view)
- Tylko edycja wartości — NIE dodawanie/usuwanie pól custom fields
- Tylko pola typu text i image — nie dodawaj nowych typów pól
- NIE zmieniaj sposobu przechowywania custom fields w zamówieniach (pp_shop_order_products)
- NIE dodawaj testów PHPUnit dla warstwy widoku (templates) — testuj tylko logikę kontrolera
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php phpunit.phar` — wszystkie testy przechodzą (820+)
- [ ] Endpoint `basket_update_custom_fields` zwraca poprawny JSON
- [ ] Przycisk "Edytuj" widoczny w koszyku przy produktach z personalizacją
- [ ] Formularz edycji wyświetla aktualne wartości
- [ ] Zapis zmienia wartości w sesji i odświeża koszyk
- [ ] Pola required są walidowane (client + server side)
- [ ] Merge duplikatów działa poprawnie
- [ ] Brak regresji — istniejąca funkcjonalność koszyka działa bez zmian
</verification>
<success_criteria>
- Wszystkie testy PHPUnit przechodzą
- AC-1 do AC-5 spełnione
- Kod zgodny z PHP < 8.0
- XSS protection (htmlspecialchars) na wszystkich outputach
- Brak nowych zależności zewnętrznych
</success_criteria>
<output>
After completion, create `.paul/phases/10-basket-edit-custom-fields/10-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,114 @@
---
phase: 10-basket-edit-custom-fields
plan: 01
subsystem: ui
tags: [basket, custom-fields, personalization, ajax, session]
requires:
- phase: none
provides: existing basket/custom fields infrastructure
provides:
- Edycja personalizacji produktu w koszyku (inline form + AJAX endpoint)
- Merge duplikatów przy identycznym product_code po edycji
affects: []
tech-stack:
added: []
patterns: [inline edit form with toggle display/edit, product_code recalculation]
key-files:
created: []
modified:
- autoload/front/Controllers/ShopBasketController.php
- templates/shop-basket/_partials/product-custom-fields.php
- templates/shop-basket/basket-details.php
- templates/shop-basket/basket.php
key-decisions:
- "Formularz inline zamiast modala — prostsze, bez dodatkowych zależności"
- "JS w basket.php zamiast basket-details.php — delegowane eventy działają po przeładowaniu AJAX"
- "ajax.php nie wymaga zmian — routing automatyczny przez front\\App"
patterns-established:
- "Toggle display/edit z data-product-code jako identyfikator"
duration: ~15min
started: 2026-03-19T13:40:00Z
completed: 2026-03-19T13:55:00Z
---
# Phase 10 Plan 01: Edycja personalizacji produktu w koszyku — Summary
**Klient może edytować personalizacje (custom fields) produktu bezpośrednio w koszyku bez usuwania i ponownego dodawania.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15 min |
| Tasks | 2 completed |
| Files modified | 4 |
| Tests | 820 passed, 0 failures |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Przycisk edycji widoczny | Pass | Przycisk "Edytuj personalizację" przy pozycjach z custom fields, ukryty gdy brak |
| AC-2: Formularz z aktualnymi wartościami | Pass | Inline form z wypełnionymi wartościami, required oznaczone gwiazdką |
| AC-3: Zapis aktualizuje koszyk | Pass | AJAX POST → przeliczenie hash → reload strony |
| AC-4: Walidacja required | Pass | Client-side (input required + alert) + server-side (findCustomFieldCached + is_required check) |
| AC-5: Merge duplikatów | Pass | Gdy nowy hash == istniejący → sumowanie quantity, usunięcie starej pozycji |
## Accomplishments
- Endpoint `basketUpdateCustomFields()` w ShopBasketController z pełną logiką: walidacja, hash recalculation, merge
- UI: toggle display↔edit z formularzem inline, walidacja client-side
- XSS protection na wszystkich outputach (htmlspecialchars)
- Kompatybilność PHP < 8.0 (brak match, str_contains, union types)
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/front/Controllers/ShopBasketController.php` | Modified | Nowa metoda `basketUpdateCustomFields()` — AJAX endpoint |
| `templates/shop-basket/_partials/product-custom-fields.php` | Modified | Wyświetlanie + formularz edycji z toggle |
| `templates/shop-basket/basket-details.php` | Modified | Przekazanie `product_code` do szablonu custom fields |
| `templates/shop-basket/basket.php` | Modified | JavaScript: edycja, anulowanie, zapis AJAX |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Scope change | 2 | Minimalne — lepsze dopasowanie do architektury |
**Total impact:** Drobne odchylenia, brak wpływu na funkcjonalność.
### Details
1. **ajax.php nie zmodyfikowany** — plan zakładał rejestrację endpointu w ajax.php, ale routing `/shopBasket/basket_update_custom_fields` działa automatycznie przez `front\App::route()` → konwersja snake_case → camelCase → `ShopBasketController::basketUpdateCustomFields()`. Zmiana w ajax.php była niepotrzebna.
2. **JS w basket.php zamiast basket-details.php** — plan wskazywał basket-details.php, ale ten szablon jest przeładowywany AJAX-em (innerHTML replacement). Delegowane eventy muszą być w basket.php który jest stały. Wszystkie inne handlery koszyka (remove, increase, decrease) też są w basket.php.
## Issues Encountered
None.
## Next Phase Readiness
**Ready:**
- Edycja personalizacji w koszyku gotowa do testów manualnych na produkcji
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 10-basket-edit-custom-fields, Plan: 01*
*Completed: 2026-03-19*

View File

@@ -0,0 +1,225 @@
---
phase: 11-datalayer-ga4-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- templates/shop-order/order-details.php
- templates/shop-basket/summary-view.php
- templates/shop-product/product.php
- templates/shop-basket/basket.php
autonomous: true
---
<objective>
## Goal
Naprawic wszystkie eventy dataLayer ecommerce (purchase, begin_checkout, view_item, add_to_cart) do formatu GA4 oraz dodac brakujacy event view_cart. Poprawki krytyczne dla remarketingu dynamicznego i konwersji.
## Purpose
Bez tych poprawek remarketing dynamiczny Google Ads i konwersje GA4 nie dzialaja poprawnie — ceny produktow sa zerowe, klucze itemow niezgodne z GA4, brakuje walut i eventow.
## Output
Poprawione 4 szablony PHP z prawidlowymi eventami dataLayer GA4.
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@templates/shop-order/order-details.php (purchase event, lines 164-192)
@templates/shop-basket/summary-view.php (begin_checkout event, lines 72-80, 175-187)
@templates/shop-product/product.php (view_item lines 273-288, add_to_cart lines 607-625)
@templates/shop-basket/basket.php (brak view_cart — do dodania)
## Technical Reference
@poprawki_datalayer_projectpro.md (specyfikacja zmian z audytu)
## Data Model
Order products (pp_shop_order_products) mają kolumny: product_id, name, price_brutto, price_brutto_promo, quantity.
Basket products — surowa tablica z sesji, product data fetchowana przez ProductRepository::findCached().
</context>
<skills>
No specialized flows configured — standard execute plan.
</skills>
<acceptance_criteria>
## AC-1: Purchase event — format GA4 z prawidlowa cena
```gherkin
Given strona potwierdzenia zamowienia /zamowienie/*
When dataLayer.push(purchase) jest wywolany
Then items maja klucze item_id (string), item_name, price (number > 0), quantity (number), google_business_vertical: "retail"
And ecommerce ma currency: "PLN"
And nie ma zduplikowanego klucza value ani hardcoded wartosci
```
## AC-2: Begin_checkout event — format GA4
```gherkin
Given strona /koszyk-podsumowanie z produktami w koszyku
When dataLayer.push(begin_checkout) jest wywolany
Then items maja klucze item_id (string), item_name (zamiast id, name), price (number), quantity (number), google_business_vertical: "retail"
```
## AC-3: View_item event — kompletne dane
```gherkin
Given strona produktu
When dataLayer.push(view_item) jest wywolany
Then ecommerce zawiera currency: "PLN" i value (number)
And items maja price jako number (nie string), google_business_vertical: "retail"
```
## AC-4: Add_to_cart event — poprawne typy
```gherkin
Given klikniecie "dodaj do koszyka" na stronie produktu
When dataLayer.push(add_to_cart) jest wywolany
Then items maja google_business_vertical: "retail"
And quantity jest number (nie string)
```
## AC-5: View_cart event — nowy event na stronie koszyka
```gherkin
Given strona /koszyk z produktami w koszyku
When strona sie zaladuje
Then dataLayer.push({event: "view_cart"}) jest wywolany z currency, value i items w formacie GA4
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Naprawic istniejace eventy dataLayer (purchase, begin_checkout, view_item, add_to_cart)</name>
<files>templates/shop-order/order-details.php, templates/shop-basket/summary-view.php, templates/shop-product/product.php</files>
<action>
**order-details.php (purchase event, linie 167-187):**
1. Usunac hardcoded `value: 25.42` (linia 172) — zostawic tylko dynamiczny `value` z linii 174
2. Zamienic `'id': <?= (int)$product['product_id'];?>` na `item_id: "<?= $product['product_id'];?>"`
3. Zamienic `'name': '<?= $product['name'];?>'` na `item_name: "<?= str_replace('"', '', $product['name']);?>"`
4. Zamienic logike price na: `price: <?= ((float)$product['price_brutto_promo'] > 0 && (float)$product['price_brutto_promo'] < (float)$product['price_brutto']) ? \Shared\Helpers\Helpers::normalize_decimal($product['price_brutto_promo']) : \Shared\Helpers\Helpers::normalize_decimal($product['price_brutto']);?>`
5. Dodac `google_business_vertical: "retail"` do kazdego itemu
6. Zamienic single quotes na double quotes w kluczach itemow (konsystencja)
7. Dodac `'quantity': <?= (int)$product['quantity'];?>` (rzutowanie na int)
**summary-view.php (begin_checkout items, linie 72-80):**
1. Zamienic `'"id": "' . $product['id']` na `'"item_id": "' . $product['id']`
2. Zamienic `'"name": "' . $product['language']['name']` na `'"item_name": "' . str_replace('"', '', $product['language']['name'])`
3. Dodac `'"google_business_vertical": "retail"'` do kazdego itemu
**product.php (view_item, linie 273-287):**
1. Dodac `currency: "PLN",` do obiektu ecommerce (przed items)
2. Dodac `value: <cena>,` do obiektu ecommerce (po currency)
3. Zmienic `price: '<cena>'` na `price: <cena>` (usunac cudzyslow — number zamiast string)
4. Dodac `google_business_vertical: "retail"` do itemu
**product.php (add_to_cart, linie 607-624):**
1. Dodac `google_business_vertical: "retail"` do itemu
- quantity jest juz prawidlowo number (zmienna JS `quantity` pochodzi z parseInt/parseFloat lub .val() — sprawdzic i ewentualnie dodac parseInt)
**Wazne:** Nie zmieniac struktury warunkow `if ($this->settings['google_tag_manager_id'])` — zostawic identycznie.
**Wazne:** Uzywac normalize_decimal() dla cen (zapewnia format z kropka, nie przecinkiem).
</action>
<verify>
1. Przegladnac wygenerowany HTML kazdego eventu — sprawdzic format kluczy, typy, obecnosc currency i google_business_vertical
2. Sprawdzic brak bledow skladni JS (cudzyslow, przecinki)
3. Testy PHPUnit nie powinny byc dotknięte (zmiany tylko w szablonach)
</verify>
<done>AC-1, AC-2, AC-3, AC-4 satisfied: Wszystkie eventy uzywaja item_id/item_name, price jako number, currency PLN, google_business_vertical</done>
</task>
<task type="auto">
<name>Task 2: Dodac event view_cart na stronie koszyka</name>
<files>templates/shop-basket/basket.php</files>
<action>
Dodac dataLayer.push dla view_cart w sekcji `<script>` na poczatku bloku `$(function() {` w basket.php (linia 209).
Implementacja:
1. Dodac blok PHP+JS wewnatrz istniejacego `<script>` (po linii 50, w nowym `<script>` z warunkiem GTM):
```
<? if ( $this -> settings['google_tag_manager_id'] ?? false ): ?>
<? if ( is_array( $this -> basket ) and count( $this -> basket ) ): ?>
<script type="text/javascript">
dataLayer.push({ ecommerce: null });
dataLayer.push({
event: "view_cart",
ecommerce: {
currency: "PLN",
value: [obliczona suma],
items: [
// iteracja po $this->basket z fetchem produktu przez ProductRepository
]
}
});
</script>
<? endif; ?>
<? endif; ?>
```
2. Iterowac po `$this->basket`, dla kazdego elementu pobrac product data (ProductRepository::findCached) i zbudowac item z item_id, item_name, price, quantity, google_business_vertical.
3. Obliczyc value jako sume (price * quantity) wszystkich produktow.
**Uwaga:** basket.php ma dostep do `$this->basket` (raw basket array). Kazdy element ma klucze: 'product-id', 'quantity', 'parent_id', 'attributes'.
Product data nalezy pobrac przez: `(new \Domain\Product\ProductRepository($GLOBALS['mdb']))->findCached((int)$position['product-id'], $lang_id)` — identycznie jak robi basket-details.php.
Uzyc `$GLOBALS['mdb']` i `(new \Domain\Languages\LanguagesRepository($GLOBALS['mdb']))->defaultLanguage()` dla lang_id (lub sprawdzic czy $this->lang_id jest dostepny — jesli nie, pobrac z sesji).
**Wazne:** Dodac nowy `<script>` blok PRZED istniejacym blokiem `<script>` (przed linia 36), nie wewnatrz istniejacego — zeby uniknac konfliktow z jQuery ready i AJAX reload.
**Wazne:** Warunek `settings['google_tag_manager_id']` — uzyc `$settings` (global) lub `$this->settings` — sprawdzic ktore jest dostepne w basket.php (linia 1: `global $settings` sugeruje ze $settings jest dostepny).
</action>
<verify>
1. Otworzyc /koszyk z produktami — sprawdzic w konsoli przegladarki dataLayer na obecnosc view_cart
2. Sprawdzic czy items maja poprawne pola: item_id (string), item_name, price (number), quantity (number), google_business_vertical
3. Sprawdzic czy value = suma cen * ilosci
4. Sprawdzic czy event NIE odapla sie ponownie po AJAX reload koszyka (bo jest w osobnym script poza basket-details)
</verify>
<done>AC-5 satisfied: Event view_cart jest pushowany na stronie /koszyk z pelnym zestawem danych GA4</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- autoload/Domain/* (warstwa domenowa — bez zmian)
- autoload/front/Controllers/* (kontrolery — bez zmian)
- templates/shop-basket/basket-details.php (AJAX-replaceable — nie dodawac tam skryptow)
- Logika sesji google-analytics-purchase (purchase dedup)
- Warunki `if ($this->settings['google_tag_manager_id'])` — zachowac identycznie
## SCOPE LIMITS
- Tylko eventy dataLayer — nie dodawac/zmieniac Facebook Pixel, gtag, ani innych trackerow
- Nie zmieniac struktury HTML szablonow
- Nie dodawac user_data do purchase (opcjonalne w specyfikacji, wymaga osobnej analizy RODO)
- Nie usuwac/przenosic kodu GADS conversion (nie znaleziono w kodzie — prawdopodobnie w GTM)
- Nie dodawac nowych eventow poza view_cart (np. remove_from_cart — poza zakresem)
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Wszystkie eventy uzywaja item_id (string) i item_name zamiast id/name
- [ ] price jest zawsze number (nie string, nie 0 dla prawidlowych produktow)
- [ ] currency: "PLN" obecne we wszystkich eventach ecommerce
- [ ] google_business_vertical: "retail" w kazdym item
- [ ] quantity jest zawsze number
- [ ] Nowy event view_cart dziala na /koszyk
- [ ] Brak hardcoded value: 25.42 w purchase
- [ ] Brak bledow skladni JS w wygenerowanym HTML
- [ ] PHPUnit testy przechodzą (./test.ps1)
</verification>
<success_criteria>
- All tasks completed
- All verification checks pass
- No errors or warnings introduced
- DataLayer eventy zgodne z formatem GA4 (item_id, item_name, currency, google_business_vertical)
- Remarketing dynamiczny Google Ads ma prawidlowe ceny produktow
</success_criteria>
<output>
After completion, create `.paul/phases/11-datalayer-ga4-fix/11-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,114 @@
---
phase: 11-datalayer-ga4-fix
plan: 01
subsystem: frontend
tags: [datalayer, ga4, gtm, ecommerce, analytics, remarketing]
requires:
- phase: none
provides: n/a
provides:
- GA4-compliant dataLayer events (purchase, begin_checkout, view_item, add_to_cart, view_cart)
- google_business_vertical for dynamic remarketing
affects: []
tech-stack:
added: []
patterns: [GA4 ecommerce item format with item_id/item_name/google_business_vertical]
key-files:
created: []
modified:
- templates/shop-order/order-details.php
- templates/shop-basket/summary-view.php
- templates/shop-product/product.php
- templates/shop-basket/basket.php
key-decisions:
- "view_cart event in basket.php (not basket-details.php) — basket-details is AJAX-replaceable"
- "No user_data in purchase — requires RODO analysis, deferred"
patterns-established:
- "GA4 item format: item_id (string), item_name, price (number), quantity (number), google_business_vertical: retail"
- "All ecommerce events must include currency: PLN"
duration: 15min
completed: 2026-03-25
---
# Phase 11 Plan 01: DataLayer GA4 Analytics Fix Summary
**Naprawione 5 eventow dataLayer ecommerce do formatu GA4 — remarketing dynamiczny i konwersje teraz dzialaja poprawnie.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15min |
| Completed | 2026-03-25 |
| Tasks | 2 completed |
| Files modified | 4 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Purchase event — format GA4 z prawidlowa cena | Pass | item_id (string), item_name, price via normalize_decimal, google_business_vertical, usuniety hardcoded value: 25.42 |
| AC-2: Begin_checkout event — format GA4 | Pass | id→item_id, name→item_name, dodany google_business_vertical |
| AC-3: View_item event — kompletne dane | Pass | Dodane currency: PLN, value, price jako number, google_business_vertical |
| AC-4: Add_to_cart event — poprawne typy | Pass | Dodany google_business_vertical, parseInt(quantity) |
| AC-5: View_cart event — nowy event | Pass | Nowy event na /koszyk z pelnym zestawem danych GA4 |
## Accomplishments
- Naprawione klucze itemow we wszystkich eventach: id/name → item_id/item_name (format GA4)
- Dodane brakujace pola: currency: PLN, value, google_business_vertical: retail
- Usuniety hardcoded `value: 25.42` z purchase event (debug artifact)
- Dodany nowy event `view_cart` na stronie koszyka /koszyk
- Poprawione typy danych: price jako number (nie string), quantity jako int
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `templates/shop-order/order-details.php` | Modified | Purchase event: item_id/item_name, fix price, remove hardcoded value, add google_business_vertical |
| `templates/shop-basket/summary-view.php` | Modified | Begin_checkout event: item_id/item_name, add google_business_vertical |
| `templates/shop-product/product.php` | Modified | View_item: add currency/value/google_business_vertical. Add_to_cart: add google_business_vertical, parseInt(quantity) |
| `templates/shop-basket/basket.php` | Modified | New view_cart event with full GA4 item data |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| view_cart w basket.php, nie basket-details.php | basket-details jest AJAX-replaceable — script by sie odpalal przy kazdym AJAX reload | Konsystentne z decyzja z fazy 10 |
| Pominiecie user_data w purchase | Wymaga analizy RODO/GDPR przed wyslaniem PII do dataLayer | Mozna dodac w przyszlosci po analizie |
| GADS conversion na checkout — nie znaleziono | Grep nie znalazl hardcoded GADS conversion w szablonach — prawdopodobnie w GTM | Nie trzeba usuwac z kodu |
## Deviations from Plan
None — plan executed exactly as written.
## Issues Encountered
None.
## Skill Audit
- /feature-dev: not invoked (optional for template-only changes)
- /koniec-pracy: pending (release workflow)
## Next Phase Readiness
**Ready:**
- Wszystkie eventy dataLayer zgodne z GA4
- Gotowe do weryfikacji w GTM Preview / GA4 DebugView na produkcji
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 11-datalayer-ga4-fix, Plan: 01*
*Completed: 2026-03-25*

View File

@@ -0,0 +1,125 @@
---
phase: 12-summaryview-redirect-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [autoload/front/Controllers/ShopBasketController.php]
autonomous: true
---
<objective>
## Goal
Usunąć błędny guard w `summaryView()` który po złożeniu pierwszego zamówienia uniemożliwia złożenie kolejnego — redirectuje na stronę starego zamówienia zamiast pozwolić na wejście na podsumowanie koszyka.
## Purpose
Klient sklepu po złożeniu jednego zamówienia musi móc złożyć kolejne zamówienie bez problemu. Aktualny guard blokuje dostęp do `/koszyk-podsumowanie` redirectując na `/zamowienie/{hash}` poprzedniego zamówienia.
## Output
Zmodyfikowany `ShopBasketController.php` bez problematycznego bloku redirect w `summaryView()`.
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@autoload/front/Controllers/ShopBasketController.php
@change.md — opis błędu z instancji klienta
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
No required skills for this hotfix — simple code removal, no new feature development.
</skills>
<acceptance_criteria>
## AC-1: Klient może złożyć drugie zamówienie po pierwszym
```gherkin
Given klient właśnie złożył zamówienie (sesja zawiera order-submit-last-order-id)
When klient wraca na /koszyk-podsumowanie z nowym koszykiem
Then widzi stronę podsumowania zamówienia (nie redirect na stare zamówienie)
```
## AC-2: Ochrona double-submit pozostaje nienaruszona
```gherkin
Given klient jest na stronie podsumowania i klika "złóż zamówienie"
When formularz zostaje wysłany dwa razy (double-click)
Then tylko jedno zamówienie zostaje złożone (mechanizm w basketSave() działa)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Usunięcie błędnego guardu redirect w summaryView()</name>
<files>autoload/front/Controllers/ShopBasketController.php</files>
<action>
Usunąć blok kodu w metodzie `summaryView()` (linie 279-290):
```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;
}
}
```
NIE usuwać:
- Stałej `ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY` (używana w `basketSave()` i `createOrderSubmitToken()`)
- Żadnego kodu w `basketSave()` — tam mechanizm double-submit działa poprawnie
- Linii 312 (w `basketSave()`) ani 378, 549 — te użycia klucza sesyjnego są poprawne
</action>
<verify>
1. Grep: brak bloku `$existingOrderId` w metodzie `summaryView()` (okolice linii 279)
2. Grep: klucz `ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY` nadal istnieje w `basketSave()` i `createOrderSubmitToken()`
3. Testy: `./test.ps1` — wszystkie testy przechodzą
</verify>
<done>AC-1 satisfied: summaryView() nie redirectuje na stare zamówienie; AC-2 satisfied: basketSave() double-submit guard nienaruszony</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Metoda `basketSave()` — mechanizm double-submit protection
- Metoda `createOrderSubmitToken()` — generowanie tokenu
- Stała `ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY` — używana w innych miejscach
- Linie 312, 378, 549 — poprawne użycia klucza sesyjnego
## SCOPE LIMITS
- Tylko usunięcie bloku redirect w `summaryView()`, żadne inne zmiany
- Brak zmian w logice koszyka, płatności ani zamówień
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Blok redirect (dawne linie 279-290) usunięty z `summaryView()`
- [ ] Stała `ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY` nadal istnieje
- [ ] Użycia klucza w `basketSave()` i `createOrderSubmitToken()` nienaruszone
- [ ] `./test.ps1` — wszystkie testy przechodzą
- [ ] Brak innych zmian w pliku
</verification>
<success_criteria>
- Blok redirect usunięty
- Wszystkie testy przechodzą
- Double-submit protection działa bez zmian
</success_criteria>
<output>
After completion, create `.paul/phases/12-summaryview-redirect-fix/12-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,88 @@
---
phase: 12-summaryview-redirect-fix
plan: 01
subsystem: frontend
tags: [basket, checkout, redirect, session]
requires:
- phase: none
provides: n/a
provides:
- Fix summaryView() redirect blocking subsequent orders
affects: []
tech-stack:
added: []
patterns: []
key-files:
created: []
modified: [autoload/front/Controllers/ShopBasketController.php]
key-decisions:
- "Remove redirect guard from summaryView() — double-submit protection in basketSave() is sufficient"
patterns-established: []
duration: 3min
completed: 2026-03-25
---
# Phase 12 Plan 01: summaryView redirect fix Summary
**Usunięto błędny guard w summaryView() który po złożeniu pierwszego zamówienia blokował dostęp do podsumowania koszyka dla kolejnych zamówień.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~3 min |
| Completed | 2026-03-25 |
| Tasks | 1 completed |
| Files modified | 1 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Klient może złożyć drugie zamówienie po pierwszym | Pass | Blok redirect usunięty z summaryView() |
| AC-2: Ochrona double-submit pozostaje nienaruszona | Pass | basketSave() guard nienaruszony (linie 299, 365, 536) |
## Accomplishments
- Usunięto blok kodu (12 linii) sprawdzający `order-submit-last-order-id` w `summaryView()` który redirectował na stare zamówienie
- Double-submit protection w `basketSave()` pozostaje w pełni funkcjonalna
- 820 testów, 2277 asercji — wszystkie przechodzą
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/front/Controllers/ShopBasketController.php` | Modified | Usunięty blok redirect (dawne linie 279-290) z summaryView() |
## Decisions Made
None — followed plan as specified
## Deviations from Plan
None — plan executed exactly as written
## Issues Encountered
None
## Next Phase Readiness
**Ready:**
- Poprawka gotowa do wdrożenia w update package
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 12-summaryview-redirect-fix, Plan: 01*
*Completed: 2026-03-25*

View File

@@ -0,0 +1,270 @@
---
phase: 13-basket-logging-ttl-token
plan: 01
type: execute
wave: 1
depends_on: ["12-01"]
files_modified: [autoload/front/Controllers/ShopBasketController.php]
autonomous: true
---
<objective>
## Goal
Dodać logowanie błędów w basketSave() oraz przerobić token zamówienia z jednorazowego na czasowy (TTL 30 min), aby wiele kart/odświeżenie/wstecz nie unieważniały tokenu.
## Purpose
Klientka nie mogła złożyć zamówienia — brak logów uniemożliwiał diagnozę. Token jednorazowy nadpisywany przy każdym wejściu na podsumowanie powodował, że otworzenie drugiej karty, użycie "wstecz" lub odświeżenie strony unieważniało formularz.
## Output
Zmodyfikowany `ShopBasketController.php` z logowaniem i TTL-based tokenem.
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/12-summaryview-redirect-fix/12-01-SUMMARY.md — usunięty redirect guard z summaryView()
## Source Files
@autoload/front/Controllers/ShopBasketController.php
@change.md — opis zmian z instancji klienta (Zmiana 2)
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
No required skills for this hotfix.
</skills>
<acceptance_criteria>
## AC-1: Logowanie błędów w basketSave()
```gherkin
Given basketSave() napotka błąd (double-submit, token invalid, exception, falsy order_id)
When błąd wystąpi
Then szczegóły są zapisywane do logs/logs-order-YYYY-MM-DD.log via metoda logOrder()
```
## AC-2: Token TTL 30 min — wiele kart działa
```gherkin
Given klient jest na stronie podsumowania zamówienia
When otworzy drugą kartę z podsumowaniem lub odświeży stronę
Then obie karty mają ten sam ważny token i mogą złożyć zamówienie
```
## AC-3: Token wygasa po 30 minutach
```gherkin
Given klient jest na stronie podsumowania z tokenem starszym niż 30 min
When spróbuje złożyć zamówienie
Then zostaje przekierowany na /koszyk-podsumowanie (nie /koszyk) i dostaje nowy token
```
## AC-4: Double-submit guard dla pustego koszyka
```gherkin
Given klient złożył zamówienie (koszyk pusty, order ID w sesji)
When spróbuje ponownie wysłać formularz
Then zostaje przekierowany na stronę istniejącego zamówienia
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Dodanie stałej TTL i metody logOrder()</name>
<files>autoload/front/Controllers/ShopBasketController.php</files>
<action>
1. Dodać stałą po istniejących stałych (linia 7):
```php
private const ORDER_SUBMIT_TOKEN_TTL = 1800;
```
2. Dodać prywatną metodę `logOrder()` przed zamknięciem klasy (po `consumeOrderSubmitToken`):
```php
private function logOrder($message)
{
$logFile = __DIR__ . '/../../../logs/logs-order-' . date('Y-m-d') . '.log';
$line = '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
@file_put_contents($logFile, $line, FILE_APPEND);
}
```
Schemat nazewnictwa: `logs/logs-order-YYYY-MM-DD.log` (jak `logs-db-*`).
Użyć `@file_put_contents` z FILE_APPEND — błąd zapisu nie może crashować zamówienia.
</action>
<verify>Grep: `ORDER_SUBMIT_TOKEN_TTL` i `function logOrder` istnieją w pliku</verify>
<done>Infrastruktura dla AC-1 (logOrder) gotowa</done>
</task>
<task type="auto">
<name>Task 2: Przerobienie tokena na TTL + logowanie w basketSave()</name>
<files>autoload/front/Controllers/ShopBasketController.php</files>
<action>
**A. Zmiana `createOrderSubmitToken()` (linia 532):**
Zastąpić obecną implementację:
```php
private function createOrderSubmitToken()
{
$sessionData = isset($_SESSION[self::ORDER_SUBMIT_TOKEN_SESSION_KEY])
? $_SESSION[self::ORDER_SUBMIT_TOKEN_SESSION_KEY]
: null;
if (is_array($sessionData) && isset($sessionData['token'], $sessionData['created_at']))
{
if ((time() - $sessionData['created_at']) < self::ORDER_SUBMIT_TOKEN_TTL)
{
return $sessionData['token'];
}
}
$token = $this->generateOrderSubmitToken();
\Shared\Helpers\Helpers::set_session(self::ORDER_SUBMIT_TOKEN_SESSION_KEY, [
'token' => $token,
'created_at' => time()
]);
\Shared\Helpers\Helpers::delete_session(self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY);
return $token;
}
```
**B. Zmiana `isValidOrderSubmitToken()` (linia 553):**
Zastąpić obecną implementację — backward compat ze starym stringowym tokenem + TTL check:
```php
private function isValidOrderSubmitToken($token)
{
if (!$token)
return false;
$sessionData = isset($_SESSION[self::ORDER_SUBMIT_TOKEN_SESSION_KEY])
? $_SESSION[self::ORDER_SUBMIT_TOKEN_SESSION_KEY]
: null;
if (!$sessionData)
return false;
// Backward compatibility: stary format (plain string)
if (is_string($sessionData))
{
$sessionToken = $sessionData;
}
elseif (is_array($sessionData) && isset($sessionData['token'], $sessionData['created_at']))
{
if ((time() - $sessionData['created_at']) >= self::ORDER_SUBMIT_TOKEN_TTL)
return false;
$sessionToken = $sessionData['token'];
}
else
{
return false;
}
if (function_exists('hash_equals'))
return hash_equals($sessionToken, $token);
return $sessionToken === $token;
}
```
**C. Dodanie logowania w `basketSave()` — 4 miejsca:**
1. **Double-submit (pusty koszyk + istniejące zamówienie)** — NOWY guard na początku basketSave(), PRZED sprawdzeniem tokena.
Dodać po linii 299 (po pobraniu $existingOrderId), PRZED `if (!$this->isValidOrderSubmitToken...)`:
```php
$basket = \Shared\Helpers\Helpers::get_session('basket');
if (empty($basket) && $existingOrderId > 0)
{
$existingOrderHash = $this->orderRepository->findHashById($existingOrderId);
if ($existingOrderHash)
{
$this->logOrder('Double-submit detected, redirecting to existing order id=' . $existingOrderId);
header('Location: /zamowienie/' . $existingOrderHash);
exit;
}
}
```
2. **Token nieprawidłowy** — w istniejącym bloku `if (!$this->isValidOrderSubmitToken...)`, dodać logowanie PRZED komunikatem błędu.
Dodać linię:
```php
$this->logOrder('Token validation failed. formToken=' . $orderSubmitToken . ' existingOrderId=' . $existingOrderId);
```
3. **Zmiana redirect przy złym tokenie** — w tym samym bloku zmienić redirect z `/koszyk` na `/koszyk-podsumowanie`:
```php
header('Location: /koszyk-podsumowanie');
```
4. **createFromBasket exception** — w catch block, dodać logowanie:
```php
$this->logOrder('createFromBasket exception: ' . $e->getMessage());
```
(error_log zostaje też)
5. **Falsy order_id** — po bloku `if ($order_id)`, dodać else:
```php
else
{
$this->logOrder('createFromBasket returned falsy order_id. client_id=' . ($client['id'] ?? '?') . ' email=' . (\Shared\Helpers\Helpers::get('email', true) ?: '?'));
\Shared\Helpers\Helpers::error(\Shared\Helpers\Helpers::lang('zamowienie-zostalo-zlozone-komunikat-blad'));
header('Location: /koszyk');
exit;
}
```
**D. Usunięcie starego double-submit bloku** z wnętrza `if (!$this->isValidOrderSubmitToken...)`:
Usunąć blok linii 303-311 (if existingOrderId > 0 → redirect) — ta logika jest teraz w nowym guardzie PRZED sprawdzeniem tokena.
</action>
<verify>
1. Grep: `logOrder` wywołane 4 razy w basketSave()
2. Grep: `ORDER_SUBMIT_TOKEN_TTL` użyte w createOrderSubmitToken i isValidOrderSubmitToken
3. Grep: `/koszyk-podsumowanie` jako redirect przy złym tokenie
4. Grep: `is_array.*sessionData` w isValidOrderSubmitToken (backward compat)
5. Testy: `php phpunit.phar` — wszystkie przechodzą
</verify>
<done>AC-1 (logowanie), AC-2 (TTL token), AC-3 (wygasanie + redirect), AC-4 (double-submit guard) satisfied</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Metoda `generateOrderSubmitToken()` — generowanie samego tokenu bez zmian
- Metoda `consumeOrderSubmitToken()` — konsumowanie tokenu po złożeniu zamówienia bez zmian
- Logika czyszczenia koszyka po złożeniu zamówienia (linie 367-374)
- Logika sesji purchase piksel/adwords/analytics/ekomi (linie 376-379)
- Redis flushAll po zamówieniu (linie 381-383)
## SCOPE LIMITS
- Tylko ShopBasketController.php — żadne inne pliki
- Brak zmian w createFromBasket() ani OrderRepository
- Brak zmian w szablonach widoków
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `logOrder()` metoda istnieje i zapisuje do `logs/logs-order-YYYY-MM-DD.log`
- [ ] Token przechowywany jako array `['token' => ..., 'created_at' => ...]`
- [ ] `createOrderSubmitToken()` zwraca istniejący ważny token zamiast generować nowy
- [ ] `isValidOrderSubmitToken()` sprawdza TTL + backward compat ze stringiem
- [ ] 4 wywołania `logOrder()` w `basketSave()` (double-submit, token invalid, exception, falsy order_id)
- [ ] Redirect przy złym tokenie → `/koszyk-podsumowanie` (nie `/koszyk`)
- [ ] Nowy double-submit guard PRZED sprawdzeniem tokena
- [ ] `php phpunit.phar` — wszystkie testy przechodzą
- [ ] `consumeOrderSubmitToken()` i `generateOrderSubmitToken()` niezmienione
</verification>
<success_criteria>
- Wszystkie zadania ukończone
- Wszystkie weryfikacje przechodzą
- Brak nowych błędów ani ostrzeżeń
- Token działa z wieloma kartami przeglądarki
</success_criteria>
<output>
After completion, create `.paul/phases/13-basket-logging-ttl-token/13-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,102 @@
---
phase: 13-basket-logging-ttl-token
plan: 01
subsystem: frontend
tags: [basket, checkout, logging, token, session, TTL]
requires:
- phase: 12-summaryview-redirect-fix
provides: summaryView() redirect guard removed
provides:
- Order error logging to logs/logs-order-YYYY-MM-DD.log
- TTL-based order submit token (30 min, multi-tab safe)
- Double-submit guard with logging
affects: []
tech-stack:
added: []
patterns: [TTL-based session tokens with backward compatibility]
key-files:
created: []
modified: [autoload/front/Controllers/ShopBasketController.php]
key-decisions:
- "Token format: array ['token' => ..., 'created_at' => ...] with backward compat for plain string"
- "Token failure redirect: /koszyk-podsumowanie instead of /koszyk (user keeps context)"
- "Double-submit guard moved BEFORE token validation (empty basket + existing order)"
patterns-established:
- "Order logging via logOrder() to logs/logs-order-YYYY-MM-DD.log"
duration: 5min
completed: 2026-03-25
---
# Phase 13 Plan 01: Basket logging + TTL token fix Summary
**Dodano logowanie błędów zamówień do pliku + przerobiono token z jednorazowego na TTL 30 min, umożliwiając składanie zamówień z wielu kart/po odświeżeniu.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~5 min |
| Completed | 2026-03-25 |
| Tasks | 2 completed |
| Files modified | 1 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Logowanie błędów w basketSave() | Pass | 4 punkty logowania via logOrder() |
| AC-2: Token TTL 30 min — wiele kart działa | Pass | createOrderSubmitToken() reuses valid token |
| AC-3: Token wygasa po 30 min | Pass | isValidOrderSubmitToken() checks TTL, redirect → /koszyk-podsumowanie |
| AC-4: Double-submit guard dla pustego koszyka | Pass | Nowy guard przed sprawdzeniem tokena |
## Accomplishments
- Dodano metodę `logOrder()` zapisującą do `logs/logs-order-YYYY-MM-DD.log` + 4 punkty logowania w `basketSave()`
- Token zamówienia przerobiony z jednorazowego na TTL 30 min — wiele kart, odświeżenie, "wstecz" nie unieważniają tokena
- Backward compatibility ze starymi stringowymi tokenami w sesji
- Double-submit guard przeniesiony PRZED sprawdzenie tokena (pusty koszyk + istniejące zamówienie → redirect)
- Redirect przy błędzie tokena zmieniony z `/koszyk` na `/koszyk-podsumowanie`
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/front/Controllers/ShopBasketController.php` | Modified | Stała TTL, logOrder(), TTL token, logowanie, double-submit guard |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Token jako array z created_at | Umożliwia TTL check bez dodatkowej sesji | Backward compat z plain string |
| Redirect na /koszyk-podsumowanie | Użytkownik nie traci kontekstu, dostaje nowy token | Lepsza UX |
| Double-submit guard przed token check | Pusty koszyk = pewny double-submit, nie trzeba sprawdzać tokena | Szybsze wykrycie |
## Deviations from Plan
None — plan executed exactly as written
## Issues Encountered
None
## Next Phase Readiness
**Ready:**
- Poprawka gotowa do wdrożenia w update package
- Fazy 12 + 13 razem stanowią kompletny fix checkout flow
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 13-basket-logging-ttl-token, Plan: 01*
*Completed: 2026-03-25*

View File

@@ -0,0 +1,150 @@
---
phase: 14-custom-fields-delete-bug
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- admin/templates/shop-product/product-edit-custom-script.php
- autoload/Domain/Product/ProductRepository.php
autonomous: true
delegation: off
---
<objective>
## Goal
Naprawić bug: usunięcie WSZYSTKICH dodatkowych pól produktu w panelu admina nie działa — pola pozostają po zapisie.
## Purpose
Właściciel sklepu musi mieć możliwość usunięcia wszystkich custom fields z produktu. Obecny bug blokuje tę operację.
## Output
- Poprawiony JS w szablonie — hidden field gwarantujący obecność klucza `custom_field_name` w POST
- Defensive check w repozytorium (opcjonalnie)
- Test jednostkowy potwierdzający poprawkę
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@admin/templates/shop-product/product-edit-custom-script.php
@autoload/Domain/Product/ProductRepository.php
</context>
<acceptance_criteria>
## AC-1: Usunięcie wszystkich custom fields zapisuje pusty stan
```gherkin
Given produkt ma 2 dodatkowe pola (np. "Grawerunek", "Kolor")
When admin usuwa oba pola i klika "Zatwierdź"
Then po zapisie produkt nie ma żadnych dodatkowych pól
And tabela pp_shop_products_custom_fields nie zawiera rekordów dla tego produktu
```
## AC-2: Częściowe usunięcie nadal działa
```gherkin
Given produkt ma 3 dodatkowe pola
When admin usuwa 1 pole i klika "Zatwierdź"
Then po zapisie produkt ma 2 dodatkowe pola
And usunięte pole nie istnieje w bazie
```
## AC-3: Dodawanie pól nadal działa
```gherkin
Given produkt nie ma dodatkowych pól
When admin dodaje 2 nowe pola i klika "Zatwierdź"
Then po zapisie produkt ma 2 dodatkowe pola
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Dodać hidden field gwarantujący klucz custom_field_name w POST</name>
<files>admin/templates/shop-product/product-edit-custom-script.php</files>
<action>
W szablonie product-edit-custom-script.php dodać ukryte pole w sekcji custom fields:
```html
<input type="hidden" name="custom_field_name_present" value="1">
```
To pole musi być ZAWSZE obecne w formularzu (nie wewnątrz dynamicznych wierszy pól),
tak aby serwer wiedział, że sekcja custom fields była obecna w formularzu.
ALTERNATYWNIE (lepsze rozwiązanie): zamiast hidden field, zmienić warunek w ProductRepository
z `array_key_exists('custom_field_name', $d)` na sprawdzanie obecności markera
`custom_field_name_present`.
Podejście: dodać hidden field `custom_field_name_present` w szablonie
+ zmienić warunek w ProductRepository na:
```php
if ( array_key_exists( 'custom_field_name_present', $d ) ) {
```
Dzięki temu:
- Gdy formularz jest renderowany → marker ZAWSZE w POST → saveCustomFields() ZAWSZE wywoływany
- Gdy API partial update bez custom fields → marker BRAK → skip (backward compat)
</action>
<verify>
1. Otworzyć edycję produktu z custom fields w przeglądarce
2. Usunąć wszystkie pola → Zatwierdź → sprawdzić że pola zniknęły
3. Otworzyć ponownie → potwierdzić brak pól
</verify>
<done>AC-1 satisfied: usunięcie wszystkich pól działa poprawnie</done>
</task>
<task type="auto">
<name>Task 2: Test jednostkowy — saveCustomFields z pustą listą</name>
<files>tests/Unit/Domain/Product/ProductRepositoryTest.php</files>
<action>
Dodać test weryfikujący że saveCustomFields() z pustymi tablicami
wywołuje delete na pp_shop_products_custom_fields dla danego produktu.
Test powinien mockować Medoo i sprawdzić:
- Że `delete('pp_shop_products_custom_fields', ['id_product' => $productId])` jest wywoływany
- Że żaden insert/update nie jest wywoływany
saveCustomFields() jest private — użyć Reflection do wywołania
lub testować przez publiczną metodę saveProduct() z odpowiednim payloadem
zawierającym `custom_field_name_present` i puste tablice.
</action>
<verify>./test.ps1 --filter testSaveCustomFieldsDeletesAllWhenEmpty</verify>
<done>AC-1 potwierdzone testem jednostkowym</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Logika saveCustomFields() dla niepustych list pól (insert/update) — działa poprawnie
- API partial update — brak markera = skip custom fields (backward compat)
- Inne sekcje formularza edycji produktu
## SCOPE LIMITS
- Tylko naprawa buga usuwania pól — żadne refactoring ani nowe feature
- Nie zmieniać struktury tabeli pp_shop_products_custom_fields
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Usunięcie wszystkich custom fields → po zapisie brak pól (AC-1)
- [ ] Usunięcie części custom fields → pozostałe zachowane (AC-2)
- [ ] Dodanie nowych custom fields → poprawnie zapisane (AC-3)
- [ ] Testy przechodzą: ./test.ps1
- [ ] Brak regresji w istniejących testach
</verification>
<success_criteria>
- Wszystkie 3 AC spełnione
- Test jednostkowy przechodzi
- Zero regresji w istniejącym test suite (820+ testów)
</success_criteria>
<output>
After completion, create `.paul/phases/14-custom-fields-delete-bug/14-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,95 @@
---
phase: 14-custom-fields-delete-bug
plan: 01
subsystem: admin
tags: [custom-fields, product-edit, form-serialize, hidden-field]
requires: []
provides:
- Fix usuwania wszystkich custom fields z produktu
affects: []
tech-stack:
added: []
patterns: [hidden marker field for form section detection]
key-files:
created: []
modified:
- autoload/admin/Controllers/ShopProductController.php
- autoload/Domain/Product/ProductRepository.php
- tests/Unit/Domain/Product/ProductRepositoryTest.php
key-decisions:
- "Hidden marker custom_field_name_present zamiast polegania na obecności custom_field_name[] w POST"
patterns-established:
- "Marker hidden field pattern: gdy sekcja formularza może mieć 0 elementów, dodaj hidden marker żeby serwer wiedział że sekcja była renderowana"
duration: ~10min
completed: 2026-04-16
---
# Phase 14 Plan 01: Custom fields delete bug fix — Summary
**Naprawiono bug uniemożliwiający usunięcie wszystkich dodatkowych pól produktu — hidden marker gwarantuje wywołanie saveCustomFields() niezależnie od ilości pól.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~10min |
| Completed | 2026-04-16 |
| Tasks | 2 completed |
| Files modified | 3 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Usunięcie wszystkich custom fields | Pass | saveCustomFields() wywoływany dzięki markerowi, else branch kasuje wszystkie rekordy |
| AC-2: Częściowe usunięcie nadal działa | Pass | Logika saveCustomFields() dla niepustych list bez zmian |
| AC-3: Dodawanie pól nadal działa | Pass | Marker nie wpływa na insert/update path |
## Accomplishments
- Dodano hidden field `custom_field_name_present` w `renderCustomFieldsBox()` — zawsze obecny w POST
- Zmieniono warunek w `ProductRepository:1339` z `custom_field_name` na `custom_field_name_present`
- Dodano test jednostkowy potwierdzający delete all path (821 testów, 0 regresji)
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/admin/Controllers/ShopProductController.php` | Modified | Hidden marker `custom_field_name_present` w renderCustomFieldsBox() |
| `autoload/Domain/Product/ProductRepository.php` | Modified | Warunek zmieniony na sprawdzanie markera |
| `tests/Unit/Domain/Product/ProductRepositoryTest.php` | Modified | Test testSaveCustomFieldsDeletesAllWhenEmpty |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Hidden marker zamiast wysyłania pustego array | jQuery .serialize() pomija puste pola array — marker jest niezawodny | Backward compat z API partial update (brak markera = skip) |
## Deviations from Plan
None — plan executed exactly as written.
## Issues Encountered
None.
## Next Phase Readiness
**Ready:**
- Bug naprawiony, test przechodzi, zero regresji
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 14-custom-fields-delete-bug, Plan: 01*
*Completed: 2026-04-16*

File diff suppressed because one or more lines are too long

0
.scannerwork/.sonar_lock Normal file
View File

View File

@@ -0,0 +1,6 @@
projectKey=shopPRO
serverUrl=https://sonar.project-pro.pl
serverVersion=26.3.0.120487
dashboardUrl=https://sonar.project-pro.pl/dashboard?id=shopPRO
ceTaskId=77fcbbea-9d8f-45d6-86d7-b262e33f979e
ceTaskUrl=https://sonar.project-pro.pl/api/ce/task?id=77fcbbea-9d8f-45d6-86d7-b262e33f979e

View File

@@ -132,3 +132,17 @@ read_only_memory_patterns: []
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}

View File

@@ -51,3 +51,13 @@ cron/temp/
# Cache testów
.phpunit.result.cache
# SonarQube
.scannerwork/
.sonar_lock
sonar-project.properties
report-task.txt
Zapis/
# Paul framework
.paul/

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

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

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

View File

@@ -45,16 +45,20 @@ shopPRO is a PHP e-commerce platform with an admin panel and customer-facing sto
# Specific test method
./test.ps1 --filter testGetQuantityReturnsCorrectValue
# Alternative
composer test
# Alternatives
composer test # standard
./test.bat # testdox (readable list)
./test-simple.bat # dots
./test-debug.bat # debug output
./test.sh # Git Bash
```
PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`.
Current suite: **810 tests, 2264 assertions**.
Current suite: **821 tests, 2278 assertions**.
### Creating Updates
See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs.
See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs. ZIP structure must start directly from project directories — no version subfolder inside the archive.
## Architecture
@@ -229,6 +233,9 @@ Before starting implementation, review current state of docs.
- `docs/DATABASE_STRUCTURE.md` — full database schema
- `docs/TESTING.md` — test suite guide and structure
- `docs/FORM_EDIT_SYSTEM.md` — form system architecture
- `docs/CLASS_CATALOG.md` — full catalog of all classes with descriptions
- `docs/TODO.md` — outstanding tasks and planned features
- `docs/CRON_QUEUE_PLAN.md` — planned cron/queue architecture
- `docs/CHANGELOG.md` — version history
- `api-docs/api-reference.json` — REST API documentation (ordersPRO)
- `api-docs/index.html` — REST API documentation (ordersPRO)

0
Zapis/.sonar_lock Normal file
View File

6
Zapis/report-task.txt Normal file
View File

@@ -0,0 +1,6 @@
projectKey=shopPRO
serverUrl=https://sonar.project-pro.pl
serverVersion=26.3.0.120487
dashboardUrl=https://sonar.project-pro.pl/dashboard?id=shopPRO
ceTaskId=4e3e7642-2ed0-4ea7-a1f9-d2c82022acea
ceTaskUrl=https://sonar.project-pro.pl/api/ce/task?id=4e3e7642-2ed0-4ea7-a1f9-d2c82022acea

View File

@@ -78,7 +78,8 @@ $_SESSION['can_use_rfm'] = true;
action="<?= htmlspecialchars($form->action) ?>" enctype="multipart/form-data">
<input type="hidden" name="_form_id" value="<?= htmlspecialchars($form->formId) ?>">
<input type="hidden" name="_csrf_token" value="<?= htmlspecialchars(\Shared\Security\CsrfToken::getToken()) ?>">
<?php foreach ($form->hiddenFields as $name => $value): ?>
<input type="hidden" name="<?= htmlspecialchars($name) ?>" value="<?= htmlspecialchars($value ?? '') ?>">
<?php endforeach; ?>

View File

@@ -37,12 +37,13 @@
?>
<div class="alert alert-danger alert-dismissable">
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
<i class="icon fa fa-ban "></i><?= $alert;?>
<i class="icon fa fa-ban "></i><?= htmlspecialchars($alert) ?>
</div>
<? endif;
?>
<form method="POST" action="/admin/" class="form-horizontal" rol="form">
<input type="hidden" name="s-action" value="user-logon" />
<input type="hidden" name="_csrf_token" value="<?= htmlspecialchars(\Shared\Security\CsrfToken::getToken()) ?>">
<div class="form-group form-inline row">
<div class="col-12">
<div class="input-group input-login">

View File

@@ -1,5 +1,6 @@
<form method="POST" action="/admin/" class="form-horizontal" rol="form">
<input type="hidden" name="s-action" value="user-2fa-verify">
<input type="hidden" name="_csrf_token" value="<?= htmlspecialchars(\Shared\Security\CsrfToken::getToken()) ?>">
<div class="form-group row">
<label class="col col-sm-4 control-label" for="login">Kod z e-maila:</label>
<div class="col col-sm-8">
@@ -14,5 +15,6 @@
</form>
<form method="POST" action="/admin/" style="margin-top:10px">
<input type="hidden" name="s-action" value="user-2fa-resend">
<input type="hidden" name="_csrf_token" value="<?= htmlspecialchars(\Shared\Security\CsrfToken::getToken()) ?>">
<button class="btn btn-danger">Wyślij kod ponownie</button>
</form>

View File

@@ -318,9 +318,7 @@ class ArticleRepository
if (is_array($results)) {
foreach ($results as $row) {
if (file_exists('../' . $row['src'])) {
unlink('../' . $row['src']);
}
$this->safeUnlink($row['src']);
}
}
@@ -337,9 +335,7 @@ class ArticleRepository
if (is_array($results)) {
foreach ($results as $row) {
if (file_exists('../' . $row['src'])) {
unlink('../' . $row['src']);
}
$this->safeUnlink($row['src']);
}
}
@@ -819,9 +815,7 @@ class ArticleRepository
$results = $this->db->select('pp_articles_files', '*', ['article_id' => null]);
if (is_array($results)) {
foreach ($results as $row) {
if (file_exists('../' . $row['src'])) {
unlink('../' . $row['src']);
}
$this->safeUnlink($row['src']);
}
}
@@ -836,15 +830,31 @@ class ArticleRepository
$results = $this->db->select('pp_articles_images', '*', ['article_id' => null]);
if (is_array($results)) {
foreach ($results as $row) {
if (file_exists('../' . $row['src'])) {
unlink('../' . $row['src']);
}
$this->safeUnlink($row['src']);
}
}
$this->db->delete('pp_articles_images', ['article_id' => null]);
}
/**
* Usuwa plik z dysku tylko jeśli ścieżka pozostaje wewnątrz katalogu upload/.
* Zapobiega path traversal przy danych z bazy.
*/
private function safeUnlink(string $src): void
{
$base = realpath('../upload');
if (!$base) {
return;
}
$full = realpath('../' . ltrim($src, '/'));
if ($full && strpos($full, $base . DIRECTORY_SEPARATOR) === 0 && is_file($full)) {
unlink($full);
} elseif ($full) {
error_log( '[shopPRO] safeUnlink: ścieżka poza upload/: ' . $src );
}
}
/**
* Pobiera artykuly opublikowane w podanym zakresie dat.
*/

View File

@@ -130,10 +130,22 @@ class CronJobRepository
*/
public function markFailed($jobId, $error, $attempt = 1)
{
$job = $this->db->get('pp_cron_jobs', ['max_attempts', 'attempts'], ['id' => $jobId]);
$job = $this->db->get('pp_cron_jobs', ['job_type', 'max_attempts', 'attempts'], ['id' => $jobId]);
$attempts = $job ? (int) $job['attempts'] : $attempt;
$maxAttempts = $job ? (int) $job['max_attempts'] : 10;
$jobType = $job ? $job['job_type'] : '';
// Order-related Apilo joby — infinite retry co 30 min
if (CronJobType::isOrderRelatedApiloJob($jobType)) {
$nextRun = date('Y-m-d H:i:s', time() + CronJobType::APILO_ORDER_BACKOFF_SECONDS);
$this->db->update('pp_cron_jobs', [
'status' => CronJobType::STATUS_PENDING,
'last_error' => mb_substr($error, 0, 500),
'scheduled_at' => $nextRun,
], ['id' => $jobId]);
return;
}
if ($attempts >= $maxAttempts) {
// Przekroczono limit prób — trwale failed

View File

@@ -34,6 +34,7 @@ class CronJobType
// Backoff
const BASE_BACKOFF_SECONDS = 60;
const MAX_BACKOFF_SECONDS = 3600;
const APILO_ORDER_BACKOFF_SECONDS = 1800; // 30 min — stały interwał dla order jobów
/**
* @return string[]
@@ -69,6 +70,19 @@ class CronJobType
];
}
/**
* @param string $jobType
* @return bool
*/
public static function isOrderRelatedApiloJob($jobType)
{
return in_array($jobType, [
self::APILO_SEND_ORDER,
self::APILO_SYNC_PAYMENT,
self::APILO_SYNC_STATUS,
], true);
}
/**
* @param int $attempt
* @return int

View File

@@ -0,0 +1,567 @@
<?php
namespace Domain\Integrations;
class ApiloRepository
{
private $db;
private const SETTINGS_TABLE = 'pp_shop_apilo_settings';
private const APILO_ENDPOINTS = [
'platform' => 'https://projectpro.apilo.com/rest/api/orders/platform/map/',
'status' => 'https://projectpro.apilo.com/rest/api/orders/status/map/',
'carrier' => 'https://projectpro.apilo.com/rest/api/orders/carrier-account/map/',
'payment' => 'https://projectpro.apilo.com/rest/api/orders/payment/map/',
];
private const APILO_SETTINGS_KEYS = [
'platform' => 'platform-list',
'status' => 'status-types-list',
'carrier' => 'carrier-account-list',
'payment' => 'payment-types-list',
];
public function __construct( $db )
{
$this->db = $db;
}
// ── Settings access (Apilo-specific) ────────────────────────
private function getApiloSettings(): array
{
$rows = $this->db->select( self::SETTINGS_TABLE, [ 'name', 'value' ] );
$settings = [];
foreach ( $rows ?: [] as $row )
$settings[$row['name']] = $row['value'];
return $settings;
}
private function saveApiloSetting( string $name, $value ): void
{
if ( $this->db->count( self::SETTINGS_TABLE, [ 'name' => $name ] ) ) {
$this->db->update( self::SETTINGS_TABLE, [ 'value' => $value ], [ 'name' => $name ] );
} else {
$this->db->insert( self::SETTINGS_TABLE, [ 'name' => $name, 'value' => $value ] );
}
\Shared\Helpers\Helpers::delete_dir( '../temp/' );
}
// ── Apilo OAuth ─────────────────────────────────────────────
public function apiloAuthorize( string $clientId, string $clientSecret, string $authCode ): bool
{
$postData = [
'grantType' => 'authorization_code',
'token' => $authCode,
];
$ch = curl_init( "https://projectpro.apilo.com/rest/auth/token/" );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" );
curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $postData ) );
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
"Authorization: Basic " . base64_encode( $clientId . ":" . $clientSecret ),
"Accept: application/json"
] );
$response = curl_exec( $ch );
if ( curl_errno( $ch ) ) {
curl_close( $ch );
return false;
}
curl_close( $ch );
$response = json_decode( $response, true );
if ( empty( $response['accessToken'] ) )
return false;
try {
$this->saveApiloSetting( 'access-token', $response['accessToken'] );
$this->saveApiloSetting( 'refresh-token', $response['refreshToken'] );
$this->saveApiloSetting( 'access-token-expire-at', $response['accessTokenExpireAt'] );
$this->saveApiloSetting( 'refresh-token-expire-at', $response['refreshTokenExpireAt'] );
} catch ( \Exception $e ) {
error_log( '[shopPRO] Apilo: błąd zapisu tokenów: ' . $e->getMessage() );
return false;
}
return true;
}
public function apiloGetAccessToken( int $refreshLeadSeconds = 300 ): ?string
{
$settings = $this->getApiloSettings();
$hasRefreshCredentials = !empty( $settings['refresh-token'] )
&& !empty( $settings['client-id'] )
&& !empty( $settings['client-secret'] );
$accessToken = trim( (string)($settings['access-token'] ?? '') );
$accessTokenExpireAt = trim( (string)($settings['access-token-expire-at'] ?? '') );
if ( $accessToken !== '' && $accessTokenExpireAt !== '' ) {
if ( !$this->shouldRefreshAccessToken( $accessTokenExpireAt, $refreshLeadSeconds ) ) {
return $accessToken;
}
}
if ( !$hasRefreshCredentials ) {
return null;
}
if (
!empty( $settings['refresh-token-expire-at'] ) &&
!$this->isFutureDate( (string)$settings['refresh-token-expire-at'] )
) {
return null;
}
return $this->refreshApiloAccessToken( $settings );
}
/**
* Keepalive tokenu Apilo do uzycia w CRON.
* Odswieza token, gdy wygasa lub jest bliski wygasniecia.
*
* @return array{success:bool,skipped:bool,message:string}
*/
public function apiloKeepalive( int $refreshLeadSeconds = 300 ): array
{
$settings = $this->getApiloSettings();
if ( (int)($settings['enabled'] ?? 0) !== 1 ) {
return [
'success' => false,
'skipped' => true,
'message' => 'Apilo disabled.',
];
}
if ( empty( $settings['client-id'] ) || empty( $settings['client-secret'] ) ) {
return [
'success' => false,
'skipped' => true,
'message' => 'Missing Apilo credentials.',
];
}
$token = $this->apiloGetAccessToken( $refreshLeadSeconds );
if ( !$token ) {
return [
'success' => false,
'skipped' => false,
'message' => 'Unable to refresh Apilo token.',
];
}
$this->saveApiloSetting( 'token-keepalive-at', date( 'Y-m-d H:i:s' ) );
return [
'success' => true,
'skipped' => false,
'message' => 'Apilo token keepalive OK.',
];
}
private function refreshApiloAccessToken( array $settings ): ?string
{
$postData = [
'grantType' => 'refresh_token',
'token' => $settings['refresh-token'],
];
$ch = curl_init( "https://projectpro.apilo.com/rest/auth/token/" );
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
"Authorization: Basic " . base64_encode( $settings['client-id'] . ":" . $settings['client-secret'] ),
"Accept: application/json"
] );
curl_setopt( $ch, CURLOPT_POST, true );
curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $postData ) );
curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
$response = curl_exec( $ch );
if ( curl_errno( $ch ) ) {
curl_close( $ch );
return null;
}
curl_close( $ch );
$response = json_decode( $response, true );
if ( empty( $response['accessToken'] ) ) {
return null;
}
$this->saveApiloSetting( 'access-token', $response['accessToken'] );
$this->saveApiloSetting( 'refresh-token', $response['refreshToken'] ?? ( $settings['refresh-token'] ?? '' ) );
$this->saveApiloSetting( 'access-token-expire-at', $response['accessTokenExpireAt'] ?? null );
$this->saveApiloSetting( 'refresh-token-expire-at', $response['refreshTokenExpireAt'] ?? null );
return $response['accessToken'];
}
private function shouldRefreshAccessToken( string $expiresAtRaw, int $leadSeconds = 300 ): bool
{
try {
$expiresAt = new \DateTime( $expiresAtRaw );
} catch ( \Exception $e ) {
return true;
}
$threshold = new \DateTime( date( 'Y-m-d H:i:s', time() + max( 0, $leadSeconds ) ) );
return $expiresAt <= $threshold;
}
private function isFutureDate( string $dateRaw ): bool
{
try {
$date = new \DateTime( $dateRaw );
} catch ( \Exception $e ) {
return false;
}
return $date > new \DateTime( date( 'Y-m-d H:i:s' ) );
}
/**
* Sprawdza aktualny stan integracji Apilo i zwraca komunikat dla UI.
*
* @return array{is_valid:bool,severity:string,message:string}
*/
public function apiloIntegrationStatus(): array
{
$settings = $this->getApiloSettings();
$missing = [];
foreach ( [ 'client-id', 'client-secret' ] as $field ) {
if ( trim( (string)($settings[$field] ?? '') ) === '' )
$missing[] = $field;
}
if ( !empty( $missing ) ) {
return [
'is_valid' => false,
'severity' => 'danger',
'message' => 'Brakuje konfiguracji Apilo: ' . implode( ', ', $missing ) . '.',
];
}
$accessToken = trim( (string)($settings['access-token'] ?? '') );
$authorizationCode = trim( (string)($settings['authorization-code'] ?? '') );
if ( $accessToken === '' ) {
if ( $authorizationCode === '' ) {
return [
'is_valid' => false,
'severity' => 'warning',
'message' => 'Brak authorization-code i access-token. Wpisz kod autoryzacji i uruchom autoryzacje.',
];
}
return [
'is_valid' => false,
'severity' => 'warning',
'message' => 'Brak access-token. Uruchom autoryzacje Apilo.',
];
}
$token = $this->apiloGetAccessToken();
if ( !$token ) {
return [
'is_valid' => false,
'severity' => 'danger',
'message' => 'Token Apilo jest niewazny lub wygasl i nie udal sie refresh. Wykonaj ponowna autoryzacje.',
];
}
$expiresAt = trim( (string)($settings['access-token-expire-at'] ?? '') );
$suffix = $expiresAt !== '' ? ( ' Token wazny do: ' . $expiresAt . '.' ) : '';
return [
'is_valid' => true,
'severity' => 'success',
'message' => 'Integracja Apilo jest aktywna.' . $suffix,
];
}
// ── Apilo API fetch lists ───────────────────────────────────
/**
* Fetch list from Apilo API and save to settings.
* @param string $type platform|status|carrier|payment
*/
public function apiloFetchList( string $type ): bool
{
$result = $this->apiloFetchListResult( $type );
return !empty( $result['success'] );
}
/**
* Fetch list from Apilo API and return detailed status for UI.
*
* @param string $type platform|status|carrier|payment
* @return array{success:bool,count:int,message:string}
*/
public function apiloFetchListResult( string $type ): array
{
if ( !isset( self::APILO_ENDPOINTS[$type] ) )
throw new \InvalidArgumentException( "Unknown apilo list type: $type" );
$settings = $this->getApiloSettings();
$missingFields = [];
foreach ( [ 'client-id', 'client-secret' ] as $requiredField ) {
if ( trim( (string)($settings[$requiredField] ?? '') ) === '' )
$missingFields[] = $requiredField;
}
if ( !empty( $missingFields ) ) {
return [
'success' => false,
'count' => 0,
'message' => 'Brakuje konfiguracji Apilo: ' . implode( ', ', $missingFields ) . '. Uzupelnij pola i zapisz ustawienia.',
];
}
$accessToken = $this->apiloGetAccessToken();
if ( !$accessToken ) {
return [
'success' => false,
'count' => 0,
'message' => 'Brak aktywnego tokenu Apilo. Wykonaj autoryzacje Apilo i sprobuj ponownie.',
];
}
$ch = curl_init( self::APILO_ENDPOINTS[$type] );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $accessToken,
"Accept: application/json"
] );
$response = curl_exec( $ch );
if ( curl_errno( $ch ) ) {
$error = curl_error( $ch );
curl_close( $ch );
return [
'success' => false,
'count' => 0,
'message' => 'Blad polaczenia z Apilo: ' . $error . '. Sprawdz polaczenie serwera i sprobuj ponownie.',
];
}
$httpCode = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
curl_close( $ch );
$data = json_decode( $response, true );
if ( !is_array( $data ) ) {
$responsePreview = substr( trim( (string)$response ), 0, 180 );
if ( $responsePreview === '' )
$responsePreview = '[pusta odpowiedz]';
return [
'success' => false,
'count' => 0,
'message' => 'Apilo zwrocilo niepoprawny format odpowiedzi (HTTP ' . $httpCode . '). Odpowiedz: ' . $responsePreview,
];
}
if ( $httpCode >= 400 ) {
return [
'success' => false,
'count' => 0,
'message' => 'Apilo zwrocilo blad HTTP ' . $httpCode . ': ' . $this->extractApiloErrorMessage( $data ),
];
}
$normalizedList = $this->normalizeApiloMapList( $data );
if ( $normalizedList === null ) {
return [
'success' => false,
'count' => 0,
'message' => 'Apilo zwrocilo dane w nieoczekiwanym formacie. Odswiez token i sproboj ponownie.',
];
}
$this->saveApiloSetting( self::APILO_SETTINGS_KEYS[$type], $normalizedList );
return [
'success' => true,
'count' => count( $normalizedList ),
'message' => 'OK',
];
}
/**
* Normalizuje odpowiedz API mapowania do listy rekordow ['id' => ..., 'name' => ...].
* Zwraca null dla payloadu bledow lub nieoczekiwanego formatu.
*
* @return array<int, array{id:mixed,name:mixed}>|null
*/
private function normalizeApiloMapList( array $data ): ?array
{
if ( isset( $data['message'] ) && isset( $data['code'] ) )
return null;
if ( $this->isMapListShape( $data ) )
return $data;
if ( isset( $data['items'] ) && is_array( $data['items'] ) && $this->isMapListShape( $data['items'] ) )
return $data['items'];
if ( isset( $data['data'] ) && is_array( $data['data'] ) && $this->isMapListShape( $data['data'] ) )
return $data['data'];
// Dopuszczamy rowniez format asocjacyjny: [id => name, ...], ale tylko dla kluczy liczbowych.
if ( !empty( $data ) ) {
$normalized = [];
foreach ( $data as $key => $value ) {
if ( !( is_int( $key ) || ( is_string( $key ) && preg_match('/^-?\d+$/', $key) === 1 ) ) )
return null;
if ( !is_scalar( $value ) )
return null;
$normalized[] = [
'id' => $key,
'name' => (string) $value,
];
}
return !empty( $normalized ) ? $normalized : null;
}
return null;
}
private function isMapListShape( array $list ): bool
{
if ( empty( $list ) )
return false;
foreach ( $list as $row ) {
if ( !is_array( $row ) || !array_key_exists( 'id', $row ) || !array_key_exists( 'name', $row ) )
return false;
}
return true;
}
private function extractApiloErrorMessage( array $data ): string
{
foreach ( [ 'message', 'error', 'detail', 'title' ] as $key ) {
if ( isset( $data[$key] ) && is_scalar( $data[$key] ) ) {
$message = trim( (string)$data[$key] );
if ( $message !== '' )
return $message;
}
}
if ( isset( $data['errors'] ) ) {
if ( is_array( $data['errors'] ) ) {
$flat = [];
foreach ( $data['errors'] as $errorItem ) {
if ( is_scalar( $errorItem ) )
$flat[] = (string)$errorItem;
elseif ( is_array( $errorItem ) )
$flat[] = json_encode( $errorItem, JSON_UNESCAPED_UNICODE );
}
if ( !empty( $flat ) )
return implode( '; ', $flat );
} elseif ( is_scalar( $data['errors'] ) ) {
return (string)$data['errors'];
}
}
return 'Nieznany blad odpowiedzi API.';
}
// ── Apilo product operations ────────────────────────────────
public function apiloProductSearch( string $sku ): array
{
$accessToken = $this->apiloGetAccessToken();
if ( !$accessToken )
return [ 'status' => 'error', 'msg' => 'Brak tokenu Apilo.' ];
$url = "https://projectpro.apilo.com/rest/api/warehouse/product/?" . http_build_query( [ 'sku' => $sku ] );
$ch = curl_init( $url );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $accessToken,
"Accept: application/json"
] );
$response = curl_exec( $ch );
if ( curl_errno( $ch ) ) {
$error = curl_error( $ch );
curl_close( $ch );
return [ 'status' => 'error', 'msg' => 'Błąd cURL: ' . $error ];
}
curl_close( $ch );
$data = json_decode( $response, true );
if ( $data && isset( $data['products'] ) ) {
$data['status'] = 'SUCCESS';
return $data;
}
return [ 'status' => 'SUCCESS', 'msg' => 'Brak wyników dla podanego SKU.', 'products' => '' ];
}
public function apiloCreateProduct( int $productId ): array
{
$accessToken = $this->apiloGetAccessToken();
if ( !$accessToken )
return [ 'success' => false, 'message' => 'Brak tokenu Apilo.' ];
$product = ( new \Domain\Product\ProductRepository( $this->db ) )->findCached( $productId );
$params = [
'sku' => $product['sku'],
'ean' => $product['ean'],
'name' => $product['language']['name'],
'tax' => (int) $product['vat'],
'status' => 1,
'quantity' => (int) $product['quantity'],
'priceWithTax' => $product['price_brutto'],
'description' => $product['language']['description'] . '<br>' . $product['language']['short_description'],
'shortDescription' => '',
'images' => [],
];
foreach ( $product['images'] as $image )
$params['images'][] = "https://" . $_SERVER['HTTP_HOST'] . $image['src'];
$ch = curl_init( "https://projectpro.apilo.com/rest/api/warehouse/product/" );
curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( [ $params ] ) );
curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" );
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $accessToken,
"Content-Type: application/json",
"Accept: application/json"
] );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
$response = curl_exec( $ch );
$responseData = json_decode( $response, true );
if ( curl_errno( $ch ) ) {
$error = curl_error( $ch );
curl_close( $ch );
return [ 'success' => false, 'message' => 'Błąd cURL: ' . $error ];
}
curl_close( $ch );
if ( !empty( $responseData['products'] ) ) {
$this->db->update( 'pp_shop_products', [
'apilo_product_id' => reset( $responseData['products'] ),
'apilo_product_name' => $product['language']['name'],
], [ 'id' => $product['id'] ] );
return [ 'success' => true, 'message' => 'Produkt został dodany do magazynu APILO.' ];
}
return [ 'success' => false, 'message' => 'Podczas dodawania produktu wystąpił błąd.' ];
}
}

View File

@@ -130,444 +130,7 @@ class IntegrationsRepository
], [ 'id' => $productId ] );
}
// ── Apilo OAuth ─────────────────────────────────────────────
public function apiloAuthorize( string $clientId, string $clientSecret, string $authCode ): bool
{
$postData = [
'grantType' => 'authorization_code',
'token' => $authCode,
];
$ch = curl_init( "https://projectpro.apilo.com/rest/auth/token/" );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" );
curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $postData ) );
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
"Authorization: Basic " . base64_encode( $clientId . ":" . $clientSecret ),
"Accept: application/json"
] );
$response = curl_exec( $ch );
if ( curl_errno( $ch ) ) {
curl_close( $ch );
return false;
}
curl_close( $ch );
$response = json_decode( $response, true );
if ( empty( $response['accessToken'] ) )
return false;
$this->saveSetting( 'apilo', 'access-token', $response['accessToken'] );
$this->saveSetting( 'apilo', 'refresh-token', $response['refreshToken'] );
$this->saveSetting( 'apilo', 'access-token-expire-at', $response['accessTokenExpireAt'] );
$this->saveSetting( 'apilo', 'refresh-token-expire-at', $response['refreshTokenExpireAt'] );
return true;
}
public function apiloGetAccessToken( int $refreshLeadSeconds = 300 ): ?string
{
$settings = $this->getSettings( 'apilo' );
$hasRefreshCredentials = !empty( $settings['refresh-token'] )
&& !empty( $settings['client-id'] )
&& !empty( $settings['client-secret'] );
$accessToken = trim( (string)($settings['access-token'] ?? '') );
$accessTokenExpireAt = trim( (string)($settings['access-token-expire-at'] ?? '') );
if ( $accessToken !== '' && $accessTokenExpireAt !== '' ) {
if ( !$this->shouldRefreshAccessToken( $accessTokenExpireAt, $refreshLeadSeconds ) ) {
return $accessToken;
}
}
if ( !$hasRefreshCredentials ) {
return null;
}
if (
!empty( $settings['refresh-token-expire-at'] ) &&
!$this->isFutureDate( (string)$settings['refresh-token-expire-at'] )
) {
return null;
}
return $this->refreshApiloAccessToken( $settings );
}
/**
* Keepalive tokenu Apilo do uzycia w CRON.
* Odswieza token, gdy wygasa lub jest bliski wygasniecia.
*
* @return array{success:bool,skipped:bool,message:string}
*/
public function apiloKeepalive( int $refreshLeadSeconds = 300 ): array
{
$settings = $this->getSettings( 'apilo' );
if ( (int)($settings['enabled'] ?? 0) !== 1 ) {
return [
'success' => false,
'skipped' => true,
'message' => 'Apilo disabled.',
];
}
if ( empty( $settings['client-id'] ) || empty( $settings['client-secret'] ) ) {
return [
'success' => false,
'skipped' => true,
'message' => 'Missing Apilo credentials.',
];
}
$token = $this->apiloGetAccessToken( $refreshLeadSeconds );
if ( !$token ) {
return [
'success' => false,
'skipped' => false,
'message' => 'Unable to refresh Apilo token.',
];
}
$this->saveSetting( 'apilo', 'token-keepalive-at', date( 'Y-m-d H:i:s' ) );
return [
'success' => true,
'skipped' => false,
'message' => 'Apilo token keepalive OK.',
];
}
private function refreshApiloAccessToken( array $settings ): ?string
{
$postData = [
'grantType' => 'refresh_token',
'token' => $settings['refresh-token'],
];
$ch = curl_init( "https://projectpro.apilo.com/rest/auth/token/" );
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
"Authorization: Basic " . base64_encode( $settings['client-id'] . ":" . $settings['client-secret'] ),
"Accept: application/json"
] );
curl_setopt( $ch, CURLOPT_POST, true );
curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $postData ) );
curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
$response = curl_exec( $ch );
if ( curl_errno( $ch ) ) {
curl_close( $ch );
return null;
}
curl_close( $ch );
$response = json_decode( $response, true );
if ( empty( $response['accessToken'] ) ) {
return null;
}
$this->saveSetting( 'apilo', 'access-token', $response['accessToken'] );
$this->saveSetting( 'apilo', 'refresh-token', $response['refreshToken'] ?? ( $settings['refresh-token'] ?? '' ) );
$this->saveSetting( 'apilo', 'access-token-expire-at', $response['accessTokenExpireAt'] ?? null );
$this->saveSetting( 'apilo', 'refresh-token-expire-at', $response['refreshTokenExpireAt'] ?? null );
return $response['accessToken'];
}
private function shouldRefreshAccessToken( string $expiresAtRaw, int $leadSeconds = 300 ): bool
{
try {
$expiresAt = new \DateTime( $expiresAtRaw );
} catch ( \Exception $e ) {
return true;
}
$threshold = new \DateTime( date( 'Y-m-d H:i:s', time() + max( 0, $leadSeconds ) ) );
return $expiresAt <= $threshold;
}
private function isFutureDate( string $dateRaw ): bool
{
try {
$date = new \DateTime( $dateRaw );
} catch ( \Exception $e ) {
return false;
}
return $date > new \DateTime( date( 'Y-m-d H:i:s' ) );
}
/**
* Sprawdza aktualny stan integracji Apilo i zwraca komunikat dla UI.
*
* @return array{is_valid:bool,severity:string,message:string}
*/
public function apiloIntegrationStatus(): array
{
$settings = $this->getSettings( 'apilo' );
$missing = [];
foreach ( [ 'client-id', 'client-secret' ] as $field ) {
if ( trim( (string)($settings[$field] ?? '') ) === '' )
$missing[] = $field;
}
if ( !empty( $missing ) ) {
return [
'is_valid' => false,
'severity' => 'danger',
'message' => 'Brakuje konfiguracji Apilo: ' . implode( ', ', $missing ) . '.',
];
}
$accessToken = trim( (string)($settings['access-token'] ?? '') );
$authorizationCode = trim( (string)($settings['authorization-code'] ?? '') );
if ( $accessToken === '' ) {
if ( $authorizationCode === '' ) {
return [
'is_valid' => false,
'severity' => 'warning',
'message' => 'Brak authorization-code i access-token. Wpisz kod autoryzacji i uruchom autoryzacje.',
];
}
return [
'is_valid' => false,
'severity' => 'warning',
'message' => 'Brak access-token. Uruchom autoryzacje Apilo.',
];
}
$token = $this->apiloGetAccessToken();
if ( !$token ) {
return [
'is_valid' => false,
'severity' => 'danger',
'message' => 'Token Apilo jest niewazny lub wygasl i nie udal sie refresh. Wykonaj ponowna autoryzacje.',
];
}
$expiresAt = trim( (string)($settings['access-token-expire-at'] ?? '') );
$suffix = $expiresAt !== '' ? ( ' Token wazny do: ' . $expiresAt . '.' ) : '';
return [
'is_valid' => true,
'severity' => 'success',
'message' => 'Integracja Apilo jest aktywna.' . $suffix,
];
}
// ── Apilo API fetch lists ───────────────────────────────────
private const APILO_ENDPOINTS = [
'platform' => 'https://projectpro.apilo.com/rest/api/orders/platform/map/',
'status' => 'https://projectpro.apilo.com/rest/api/orders/status/map/',
'carrier' => 'https://projectpro.apilo.com/rest/api/orders/carrier-account/map/',
'payment' => 'https://projectpro.apilo.com/rest/api/orders/payment/map/',
];
private const APILO_SETTINGS_KEYS = [
'platform' => 'platform-list',
'status' => 'status-types-list',
'carrier' => 'carrier-account-list',
'payment' => 'payment-types-list',
];
/**
* Fetch list from Apilo API and save to settings.
* @param string $type platform|status|carrier|payment
*/
public function apiloFetchList( string $type ): bool
{
$result = $this->apiloFetchListResult( $type );
return !empty( $result['success'] );
}
/**
* Fetch list from Apilo API and return detailed status for UI.
*
* @param string $type platform|status|carrier|payment
* @return array{success:bool,count:int,message:string}
*/
public function apiloFetchListResult( string $type ): array
{
if ( !isset( self::APILO_ENDPOINTS[$type] ) )
throw new \InvalidArgumentException( "Unknown apilo list type: $type" );
$settings = $this->getSettings( 'apilo' );
$missingFields = [];
foreach ( [ 'client-id', 'client-secret' ] as $requiredField ) {
if ( trim( (string)($settings[$requiredField] ?? '') ) === '' )
$missingFields[] = $requiredField;
}
if ( !empty( $missingFields ) ) {
return [
'success' => false,
'count' => 0,
'message' => 'Brakuje konfiguracji Apilo: ' . implode( ', ', $missingFields ) . '. Uzupelnij pola i zapisz ustawienia.',
];
}
$accessToken = $this->apiloGetAccessToken();
if ( !$accessToken ) {
return [
'success' => false,
'count' => 0,
'message' => 'Brak aktywnego tokenu Apilo. Wykonaj autoryzacje Apilo i sprobuj ponownie.',
];
}
$ch = curl_init( self::APILO_ENDPOINTS[$type] );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $accessToken,
"Accept: application/json"
] );
$response = curl_exec( $ch );
if ( curl_errno( $ch ) ) {
$error = curl_error( $ch );
curl_close( $ch );
return [
'success' => false,
'count' => 0,
'message' => 'Blad polaczenia z Apilo: ' . $error . '. Sprawdz polaczenie serwera i sprobuj ponownie.',
];
}
$httpCode = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
curl_close( $ch );
$data = json_decode( $response, true );
if ( !is_array( $data ) ) {
$responsePreview = substr( trim( (string)$response ), 0, 180 );
if ( $responsePreview === '' )
$responsePreview = '[pusta odpowiedz]';
return [
'success' => false,
'count' => 0,
'message' => 'Apilo zwrocilo niepoprawny format odpowiedzi (HTTP ' . $httpCode . '). Odpowiedz: ' . $responsePreview,
];
}
if ( $httpCode >= 400 ) {
return [
'success' => false,
'count' => 0,
'message' => 'Apilo zwrocilo blad HTTP ' . $httpCode . ': ' . $this->extractApiloErrorMessage( $data ),
];
}
$normalizedList = $this->normalizeApiloMapList( $data );
if ( $normalizedList === null ) {
return [
'success' => false,
'count' => 0,
'message' => 'Apilo zwrocilo dane w nieoczekiwanym formacie. Odswiez token i sproboj ponownie.',
];
}
$this->saveSetting( 'apilo', self::APILO_SETTINGS_KEYS[$type], $normalizedList );
return [
'success' => true,
'count' => count( $normalizedList ),
'message' => 'OK',
];
}
/**
* Normalizuje odpowiedz API mapowania do listy rekordow ['id' => ..., 'name' => ...].
* Zwraca null dla payloadu bledow lub nieoczekiwanego formatu.
*
* @return array<int, array{id:mixed,name:mixed}>|null
*/
private function normalizeApiloMapList( array $data ): ?array
{
if ( isset( $data['message'] ) && isset( $data['code'] ) )
return null;
if ( $this->isMapListShape( $data ) )
return $data;
if ( isset( $data['items'] ) && is_array( $data['items'] ) && $this->isMapListShape( $data['items'] ) )
return $data['items'];
if ( isset( $data['data'] ) && is_array( $data['data'] ) && $this->isMapListShape( $data['data'] ) )
return $data['data'];
// Dopuszczamy rowniez format asocjacyjny: [id => name, ...], ale tylko dla kluczy liczbowych.
if ( !empty( $data ) ) {
$normalized = [];
foreach ( $data as $key => $value ) {
if ( !( is_int( $key ) || ( is_string( $key ) && preg_match('/^-?\d+$/', $key) === 1 ) ) )
return null;
if ( !is_scalar( $value ) )
return null;
$normalized[] = [
'id' => $key,
'name' => (string) $value,
];
}
return !empty( $normalized ) ? $normalized : null;
}
return null;
}
private function isMapListShape( array $list ): bool
{
if ( empty( $list ) )
return false;
foreach ( $list as $row ) {
if ( !is_array( $row ) || !array_key_exists( 'id', $row ) || !array_key_exists( 'name', $row ) )
return false;
}
return true;
}
private function extractApiloErrorMessage( array $data ): string
{
foreach ( [ 'message', 'error', 'detail', 'title' ] as $key ) {
if ( isset( $data[$key] ) && is_scalar( $data[$key] ) ) {
$message = trim( (string)$data[$key] );
if ( $message !== '' )
return $message;
}
}
if ( isset( $data['errors'] ) ) {
if ( is_array( $data['errors'] ) ) {
$flat = [];
foreach ( $data['errors'] as $errorItem ) {
if ( is_scalar( $errorItem ) )
$flat[] = (string)$errorItem;
elseif ( is_array( $errorItem ) )
$flat[] = json_encode( $errorItem, JSON_UNESCAPED_UNICODE );
}
if ( !empty( $flat ) )
return implode( '; ', $flat );
} elseif ( is_scalar( $data['errors'] ) ) {
return (string)$data['errors'];
}
}
return 'Nieznany blad odpowiedzi API.';
}
// ── Apilo product operations ────────────────────────────────
// ── Product data ─────────────────────────────────────────────
public function getProductSku( int $productId ): ?string
{
@@ -575,93 +138,6 @@ class IntegrationsRepository
return $sku ?: null;
}
public function apiloProductSearch( string $sku ): array
{
$accessToken = $this->apiloGetAccessToken();
if ( !$accessToken )
return [ 'status' => 'error', 'msg' => 'Brak tokenu Apilo.' ];
$url = "https://projectpro.apilo.com/rest/api/warehouse/product/?" . http_build_query( [ 'sku' => $sku ] );
$ch = curl_init( $url );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $accessToken,
"Accept: application/json"
] );
$response = curl_exec( $ch );
if ( curl_errno( $ch ) ) {
$error = curl_error( $ch );
curl_close( $ch );
return [ 'status' => 'error', 'msg' => 'Błąd cURL: ' . $error ];
}
curl_close( $ch );
$data = json_decode( $response, true );
if ( $data && isset( $data['products'] ) ) {
$data['status'] = 'SUCCESS';
return $data;
}
return [ 'status' => 'SUCCESS', 'msg' => 'Brak wyników dla podanego SKU.', 'products' => '' ];
}
public function apiloCreateProduct( int $productId ): array
{
$accessToken = $this->apiloGetAccessToken();
if ( !$accessToken )
return [ 'success' => false, 'message' => 'Brak tokenu Apilo.' ];
$product = ( new \Domain\Product\ProductRepository( $this->db ) )->findCached( $productId );
$params = [
'sku' => $product['sku'],
'ean' => $product['ean'],
'name' => $product['language']['name'],
'tax' => (int) $product['vat'],
'status' => 1,
'quantity' => (int) $product['quantity'],
'priceWithTax' => $product['price_brutto'],
'description' => $product['language']['description'] . '<br>' . $product['language']['short_description'],
'shortDescription' => '',
'images' => [],
];
foreach ( $product['images'] as $image )
$params['images'][] = "https://" . $_SERVER['HTTP_HOST'] . $image['src'];
$ch = curl_init( "https://projectpro.apilo.com/rest/api/warehouse/product/" );
curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( [ $params ] ) );
curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" );
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $accessToken,
"Content-Type: application/json",
"Accept: application/json"
] );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
$response = curl_exec( $ch );
$responseData = json_decode( $response, true );
if ( curl_errno( $ch ) ) {
$error = curl_error( $ch );
curl_close( $ch );
return [ 'success' => false, 'message' => 'Błąd cURL: ' . $error ];
}
curl_close( $ch );
if ( !empty( $responseData['products'] ) ) {
$this->db->update( 'pp_shop_products', [
'apilo_product_id' => reset( $responseData['products'] ),
'apilo_product_name' => $product['language']['name'],
], [ 'id' => $product['id'] ] );
return [ 'success' => true, 'message' => 'Produkt został dodany do magazynu APILO.' ];
}
return [ 'success' => false, 'message' => 'Podczas dodawania produktu wystąpił błąd.' ];
}
// ── ShopPRO import ──────────────────────────────────────────
public function shopproImportProduct( int $productId ): array

View File

@@ -419,8 +419,8 @@ class OrderAdminService
return false;
}
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb );
$accessToken = $integrationsRepository -> apiloGetAccessToken();
$apiloRepository = new \Domain\Integrations\ApiloRepository( $mdb );
$accessToken = $apiloRepository->apiloGetAccessToken();
if (!$accessToken) {
\Domain\Integrations\ApiloLogger::log(
$mdb,
@@ -675,7 +675,7 @@ class OrderAdminService
global $config;
$db = $this->orders->getDb();
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db);
$apiloRepository = new \Domain\Integrations\ApiloRepository($db);
if (empty($order['apilo_order_id'])) {
return true;
@@ -687,7 +687,7 @@ class OrderAdminService
}
$payment_date = new \DateTime($order['date_order']);
$access_token = $integrationsRepository->apiloGetAccessToken();
$access_token = $apiloRepository->apiloGetAccessToken();
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/" . $order['apilo_order_id'] . '/payment/');
@@ -742,13 +742,13 @@ class OrderAdminService
global $config;
$db = $this->orders->getDb();
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db);
$apiloRepository = new \Domain\Integrations\ApiloRepository($db);
if (empty($order['apilo_order_id'])) {
return true;
}
$access_token = $integrationsRepository->apiloGetAccessToken();
$access_token = $apiloRepository->apiloGetAccessToken();
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/" . $order['apilo_order_id'] . '/status/');

View File

@@ -790,8 +790,8 @@ class OrderRepository
}
}
if ($coupon && $coupon->is_one_time()) {
$coupon->set_as_used();
if ($coupon && (int)$coupon->one_time === 1) {
(new \Domain\Coupon\CouponRepository($this->db))->markAsUsed((int)$coupon->id);
}
$order = $this->orderDetailsFrontend($order_id);
@@ -814,7 +814,7 @@ class OrderRepository
\Shared\Helpers\Helpers::send_email($settings['contact_email'], 'Nowe zamówienie / ' . $settings['firm_name'] . ' / ' . $order['number'] . ' - ' . $order['client_surname'] . ' ' . $order['client_name'], $mail_order);
// zmiana statusu w realizacji jeżeli płatność przy odbiorze
if ($payment_id == 3) {
if (!empty($payment_method['is_cod'])) {
$this->updateOrderStatus($order_id, 4);
$this->insertStatusHistory($order_id, 4, 1);
}

View File

@@ -122,6 +122,7 @@ class PaymentMethodRepository
'apilo_payment_type_id' => $this->normalizeApiloPaymentTypeId($data['apilo_payment_type_id'] ?? null),
'min_order_amount' => $this->normalizeDecimalOrNull($data['min_order_amount'] ?? null),
'max_order_amount' => $this->normalizeDecimalOrNull($data['max_order_amount'] ?? null),
'is_cod' => (int)(!empty($data['is_cod']) ? 1 : 0),
];
$this->db->update('pp_shop_payment_methods', $row, ['id' => $paymentMethodId]);
@@ -240,7 +241,8 @@ class PaymentMethodRepository
spm.status,
spm.apilo_payment_type_id,
spm.min_order_amount,
spm.max_order_amount
spm.max_order_amount,
spm.is_cod
FROM pp_shop_payment_methods AS spm
INNER JOIN pp_shop_transport_payment_methods AS stpm
ON stpm.id_payment_method = spm.id
@@ -335,6 +337,7 @@ class PaymentMethodRepository
$row['apilo_payment_type_id'] = $this->normalizeApiloPaymentTypeId($row['apilo_payment_type_id'] ?? null);
$row['min_order_amount'] = $this->normalizeDecimalOrNull($row['min_order_amount'] ?? null);
$row['max_order_amount'] = $this->normalizeDecimalOrNull($row['max_order_amount'] ?? null);
$row['is_cod'] = (int)($row['is_cod'] ?? 0);
return $row;
}

View File

@@ -1335,8 +1335,9 @@ class ProductRepository
$this->saveImagesOrder( $productId, $d['gallery_order'] );
}
// Zapisz custom fields tylko gdy jawnie podane (partial update przez API może nie zawierać tego klucza)
if ( array_key_exists( 'custom_field_name', $d ) ) {
// Zapisz custom fields tylko gdy formularz edycji renderował sekcję (marker hidden field)
// API partial update nie zawiera tego markera — custom fields pominięte
if ( array_key_exists( 'custom_field_name_present', $d ) ) {
$this->saveCustomFields( $productId, $d['custom_field_name'] ?? [], $d['custom_field_type'] ?? [], $d['custom_field_required'] ?? [] );
}
@@ -1601,9 +1602,7 @@ class ProductRepository
$results = $this->db->select( 'pp_shop_products_files', '*', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] );
if ( is_array( $results ) ) {
foreach ( $results as $row ) {
if ( file_exists( '../' . $row['src'] ) ) {
unlink( '../' . $row['src'] );
}
$this->safeUnlink( $row['src'] );
}
}
$this->db->delete( 'pp_shop_products_files', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] );
@@ -1614,9 +1613,7 @@ class ProductRepository
$results = $this->db->select( 'pp_shop_products_images', '*', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] );
if ( is_array( $results ) ) {
foreach ( $results as $row ) {
if ( file_exists( '../' . $row['src'] ) ) {
unlink( '../' . $row['src'] );
}
$this->safeUnlink( $row['src'] );
}
}
$this->db->delete( 'pp_shop_products_images', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] );
@@ -1755,8 +1752,10 @@ class ProductRepository
if ( \Shared\Helpers\Helpers::is_array_fix( $customFields ) ) {
foreach ( $customFields as $row ) {
$this->db->insert( 'pp_shop_products_custom_fields', [
'id_product' => $newProductId,
'name' => $row['name'],
'id_product' => $newProductId,
'name' => $row['name'],
'type' => $row['type'] ?? 'text',
'is_required' => $row['is_required'] ?? 0,
] );
}
}
@@ -2125,14 +2124,30 @@ class ProductRepository
$results = $this->db->select( 'pp_shop_products_images', '*', [ 'product_id' => null ] );
if ( is_array( $results ) ) {
foreach ( $results as $row ) {
if ( file_exists( '../' . $row['src'] ) ) {
unlink( '../' . $row['src'] );
}
$this->safeUnlink( $row['src'] );
}
}
$this->db->delete( 'pp_shop_products_images', [ 'product_id' => null ] );
}
/**
* Usuwa plik z dysku tylko jeśli ścieżka pozostaje wewnątrz katalogu upload/.
* Zapobiega path traversal przy danych z bazy.
*/
private function safeUnlink(string $src): void
{
$base = realpath('../upload');
if (!$base) {
return;
}
$full = realpath('../' . ltrim($src, '/'));
if ($full && strpos($full, $base . DIRECTORY_SEPARATOR) === 0 && is_file($full)) {
unlink($full);
} elseif ($full) {
error_log( '[shopPRO] safeUnlink: ścieżka poza upload/: ' . $src );
}
}
/**
* Oznacza plik do usunięcia.
*/

View File

@@ -0,0 +1,26 @@
<?php
namespace Shared\Security;
class CsrfToken
{
const SESSION_KEY = 'csrf_token';
public static function getToken(): string
{
if (empty($_SESSION[self::SESSION_KEY])) {
$_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(32));
}
return (string) $_SESSION[self::SESSION_KEY];
}
public static function validate(string $token): bool
{
$sessionToken = isset($_SESSION[self::SESSION_KEY]) ? (string) $_SESSION[self::SESSION_KEY] : '';
return $sessionToken !== '' && hash_equals($sessionToken, $token);
}
public static function regenerate(): void
{
$_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(32));
}
}

View File

@@ -43,6 +43,15 @@ class App
$sa = \Shared\Helpers\Helpers::get( 's-action' );
if ( !$sa ) return;
if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) {
$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;
}
}
$domain = preg_replace( '/^www\./', '', $_SERVER['SERVER_NAME'] );
$cookie_name = 'admin_remember_' . str_replace( '.', '-', $domain );
$users = new \Domain\User\UserRepository( $mdb );
@@ -84,6 +93,7 @@ class App
exit;
}
\Shared\Security\CsrfToken::regenerate();
self::finalize_admin_login( $user, $domain, $cookie_name, (bool) \Shared\Helpers\Helpers::get( 'remember' ) );
header( 'Location: /admin/articles/list/' );
exit;
@@ -127,6 +137,7 @@ class App
header( 'Location: /admin/' );
exit;
}
\Shared\Security\CsrfToken::regenerate();
self::finalize_admin_login( $user, $domain, $cookie_name, !empty( $pending['remember'] ) );
header( 'Location: /admin/articles/list/' );
exit;
@@ -372,7 +383,8 @@ class App
'Integrations' => function() {
global $mdb;
return new \admin\Controllers\IntegrationsController(
new \Domain\Integrations\IntegrationsRepository( $mdb )
new \Domain\Integrations\IntegrationsRepository( $mdb ),
new \Domain\Integrations\ApiloRepository( $mdb )
);
},
'ShopStatuses' => function() {

View File

@@ -2,15 +2,18 @@
namespace admin\Controllers;
use Domain\Integrations\IntegrationsRepository;
use Domain\Integrations\ApiloRepository;
use admin\ViewModels\Common\PaginatedTableViewModel;
class IntegrationsController
{
private IntegrationsRepository $repository;
private ApiloRepository $apiloRepository;
public function __construct( IntegrationsRepository $repository )
public function __construct( IntegrationsRepository $repository, ApiloRepository $apiloRepository )
{
$this->repository = $repository;
$this->apiloRepository = $apiloRepository;
}
public function logs(): string
@@ -125,7 +128,7 @@ class IntegrationsController
{
return \Shared\Tpl\Tpl::view( 'integrations/apilo-settings', [
'settings' => $this->repository->getSettings( 'apilo' ),
'apilo_status' => $this->repository->apiloIntegrationStatus(),
'apilo_status' => $this->apiloRepository->apiloIntegrationStatus(),
] );
}
@@ -147,7 +150,7 @@ class IntegrationsController
{
$settings = $this->repository->getSettings( 'apilo' );
if ( $this->repository->apiloAuthorize(
if ( $this->apiloRepository->apiloAuthorize(
(string)($settings['client-id'] ?? ''),
(string)($settings['client-secret'] ?? ''),
(string)($settings['authorization-code'] ?? '')
@@ -156,7 +159,7 @@ class IntegrationsController
exit;
}
$status = $this->repository->apiloIntegrationStatus();
$status = $this->apiloRepository->apiloIntegrationStatus();
$message = trim( (string)($status['message'] ?? '') );
if ( $message === '' ) {
$message = 'Podczas autoryzacji wystapil blad. Prosze sprawdzic dane i sprobowac ponownie.';
@@ -191,7 +194,7 @@ class IntegrationsController
public function apilo_create_product(): void
{
$productId = (int) \Shared\Helpers\Helpers::get( 'product_id' );
$result = $this->repository->apiloCreateProduct( $productId );
$result = $this->apiloRepository->apiloCreateProduct( $productId );
\Shared\Helpers\Helpers::alert( (string)($result['message'] ?? 'Wystapil blad podczas tworzenia produktu w Apilo.') );
header( 'Location: /admin/shop_product/view_list/' );
@@ -208,7 +211,7 @@ class IntegrationsController
exit;
}
echo json_encode( $this->repository->apiloProductSearch( $sku ) );
echo json_encode( $this->apiloRepository->apiloProductSearch( $sku ) );
exit;
}
@@ -267,7 +270,7 @@ class IntegrationsController
private function fetchApiloListWithFeedback( string $type, string $label ): void
{
$result = $this->repository->apiloFetchListResult( $type );
$result = $this->apiloRepository->apiloFetchListResult( $type );
if ( !empty( $result['success'] ) ) {
$count = (int)($result['count'] ?? 0);

View File

@@ -184,6 +184,7 @@ class ShopPaymentMethodController
'apilo_payment_type_id' => $paymentMethod['apilo_payment_type_id'] ?? '',
'min_order_amount' => $paymentMethod['min_order_amount'] ?? '',
'max_order_amount' => $paymentMethod['max_order_amount'] ?? '',
'is_cod' => (int)($paymentMethod['is_cod'] ?? 0),
];
$fields = [
@@ -220,6 +221,10 @@ class ShopPaymentMethodController
'tab' => 'settings',
'options' => $apiloOptions,
]),
FormField::switch('is_cod', [
'label' => 'Platnosc przy odbiorze',
'tab' => 'settings',
]),
FormField::switch('status', [
'label' => 'Aktywny',
'tab' => 'settings',

View File

@@ -699,7 +699,8 @@ class ShopProductController
private function renderCustomFieldsBox( array $product ): string
{
$html = '<a href="#" class="btn btn-success" id="add_custom_field"><i class="fa fa-plus"></i> dodaj niestandardowe pole</a>';
$html = '<input type="hidden" name="custom_field_name_present" value="1">';
$html .= '<a href="#" class="btn btn-success" id="add_custom_field"><i class="fa fa-plus"></i> dodaj niestandardowe pole</a>';
$html .= '<div class="additional_fields pt-3">';
$customFields = is_array( $product['custom_fields'] ?? null ) ? $product['custom_fields'] : [];

View File

@@ -32,6 +32,13 @@ class FormRequestHandler
'data' => []
];
// Walidacja CSRF
$csrfToken = isset($postData['_csrf_token']) ? (string) $postData['_csrf_token'] : '';
if (!\Shared\Security\CsrfToken::validate($csrfToken)) {
$result['errors'] = ['csrf' => 'Nieprawidłowy token bezpieczeństwa. Odśwież stronę i spróbuj ponownie.'];
return $result;
}
// Walidacja
$errors = $this->validator->validate($postData, $formViewModel->fields, $formViewModel->languages);

View File

@@ -5,6 +5,7 @@ class ShopBasketController
{
private const ORDER_SUBMIT_TOKEN_SESSION_KEY = 'order-submit-token';
private const ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY = 'order-submit-last-order-id';
private const ORDER_SUBMIT_TOKEN_TTL = 1800;
public static $title = [
'mainView' => 'Koszyk'
@@ -298,20 +299,23 @@ class ShopBasketController
$orderSubmitToken = (string)\Shared\Helpers\Helpers::get( 'order_submit_token', true );
$existingOrderId = isset( $_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ] ) ? (int)$_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ] : 0;
$basket = \Shared\Helpers\Helpers::get_session( 'basket' );
if ( empty( $basket ) && $existingOrderId > 0 )
{
$existingOrderHash = $this->orderRepository->findHashById( $existingOrderId );
if ( $existingOrderHash )
{
$this->logOrder( 'Double-submit detected, redirecting to existing order id=' . $existingOrderId );
header( 'Location: /zamowienie/' . $existingOrderHash );
exit;
}
}
if ( !$this->isValidOrderSubmitToken( $orderSubmitToken ) )
{
if ( $existingOrderId > 0 )
{
$existingOrderHash = $this->orderRepository->findHashById( $existingOrderId );
if ( $existingOrderHash )
{
header( 'Location: /zamowienie/' . $existingOrderHash );
exit;
}
}
$this->logOrder( 'Token validation failed. formToken=' . $orderSubmitToken . ' existingOrderId=' . $existingOrderId );
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
header( 'Location: /koszyk' );
header( 'Location: /koszyk-podsumowanie' );
exit;
}
@@ -325,7 +329,10 @@ class ShopBasketController
exit;
}
if ( $order_id = $this->orderRepository->createFromBasket(
$order_id = null;
try
{
$order_id = $this->orderRepository->createFromBasket(
$client[ 'id' ],
\Shared\Helpers\Helpers::get_session( 'basket' ),
\Shared\Helpers\Helpers::get_session( 'basket-transport-method-id' ),
@@ -347,7 +354,18 @@ class ShopBasketController
\Shared\Helpers\Helpers::get_session( 'basket_orlen_point_info' ),
\Shared\Helpers\Helpers::get_session( 'coupon' ),
\Shared\Helpers\Helpers::get_session( 'basket_message' )
) )
);
}
catch ( \Exception $e )
{
$this->logOrder( 'createFromBasket exception: ' . $e->getMessage() );
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 )
{
\Shared\Helpers\Helpers::set_session( self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY, (int)$order_id );
\Shared\Helpers\Helpers::alert( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat' ) );
@@ -374,6 +392,7 @@ class ShopBasketController
}
else
{
$this->logOrder( 'createFromBasket returned falsy order_id. client_id=' . ( $client['id'] ?? '?' ) . ' email=' . ( \Shared\Helpers\Helpers::get( 'email', true ) ?: '?' ) );
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
header( 'Location: /koszyk' );
exit;
@@ -420,6 +439,79 @@ class ShopBasketController
] );
}
public function basketUpdateCustomFields()
{
$basket = \Shared\Helpers\Helpers::get_session( 'basket' );
$product_code = \Shared\Helpers\Helpers::get( 'product_code' );
if ( !isset( $basket[ $product_code ] ) )
{
echo json_encode( [ 'result' => 'error', 'message' => 'Pozycja nie istnieje w koszyku' ] );
exit;
}
$position = $basket[ $product_code ];
$new_custom_fields = [];
$custom_fields_raw = \Shared\Helpers\Helpers::get( 'custom_field' );
if ( is_array( $custom_fields_raw ) )
{
foreach ( $custom_fields_raw as $field_id => $value )
{
$new_custom_fields[ (int)$field_id ] = $value;
}
}
$productRepo = new \Domain\Product\ProductRepository( $GLOBALS['mdb'] );
$missing_fields = [];
foreach ( $new_custom_fields as $field_id => $value )
{
$field_meta = $productRepo->findCustomFieldCached( $field_id );
if ( $field_meta && (int)$field_meta['is_required'] === 1 && trim( $value ) === '' )
{
$missing_fields[] = $field_meta['name'];
}
}
if ( count( $missing_fields ) > 0 )
{
echo json_encode( [ 'result' => 'error', 'message' => 'Wypełnij wymagane pola: ' . implode( ', ', $missing_fields ) ] );
exit;
}
$attributes_implode = '';
if ( isset( $position['attributes'] ) && is_array( $position['attributes'] ) && count( $position['attributes'] ) > 0 )
{
$attributes_implode = implode( '|', $position['attributes'] );
}
$message = isset( $position['message'] ) ? $position['message'] : '';
$new_product_code = md5( $position['product-id'] . $attributes_implode . $message . json_encode( $new_custom_fields ) );
if ( $new_product_code === $product_code )
{
$basket[ $product_code ]['custom_fields'] = $new_custom_fields;
}
elseif ( isset( $basket[ $new_product_code ] ) )
{
$basket[ $new_product_code ]['quantity'] += $position['quantity'];
unset( $basket[ $product_code ] );
}
else
{
$position['custom_fields'] = $new_custom_fields;
$basket[ $new_product_code ] = $position;
unset( $basket[ $product_code ] );
}
$basket = ( new \Domain\Promotion\PromotionRepository( $GLOBALS['mdb'] ) )->findPromotion( $basket );
\Shared\Helpers\Helpers::set_session( 'basket', $basket );
echo json_encode( [ 'result' => 'ok' ] );
exit;
}
private function jsonBasketResponse( $basket, $coupon, $lang_id, $basket_transport_method_id )
{
global $settings;
@@ -445,8 +537,23 @@ class ShopBasketController
private function createOrderSubmitToken()
{
$sessionData = isset( $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] )
? $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ]
: null;
if ( is_array( $sessionData ) && isset( $sessionData['token'], $sessionData['created_at'] ) )
{
if ( ( time() - $sessionData['created_at'] ) < self::ORDER_SUBMIT_TOKEN_TTL )
{
return $sessionData['token'];
}
}
$token = $this->generateOrderSubmitToken();
\Shared\Helpers\Helpers::set_session( self::ORDER_SUBMIT_TOKEN_SESSION_KEY, $token );
\Shared\Helpers\Helpers::set_session( self::ORDER_SUBMIT_TOKEN_SESSION_KEY, [
'token' => $token,
'created_at' => time()
] );
\Shared\Helpers\Helpers::delete_session( self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY );
return $token;
@@ -469,10 +576,29 @@ class ShopBasketController
if ( !$token )
return false;
$sessionToken = isset( $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] ) ? (string)$_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] : '';
if ( !$sessionToken )
$sessionData = isset( $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] )
? $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ]
: null;
if ( !$sessionData )
return false;
// Backward compatibility: stary format (plain string)
if ( is_string( $sessionData ) )
{
$sessionToken = $sessionData;
}
elseif ( is_array( $sessionData ) && isset( $sessionData['token'], $sessionData['created_at'] ) )
{
if ( ( time() - $sessionData['created_at'] ) >= self::ORDER_SUBMIT_TOKEN_TTL )
return false;
$sessionToken = $sessionData['token'];
}
else
{
return false;
}
if ( function_exists( 'hash_equals' ) )
return hash_equals( $sessionToken, $token );
@@ -483,4 +609,11 @@ class ShopBasketController
{
\Shared\Helpers\Helpers::delete_session( self::ORDER_SUBMIT_TOKEN_SESSION_KEY );
}
private function logOrder( $message )
{
$logFile = __DIR__ . '/../../../logs/logs-order-' . date( 'Y-m-d' ) . '.log';
$line = '[' . date( 'Y-m-d H:i:s' ) . '] ' . $message . "\n";
@file_put_contents( $logFile, $line, FILE_APPEND );
}
}

View File

@@ -259,7 +259,7 @@ if (-not $ChangelogEntry) {
# --- 8. Tworzenie temp i kopiowanie plikow ---
$tempDir = "temp/temp_$versionInt"
$tempDir = "$env:TEMP\shopPRO_build_$versionInt"
if (Test-Path $tempDir) {
Remove-Item -Recurse -Force $tempDir
@@ -301,15 +301,16 @@ if (Test-Path $zipPath) {
# Pakuj zawartosc temp dir (bez folderu temp/)
$originalLocation = Get-Location
$absoluteZipPath = Join-Path $originalLocation $zipPath
Set-Location $tempDir
$tempItems = Get-ChildItem -Force
if ($tempItems) {
Compress-Archive -Path '*' -DestinationPath "../../$zipPath" -Force
Compress-Archive -Path '*' -DestinationPath $absoluteZipPath -Force
} else {
# SQL-only update: create minimal ZIP with empty placeholder
$placeholderPath = "_sql_only_update.txt"
Set-Content -Path $placeholderPath -Value "SQL-only update $versionNumber"
Compress-Archive -Path $placeholderPath -DestinationPath "../../$zipPath" -Force
Compress-Archive -Path $placeholderPath -DestinationPath $absoluteZipPath -Force
Remove-Item $placeholderPath -Force
}
Set-Location $originalLocation
@@ -378,7 +379,7 @@ if (Test-Path $changelogFile) {
$dateStr = Get-Date -Format "dd.MM.yyyy"
$newEntry = "<b>ver. $versionNumber - $dateStr</b><br />`n$ChangelogEntry`n<hr>`n"
$changelogContent = Get-Content $changelogFile -Raw
$changelogContent = [System.IO.File]::ReadAllText($changelogFile, $Utf8NoBom)
$changelogContent = $newEntry + $changelogContent
[System.IO.File]::WriteAllText($changelogFile, $changelogContent, $Utf8NoBom)
Write-Ok "Zaktualizowano changelog-data.html"

View File

@@ -19,4 +19,9 @@ $config['trustmate']['enabled'] = true;
$config['trustmate']['uid'] = '34eb36ba-c715-4cdc-8707-22376c9f14c7';
$config['cron_key'] = 'Gi7FzWtkry19hZ1BqT1LKEWfwokQpigh';
$database_inst['host_remote'] = 'host700513.hostido.net.pl';
$database_inst['user'] = 'host700513_pomysloweprezenty-pl';
$database_inst['password'] = 'QBVbveHAzR78UN8pc7Um';
$database_inst['name'] = 'host700513_pomysloweprezenty-pl';
?>

102
cron.php
View File

@@ -1,5 +1,5 @@
<?php
error_reporting( E_ALL ^ E_NOTICE ^ E_STRICT ^ E_WARNING ^ E_DEPRECATED );
error_reporting( E_ALL ^ E_NOTICE ^ E_STRICT );
function __autoload_my_classes( $classname )
{
@@ -131,6 +131,7 @@ function getImageUrlById($id) {
$settings = ( new \Domain\Settings\SettingsRepository( $mdb ) )->allSettings();
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb );
$apiloRepository = new \Domain\Integrations\ApiloRepository( $mdb );
$orderRepo = new \Domain\Order\OrderRepository( $mdb );
$cronRepo = new \Domain\CronJob\CronJobRepository( $mdb );
$orderAdminService = new \Domain\Order\OrderAdminService( $orderRepo, null, null, null, $cronRepo );
@@ -184,21 +185,28 @@ if ( file_exists( $json_queue_path ) )
// =========================================================================
// 1. Apilo token keepalive (priorytet: krytyczny)
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_TOKEN_KEEPALIVE, function($payload) use ($integrationsRepository) {
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_TOKEN_KEEPALIVE, function($payload) use ($integrationsRepository, $apiloRepository) {
$apilo_settings = $integrationsRepository->getSettings('apilo');
if ( !(int)($apilo_settings['enabled'] ?? 0) ) return true; // skip if disabled
$integrationsRepository->apiloKeepalive( 300 );
$apiloRepository->apiloKeepalive( 300 );
echo '<p>Apilo token keepalive</p>';
return true;
});
// 2. Apilo send order (priorytet: wysoki)
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SEND_ORDER, function($payload) use ($mdb, $integrationsRepository, $orderAdminService, $config) {
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SEND_ORDER, function($payload) use ($mdb, $integrationsRepository, $apiloRepository, $orderAdminService, $config) {
$apilo_settings = $integrationsRepository->getSettings('apilo');
if ( !$apilo_settings['enabled'] || !$apilo_settings['sync_orders'] || !$apilo_settings['access-token'] || $apilo_settings['sync_orders_date_start'] > date('Y-m-d H:i:s') ) return true;
$orders = $mdb->select( 'pp_shop_orders', '*', [ 'AND' => [ 'apilo_order_id' => null, 'date_order[>=]' => $apilo_settings['sync_orders_date_start'] ], 'ORDER' => [ 'date_order' => 'ASC' ], 'LIMIT' => 1 ] );
// Jeśli brak nowych, ponów failed (-1) z interwałem 1h
if ( empty($orders) ) {
$retryAfter = date( 'Y-m-d H:i:s', strtotime( '-1 hour' ) );
$orders = $mdb->select( 'pp_shop_orders', '*', [ 'AND' => [ 'apilo_order_id' => -1, 'apilo_order_status_date[<=]' => $retryAfter, 'date_order[>=]' => $apilo_settings['sync_orders_date_start'] ], 'ORDER' => [ 'date_order' => 'ASC' ], 'LIMIT' => 1 ] );
}
if ( empty($orders) ) return true;
foreach ( $orders as $order )
@@ -276,7 +284,7 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SEND_ORDER, func
continue;
}
$access_token = $integrationsRepository->apiloGetAccessToken();
$access_token = $apiloRepository->apiloGetAccessToken();
$order_date = new DateTime( $order['date_order'] );
$paczkomatData = parsePaczkomatAddress( $order['inpost_paczkomat'] );
$orlenPointData = parseOrlenAddress( $order['orlen_point'] );
@@ -423,6 +431,7 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SEND_ORDER, func
if (curl_errno( $ch ) ) {
$curl_error_send = curl_error( $ch );
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Błąd cURL przy wysyłaniu zamówienia: ' . $curl_error_send, [ 'curl_error' => $curl_error_send ] );
\Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Błąd cURL wysyłania zamówienia #' . $order['id'] . ' do apilo.com', 'Zamówienie #' . $order['id'] . ' nie zostało wysłane do Apilo z powodu błędu połączenia (cURL).' . "\n\n" . 'Błąd: ' . $curl_error_send );
echo 'Błąd cURL: ' . $curl_error_send;
}
$http_code_send = (int)curl_getinfo( $ch, CURLINFO_HTTP_CODE );
@@ -500,8 +509,8 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SEND_ORDER, func
}
elseif ( $http_code_send >= 400 || !isset( $response['id'] ) )
{
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => -1 ], [ 'id' => $order['id'] ] );
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Błąd wysyłania zamówienia do Apilo (HTTP ' . $http_code_send . ')', [ 'http_code' => $http_code_send, 'response' => $response ] );
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => -1, 'apilo_order_status_date' => date('Y-m-d H:i:s') ], [ 'id' => $order['id'] ] );
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Błąd wysyłania zamówienia do Apilo (HTTP ' . $http_code_send . ') — ponowna próba za 1h', [ 'http_code' => $http_code_send, 'response' => $response ] );
$email_data = 'HTTP Code: ' . $http_code_send . "\n\n";
$email_data .= print_r( $response, true );
$email_data .= print_r( $postData, true );
@@ -512,6 +521,17 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SEND_ORDER, func
{
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => $response['id'] ], [ 'id' => $order['id'] ] );
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Zamówienie wysłane do Apilo (apilo_order_id: ' . $response['id'] . ')', [ 'http_code' => $http_code_send, 'response' => $response ] );
// Wyczyść stare stuck joby sync_payment/sync_status dla tego zamówienia
$orderPayloadJson = json_encode(['order_id' => (int)$order['id']]);
$mdb->delete('pp_cron_jobs', [
'AND' => [
'job_type' => [\Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT, \Domain\CronJob\CronJobType::APILO_SYNC_STATUS],
'payload' => $orderPayloadJson,
'status' => [\Domain\CronJob\CronJobType::STATUS_PENDING, \Domain\CronJob\CronJobType::STATUS_FAILED],
]
]);
echo '<p>Wysłałem zamówienie do apilo.com: ID: ' . $order['id'] . ' - ' . $response['id'] . '</p>';
}
}
@@ -549,7 +569,7 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SYNC_STATUS, fun
});
// 5. Apilo product sync
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRODUCT_SYNC, function($payload) use ($mdb, $integrationsRepository) {
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRODUCT_SYNC, function($payload) use ($mdb, $integrationsRepository, $apiloRepository) {
$apilo_settings = $integrationsRepository->getSettings('apilo');
if ( !$apilo_settings['enabled'] || !$apilo_settings['sync_products'] || !$apilo_settings['access-token'] ) return true;
@@ -557,7 +577,7 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRODUCT_SYNC, fu
$result = $stmt ? $stmt->fetch( \PDO::FETCH_ASSOC ) : null;
if ( !$result ) return true;
$access_token = $integrationsRepository->apiloGetAccessToken();
$access_token = $apiloRepository->apiloGetAccessToken();
$url = 'https://projectpro.apilo.com/rest/api/warehouse/product/' . $result['apilo_product_id'] . '/';
$curl = curl_init( $url );
curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true );
@@ -582,11 +602,11 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRODUCT_SYNC, fu
});
// 6. Apilo pricelist sync
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRICELIST_SYNC, function($payload) use ($mdb, $integrationsRepository) {
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRICELIST_SYNC, function($payload) use ($mdb, $integrationsRepository, $apiloRepository) {
$apilo_settings = $integrationsRepository->getSettings('apilo');
if ( !$apilo_settings['enabled'] || !$apilo_settings['access-token'] ) return true;
$access_token = $integrationsRepository->apiloGetAccessToken();
$access_token = $apiloRepository->apiloGetAccessToken();
$url = 'https://projectpro.apilo.com/rest/api/warehouse/price-calculated/?price=' . $apilo_settings['pricelist_id'];
$curl = curl_init( $url );
@@ -628,7 +648,7 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRICELIST_SYNC,
});
// 7. Apilo status poll
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_STATUS_POLL, function($payload) use ($mdb, $integrationsRepository, $orderRepo, $orderAdminService) {
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_STATUS_POLL, function($payload) use ($mdb, $integrationsRepository, $apiloRepository, $orderRepo, $orderAdminService) {
$apilo_settings = $integrationsRepository->getSettings('apilo');
if ( !$apilo_settings['enabled'] || !$apilo_settings['sync_orders'] || !$apilo_settings['access-token'] ) return true;
@@ -639,7 +659,7 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_STATUS_POLL, fun
{
if ( $order['apilo_order_id'] )
{
$access_token = $integrationsRepository->apiloGetAccessToken();
$access_token = $apiloRepository->apiloGetAccessToken();
$url = 'https://projectpro.apilo.com/rest/api/orders/' . $order['apilo_order_id'] . '/';
$ch = curl_init( $url );
@@ -751,5 +771,61 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::TRUSTMATE_INVITATION,
$result = $processor->run( 20 );
// Powiadomienie mailowe o problemach Apilo
// 1. Trwale failed joby (nie-order: token, product sync itp.)
$failedApiloJobs = $mdb->select('pp_cron_jobs', ['id', 'job_type', 'last_error', 'payload', 'attempts', 'completed_at'], [
'AND' => [
'status' => 'failed',
'job_type[~]' => 'apilo_%',
'completed_at[>=]' => date('Y-m-d H:i:s', time() - 120),
]
]);
// 2. Order joby z wieloma próbami (infinite retry, ale wymagają uwagi)
$stuckOrderJobs = $mdb->select('pp_cron_jobs', ['id', 'job_type', 'last_error', 'payload', 'attempts', 'scheduled_at'], [
'AND' => [
'status' => 'pending',
'job_type' => [\Domain\CronJob\CronJobType::APILO_SEND_ORDER, \Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT, \Domain\CronJob\CronJobType::APILO_SYNC_STATUS],
'attempts[>=]' => 10,
]
]);
$allProblems = array_merge($failedApiloJobs, $stuckOrderJobs);
if (!empty($allProblems)) {
$emailBody = "";
$orderNumbers = [];
foreach ($allProblems as $fj) {
$payloadData = is_string($fj['payload']) ? json_decode($fj['payload'], true) : $fj['payload'];
$orderId = isset($payloadData['order_id']) ? (int)$payloadData['order_id'] : 0;
$isOrderJob = \Domain\CronJob\CronJobType::isOrderRelatedApiloJob($fj['job_type']);
$statusLabel = $isOrderJob ? 'PONAWIANY CO 30 MIN' : 'TRWAŁY BŁĄD';
$emailBody .= "Job #" . $fj['id'] . " (" . $fj['job_type'] . ") — " . $statusLabel . "\n";
if ($orderId > 0) {
$order = $mdb->get('pp_shop_orders', ['id', 'client_name', 'client_surname', 'date_order', 'summary'], ['id' => $orderId]);
if ($order) {
$emailBody .= " Zamówienie: #" . $order['id'] . "\n";
$emailBody .= " Klient: " . trim($order['client_name'] . ' ' . $order['client_surname']) . "\n";
$emailBody .= " Data zamówienia: " . $order['date_order'] . "\n";
$emailBody .= " Kwota: " . $order['summary'] . " PLN\n";
$orderNumbers[] = '#' . $order['id'];
}
}
$emailBody .= " Próby: " . $fj['attempts'] . "\n";
$emailBody .= " Błąd: " . $fj['last_error'] . "\n";
$emailBody .= " Data: " . ($fj['completed_at'] ? $fj['completed_at'] : $fj['scheduled_at']) . "\n\n";
}
$subject = 'shopPRO: Problemy synchronizacji Apilo';
if (!empty($orderNumbers)) {
$subject .= ' — zamówienia ' . implode(', ', array_unique($orderNumbers));
}
$subject .= ' (' . count($allProblems) . ' zadań)';
\Shared\Helpers\Helpers::send_email('biuro@project-pro.pl', $subject, $emailBody);
}
echo '<hr>';
echo '<p><small>CronJob stats: scheduled=' . $result['scheduled'] . ', processed=' . $result['processed'] . ', succeeded=' . $result['succeeded'] . ', failed=' . $result['failed'] . ', skipped=' . $result['skipped'] . '</small></p>';

147
docs/CARL_WORKFLOW.md Normal file
View File

@@ -0,0 +1,147 @@
# CARL — Dynamiczne reguły dla Claude Code
[CARL](https://github.com/ChristopherKahler/carl) (Context Augmentation & Reinforcement Layer) to system wstrzykiwania reguł do Claude Code — reguły ładują się **tylko gdy są potrzebne**, zamiast zapychać kontekst sesji regułami, których akurat nie używasz.
Zainstalowany globalnie w `~/.carl/`.
---
## Jak to działa
Claude Code ma ograniczony kontekst sesji. CARL rozwiązuje problem "zbyt wielu reguł na raz":
- **Domeny** — zestawy reguł uruchamiane automatycznie przez słowa kluczowe w prompcie
- **Star-commands** (`*dev`, `*review`, itp.) — tryby uruchamiane ręcznie przez wpisanie `*nazwatrybe`
- **Global** — reguły zawsze aktywne (minimalne, uniwersalne)
Efekt: zamiast 50 reguł na każdą sesję, Claude dostaje 510 tych, które są relevantne teraz.
---
## Struktura plików
```
~/.carl/ # Globalna konfiguracja (wszystkie projekty)
├── manifest # Rejestr domen (stany + słowa kluczowe)
├── global # Reguły zawsze aktywne
├── commands # Definicje star-commands
├── context # Reguły kontekstowe (rozmiar okna kontekstu)
└── {nazwa-domeny} # Twoja domena (bez rozszerzenia!)
.carl/ # Konfiguracja lokalna (tylko ten projekt)
└── {nazwa-domeny} # Reguły specyficzne dla shopPRO
```
**Ważne:** Nazwy plików domen — **małe litery, bez rozszerzenia** (`phpdev`, nie `phpdev.carl`).
---
## Dostępne star-commands
Wpisz `*nazwa` na początku wiadomości lub w środku promptu, żeby przełączyć tryb:
| Komenda | Tryb | Kiedy używać |
|---------|------|-------------|
| `*dev` | Development | Implementacja, szybkie zmiany bez tłumaczeń |
| `*review` | Code review | Przegląd kodu, bezpieczeństwo, edge cases |
| `*brief` | Zwięzłe odpowiedzi | Tylko bullet points, bez elaboracji |
| `*plan` | Planowanie | Eksploracja przed implementacją, opcje + tradeoffs |
| `*discuss` | Dyskusja | Burza mózgów, wiele podejść, bez skakania do kodu |
| `*debug` | Debugowanie | Systematyczna diagnoza, root cause analysis |
| `*explain` | Wyjaśnianie | Nauka, koncepty, stopniowe budowanie wiedzy |
| `*carl` | Pomoc CARL | Zarządzanie domenami, konfiguracja, pytania o CARL |
### Przykłady
```
*dev Napraw błąd w summaryView() gdzie duplikuje zamówienia
*review Przejrzyj OrderRepository::createFromBasket pod kątem bezpieczeństwa
*brief Co robi CacheHandler::deletePattern()
*plan Chcę dodać system rabatów do koszyka
*discuss Czy lepiej rozdzielić ApiloRepository na sync i admin?
```
---
## Tworzenie własnej domeny (projekt)
Kiedy masz zestaw reguł specyficznych dla shopPRO, utwórz lokalną domenę w `.carl/`.
### Krok 1 — Plik domeny
Utwórz `.carl/shoppro` (bez rozszerzenia):
```
# shopPRO Domain Rules
SHOPPRO_RULE_0=PHP < 8.0 — nie używaj match, named args, union types, str_contains
SHOPPRO_RULE_1=ORM: Medoo ($mdb), zawsze prepared statements, nigdy string concatenation
SHOPPRO_RULE_2=Namespace \Domain\ mapuje do autoload/Domain/ (D uppercase, a lowercase)
SHOPPRO_RULE_3=Testy: PHPUnit 9.6, pattern AAA, mock Medoo przez createMock(\medoo::class)
SHOPPRO_RULE_4=Cache: CacheHandler::deletePattern() do kasowania, TTL 86400, dane serialized
```
### Krok 2 — Wpis w manifeście
Dodaj do `.carl/manifest` (lub `~/.carl/manifest` jeśli globalna):
```
SHOPPRO_STATE=active
SHOPPRO_RECALL=shopPRO, medoo, zamówienie, koszyk, OrderRepository, Domain
SHOPPRO_EXCLUDE=
SHOPPRO_ALWAYS_ON=false
```
- `RECALL` — słowa kluczowe które triggerują domenę (przecinek = OR)
- `ALWAYS_ON=true` — ładuj przy każdym prompcie (tylko dla naprawdę universalnych reguł)
- `EXCLUDE` — słowa które blokują domenę mimo dopasowania RECALL
### Krok 3 — Weryfikacja
Wpisz `*carl` w czacie i zapytaj: _"Pokaż mi aktywne domeny"_.
---
## Zarządzanie przez Claude
Zamiast ręcznie edytować pliki, możesz zarządzać CARL przez Claude:
```
*carl Dodaj domenę dla testów PHPUnit w shopPRO
*carl Pokaż moją aktualną konfigurację
*carl Wyłącz domenę SHOPPRO tymczasowo
*carl Dodaj regułę do domeny dev: zawsze uruchamiaj ./test.ps1 po zmianach
```
Claude użyje skills `carl:manager` / `carl:tasks:*` do operacji na plikach.
---
## Integracja z PAUL
CARL i [PAUL](./PAUL_WORKFLOW.md) działają uzupełniająco:
- **PAUL** strukturyzuje *proces* (plan → apply → unify)
- **CARL** dostarcza *reguły domenowe* wtedy gdy są potrzebne
Praktycznie: podczas `/paul:apply` możesz prefixować `*dev` żeby Claude skupił się na kodzie bez elaboracji. Podczas `/paul:discuss``*discuss` żeby dostać pełną analizę opcji.
---
## Dobre praktyki
- **RECALL słowa** — używaj konkretnych, rzadkich słów żeby unikać false triggers. `medoo` lepsze niż `php`.
- **Mało reguł per domena** — 58 reguł to optymalnie. Więcej = wolniejsze matching, więcej tokenów.
- **ALWAYS_ON=false** domyślnie — ALWAYS_ON=true tylko dla reguł naprawdę universalnych (jak GLOBAL).
- **Star-commands przy dużych taskach** — na początku sesji wpisz `*dev` lub `*plan` żeby ustawić tryb.
- **Nie duplikuj CLAUDE.md** — CARL nie zastępuje CLAUDE.md. CLAUDE.md to architektura projektu. CARL to reguły zachowania Claude.
---
*Docs: 2026-03-12*

View File

@@ -4,6 +4,123 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze.
---
## ver. 0.346 (2026-04-16) - Fix usuwania wszystkich dodatkowych pól produktu
- **FIX**: `autoload/admin/Controllers/ShopProductController.php` — dodany hidden marker `custom_field_name_present` w `renderCustomFieldsBox()`, gwarantujący że sekcja custom fields jest zawsze rozpoznawana w POST nawet gdy wszystkie pola usunięte
- **FIX**: `autoload/Domain/Product/ProductRepository.php` — warunek zapisu custom fields zmieniony z `array_key_exists('custom_field_name')` na `array_key_exists('custom_field_name_present')` — naprawa buga gdzie jQuery `.serialize()` pomijał klucz pustej tablicy
- **NEW**: `tests/Unit/Domain/Product/ProductRepositoryTest.php` — test `testSaveCustomFieldsDeletesAllWhenEmpty` potwierdzający poprawne kasowanie wszystkich pól
---
## ver. 0.345 (2026-03-25) - DataLayer GA4 fix + checkout token fix
- **FIX**: `templates/shop-order/order-details.php` — event purchase: id→item_id (string), name→item_name, price via normalize_decimal (fix price:0), usunięty hardcoded value: 25.42, dodany google_business_vertical
- **FIX**: `templates/shop-basket/summary-view.php` — event begin_checkout: id→item_id, name→item_name, dodany google_business_vertical
- **FIX**: `templates/shop-product/product.php` — event view_item: dodany currency PLN, value, price jako number (nie string), google_business_vertical; event add_to_cart: dodany google_business_vertical, parseInt(quantity)
- **NEW**: `templates/shop-basket/basket.php` — nowy event view_cart na stronie koszyka z pełnym zestawem danych GA4 (item_id, item_name, price, quantity, currency, google_business_vertical)
- **FIX**: `autoload/front/Controllers/ShopBasketController.php` — usunięty błędny guard w summaryView() blokujący kolejne zamówienia po pierwszym (redirect na stare zamówienie zamiast podsumowanie)
- **FIX**: `autoload/front/Controllers/ShopBasketController.php` — token zamówienia z jednorazowego na TTL 30 min (wiele kart, odświeżenie, "wstecz" nie unieważniają formularza)
- **NEW**: `autoload/front/Controllers/ShopBasketController.php` — logowanie błędów zamówień do `logs/logs-order-YYYY-MM-DD.log` (double-submit, token invalid, exception, falsy order_id)
- **FIX**: `autoload/front/Controllers/ShopBasketController.php` — redirect przy złym tokenie na `/koszyk-podsumowanie` zamiast `/koszyk` (użytkownik nie traci kontekstu)
---
## ver. 0.344 (2026-03-19) - Edycja personalizacji produktu w koszyku
- **NEW**: `autoload/front/Controllers/ShopBasketController.php` — nowa metoda `basketUpdateCustomFields()`: AJAX endpoint do edycji custom fields w koszyku z walidacją required, przeliczaniem product_code (MD5 hash) i merge duplikatów
- **NEW**: `templates/shop-basket/_partials/product-custom-fields.php` — przycisk "Edytuj personalizację" + formularz inline z aktualnymi wartościami
- **NEW**: `templates/shop-basket/basket-details.php` — przekazanie `product_code` do szablonu custom fields
- **NEW**: `templates/shop-basket/basket.php` — JavaScript obsługi edycji/zapisu/anulowania personalizacji
---
## ver. 0.343 (2026-03-19) - Custom fields: type + is_required + obsługa obrazków w koszyku
- **FIX**: `autoload/Domain/Product/ProductRepository.php` — kopiowanie custom fields przy duplikacji produktu uwzględnia teraz pola `type` i `is_required`
- **FIX**: `templates/shop-basket/_partials/product-custom-fields.php` — ochrona XSS (htmlspecialchars), obsługa pola typu `image`, bezpieczny fallback typu na `text`
---
## ver. 0.342 (2026-03-19) - Apilo: email z danymi zamówienia + infinite retry dla order jobów
- **FIX**: `cron.php` — email notyfikacji Apilo zawiera teraz dane zamówienia (numer, klient, data, kwota) zamiast surowego JSON payload; temat emaila zawiera numery zamówień
- **NEW**: `autoload/Domain/CronJob/CronJobType.php``isOrderRelatedApiloJob()` identyfikuje order joby (send_order, sync_payment, sync_status)
- **NEW**: `autoload/Domain/CronJob/CronJobRepository.php` — order-related Apilo joby ponawiane w nieskończoność co 30 min zamiast permanent failure po 10 próbach
- **NEW**: `cron.php` — email rozróżnia "PONAWIANY CO 30 MIN" (order joby) vs "TRWAŁY BŁĄD" (inne joby)
- **NEW**: `cron.php` — po udanym wysłaniu zamówienia do Apilo czyszczone są stuck joby sync_payment/sync_status
- **TEST**: +2 testy infinite retry w `CronJobRepositoryTest`
---
## ver. 0.341 (2026-03-16) - Bugfix: zamówienia nie wysyłały się do Apilo + retry i powiadomienia
- **FIX**: `cron.php` — dodano brakujące `$apiloRepository` do klauzul `use()` w 5 handlerach cron (APILO_TOKEN_KEEPALIVE, APILO_SEND_ORDER, APILO_PRODUCT_SYNC, APILO_PRICELIST_SYNC, APILO_STATUS_POLL); regresja z ver. 0.339 (split IntegrationsRepository → ApiloRepository) powodowała `Call to a member function apiloGetAccessToken() on null`
- **FIX**: `cron.php` — zamówienia z `apilo_order_id = -1` (failed) są teraz automatycznie ponawiane co 1h zamiast trwale pomijane; priorytet: najpierw nowe zamówienia (NULL), potem retry (-1)
- **NEW**: `cron.php` — powiadomienie mailowe na `biuro@project-pro.pl` przy błędzie cURL wysyłania zamówienia do Apilo
- **NEW**: `cron.php` — powiadomienie mailowe o trwale nieudanych zadaniach Apilo (po wyczerpaniu `max_attempts`)
---
## ver. 0.340 (2026-03-15) - Bugfix: crash przy składaniu zamówienia z kuponem rabatowym
- **FIX**: `autoload/Domain/Order/OrderRepository.php:793` — naprawiono Fatal Error `Call to undefined method stdClass::is_one_time()` przy składaniu zamówienia z kodem rabatowym; zamieniono wywołania nieistniejących metod na stdClass (`is_one_time()`, `set_as_used()`) na dostęp do właściwości + istniejącą metodę `CouponRepository::markAsUsed()`
- **SONARQUBE**: Pierwszy skan SonarQube — wyniki zapisane w `docs/TODO.md` (4 bugi, 31 critical code smells, 10 major, 8 minor)
---
## ver. 0.339 (2026-03-12) - Refactoring: wydzielenie ApiloRepository z IntegrationsRepository
- **REFACTOR**: `autoload/Domain/Integrations/ApiloRepository.php` — nowa klasa `\Domain\Integrations\ApiloRepository` z 19 metodami apilo* (sync produktów, zamówień, konfiguracja) wydzielonymi z `IntegrationsRepository`
- **REFACTOR**: `autoload/Domain/Integrations/IntegrationsRepository.php` — usunięto 19 metod apilo* (~540 linii); klasa zmniejszona z ~875 do ~340 linii, zawiera wyłącznie generyczną logikę integracji (settings, logi, product linking)
- **REFACTOR**: `autoload/admin/Controllers/IntegrationsController.php` — konsumuje `ApiloRepository` przez DI zamiast `IntegrationsRepository` dla operacji apilo
- **REFACTOR**: `autoload/Domain/Order/OrderAdminService.php` — używa `ApiloRepository` do wysyłki zamówień do Apilo
- **REFACTOR**: `cron.php` — używa `ApiloRepository` do synchronizacji cron
- **REFACTOR**: `autoload/admin/App.php` — wiring DI dla `ApiloRepository`
- **TEST**: `tests/Unit/Domain/Integrations/ApiloRepositoryTest.php` — nowe testy dla `ApiloRepository`; suite: 818 testów, 2275 asercji
---
## ver. 0.338 (2026-03-12) - Bugfix: duplikaty zamówień + status COD
- **FIX**: `autoload/front/Controllers/ShopBasketController::summaryView()` — guard przed ponownym złożeniem zamówienia: jeśli sesja zawiera `ORDER_SUBMIT_LAST_ORDER_ID`, użytkownik jest przekierowywany do istniejącego zamówienia zamiast widzieć formularz ponownie
- **FIX**: `autoload/front/Controllers/ShopBasketController::basketSave()` — owinięcie wywołania `createFromBasket()` w try-catch; wyjątek jest logowany przez `error_log()`, użytkownik widzi komunikat błędu, koszyk sesyjny zostaje zachowany
- **FIX**: `autoload/Domain/Order/OrderRepository::createFromBasket()` — usunięcie hardkodowanego `payment_id == 3` do wykrywania płatności przy odbiorze; zamiast tego używana jest flaga `$payment_method['is_cod']`
- **FEATURE**: `autoload/Domain/PaymentMethod/PaymentMethodRepository` — nowa kolumna `is_cod` (normalizacja, zapis w `save()`, kolumna w `forTransport()` SQL)
- **FEATURE**: `autoload/admin/Controllers/ShopPaymentMethodController` — nowe pole "Platnosc przy odbiorze" w formularzu edycji metody płatności
- **MIGRATION**: `migrations/0.338.sql``ALTER TABLE pp_shop_payment_methods ADD COLUMN is_cod TINYINT(1) NOT NULL DEFAULT 0`
---
## ver. 0.337 (2026-03-12) - Bezpieczeństwo: ochrona CSRF panelu administracyjnego
- **SECURITY**: `autoload/Shared/Security/CsrfToken.php` — nowa klasa z `getToken()`, `validate()`, `regenerate()` (token 64-znakowy hex, `hash_equals()` przeciw timing attacks)
- **SECURITY**: `admin/templates/components/form-edit.php` — dodano ukryte pole `_csrf_token` we wszystkich formularzach edycji
- **SECURITY**: `autoload/admin/Support/Forms/FormRequestHandler::handleSubmit()` — walidacja CSRF przed przetworzeniem danych formularza
- **SECURITY**: `admin/templates/site/unlogged-layout.php` — token CSRF w formularzu logowania + fix XSS na komunikacie alertu (`htmlspecialchars`)
- **SECURITY**: `admin/templates/users/user-2fa.php` — token CSRF w obu formularzach 2FA (weryfikacja i resend)
- **SECURITY**: `autoload/admin/App::special_actions()` — walidacja CSRF dla żądań POST; regeneracja tokenu po udanym logowaniu (obie ścieżki: bezpośrednia i przez 2FA)
- **TEST**: `tests/Unit/Shared/Security/CsrfTokenTest.php` — 7 nowych testów; suite: 817 testów, 2271 asercji
---
## ver. 0.336 (2026-03-12) - Poprawki bezpieczeństwa: error handling w krytycznych ścieżkach
- **FIX**: `cron.php` — przywrócono `E_WARNING` i `E_DEPRECATED` (wyciszano je od zawsze, ukrywając potencjalne błędy)
- **FIX**: `IntegrationsRepository::apiloAuthorize()` — try-catch po zapisie tokenów Apilo; błąd DB logowany i zwraca `false` zamiast cicho kontynuować
- **FIX**: `ProductRepository::safeUnlink()``error_log()` gdy ścieżka istnieje ale jest poza `upload/`
- **FIX**: `ArticleRepository::safeUnlink()` — to samo
---
## ver. 0.335 (2026-03-12) - Poprawki bezpieczeństwa: path traversal i XSS w szablonach
- **SECURITY**: `ProductRepository` — dodano `safeUnlink()` z walidacją `realpath()` zapobiegającą path traversal; użyta w `cleanupDeletedFiles()`, `cleanupDeletedImages()`, `deleteNonassignedImages()`
- **SECURITY**: `ArticleRepository` — to samo; użyta w `deleteMarkedImages()`, `deleteMarkedFiles()`, `deleteNonassignedFiles()`, `deleteNonassignedImages()`
- **SECURITY**: `templates/articles/article-full.php``htmlspecialchars()` na tytule artykułu, `$_SERVER['SERVER_NAME']` i `$url` w linkach social media
- **SECURITY**: `templates/articles/article-entry.php``htmlspecialchars()` na tytule i `$url` (3 miejsca: href, title, alt)
---
## ver. 0.334 (2026-03-12) - Poprawki bezpieczeństwa: debug log, SQL, RedBeanPHP
- **SECURITY**: `ShopOrderController::paymentStatusTpay()` — usunięto `file_put_contents('tpay.txt', ...)` który logował pełne dane POST/GET płatności do publicznego pliku

View File

@@ -521,10 +521,13 @@ Metody platnosci sklepu (modul `/admin/shop_payment_method`).
| apilo_payment_type_id | ID typu platnosci Apilo (NULL gdy brak mapowania) |
| min_order_amount | Minimalna kwota zamowienia (DECIMAL(10,2), NULL = brak limitu) |
| max_order_amount | Maksymalna kwota zamowienia (DECIMAL(10,2), NULL = brak limitu) |
| is_cod | Platnosc przy odbiorze: 1 = tak, 0 = nie (TINYINT DEFAULT 0) |
| sellasist_payment_type_id | DEPRECATED (integracja Sellasist usunieta w ver. 0.263) |
**Uzywane w:** `Domain\PaymentMethod\PaymentMethodRepository`, `admin\Controllers\ShopPaymentMethodController`, `front\factory\ShopPaymentMethod`, `shop\PaymentMethod`, `admin\controls\ShopTransport`, `cron.php`
**Aktualizacja 2026-03-12 (ver. 0.338):** dodano kolumne `is_cod` — flaga platnosci przy odbiorze, zastepuje hardkodowane `payment_id == 3` w `OrderRepository::createFromBasket()`.
**Aktualizacja 2026-02-14 (ver. 0.268):** modul `/admin/shop_payment_method` korzysta z `Domain\PaymentMethod\PaymentMethodRepository` przez `admin\Controllers\ShopPaymentMethodController`. Usunieto legacy klasy `admin\controls\ShopPaymentMethod`, `admin\factory\ShopPaymentMethod`, `admin\view\ShopPaymentMethod` oraz widok `admin/templates/shop-payment-method/view-list.php`.
**Aktualizacja 2026-02-22 (ver. 0.304):** dodano kolumny `min_order_amount` i `max_order_amount` — konfigurowalne limity kwotowe metod platnosci. Zastapiono hardcoded warunek PayPo (id=6, 40-1000 PLN) generycznym filtrowaniem na froncie.

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

View File

@@ -23,10 +23,10 @@ composer test # standard
## Aktualny stan
```text
OK (810 tests, 2264 assertions)
OK (821 tests, 2278 assertions)
```
Zweryfikowano: 2026-03-10 (ver. 0.333)
Zweryfikowano: 2026-04-16 (ver. 0.346)
## Konfiguracja
@@ -71,6 +71,9 @@ tests/
| | |-- Transport/TransportRepositoryTest.php
| | |-- Update/UpdateRepositoryTest.php
| | `-- User/UserRepositoryTest.php
| |-- Shared/
| | `-- Security/
| | `-- CsrfTokenTest.php
| `-- admin/
| `-- Controllers/
| |-- ArticlesControllerTest.php

View File

@@ -5,4 +5,112 @@ program lojalnościowy
proponowane produkty w koszyku
Do zamówień w statusie: realizowane lub oczekuje na wpłatę. Opcja tylko dla zarejestrowanych klientów. https://royal-stone.pl/pl/order1.html
Dodać możliwość ustawienia limitu znaków w wiadomościach do produktu
8. [] Przerobić analitykę Google Analytics i Google ADS
8. [] Przerobić analitykę Google Analytics i Google ADS
9. [x] Rozważyć integrację SonarQube (statyczna analiza kodu PHP — bugi, security, code smells). Community Edition darmowy, self-hosted. Wymaga serwera + MCP server w Claude Code.
## SonarQube — 0.340 (2026-03-15)
### Bugs
- [ ] [MAJOR] cron.php:192 — Review the data-flow - use of uninitialized value (php:S836)
- [ ] [MAJOR] cron.php:561 — Review the data-flow - use of uninitialized value (php:S836)
- [ ] [MAJOR] cron.php:590 — Review the data-flow - use of uninitialized value (php:S836)
- [ ] [MAJOR] cron.php:643 — Review the data-flow - use of uninitialized value (php:S836)
### Code Smells — CRITICAL
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:35 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:66 — Define a constant instead of duplicating "Accept: application/json" 5 times (php:S1192)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:77 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:159 — Define a constant instead of duplicating "Y-m-d H:i:s" 3 times (php:S1192)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:239 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:309 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:315 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:339 — Define a constant instead of duplicating "Authorization: Bearer " 3 times (php:S1192)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:359 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:400 — Refactor this function to reduce its Cognitive Complexity (php:S3776)
- [ ] [CRITICAL] autoload/front/Controllers/ShopBasketController.php:499 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/front/Controllers/ShopBasketController.php:502 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/api/Controllers/ProductsApiController.php:396 — Refactor this function to reduce its Cognitive Complexity from 83 to 15 (php:S3776)
- [ ] [CRITICAL] autoload/Shared/Helpers/Helpers.php:408 — Refactor this function to reduce its Cognitive Complexity from 165 to 15 (php:S3776)
- [ ] [CRITICAL] autoload/Shared/Helpers/Helpers.php:520 — Define a constant instead of duplicating "/([0-9]+)$" 3 times (php:S1192)
- [ ] [CRITICAL] autoload/Shared/Helpers/Helpers.php:607 — Define a constant instead of duplicating " Order Deny,Allow" 3 times (php:S1192)
- [ ] [CRITICAL] autoload/Shared/Helpers/Helpers.php:650 — Define a constant instead of duplicating "&lang=" 7 times (php:S1192)
- [ ] [CRITICAL] cron.php:200 — Define a constant instead of duplicating "Y-m-d H:i:s" 7 times (php:S1192)
- [ ] [CRITICAL] cron.php:200 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:203 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:418 — Define a constant instead of duplicating "Authorization: Bearer " 5 times (php:S1192)
- [ ] [CRITICAL] cron.php:419 — Define a constant instead of duplicating "Accept: application/json" 5 times (php:S1192)
- [ ] [CRITICAL] cron.php:526 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:529 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:531 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:533 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:542 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:545 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:547 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:555 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] cron.php:559 — Add curly braces around nested statement(s) (php:S121)
### Code Smells — MAJOR
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:130 — Method has 4 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:233 — Method has 5 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:307 — Method has 7 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:400 — Method has 8 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:449 — Method has 4 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:481 — Method has 4 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:513 — Method has 4 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/front/Controllers/ShopBasketController.php:493 — Method has 4 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Order/OrderAdminService.php:673 — Method has 4 returns, max 3 allowed (php:S1142)
- [ ] [MAJOR] autoload/Domain/Order/OrderAdminService.php:740 — Method has 4 returns, max 3 allowed (php:S1142)
### Code Smells — MINOR
- [ ] [MINOR] autoload/Domain/Order/OrderRepository.php — Add a new line at the end of file (php:S113)
- [ ] [MINOR] admin/templates/site/unlogged-layout.php — Add a new line at the end of file (php:S113)
- [ ] [MINOR] admin/templates/users/user-2fa.php — Add a new line at the end of file (php:S113)
- [ ] [MINOR] autoload/admin/Controllers/ProductArchiveController.php:196 — Rename function "bulk_delete_permanent" to match camelCase (php:S100)
- [ ] [MINOR] autoload/api/ApiRouter.php:107 — Remove unused "$db" local variable (php:S1481)
- [ ] [MINOR] cron.php:198 — Remove unused "$orderAdminService" local variable (php:S1481)
- [ ] [MINOR] cron.php:524 — Remove unused "$mdb" local variable (php:S1481)
- [ ] [MINOR] cron.php:539 — Remove unused "$mdb" local variable (php:S1481)
## SonarQube — 0.343 (2026-03-19)
### Nowe issues (nie występowały w 0.340)
#### Code Smells — CRITICAL
- [ ] [CRITICAL] autoload/admin/App.php:39 — Cognitive Complexity 37 (max 15) (php:S3776)
- [ ] [CRITICAL] autoload/admin/App.php:50 — Duplicated literal "Location: /admin/" 8 times (php:S1192)
- [ ] [CRITICAL] autoload/front/Controllers/ShopOrderController.php:86 — Cognitive Complexity 22 (max 15) (php:S3776)
- [ ] [CRITICAL] autoload/front/Controllers/ShopBasketController.php:275 — Duplicated literal "Location: /koszyk" 6 times (php:S1192)
- [ ] [CRITICAL] autoload/front/Controllers/ShopBasketController.php:287 — Duplicated literal "Location: /zamowienie/" 3 times (php:S1192)
- [ ] [CRITICAL] autoload/front/Controllers/ShopBasketController.php:495 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/IntegrationsRepository.php:33 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/Domain/Integrations/ApiloRepository.php:449 — Cognitive Complexity 22 (max 15) (php:S3776)
- [ ] [CRITICAL] autoload/Domain/Order/OrderRepository.php:635 — Cognitive Complexity 61 (max 15) (php:S3776)
- [ ] [CRITICAL] cron.php:198 — Cognitive Complexity 109 (max 15) (php:S3776)
- [ ] [CRITICAL] cron.php:651 — Cognitive Complexity 18 (max 15) (php:S3776)
#### Code Smells — MAJOR
- [ ] [MAJOR] cron.php:198 — Function has 305 lines (max 150) (php:S138)
- [ ] [MAJOR] cron.php:572 — Unused function parameter "$payload" (php:S1172)
- [ ] [MAJOR] cron.php:572 — 5 returns (max 3) (php:S1142)
- [ ] [MAJOR] cron.php:605 — Unused function parameter "$payload" (php:S1172)
- [ ] [MAJOR] cron.php:605 — 4 returns (max 3) (php:S1142)
- [ ] [MAJOR] cron.php:651 — Unused function parameter "$payload" (php:S1172)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:53 — 4 returns (max 3) (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:93 — 4 returns (max 3) (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:105 — Merge if statement with enclosing one (php:S1066)
## SonarQube — 0.344 (2026-03-19)
- [ ] [MINOR] autoload/front/Controllers/ShopBasketController.php:484 — Use empty() to check whether the array is empty (php:S1155)
## SonarQube — 0.345 (2026-03-25)
- [ ] [MAJOR] autoload/front/Controllers/ShopBasketController.php:574 — This method has 6 returns, which is more than the 3 allowed (php:S1142)
- [ ] [CRITICAL] autoload/front/Controllers/ShopBasketController.php:576 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/front/Controllers/ShopBasketController.php:602 — Add curly braces around nested statement(s) (php:S121)

3
migrations/0.338.sql Normal file
View File

@@ -0,0 +1,3 @@
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';

16
sonar-project.properties Normal file
View File

@@ -0,0 +1,16 @@
sonar.projectKey=shopPRO
sonar.projectName=shopPRO
sonar.projectVersion=1.0
sonar.host.url=https://sonar.project-pro.pl/
sonar.token=squ_93e67b49c4297694fbd9014ebcb53a37f5882f59
sonar.sources=autoload,admin,index.php,ajax.php,api.php,cron.php
sonar.tests=tests
sonar.exclusions=libraries/**,updates/**,temp/**,admin/layout/**,*.min.js,*.min.css
sonar.language=php
sonar.sourceEncoding=UTF-8
sonar.php.coverage.reportPaths=coverage.xml
sonar.php.tests.reportPath=test-results.xml

81
temp/diagnose_apilo.php Normal file
View File

@@ -0,0 +1,81 @@
<?php
/**
* Diagnostic script for Apilo integration on the instance DB
* Temporary — delete after diagnosis
*/
$db_host = 'host700513.hostido.net.pl';
$db_user = 'host700513_pomysloweprezenty-pl';
$db_pass = 'QBVbveHAzR78UN8pc7Um';
$db_name = 'host700513_pomysloweprezenty-pl';
$pdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8", $db_user, $db_pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "=== 1. APILO SETTINGS ===\n";
$stmt = $pdo->query("SELECT name, LEFT(value, 200) as value FROM pp_shop_apilo_settings ORDER BY name");
foreach ($stmt as $row) {
echo sprintf(" %-35s = %s\n", $row['name'], $row['value']);
}
echo "\n=== 2. PENDING ORDERS (apilo_order_id IS NULL) ===\n";
$stmt = $pdo->query("SELECT COUNT(*) as cnt FROM pp_shop_orders WHERE apilo_order_id IS NULL");
$row = $stmt->fetch();
echo " Total with NULL: " . $row['cnt'] . "\n";
$stmt = $pdo->query("SELECT id, date_order, status, apilo_order_id FROM pp_shop_orders WHERE apilo_order_id IS NULL ORDER BY date_order DESC LIMIT 10");
echo " Last 10 NULL orders:\n";
foreach ($stmt as $row) {
echo sprintf(" #%s date=%s status=%s\n", $row['id'], $row['date_order'], $row['status']);
}
echo "\n=== 3. FAILED ORDERS (apilo_order_id = -1) ===\n";
$stmt = $pdo->query("SELECT COUNT(*) as cnt FROM pp_shop_orders WHERE apilo_order_id = -1");
$row = $stmt->fetch();
echo " Total with -1: " . $row['cnt'] . "\n";
$stmt = $pdo->query("SELECT id, date_order, status FROM pp_shop_orders WHERE apilo_order_id = -1 ORDER BY date_order DESC LIMIT 10");
echo " Last 10 failed orders:\n";
foreach ($stmt as $row) {
echo sprintf(" #%s date=%s status=%s\n", $row['id'], $row['date_order'], $row['status']);
}
echo "\n=== 4. SKIPPED ORDERS (apilo_order_id = -2) ===\n";
$stmt = $pdo->query("SELECT COUNT(*) as cnt FROM pp_shop_orders WHERE apilo_order_id = -2");
$row = $stmt->fetch();
echo " Total with -2: " . $row['cnt'] . "\n";
echo "\n=== 5. RECENT APILO LOGS (pp_log) ===\n";
$stmt = $pdo->query("SELECT id, action, order_id, message, date FROM pp_log WHERE action LIKE '%apilo%' OR action LIKE '%send_order%' OR action LIKE '%token%' OR action LIKE '%keepalive%' ORDER BY id DESC LIMIT 20");
$rows = $stmt->fetchAll();
if (empty($rows)) {
echo " No Apilo logs found in pp_log\n";
// Try broader search
$stmt = $pdo->query("SELECT id, action, order_id, message, date FROM pp_log ORDER BY id DESC LIMIT 10");
$rows = $stmt->fetchAll();
echo " Last 10 logs (any type):\n";
}
foreach ($rows as $row) {
echo sprintf(" [%s] #%s action=%s order=%s msg=%s\n", $row['date'], $row['id'], $row['action'], $row['order_id'], substr($row['message'], 0, 120));
}
echo "\n=== 6. CRON JOBS STATUS ===\n";
$stmt = $pdo->query("SELECT job_type, status, COUNT(*) as cnt FROM pp_cron_jobs GROUP BY job_type, status ORDER BY job_type, status");
echo " Job type / status / count:\n";
foreach ($stmt as $row) {
echo sprintf(" %-30s %-12s %s\n", $row['job_type'], $row['status'], $row['cnt']);
}
echo "\n=== 7. RECENT CRON EXECUTIONS ===\n";
$stmt = $pdo->query("SELECT id, job_type, status, created_at, processed_at FROM pp_cron_jobs ORDER BY id DESC LIMIT 15");
foreach ($stmt as $row) {
echo sprintf(" #%s type=%s status=%s created=%s processed=%s\n", $row['id'], $row['job_type'], $row['status'], $row['created_at'], $row['processed_at']);
}
echo "\n=== 8. SUCCESSFULLY SENT ORDERS (last 5) ===\n";
$stmt = $pdo->query("SELECT id, date_order, apilo_order_id, status FROM pp_shop_orders WHERE apilo_order_id > 0 ORDER BY date_order DESC LIMIT 5");
foreach ($stmt as $row) {
echo sprintf(" #%s date=%s apilo_id=%s status=%s\n", $row['id'], $row['date_order'], $row['apilo_order_id'], $row['status']);
}
echo "\nDone.\n";

49
temp/diagnose_apilo2.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
$pdo = new PDO("mysql:host=host700513.hostido.net.pl;dbname=host700513_pomysloweprezenty-pl;charset=utf8", 'host700513_pomysloweprezenty-pl', 'QBVbveHAzR78UN8pc7Um');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "=== CRON JOBS TABLE COLUMNS ===\n";
$stmt = $pdo->query("DESCRIBE pp_cron_jobs");
foreach ($stmt as $row) echo " " . $row['Field'] . " (" . $row['Type'] . ")\n";
echo "\n=== RECENT CRON JOBS (last 20) ===\n";
$stmt = $pdo->query("SELECT * FROM pp_cron_jobs ORDER BY id DESC LIMIT 20");
foreach ($stmt as $row) {
echo " #" . $row['id'] . " type=" . $row['job_type'] . " status=" . $row['status'] . " created=" . ($row['created_at'] ?? $row['date'] ?? 'n/a') . "\n";
}
echo "\n=== PENDING CRON JOBS ===\n";
$stmt = $pdo->query("SELECT * FROM pp_cron_jobs WHERE status = 'pending' ORDER BY id");
foreach ($stmt as $row) {
echo " #" . $row['id'] . " type=" . $row['job_type'];
foreach ($row as $k => $v) if ($v !== null && $k !== 'id' && $k !== 'job_type') echo " $k=$v";
echo "\n";
}
echo "\n=== NULL ORDERS SINCE 2026-03-15 (after last successful sync) ===\n";
$stmt = $pdo->query("SELECT id, date_order, status FROM pp_shop_orders WHERE apilo_order_id IS NULL AND date_order >= '2026-03-15 14:00:00' ORDER BY date_order ASC");
$rows = $stmt->fetchAll();
echo " Count: " . count($rows) . "\n";
foreach ($rows as $row) {
echo sprintf(" #%s date=%s status=%s\n", $row['id'], $row['date_order'], $row['status']);
}
echo "\n=== NULL ORDERS COUNT BY DATE RANGE ===\n";
$stmt = $pdo->query("SELECT
SUM(CASE WHEN date_order >= '2026-03-15 14:00:00' THEN 1 ELSE 0 END) as since_break,
SUM(CASE WHEN date_order >= '2024-08-20' AND date_order < '2026-03-15 14:00:00' THEN 1 ELSE 0 END) as before_break_after_sync_start,
SUM(CASE WHEN date_order < '2024-08-20' THEN 1 ELSE 0 END) as before_sync_start
FROM pp_shop_orders WHERE apilo_order_id IS NULL");
$row = $stmt->fetch();
echo " Since break (2026-03-15 14:00+): " . $row['since_break'] . "\n";
echo " Before break, after sync_start: " . $row['before_break_after_sync_start'] . "\n";
echo " Before sync_start (2024-08-20): " . $row['before_sync_start'] . "\n";
echo "\n=== LAST SUCCESSFUL SEND ORDER JOB ===\n";
$stmt = $pdo->query("SELECT * FROM pp_cron_jobs WHERE job_type = 'apilo_send_order' AND status = 'completed' ORDER BY id DESC LIMIT 3");
foreach ($stmt as $row) {
foreach ($row as $k => $v) if ($v !== null) echo " $k=$v";
echo "\n";
}
echo "\nDone.\n";

77
temp/fix_apilo_queue.php Normal file
View File

@@ -0,0 +1,77 @@
<?php
/**
* Fix stuck Apilo cron jobs on the instance after deploying the closure fix.
* Run once after uploading the fixed cron.php.
*
* Temporary — delete after use.
*/
$pdo = new PDO("mysql:host=host700513.hostido.net.pl;dbname=host700513_pomysloweprezenty-pl;charset=utf8", 'host700513_pomysloweprezenty-pl', 'QBVbveHAzR78UN8pc7Um');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "=== BEFORE FIX ===\n";
$stmt = $pdo->query("SELECT job_type, status, COUNT(*) as cnt FROM pp_cron_jobs WHERE status IN ('pending','processing','failed') GROUP BY job_type, status ORDER BY job_type");
foreach ($stmt as $row) {
echo " {$row['job_type']} {$row['status']} {$row['cnt']}\n";
}
// 1. Reset the stuck apilo_send_order job (attempts back to 0, clear error)
echo "\n=== Resetting apilo_send_order pending jobs ===\n";
$stmt = $pdo->prepare("UPDATE pp_cron_jobs SET attempts = 0, last_error = NULL, scheduled_at = NOW() WHERE job_type = 'apilo_send_order' AND status = 'pending'");
$stmt->execute();
echo " Reset {$stmt->rowCount()} apilo_send_order jobs\n";
// 2. Reset token keepalive failed jobs
echo "\n=== Resetting apilo_token_keepalive failed jobs ===\n";
$stmt = $pdo->prepare("UPDATE pp_cron_jobs SET attempts = 0, last_error = NULL, status = 'pending', scheduled_at = NOW() WHERE job_type = 'apilo_token_keepalive' AND status = 'failed'");
$stmt->execute();
echo " Reset {$stmt->rowCount()} token keepalive jobs\n";
// 3. Cancel sync_payment and sync_status jobs for orders that haven't been sent yet
// (these orders have apilo_order_id = NULL so sync is pointless — they'll be re-queued after order is sent)
echo "\n=== Cancelling premature sync jobs for unsent orders ===\n";
$stmt = $pdo->query("
SELECT cj.id, cj.job_type, cj.payload
FROM pp_cron_jobs cj
WHERE cj.status = 'pending'
AND cj.job_type IN ('apilo_sync_payment', 'apilo_sync_status')
");
$to_cancel = [];
foreach ($stmt as $row) {
$payload = json_decode($row['payload'], true);
$order_id = $payload['order_id'] ?? 0;
// Check if this order has been sent to Apilo
$check = $pdo->prepare("SELECT apilo_order_id FROM pp_shop_orders WHERE id = ?");
$check->execute([$order_id]);
$order = $check->fetch();
if ($order && ($order['apilo_order_id'] === null || (int)$order['apilo_order_id'] <= 0)) {
$to_cancel[] = $row['id'];
}
}
if ($to_cancel) {
$ids = implode(',', $to_cancel);
$pdo->exec("UPDATE pp_cron_jobs SET status = 'cancelled' WHERE id IN ($ids)");
echo " Cancelled " . count($to_cancel) . " premature sync jobs\n";
} else {
echo " No premature sync jobs to cancel\n";
}
// 4. Reset failed product_sync, pricelist_sync, status_poll jobs
echo "\n=== Resetting other failed Apilo jobs ===\n";
$stmt = $pdo->prepare("UPDATE pp_cron_jobs SET attempts = 0, last_error = NULL, scheduled_at = NOW() WHERE job_type IN ('apilo_product_sync', 'apilo_pricelist_sync', 'apilo_status_poll') AND status = 'pending'");
$stmt->execute();
echo " Reset {$stmt->rowCount()} other Apilo pending jobs\n";
echo "\n=== AFTER FIX ===\n";
$stmt = $pdo->query("SELECT job_type, status, COUNT(*) as cnt FROM pp_cron_jobs WHERE status IN ('pending','processing','failed') GROUP BY job_type, status ORDER BY job_type");
foreach ($stmt as $row) {
echo " {$row['job_type']} {$row['status']} {$row['cnt']}\n";
}
echo "\n=== ORDERS NEEDING SEND (apilo_order_id IS NULL, after sync_start) ===\n";
$stmt = $pdo->query("SELECT COUNT(*) as cnt FROM pp_shop_orders WHERE apilo_order_id IS NULL AND date_order >= '2024-08-20'");
$row = $stmt->fetch();
echo " " . $row['cnt'] . " orders to send\n";
echo " (cron processes 1 per run, should catch up within " . $row['cnt'] . " cron cycles)\n";
echo "\nDone. Deploy fixed cron.php to instance, then run this script.\n";

View File

@@ -0,0 +1,70 @@
<?php
/**
* Rebuild changelog-data.html from docs/CHANGELOG.md
* One-time script — delete after use.
*/
$md = file_get_contents(__DIR__ . '/../docs/CHANGELOG.md');
$lines = explode("\n", $md);
$entries = [];
$currentVersion = null;
$currentDate = null;
$currentLines = [];
foreach ($lines as $line) {
// Match: ## ver. 0.341 (2026-03-16) - Description here
if (preg_match('/^## ver\. ([\d.]+) \((\d{4}-\d{2}-\d{2})\) - (.+)$/', $line, $m)) {
// Save previous entry
if ($currentVersion !== null) {
$entries[] = [
'version' => $currentVersion,
'date' => $currentDate,
'lines' => $currentLines,
];
}
$currentVersion = $m[1];
$currentDate = $m[2];
$currentLines = [];
} elseif ($currentVersion !== null && trim($line) !== '' && trim($line) !== '---') {
// Clean markdown formatting for HTML
$clean = $line;
$clean = preg_replace('/^\- \*\*([A-Z]+)\*\*: /', '$1 - ', $clean);
$clean = preg_replace('/`([^`]+)`/', '$1', $clean);
$clean = str_replace(['**', '__'], '', $clean);
$clean = trim($clean);
if ($clean) {
$currentLines[] = $clean;
}
}
}
// Last entry
if ($currentVersion !== null) {
$entries[] = [
'version' => $currentVersion,
'date' => $currentDate,
'lines' => $currentLines,
];
}
// Build HTML
$html = '';
foreach ($entries as $entry) {
$dateParts = explode('-', $entry['date']);
$dateFormatted = $dateParts[2] . '.' . $dateParts[1] . '.' . $dateParts[0];
$desc = implode("\n", $entry['lines']);
$desc = htmlspecialchars($desc, ENT_QUOTES, 'UTF-8');
$html .= '<b>ver. ' . $entry['version'] . ' - ' . $dateFormatted . '</b><br />' . "\n";
$html .= $desc . "\n";
$html .= '<hr>' . "\n";
}
$outPath = __DIR__ . '/../updates/changelog-data.html';
file_put_contents($outPath, $html);
$size = filesize($outPath);
echo "Generated " . count($entries) . " entries\n";
echo "File size: " . number_format($size) . " bytes\n";
echo "Output: $outPath\n";

View File

@@ -2,11 +2,12 @@
<div class="col-12 col-md-6 ">
<div class="article-entry">
<? $this -> article['language']['seo_link'] ? $url = $this -> article['language']['seo_link'] : $url = 'a-' . $this -> article['id'] . '-' . \Shared\Helpers\Helpers::seo( $this -> article['language']['title'] );?>
<? $safeTitle = htmlspecialchars( $this -> article['language']['title'], ENT_QUOTES, 'UTF-8' ); $safeUrl = htmlspecialchars( $url, ENT_QUOTES, 'UTF-8' );?>
<div class="blog-image">
<a href="/<?= $url;?>" title="<?= $this -> article['language']['title'];?>" <? if ( $this -> article['language']['noindex'] ):?>rel="nofollow"<? endif;?>> <img src="<?= \front\Views\Articles::getImage( $this -> article );?>" alt="<?= $this -> article['language']['title'];?>"></a>
<a href="/<?= $safeUrl;?>" title="<?= $safeTitle;?>" <? if ( $this -> article['language']['noindex'] ):?>rel="nofollow"<? endif;?>> <img src="<?= \front\Views\Articles::getImage( $this -> article );?>" alt="<?= $safeTitle;?>"></a>
</div>
<h3 class="article-title">
<a href="/<? if ( \Shared\Helpers\Helpers::get_session( 'current-lang' ) != ( new \Domain\Languages\LanguagesRepository( $GLOBALS['mdb'] ) )->defaultLanguage() ) echo \Shared\Helpers\Helpers::get_session( 'current-lang' ) . '/';?><?= $url;?>" title="<?= $this -> article['language']['title'];?>" <? if ( $this -> article['language']['noindex'] ):?>rel="nofollow"<? endif;?>><?= $this -> article['language']['title'];?></a>
<a href="/<? if ( \Shared\Helpers\Helpers::get_session( 'current-lang' ) != ( new \Domain\Languages\LanguagesRepository( $GLOBALS['mdb'] ) )->defaultLanguage() ) echo \Shared\Helpers\Helpers::get_session( 'current-lang' ) . '/';?><?= $safeUrl;?>" title="<?= $safeTitle;?>" <? if ( $this -> article['language']['noindex'] ):?>rel="nofollow"<? endif;?>><?= $safeTitle;?></a>
</h3>
<div class="date-add"><?= date( 'd.m.Y', strtotime( $this -> article['date_add'] ) );?></div>
<div class="entry">
@@ -32,6 +33,6 @@
}
?>
</div>
<a href="/<?= $url;?>" class="btn btn-success" title="<?= $this -> article['language']['title'];?>" <? if ( $this -> article['language']['noindex'] ):?>rel="nofollow"<? endif;?>><span class="text"><?= $lang['wiecej'];?></span></a>
<a href="/<?= $safeUrl;?>" class="btn btn-success" title="<?= $safeTitle;?>" <? if ( $this -> article['language']['noindex'] ):?>rel="nofollow"<? endif;?>><span class="text"><?= $lang['wiecej'];?></span></a>
</div>
</div>

View File

@@ -8,24 +8,26 @@ $text = \front\Views\Articles::generateHeadersIds( $text );
$this -> article['language']['seo_link'] ? $url = $this -> article['language']['seo_link'] : $url = 'a-' . $this -> article['id'] . '-' . \Shared\Helpers\Helpers::seo( $this -> article['language']['title'] );
if ( $this -> article['show_title'] )
echo '<h3 class="article-title">' . $this -> article['language']['title'] . '</h3>';
echo '<h3 class="article-title">' . htmlspecialchars( $this -> article['language']['title'], ENT_QUOTES, 'UTF-8' ) . '</h3>';
if ( $this -> article['social_icons'] ):
$safeHost = htmlspecialchars( $_SERVER['SERVER_NAME'], ENT_QUOTES, 'UTF-8' );
$safeUrl = htmlspecialchars( $url, ENT_QUOTES, 'UTF-8' );
?>
<div class="social-icons">
<a class="fb" href="http://www.facebook.com/sharer.php?u=http://www.<?= $_SERVER['SERVER_NAME'];?>/<?= $url;?>" onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=600,width=600');return false;" title="facebook" target="_blank" rel="nofollow">
<a class="fb" href="http://www.facebook.com/sharer.php?u=http://www.<?= $safeHost;?>/<?= $safeUrl;?>" onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=600,width=600');return false;" title="facebook" target="_blank" rel="nofollow">
<img src="/images/system/logo-facebook.jpg" alt="facebook">
</a>
<a class="pinterest" href="http://pinterest.com/pin/create/button/?url=http://www.<?= $_SERVER['SERVER_NAME'];?>/<?= $url;?>" onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=600,width=600');return false;" title="pinterest" target="_blank" rel="nofollow">
<a class="pinterest" href="http://pinterest.com/pin/create/button/?url=http://www.<?= $safeHost;?>/<?= $safeUrl;?>" onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=600,width=600');return false;" title="pinterest" target="_blank" rel="nofollow">
<img src="/images/system/logo-pinterest.jpg" alt="pinterest">
</a>
<a class="twitter" href="http://twitter.com/share?url=http://www.<?= $_SERVER['SERVER_NAME'];?>/<?= $url;?>" onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=450,width=600');return false;" title="twitter" target="_blank" rel="nofollow">
<a class="twitter" href="http://twitter.com/share?url=http://www.<?= $safeHost;?>/<?= $safeUrl;?>" onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=450,width=600');return false;" title="twitter" target="_blank" rel="nofollow">
<img src="/images/system/logo-twitter.jpg" alt="twitter">
</a>
<a class="linkedin" href="http://www.linkedin.com/shareArticle?mini=true&amp;url=http://www.<?= $_SERVER['SERVER_NAME'];?>/<?= $url;?>" onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=500,width=850');return false;" title="linked in" target="_blank" rel="nofollow">
<a class="linkedin" href="http://www.linkedin.com/shareArticle?mini=true&amp;url=http://www.<?= $safeHost;?>/<?= $safeUrl;?>" onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=500,width=850');return false;" title="linked in" target="_blank" rel="nofollow">
<img src="/images/system/logo-linkedin.jpg" alt="linkedin">
</a>
<a class="gp" href="https://plus.google.com/share?url=http://www.<?= $_SERVER['SERVER_NAME'];?>/<?= $url;?>" onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=600,width=600');return false;" title="google+" target="_blank" rel="nofollow">
<a class="gp" href="https://plus.google.com/share?url=http://www.<?= $safeHost;?>/<?= $safeUrl;?>" onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=600,width=600');return false;" title="google+" target="_blank" rel="nofollow">
<img src="/images/system/logo-google.jpg" alt="google+">
</a>
</div>

View File

@@ -1,20 +1,52 @@
<? if ( $this -> custom_fields ) : ?>
<? foreach ( $this -> custom_fields as $key => $val ) : ?>
<? $custom_field = ( new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ) )->findCustomFieldCached( $key ); ?>
<? if ( $custom_field['type'] == 'text' ) : ?>
<div class="custom-field">
<div class="_name">
<?
echo $custom_field['name'] . ':';
?>
</div>
<div class="_text">
<?= $val;?>
</div>
</div>
<? elseif ( $custom_field['type'] == 'image' ) : ?>
<div class="custom-fields-display" data-product-code="<?= htmlspecialchars( $this->product_code ); ?>">
<? foreach ( $this -> custom_fields as $key => $val ) : ?>
<? $custom_field = ( new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ) )->findCustomFieldCached( $key ); ?>
<? $field_type = !empty( $custom_field['type'] ) ? $custom_field['type'] : 'text'; ?>
<? endif; ?>
<? endforeach; ?>
<? endif;?>
<? if ( $field_type == 'text' ) : ?>
<div class="custom-field">
<div class="_name">
<?= htmlspecialchars( $custom_field['name'] ) . ':'; ?>
</div>
<div class="_text">
<?= nl2br( htmlspecialchars( $val ) );?>
</div>
</div>
<? elseif ( $field_type == 'image' && !empty( $val ) ) : ?>
<div class="custom-field">
<div class="_name">
<?= htmlspecialchars( $custom_field['name'] ) . ':'; ?>
</div>
<div class="_image">
<img src="<?= htmlspecialchars( $val );?>" alt="<?= htmlspecialchars( $custom_field['name'] );?>">
</div>
</div>
<? endif; ?>
<? endforeach; ?>
<a href="#" class="btn btn-sm btn-default btn-edit-custom-fields">Edytuj personalizację</a>
</div>
<div class="custom-fields-edit" data-product-code="<?= htmlspecialchars( $this->product_code ); ?>" style="display: none;">
<? foreach ( $this -> custom_fields as $key => $val ) : ?>
<? $custom_field = ( new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ) )->findCustomFieldCached( $key ); ?>
<? $field_type = !empty( $custom_field['type'] ) ? $custom_field['type'] : 'text'; ?>
<? $is_required = !empty( $custom_field['is_required'] ) ? (int)$custom_field['is_required'] : 0; ?>
<div class="custom-field-edit-row" style="margin-bottom: 5px;">
<label>
<?= htmlspecialchars( $custom_field['name'] ); ?><?= $is_required ? ' <span style="color:red;">*</span>' : ''; ?>
</label>
<? if ( $field_type == 'text' ) : ?>
<input type="text" class="form-control form-control-sm" name="custom_field[<?= (int)$key; ?>]" value="<?= htmlspecialchars( $val ); ?>" <?= $is_required ? 'required' : ''; ?>>
<? elseif ( $field_type == 'image' ) : ?>
<input type="text" class="form-control form-control-sm" name="custom_field[<?= (int)$key; ?>]" value="<?= htmlspecialchars( $val ); ?>" placeholder="URL obrazka" <?= $is_required ? 'required' : ''; ?>>
<? endif; ?>
</div>
<? endforeach; ?>
<div style="margin-top: 5px;">
<a href="#" class="btn btn-sm btn-primary btn-save-custom-fields">Zapisz</a>
<a href="#" class="btn btn-sm btn-default btn-cancel-custom-fields">Anuluj</a>
</div>
</div>
<? endif; ?>

View File

@@ -61,7 +61,8 @@
<hr>
<? endif; ?>
<?= \Shared\Tpl\Tpl::view( 'shop-basket/_partials/product-custom-fields', [
'custom_fields' => $position['custom_fields']
'custom_fields' => $position['custom_fields'],
'product_code' => $position_hash
] ); ?>
<? if ( $product['additional_message'] ):?>
<div class="basket-product-message">

View File

@@ -1,4 +1,46 @@
<? global $settings; ?>
<?
if ( $settings['google_tag_manager_id'] && is_array( $this -> basket ) && count( $this -> basket ) ):
$view_cart_items = '';
$view_cart_value = 0;
foreach ( $this -> basket as $position ):
$vc_product = (new \Domain\Product\ProductRepository($GLOBALS['mdb']))->findCached( (int)$position['product-id'], (new \Domain\Languages\LanguagesRepository($GLOBALS['mdb']))->defaultLanguage() );
if ( !$vc_product )
continue;
$vc_price = (float)$vc_product['price_brutto_promo'] > 0 && (float)$vc_product['price_brutto_promo'] < (float)$vc_product['price_brutto']
? (float)$vc_product['price_brutto_promo']
: (float)$vc_product['price_brutto'];
$vc_qty = (int)$position['quantity'];
$view_cart_value += $vc_price * $vc_qty;
if ( $view_cart_items )
$view_cart_items .= ',';
$view_cart_items .= '{';
$view_cart_items .= 'item_id: "' . $vc_product['id'] . '",';
$view_cart_items .= 'item_name: "' . str_replace( '"', '', $vc_product['language']['name'] ) . '",';
$view_cart_items .= 'price: ' . \Shared\Helpers\Helpers::normalize_decimal( $vc_price ) . ',';
$view_cart_items .= 'quantity: ' . $vc_qty . ',';
$view_cart_items .= 'google_business_vertical: "retail"';
$view_cart_items .= '}';
endforeach;
?>
<script type="text/javascript">
dataLayer.push({ ecommerce: null });
dataLayer.push({
event: "view_cart",
ecommerce: {
currency: "PLN",
value: <?= \Shared\Helpers\Helpers::normalize_decimal( $view_cart_value );?>,
items: [<?= $view_cart_items;?>]
}
});
</script>
<? endif; ?>
<div id="basket-container">
<div id="content">
<?= $this->basket_details; ?>
@@ -508,4 +550,62 @@
console.warn('#orlen_point_id nie został znaleziony.');
}
});
// edycja personalizacji produktu w koszyku
$(document).on('click', '.btn-edit-custom-fields', function(e) {
e.preventDefault();
var $display = $(this).closest('.custom-fields-display');
var productCode = $display.data('product-code');
$display.hide();
$display.siblings('.custom-fields-edit[data-product-code="' + productCode + '"]').show();
});
$(document).on('click', '.btn-cancel-custom-fields', function(e) {
e.preventDefault();
var $edit = $(this).closest('.custom-fields-edit');
var productCode = $edit.data('product-code');
$edit.hide();
$edit.siblings('.custom-fields-display[data-product-code="' + productCode + '"]').show();
});
$(document).on('click', '.btn-save-custom-fields', function(e) {
e.preventDefault();
var $edit = $(this).closest('.custom-fields-edit');
var productCode = $edit.data('product-code');
var valid = true;
$edit.find('input[required]').each(function() {
if ($.trim($(this).val()) === '') {
$(this).css('border-color', 'red');
valid = false;
} else {
$(this).css('border-color', '');
}
});
if (!valid) {
alert('Wypełnij wszystkie wymagane pola');
return;
}
var formData = { product_code: productCode };
$edit.find('input[name^="custom_field"]').each(function() {
formData[$(this).attr('name')] = $(this).val();
});
$.ajax({
type: 'POST',
cache: false,
url: '/shopBasket/basket_update_custom_fields',
data: formData,
success: function(response) {
var data = jQuery.parseJSON(response);
if (data.result === 'ok') {
location.reload();
} else {
alert(data.message || 'Wystąpił błąd');
}
}
});
});
</script>

View File

@@ -73,10 +73,11 @@
$begin_checkout_items .= ',';
$begin_checkout_items .= '{';
$begin_checkout_items .= '"id": "' . $product['id'] . '",';
$begin_checkout_items .= '"name": "' . $product['language']['name'] . '",';
$begin_checkout_items .= '"item_id": "' . $product['id'] . '",';
$begin_checkout_items .= '"item_name": "' . str_replace( '"', '', $product['language']['name'] ) . '",';
$begin_checkout_items .= '"price": ' . \Shared\Helpers\Helpers::normalize_decimal( $price_product['price_new'] ) . ',';
$begin_checkout_items .= '"quantity": ' . $position['quantity'];
$begin_checkout_items .= '"quantity": ' . (int)$position['quantity'] . ',';
$begin_checkout_items .= '"google_business_vertical": "retail"';
$begin_checkout_items .= '}';
?>
<? endforeach;?>

View File

@@ -169,17 +169,17 @@
event: "purchase",
ecommerce: {
transaction_id: "<?= $this -> order['id'];?>",
value: 25.42,
currency: "PLN",
value: <?= \Shared\Helpers\Helpers::normalize_decimal( round( $this -> order['summary'], 2 ) ) - str_replace( ',', '.', round( $this -> order['transport_cost'], 2 ) );?>,
shipping: <?= \Shared\Helpers\Helpers::normalize_decimal( $this -> order['transport_cost'] );?>,
items: [
<? foreach ( $this -> order['products'] as $product ):?>
{
'id': <?= (int)$product['product_id'];?>,
'name': '<?= $product['name'];?>',
'quantity': <?= $product['quantity'];?>,
'price': <?= ((float)$product['price_brutto_promo'] > 0 && (float)$product['price_brutto_promo'] < (float)$product['price_brutto']) ? (float)$product['price_brutto_promo'] : (float)$product['price_brutto'];?>
item_id: "<?= $product['product_id'];?>",
item_name: "<?= str_replace( '"', '', $product['name'] );?>",
quantity: <?= (int)$product['quantity'];?>,
price: <?= ((float)$product['price_brutto_promo'] > 0 && (float)$product['price_brutto_promo'] < (float)$product['price_brutto']) ? \Shared\Helpers\Helpers::normalize_decimal( $product['price_brutto_promo'] ) : \Shared\Helpers\Helpers::normalize_decimal( $product['price_brutto'] );?>,
google_business_vertical: "retail"
}<? if ( $product != end( $this -> order['products'] ) ) echo ',';?>
<? endforeach;?>
]

View File

@@ -275,12 +275,15 @@
dataLayer.push({
event: "view_item",
ecommerce: {
currency: "PLN",
value: <? if ( $this -> product['price_brutto_promo'] ): echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto_promo'] ); else: echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto'] ); endif;?>,
items: [
{
item_id: "<?= $this -> product['id'];?>",
item_name: "<?= str_replace( '"', '', $this -> product['language']['name'] );?>",
price: '<? if ( $this -> product['price_brutto_promo'] ): echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto_promo'] ); else: echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto'] ); endif;?>',
quantity: 1
price: <? if ( $this -> product['price_brutto_promo'] ): echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto_promo'] ); else: echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto'] ); endif;?>,
quantity: 1,
google_business_vertical: "retail"
}
]
}
@@ -617,7 +620,8 @@
item_id: "<?= $this -> product['id'];?>",
item_name: "<?= str_replace( '"', '', $this -> product['language']['name'] );?>",
price: <? if ( $this -> product['price_brutto_promo'] ): echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto_promo'] ); else: echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto'] ); endif;?>,
quantity: quantity
quantity: parseInt(quantity),
google_business_vertical: "retail"
}
]
}

View File

@@ -205,6 +205,7 @@ class CronJobRepositoryTest extends TestCase
{
// Job with attempts < max_attempts → reschedule with backoff
$this->mockDb->method('get')->willReturn([
'job_type' => 'price_history',
'max_attempts' => 10,
'attempts' => 2,
]);
@@ -228,6 +229,7 @@ class CronJobRepositoryTest extends TestCase
{
// Job with attempts >= max_attempts → permanent failure
$this->mockDb->method('get')->willReturn([
'job_type' => 'price_history',
'max_attempts' => 3,
'attempts' => 3,
]);
@@ -249,6 +251,7 @@ class CronJobRepositoryTest extends TestCase
public function testMarkFailedTruncatesErrorTo500Chars(): void
{
$this->mockDb->method('get')->willReturn([
'job_type' => 'price_history',
'max_attempts' => 10,
'attempts' => 1,
]);
@@ -268,6 +271,51 @@ class CronJobRepositoryTest extends TestCase
$this->repo->markFailed(1, $longError);
}
public function testMarkFailedApiloOrderJobNeverPermanentlyFails(): void
{
// apilo_send_order with max attempts reached → still pending (infinite retry)
$this->mockDb->method('get')->willReturn([
'job_type' => 'apilo_send_order',
'max_attempts' => 10,
'attempts' => 15,
]);
$this->mockDb->expects($this->once())
->method('update')
->with(
'pp_cron_jobs',
$this->callback(function ($data) {
return $data['status'] === 'pending'
&& isset($data['scheduled_at'])
&& isset($data['last_error']);
}),
['id' => 5]
);
$this->repo->markFailed(5, 'API timeout');
}
public function testMarkFailedApiloSyncPaymentInfiniteRetry(): void
{
$this->mockDb->method('get')->willReturn([
'job_type' => 'apilo_sync_payment',
'max_attempts' => 10,
'attempts' => 10,
]);
$this->mockDb->expects($this->once())
->method('update')
->with(
'pp_cron_jobs',
$this->callback(function ($data) {
return $data['status'] === 'pending';
}),
['id' => 6]
);
$this->repo->markFailed(6, 'Apilo unavailable');
}
// --- hasPendingJob ---
public function testHasPendingJobReturnsTrueWhenExists(): void

View File

@@ -0,0 +1,130 @@
<?php
namespace Tests\Unit\Domain\Integrations;
use PHPUnit\Framework\TestCase;
use Domain\Integrations\ApiloRepository;
class ApiloRepositoryTest extends TestCase
{
private $mockDb;
private ApiloRepository $repository;
protected function setUp(): void
{
$this->mockDb = $this->createMock(\medoo::class);
$this->repository = new ApiloRepository($this->mockDb);
}
public function testApiloGetAccessTokenReturnsNullWithoutSettings(): void
{
$this->mockDb->method('select')->willReturn([]);
$this->assertNull($this->repository->apiloGetAccessToken());
}
public function testShouldRefreshAccessTokenReturnsFalseForFarFutureDate(): void
{
$reflection = new \ReflectionClass($this->repository);
$method = $reflection->getMethod('shouldRefreshAccessToken');
$method->setAccessible(true);
$future = date('Y-m-d H:i:s', time() + 3600);
$result = $method->invoke($this->repository, $future, 300);
$this->assertFalse($result);
}
public function testShouldRefreshAccessTokenReturnsTrueForNearExpiryDate(): void
{
$reflection = new \ReflectionClass($this->repository);
$method = $reflection->getMethod('shouldRefreshAccessToken');
$method->setAccessible(true);
$near = date('Y-m-d H:i:s', time() + 120);
$result = $method->invoke($this->repository, $near, 300);
$this->assertTrue($result);
}
public function testApiloFetchListThrowsForInvalidType(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->repository->apiloFetchList('invalid');
}
public function testApiloFetchListResultReturnsDetailedErrorWhenConfigMissing(): void
{
$this->mockDb->expects($this->once())
->method('select')
->with('pp_shop_apilo_settings', ['name', 'value'])
->willReturn([]);
$result = $this->repository->apiloFetchListResult('payment');
$this->assertIsArray($result);
$this->assertFalse((bool)($result['success'] ?? true));
$this->assertStringContainsString('Brakuje konfiguracji Apilo', (string)($result['message'] ?? ''));
}
public function testApiloIntegrationStatusReturnsMissingConfigMessage(): void
{
$this->mockDb->expects($this->once())
->method('select')
->with('pp_shop_apilo_settings', ['name', 'value'])
->willReturn([]);
$status = $this->repository->apiloIntegrationStatus();
$this->assertIsArray($status);
$this->assertFalse((bool)($status['is_valid'] ?? true));
$this->assertStringContainsString('Brakuje konfiguracji Apilo', (string)($status['message'] ?? ''));
}
public function testNormalizeApiloMapListRejectsErrorPayload(): void
{
$reflection = new \ReflectionClass($this->repository);
$method = $reflection->getMethod('normalizeApiloMapList');
$method->setAccessible(true);
$result = $method->invoke($this->repository, [
'message' => 'Missing JWT token',
'code' => 401,
]);
$this->assertNull($result);
}
public function testNormalizeApiloMapListAcceptsIdNameList(): void
{
$reflection = new \ReflectionClass($this->repository);
$method = $reflection->getMethod('normalizeApiloMapList');
$method->setAccessible(true);
$payload = [
['id' => '1', 'name' => 'Przelew'],
['id' => '2', 'name' => 'Karta'],
];
$result = $method->invoke($this->repository, $payload);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertSame('1', (string)$result[0]['id']);
$this->assertSame('Przelew', (string)$result[0]['name']);
}
public function testAllPublicMethodsExist(): void
{
$expectedMethods = [
'apiloAuthorize', 'apiloGetAccessToken', 'apiloKeepalive', 'apiloIntegrationStatus',
'apiloFetchList', 'apiloFetchListResult', 'apiloProductSearch', 'apiloCreateProduct',
];
foreach ($expectedMethods as $method) {
$this->assertTrue(
method_exists($this->repository, $method),
"Method $method does not exist"
);
}
}
}

View File

@@ -116,98 +116,12 @@ class IntegrationsRepositoryTest extends TestCase
$this->assertTrue($this->repository->unlinkProduct(42));
}
public function testGetProductSkuReturnsValue(): void
{
$this->mockDb->expects($this->once())
->method('get')
->with('pp_shop_products', 'sku', ['id' => 10])
->willReturn('SKU-100');
$this->assertSame('SKU-100', $this->repository->getProductSku(10));
}
public function testGetProductSkuReturnsNullForMissing(): void
{
$this->mockDb->expects($this->once())
->method('get')
->with('pp_shop_products', 'sku', ['id' => 999])
->willReturn(false);
$this->assertNull($this->repository->getProductSku(999));
}
public function testApiloGetAccessTokenReturnsNullWithoutSettings(): void
{
$this->mockDb->method('select')->willReturn([]);
$this->assertNull($this->repository->apiloGetAccessToken());
}
public function testShouldRefreshAccessTokenReturnsFalseForFarFutureDate(): void
{
$reflection = new \ReflectionClass($this->repository);
$method = $reflection->getMethod('shouldRefreshAccessToken');
$method->setAccessible(true);
$future = date('Y-m-d H:i:s', time() + 3600);
$result = $method->invoke($this->repository, $future, 300);
$this->assertFalse($result);
}
public function testShouldRefreshAccessTokenReturnsTrueForNearExpiryDate(): void
{
$reflection = new \ReflectionClass($this->repository);
$method = $reflection->getMethod('shouldRefreshAccessToken');
$method->setAccessible(true);
$near = date('Y-m-d H:i:s', time() + 120);
$result = $method->invoke($this->repository, $near, 300);
$this->assertTrue($result);
}
public function testApiloFetchListThrowsForInvalidType(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->repository->apiloFetchList('invalid');
}
public function testApiloFetchListResultReturnsDetailedErrorWhenConfigMissing(): void
{
$this->mockDb->expects($this->once())
->method('select')
->with('pp_shop_apilo_settings', ['name', 'value'])
->willReturn([]);
$result = $this->repository->apiloFetchListResult('payment');
$this->assertIsArray($result);
$this->assertFalse((bool)($result['success'] ?? true));
$this->assertStringContainsString('Brakuje konfiguracji Apilo', (string)($result['message'] ?? ''));
}
public function testApiloIntegrationStatusReturnsMissingConfigMessage(): void
{
$this->mockDb->expects($this->once())
->method('select')
->with('pp_shop_apilo_settings', ['name', 'value'])
->willReturn([]);
$status = $this->repository->apiloIntegrationStatus();
$this->assertIsArray($status);
$this->assertFalse((bool)($status['is_valid'] ?? true));
$this->assertStringContainsString('Brakuje konfiguracji Apilo', (string)($status['message'] ?? ''));
}
public function testAllPublicMethodsExist(): void
{
$expectedMethods = [
'getSettings', 'getSetting', 'saveSetting',
'linkProduct', 'unlinkProduct',
'apiloAuthorize', 'apiloGetAccessToken', 'apiloKeepalive', 'apiloIntegrationStatus',
'apiloFetchList', 'apiloFetchListResult', 'apiloProductSearch', 'apiloCreateProduct',
'getLogs', 'deleteLog', 'clearLogs',
'getProductSku', 'shopproImportProduct',
];
@@ -240,40 +154,27 @@ class IntegrationsRepositoryTest extends TestCase
$this->assertSame('test.com', $settings['domain']);
}
public function testNormalizeApiloMapListRejectsErrorPayload(): void
public function testGetProductSkuReturnsValue(): void
{
$reflection = new \ReflectionClass($this->repository);
$method = $reflection->getMethod('normalizeApiloMapList');
$method->setAccessible(true);
$this->mockDb->expects($this->once())
->method('get')
->with('pp_shop_products', 'sku', ['id' => 10])
->willReturn('SKU-100');
$result = $method->invoke($this->repository, [
'message' => 'Missing JWT token',
'code' => 401,
]);
$this->assertNull($result);
$this->assertSame('SKU-100', $this->repository->getProductSku(10));
}
public function testNormalizeApiloMapListAcceptsIdNameList(): void
public function testGetProductSkuReturnsNullForMissing(): void
{
$reflection = new \ReflectionClass($this->repository);
$method = $reflection->getMethod('normalizeApiloMapList');
$method->setAccessible(true);
$this->mockDb->expects($this->once())
->method('get')
->with('pp_shop_products', 'sku', ['id' => 999])
->willReturn(false);
$payload = [
['id' => '1', 'name' => 'Przelew'],
['id' => '2', 'name' => 'Karta'],
];
$result = $method->invoke($this->repository, $payload);
$this->assertIsArray($result);
$this->assertCount(2, $result);
$this->assertSame('1', (string)$result[0]['id']);
$this->assertSame('Przelew', (string)$result[0]['name']);
$this->assertNull($this->repository->getProductSku(999));
}
// ── Logs ────────────────────────────────────────────────────
// ── Logs ────────────────────────────────────────────────────
public function testGetLogsReturnsItemsAndTotal(): void
{

View File

@@ -1292,4 +1292,25 @@ class ProductRepositoryTest extends TestCase
$this->assertFalse($result);
}
public function testSaveCustomFieldsDeletesAllWhenEmpty(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->expects($this->once())
->method('delete')
->with(
$this->equalTo('pp_shop_products_custom_fields'),
$this->equalTo(['id_product' => 55])
);
$mockDb->expects($this->never())->method('insert');
$mockDb->expects($this->never())->method('update');
$repository = new ProductRepository($mockDb);
$method = new \ReflectionMethod(ProductRepository::class, 'saveCustomFields');
$method->setAccessible(true);
$method->invoke($repository, 55, [], [], []);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Tests\Unit\Shared\Security;
use PHPUnit\Framework\TestCase;
use Shared\Security\CsrfToken;
class CsrfTokenTest extends TestCase
{
protected function setUp(): void
{
$_SESSION = [];
}
public function testGetTokenReturns64CharHexString(): void
{
$token = CsrfToken::getToken();
$this->assertIsString($token);
$this->assertSame(64, strlen($token));
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $token);
}
public function testGetTokenIsIdempotent(): void
{
$first = CsrfToken::getToken();
$second = CsrfToken::getToken();
$this->assertSame($first, $second);
}
public function testValidateReturnsTrueForCorrectToken(): void
{
$token = CsrfToken::getToken();
$this->assertTrue(CsrfToken::validate($token));
}
public function testValidateReturnsFalseForEmptyString(): void
{
CsrfToken::getToken();
$this->assertFalse(CsrfToken::validate(''));
}
public function testValidateReturnsFalseForWrongToken(): void
{
CsrfToken::getToken();
$this->assertFalse(CsrfToken::validate('aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899'));
}
public function testValidateReturnsFalseWhenNoSessionToken(): void
{
$this->assertFalse(CsrfToken::validate('sometoken'));
}
public function testRegenerateChangesToken(): void
{
$before = CsrfToken::getToken();
CsrfToken::regenerate();
$after = CsrfToken::getToken();
$this->assertNotSame($before, $after);
$this->assertSame(64, strlen($after));
}
}

Some files were not shown because too many files have changed in this diff Show More