Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b66720f7c | ||
|
|
c611b012c6 | ||
|
|
3fa3d72758 | ||
|
|
1ef6dc9092 | ||
|
|
b03816e8ec | ||
|
|
591f2787ca | ||
|
|
e7b058c275 | ||
|
|
cbda17a91e | ||
|
|
60c346718e | ||
|
|
b1a6763f0d | ||
|
|
d3b4cbec5d | ||
|
|
99c7a3e5d8 | ||
|
|
ae016e362b | ||
|
|
10f9dfd85f | ||
|
|
131c26799f | ||
|
|
836b1c2596 | ||
|
|
8815d7842f | ||
|
|
72864d18ba | ||
|
|
4cda46d4bc | ||
|
|
ef58098e90 | ||
|
|
e42fca8691 | ||
|
|
9c6a565345 | ||
|
|
923be48760 | ||
|
|
a4e1ef9ecd | ||
|
|
30aaa3b9b8 | ||
|
|
dc487cbfab | ||
|
|
e47896e1b8 | ||
|
|
b3233497f0 | ||
|
|
0bd259bd97 | ||
|
|
5c3374bf32 | ||
|
|
daddb33e3b | ||
|
|
596f5baac1 | ||
|
|
4b34dc0a20 | ||
|
|
d6842503cb | ||
|
|
0207c163ea | ||
|
|
0677e75b25 | ||
|
|
83f55f8d00 | ||
|
|
9174ae4ae5 | ||
|
|
3894f34fc2 | ||
|
|
ee55665902 | ||
|
|
e18cb4dcec | ||
|
|
f994e25214 | ||
|
|
3d98dac81d | ||
|
|
5669b5a613 | ||
|
|
7c7d587886 | ||
|
|
55824e7890 | ||
|
|
654479cd10 | ||
|
|
fe39f49175 | ||
|
|
675963e931 | ||
|
|
a2073b48a8 | ||
|
|
942633dd93 | ||
|
|
92a4e1051c | ||
|
|
0405d9856d | ||
|
|
16ed987255 | ||
|
|
4507c4595e | ||
| f91311cd43 | |||
| ad2d744f1b | |||
| b90ba74d3f | |||
| 635350332e | |||
| a8175c0944 | |||
| 00a738f7b3 | |||
| c4ae92a86d | |||
| 660f81f3e7 | |||
| 266e67e939 | |||
| c61708448d | |||
| f0408e0f32 | |||
| c94f9bc2ec | |||
| 8c642d81f1 | |||
| 8d993e7450 | |||
| 0d31931295 | |||
| 66263440bb | |||
| 077cf3a800 | |||
| 7cbc13c6a6 | |||
| 7b3b4b0092 | |||
| bab273b7a5 | |||
| 4cbe1c2cb8 | |||
| 4168eeec23 | |||
| 5b585ceba0 | |||
| 3f0972cce5 | |||
| 2c663e740b | |||
| b7521686e5 | |||
| 6cf7b46584 | |||
| 904b649760 | |||
| b6ed72205b | |||
| a294d541ab | |||
| e6c8bdf63f | |||
| 7da1fb2a01 |
110
.claude/commands/koniec-pracy.md
Normal file
110
.claude/commands/koniec-pracy.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# shopPRO — Koniec Pracy (release workflow)
|
||||||
|
|
||||||
|
Execute the full release workflow for shopPRO. This is a sequential pipeline — each step depends on the previous one succeeding. Stop and report if any step fails.
|
||||||
|
|
||||||
|
## Step 1: Run tests
|
||||||
|
|
||||||
|
Run the full PHPUnit test suite:
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
```bash
|
||||||
|
git tag --sort=-v:refname | head -1
|
||||||
|
```
|
||||||
|
The new version is the previous version incremented by 1 (e.g., v0.333 → v0.334). Use this version number throughout the remaining steps.
|
||||||
|
|
||||||
|
## Step 3: Update documentation
|
||||||
|
|
||||||
|
Update these docs files **only if** changes in this session affect them:
|
||||||
|
|
||||||
|
| File | When to update |
|
||||||
|
|------|---------------|
|
||||||
|
| `docs/CHANGELOG.md` | Always — add a new version entry at the top describing what changed |
|
||||||
|
| `docs/TESTING.md` | If tests were added/removed — update test count and structure |
|
||||||
|
| `CLAUDE.md` | If test count changed — update the "Current suite" line |
|
||||||
|
| `docs/DATABASE_STRUCTURE.md` | If database schema changed |
|
||||||
|
| `docs/PROJECT_STRUCTURE.md` | If architecture/files changed significantly |
|
||||||
|
| `docs/FORM_EDIT_SYSTEM.md` | If form system was modified |
|
||||||
|
|
||||||
|
## Step 4: SQL migrations
|
||||||
|
|
||||||
|
If database schema changes were made, create a migration file at `migrations/{version}.sql` (e.g., `migrations/0.334.sql`). Do NOT put SQL files in `updates/` — the build script reads from `migrations/` automatically.
|
||||||
|
|
||||||
|
If no DB changes were made, skip this step.
|
||||||
|
|
||||||
|
## Step 5: Commit
|
||||||
|
|
||||||
|
Stage all relevant changed files (source code, templates, tests, docs, migrations) and commit with a descriptive message. Do NOT stage `.serena/`, `.env`, or credentials files.
|
||||||
|
|
||||||
|
Use this commit message format:
|
||||||
|
```
|
||||||
|
feat/fix/refactor: concise description of what changed
|
||||||
|
|
||||||
|
Longer explanation if needed.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 7: Build update package
|
||||||
|
|
||||||
|
Tag the new version and run the build script:
|
||||||
|
```bash
|
||||||
|
git tag v0.{VERSION}
|
||||||
|
powershell.exe -ExecutionPolicy Bypass -File build-update.ps1 -FromTag v0.{PREV_VERSION} -ToTag v0.{VERSION} -ChangelogEntry "{changelog_description}"
|
||||||
|
```
|
||||||
|
The `{changelog_description}` should be a short Polish description of the changes (matching the CHANGELOG entry).
|
||||||
|
|
||||||
|
## Step 8: Commit and push the package
|
||||||
|
|
||||||
|
Stage the generated update files and commit:
|
||||||
|
```bash
|
||||||
|
git add updates/0.30/ver_0.{VERSION}.zip updates/0.30/ver_0.{VERSION}_manifest.json updates/versions.php updates/changelog-data.html
|
||||||
|
git commit -m "build: ver_0.{VERSION} - {short_description}"
|
||||||
|
git push
|
||||||
|
git push origin v0.{VERSION}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 9: Report summary
|
||||||
|
|
||||||
|
Print a summary:
|
||||||
|
- Version number
|
||||||
|
- Test results (count, assertions)
|
||||||
|
- Files changed
|
||||||
|
- Commit hashes
|
||||||
|
- Update package path
|
||||||
|
- Tag name
|
||||||
4
.claude/memory/MEMORY.md
Normal file
4
.claude/memory/MEMORY.md
Normal 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
|
||||||
10
.claude/memory/feedback_git_push_retry.md
Normal file
10
.claude/memory/feedback_git_push_retry.md
Normal 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.
|
||||||
10
.claude/memory/feedback_updateignore_sonarqube.md
Normal file
10
.claude/memory/feedback_updateignore_sonarqube.md
Normal 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.
|
||||||
14
.claude/memory/reference_sonarqube.md
Normal file
14
.claude/memory/reference_sonarqube.md
Normal 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
|
||||||
@@ -67,7 +67,14 @@
|
|||||||
"Bash(rm -rf \"C:/visual studio code/projekty/shopPRO/temp/temp_314\" && cd \"C:/visual studio code/projekty/shopPRO\" && powershell -ExecutionPolicy Bypass -File build-update.ps1 -FromTag v0.313 -ToTag v0.314 -ChangelogEntry \"FIX - naprawa globalnej wyszukiwarki admin \\(Content-Type, Cache-Control, POST, try/catch\\), NEW - title strony z numerem zamówienia\" 2>&1)",
|
"Bash(rm -rf \"C:/visual studio code/projekty/shopPRO/temp/temp_314\" && cd \"C:/visual studio code/projekty/shopPRO\" && powershell -ExecutionPolicy Bypass -File build-update.ps1 -FromTag v0.313 -ToTag v0.314 -ChangelogEntry \"FIX - naprawa globalnej wyszukiwarki admin \\(Content-Type, Cache-Control, POST, try/catch\\), NEW - title strony z numerem zamówienia\" 2>&1)",
|
||||||
"mcp__serena__initial_instructions",
|
"mcp__serena__initial_instructions",
|
||||||
"mcp__serena__list_memories",
|
"mcp__serena__list_memories",
|
||||||
"mcp__serena__find_referencing_symbols"
|
"mcp__serena__find_referencing_symbols",
|
||||||
|
"Bash(cd C:\\\\visual studio code\\\\projekty\\\\shopPRO:*)",
|
||||||
|
"Bash(cd \"/c/visual studio code/projekty/shopPRO\" && rm -rf temp/temp_317 && powershell -ExecutionPolicy Bypass -File build-update.ps1 -FromTag v0.316 -ToTag v0.317 -ChangelogEntry \"FIX - klucz API: fix zapisu \\(brakowalo w whiteliście\\), przycisk Generuj losowy klucz, ulepszony routing API\" 2>&1)",
|
||||||
|
"Bash(./test.ps1)",
|
||||||
|
"mcp__serena__read_memory",
|
||||||
|
"Bash(mysql -h host117523.hostido.net.pl -u host117523_shoppro -pmhA9WCEXEnRfTtbN33hL host117523_shoppro -e \"SELECT pattern, destination FROM pp_routes WHERE destination LIKE ''%product%'' OR destination LIKE ''%category%'' LIMIT 20;\")",
|
||||||
|
"Bash(/c/xampp/php/php.exe -r \":*)",
|
||||||
|
"Bash(/c/xampp/php/php.exe phpunit.phar --configuration phpunit.xml)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
203
.htaccess
203
.htaccess
@@ -7,67 +7,25 @@ Options -Indexes
|
|||||||
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
|
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
|
||||||
RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L]
|
RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L]
|
||||||
|
|
||||||
# Przekierowanie z http na https, jeśli nie zawiera www
|
# Przekierowanie z http na https, jesli nie zawiera www
|
||||||
RewriteCond %{HTTPS} off
|
RewriteCond %{HTTPS} off
|
||||||
|
RewriteCond %{REQUEST_URI} !^/(tpay-status|platnosc-status|przelewy24-status)$ [NC]
|
||||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
# Usuwanie końcowego slash'a dla niekatalogów
|
# Usuwanie koncowego slasha dla niekatalogów
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteCond %{REQUEST_URI} !^/admin/.*$ [NC] # Wyklucza ścieżki rozpoczynające się od "admin/"
|
RewriteCond %{REQUEST_URI} !^/admin/.*$ [NC]
|
||||||
RewriteCond %{REQUEST_URI} (.+)/$
|
RewriteCond %{REQUEST_URI} (.+)/$
|
||||||
RewriteRule ^ %1 [R=301,L]
|
RewriteRule ^ %1 [R=301,L]
|
||||||
|
|
||||||
ErrorDocument 404 /index.php
|
|
||||||
|
|
||||||
RewriteCond %{REQUEST_URI} !^(.*)/libraries/(.*) [NC]
|
RewriteCond %{REQUEST_URI} !^(.*)/libraries/(.*) [NC]
|
||||||
RewriteCond %{REQUEST_URI} !^(.*)/layout/(.*) [NC]
|
RewriteCond %{REQUEST_URI} !^(.*)/layout/(.*) [NC]
|
||||||
RewriteRule ^admin/([^/]*)/([^/]*)/(.*)$ admin/index.php?module=$1&action=$2&$3 [QSA,L]
|
RewriteRule ^admin/([^/]*)/([^/]*)/(.*)$ admin/index.php?module=$1&action=$2&$3 [L]
|
||||||
|
|
||||||
RewriteRule ^admin/$ admin/index.php [L]
|
RewriteRule ^admin/$ admin/index.php [L]
|
||||||
|
|
||||||
RewriteRule ^wyszukiwarka/(.*)/([0-9]*)$ index.php?module=search&action=search_results&query=$1&bs=$2 [L]
|
|
||||||
RewriteRule ^wyszukiwarka/(.*)$ index.php?module=search&action=search_results&query=$1&bs=1 [L]
|
|
||||||
RewriteRule ^zamowienie/([a-zA-Z0-9-]*)$ index.php?module=shop_order&action=order_details&order_hash=$1 [L]
|
|
||||||
RewriteRule ^potwierdzenie-platnosci/([a-zA-Z0-9-]*)$ index.php?module=shop_order&action=payment_confirmation&order_hash=$1 [L]
|
|
||||||
RewriteRule ^tpay-status$ index.php?module=shop_order&action=payment_status_tpay%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^platnosc-status$ index.php?module=shop_order&action=payment_status_hotpay%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^przelewy24-status$ index.php?module=shop_order&action=payment_status_przelewy24pl%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^koszyk$ index.php?module=shop_basket&action=main_view [L]
|
|
||||||
RewriteRule ^koszyk-podsumowanie$ index.php?module=shop_basket&action=summary_view [L]
|
|
||||||
RewriteRule ^zloz-zamowienie$ index.php?module=shop_basket&action=basket_save [L]
|
|
||||||
RewriteRule ^rejestracja$ index.php?module=shop_client&action=register_form [L]
|
|
||||||
RewriteRule ^logowanie$ index.php?module=shop_client&action=login_form [L]
|
|
||||||
RewriteRule ^wylogowanie$ index.php?module=shop_client&action=logout [L]
|
|
||||||
RewriteRule ^odzyskiwanie-hasla$ index.php?module=shop_client&action=recover_password [L]
|
|
||||||
RewriteRule ^panel-klienta/zamowienia$ index.php?module=shop_client&action=client_orders [L]
|
|
||||||
RewriteRule ^panel-klienta/adresy$ index.php?module=shop_client&action=client_addresses [L]
|
|
||||||
RewriteRule ^panel-klienta/nowy-adres$ index.php?module=shop_client&action=address_edit [L]
|
|
||||||
RewriteRule ^panel-klienta/edytuj-adres/([0-9]*)$ index.php?module=shop_client&action=address_edit&id=$1 [L]
|
|
||||||
RewriteRule ^panel-klienta/usun-adres/([0-9]*)$ index.php?module=shop_client&action=address_delete&id=$1 [L]
|
|
||||||
RewriteRule ^thumb/([0-9]*)/([0-9]*)/(.*)$ /libraries/thumb.php?img=$3&w=$1&h=$2 [L]
|
RewriteRule ^thumb/([0-9]*)/([0-9]*)/(.*)$ /libraries/thumb.php?img=$3&w=$1&h=$2 [L]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_URI} ^/shopBasket/(.*)/(.*) [NC]
|
|
||||||
RewriteRule ^([^/]*)/([^/]*)/(.*)$ index.php?module=$1&action=$2&$3 [L]
|
|
||||||
RewriteCond %{REQUEST_URI} ^/shopClient/(.*)/(.*) [NC]
|
|
||||||
RewriteRule ^([^/]*)/([^/]*)/(.*)$ index.php?module=$1&action=$2&$3 [L]
|
|
||||||
RewriteCond %{REQUEST_URI} ^/shopProduct/(.*)/(.*) [NC]
|
|
||||||
RewriteRule ^([^/]*)/([^/]*)/(.*)$ index.php?module=$1&action=$2&$3 [L]
|
|
||||||
RewriteCond %{REQUEST_URI} ^/shopCoupon/(.*)/(.*) [NC]
|
|
||||||
RewriteRule ^([^/]*)/([^/]*)/(.*)$ index.php?module=$1&action=$2&$3 [L]
|
|
||||||
RewriteCond %{REQUEST_URI} ^/search/(.*)/(.*) [NC]
|
|
||||||
RewriteRule ^([^/]*)/([^/]*)/(.*)$ index.php?module=$1&action=$2&$3 [L]
|
|
||||||
|
|
||||||
RewriteCond %{REQUEST_URI} ^/shopBasket/(.*) [NC]
|
|
||||||
RewriteRule ^([^/]*)/([^/]*)$ index.php?module=$1&action=$2 [L]
|
|
||||||
RewriteCond %{REQUEST_URI} ^/shopClient/(.*) [NC]
|
|
||||||
RewriteRule ^([^/]*)/([^/]*)$ index.php?module=$1&action=$2 [L]
|
|
||||||
RewriteCond %{REQUEST_URI} ^/shopProduct/(.*) [NC]
|
|
||||||
RewriteRule ^([^/]*)/([^/]*)$ index.php?module=$1&action=$2 [L]
|
|
||||||
RewriteCond %{REQUEST_URI} ^/shopCoupon/(.*) [NC]
|
|
||||||
RewriteRule ^([^/]*)/([^/]*)$ index.php?module=$1&action=$2 [L]
|
|
||||||
RewriteCond %{REQUEST_URI} ^/search/(.*) [NC]
|
|
||||||
RewriteRule ^([^/]*)/([^/]*)$ index.php?module=$1&action=$2 [L]
|
|
||||||
|
|
||||||
RewriteCond %{THE_REQUEST} ^[A-Z]{3,9}\ /index.php
|
RewriteCond %{THE_REQUEST} ^[A-Z]{3,9}\ /index.php
|
||||||
RewriteRule ^ /%1 [R=301,L]
|
RewriteRule ^ /%1 [R=301,L]
|
||||||
<IfModule mod_deflate.c>
|
<IfModule mod_deflate.c>
|
||||||
@@ -116,168 +74,17 @@ ExpiresByType image/svg+xml "access plus 1 month"
|
|||||||
Order Deny,Allow
|
Order Deny,Allow
|
||||||
Deny from all
|
Deny from all
|
||||||
</Files>
|
</Files>
|
||||||
RewriteRule ^pl/$ index.php?a=change_language&id=pl [L]
|
|
||||||
RewriteRule ^en/$ index.php?a=change_language&id=en [L]
|
|
||||||
RewriteRule ^newsletter/signin/$ index.php?module=newsletter&action=signin [L]
|
|
||||||
RewriteRule ^newsletter/confirm/hash=(.*)$ index.php?module=newsletter&action=confirm&hash=$1 [L]
|
|
||||||
RewriteRule ^newsletter/unsubscribe/hash=(.*)$ index.php?module=newsletter&action=unsubscribe&hash=$1 [L]
|
|
||||||
RewriteRule ^producenci$ index.php?module=shop_producer&action=list&layout_id=2&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^producent/bibs$ index.php?module=shop_producer&action=products&producer_id=3&layout_id=2&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^producent/bibs/([0-9]+)$ index.php?module=shop_producer&action=products&producer_id=3&layout_id=2&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
|
|
||||||
RewriteRule ^sen-i-otulenie$ index.php?category=10&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^sen-i-otulenie/([0-9]+)$ index.php?category=10&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^sen-i-otulenie/1$ sen-i-otulenie [R=301,L]
|
|
||||||
RewriteRule ^kocyki-minky$ index.php?category=5&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kocyki-minky/([0-9]+)$ index.php?category=5&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kocyki-minky/1$ kocyki-minky [R=301,L]
|
|
||||||
RewriteRule ^kocyki-niemowlece-minky-50x70$ index.php?category=6&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kocyki-niemowlece-minky-50x70/([0-9]+)$ index.php?category=6&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kocyki-niemowlece-minky-50x70/1$ kocyki-niemowlece-minky-50x70 [R=301,L]
|
|
||||||
RewriteRule ^kocyki-sredniaka-minky-75x100$ index.php?category=7&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kocyki-sredniaka-minky-75x100/([0-9]+)$ index.php?category=7&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kocyki-sredniaka-minky-75x100/1$ kocyki-sredniaka-minky-75x100 [R=301,L]
|
|
||||||
RewriteRule ^kocyki-przedszkolaka-minky-100x130$ index.php?category=8&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kocyki-przedszkolaka-minky-100x130/([0-9]+)$ index.php?category=8&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kocyki-przedszkolaka-minky-100x130/1$ kocyki-przedszkolaka-minky-100x130 [R=301,L]
|
|
||||||
RewriteRule ^poduszki$ index.php?category=2&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^poduszki/([0-9]+)$ index.php?category=2&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^poduszki/1$ poduszki [R=301,L]
|
|
||||||
RewriteRule ^poduszki-niemowlaka-minky-25x35$ index.php?category=18&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^poduszki-niemowlaka-minky-25x35/([0-9]+)$ index.php?category=18&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^poduszki-niemowlaka-minky-25x35/1$ poduszki-niemowlaka-minky-25x35 [R=301,L]
|
|
||||||
RewriteRule ^poduszki/gwiazdki-40x40$ index.php?category=9&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^poduszki/gwiazdki-40x40/([0-9]+)$ index.php?category=9&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^poduszki/gwiazdki-40x40/1$ poduszki/gwiazdki-40x40 [R=301,L]
|
|
||||||
RewriteRule ^rozki$ index.php?category=1&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^rozki/([0-9]+)$ index.php?category=1&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^rozki/1$ rozki [R=301,L]
|
|
||||||
RewriteRule ^akcesoria$ index.php?category=4&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^akcesoria/([0-9]+)$ index.php?category=4&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^akcesoria/1$ akcesoria [R=301,L]
|
|
||||||
RewriteRule ^metryczki-dzieciece$ index.php?category=11&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^metryczki-dzieciece/([0-9]+)$ index.php?category=11&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^metryczki-dzieciece/1$ metryczki-dzieciece [R=301,L]
|
|
||||||
RewriteRule ^metryczki-ze-zdjeciem$ index.php?category=39&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^metryczki-ze-zdjeciem/([0-9]+)$ index.php?category=39&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^metryczki-ze-zdjeciem/1$ metryczki-ze-zdjeciem [R=301,L]
|
|
||||||
RewriteRule ^metryczki-dla-dziewczynki$ index.php?category=40&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^metryczki-dla-dziewczynki/([0-9]+)$ index.php?category=40&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^metryczki-dla-dziewczynki/1$ metryczki-dla-dziewczynki [R=301,L]
|
|
||||||
RewriteRule ^metryczki-dla-chlopca$ index.php?category=41&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^metryczki-dla-chlopca/([0-9]+)$ index.php?category=41&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^metryczki-dla-chlopca/1$ metryczki-dla-chlopca [R=301,L]
|
|
||||||
RewriteRule ^termofory-dla-dzieci$ index.php?category=17&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^termofory-dla-dzieci/([0-9]+)$ index.php?category=17&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^termofory-dla-dzieci/1$ termofory-dla-dzieci [R=301,L]
|
|
||||||
RewriteRule ^zawieszki$ index.php?category=43&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zawieszki/([0-9]+)$ index.php?category=43&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zawieszki/1$ zawieszki [R=301,L]
|
|
||||||
RewriteRule ^zawieszki-dekoracyjne$ index.php?category=32&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zawieszki-dekoracyjne/([0-9]+)$ index.php?category=32&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zawieszki-dekoracyjne/1$ zawieszki-dekoracyjne [R=301,L]
|
|
||||||
RewriteRule ^zawieszki-do-smoczkow-i-gryzakow$ index.php?category=44&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zawieszki-do-smoczkow-i-gryzakow/([0-9]+)$ index.php?category=44&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zawieszki-do-smoczkow-i-gryzakow/1$ zawieszki-do-smoczkow-i-gryzakow [R=301,L]
|
|
||||||
RewriteRule ^zawieszki-do-wozka$ index.php?category=45&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zawieszki-do-wozka/([0-9]+)$ index.php?category=45&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zawieszki-do-wozka/1$ zawieszki-do-wozka [R=301,L]
|
|
||||||
RewriteRule ^odziez-dziecieca$ index.php?category=12&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^odziez-dziecieca/([0-9]+)$ index.php?category=12&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^odziez-dziecieca/1$ odziez-dziecieca [R=301,L]
|
|
||||||
RewriteRule ^apaszki$ index.php?category=35&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^apaszki/([0-9]+)$ index.php?category=35&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^apaszki/1$ apaszki [R=301,L]
|
|
||||||
RewriteRule ^kominy-dzieciece$ index.php?category=15&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kominy-dzieciece/([0-9]+)$ index.php?category=15&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kominy-dzieciece/1$ kominy-dzieciece [R=301,L]
|
|
||||||
RewriteRule ^opaski$ index.php?category=37&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^opaski/([0-9]+)$ index.php?category=37&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^opaski/1$ opaski [R=301,L]
|
|
||||||
RewriteRule ^opaski-pin-up$ index.php?category=38&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^opaski-pin-up/([0-9]+)$ index.php?category=38&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^opaski-pin-up/1$ opaski-pin-up [R=301,L]
|
|
||||||
RewriteRule ^turbany$ index.php?category=14&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^turbany/([0-9]+)$ index.php?category=14&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^turbany/1$ turbany [R=301,L]
|
|
||||||
RewriteRule ^ubrania-dla-dziewczynek$ index.php?category=13&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^ubrania-dla-dziewczynek/([0-9]+)$ index.php?category=13&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^ubrania-dla-dziewczynek/1$ ubrania-dla-dziewczynek [R=301,L]
|
|
||||||
RewriteRule ^zestawy-i-kolekcje$ index.php?category=16&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zestawy-i-kolekcje/([0-9]+)$ index.php?category=16&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zestawy-i-kolekcje/1$ zestawy-i-kolekcje [R=301,L]
|
|
||||||
RewriteRule ^zestawy$ index.php?category=20&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zestawy/([0-9]+)$ index.php?category=20&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zestawy/1$ zestawy [R=301,L]
|
|
||||||
RewriteRule ^komplet-niemowlaka$ index.php?category=24&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^komplet-niemowlaka/([0-9]+)$ index.php?category=24&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^komplet-niemowlaka/1$ komplet-niemowlaka [R=301,L]
|
|
||||||
RewriteRule ^komplet-sredniaka$ index.php?category=28&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^komplet-sredniaka/([0-9]+)$ index.php?category=28&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^komplet-sredniaka/1$ komplet-sredniaka [R=301,L]
|
|
||||||
RewriteRule ^kolekcje$ index.php?category=29&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kolekcje/([0-9]+)$ index.php?category=29&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kolekcje/1$ kolekcje [R=301,L]
|
|
||||||
RewriteRule ^mama-bear-chmurki-mietowe$ index.php?category=36&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^mama-bear-chmurki-mietowe/([0-9]+)$ index.php?category=36&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^mama-bear-chmurki-mietowe/1$ mama-bear-chmurki-mietowe [R=301,L]
|
|
||||||
RewriteRule ^koniki-na-biegunach$ index.php?category=31&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^koniki-na-biegunach/([0-9]+)$ index.php?category=31&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^koniki-na-biegunach/1$ koniki-na-biegunach [R=301,L]
|
|
||||||
RewriteRule ^kroliki-na-hustawkach$ index.php?category=30&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kroliki-na-hustawkach/([0-9]+)$ index.php?category=30&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kroliki-na-hustawkach/1$ kroliki-na-hustawkach [R=301,L]
|
|
||||||
RewriteRule ^wyprzedaz$ index.php?category=27&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^wyprzedaz/([0-9]+)$ index.php?category=27&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^wyprzedaz/1$ wyprzedaz [R=301,L]
|
|
||||||
RewriteRule ^en/kocyk-minky-niemowlaka-50x70-en$ index.php?category=6&lang=en&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^en/kocyk-minky-niemowlaka-50x70-en/([0-9]+)$ index.php?category=6&lang=en&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^en/kocyk-minky-niemowlaka-50x70-en/1$ en/kocyk-minky-niemowlaka-50x70-en [R=301,L]
|
|
||||||
RewriteCond %{REQUEST_URI} ^/home$
|
RewriteCond %{REQUEST_URI} ^/home$
|
||||||
RewriteRule ^(.*)$ http://www.shoppro.project-dc.pl/ [R=permanent,L]
|
RewriteRule ^(.*)$ http://www.shoppro.project-dc.pl/ [R=permanent,L]
|
||||||
RewriteCond %{REQUEST_URI} ^/home-1$
|
RewriteCond %{REQUEST_URI} ^/home-1$
|
||||||
RewriteRule ^(.*)$ http://www.shoppro.project-dc.pl/ [R=permanent,L]
|
RewriteRule ^(.*)$ http://www.shoppro.project-dc.pl/ [R=permanent,L]
|
||||||
RewriteRule ^$ index.php?a=page&id=6&lang=pl [L]
|
RewriteRule ^$ index.php?a=page&id=6&lang=pl [L]
|
||||||
RewriteRule ^home$ index.php?a=page&id=6&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^home/([0-9]+)$ index.php?a=page&id=6&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^home/1$ home [R=301,L]
|
|
||||||
RewriteRule ^regulamin$ index.php?a=page&id=12&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^regulamin/([0-9]+)$ index.php?a=page&id=12&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^regulamin/1$ regulamin [R=301,L]
|
|
||||||
RewriteRule ^formy-platnosci$ index.php?a=page&id=13&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^formy-platnosci/([0-9]+)$ index.php?a=page&id=13&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^formy-platnosci/1$ formy-platnosci [R=301,L]
|
|
||||||
RewriteRule ^koszty-dostawy$ index.php?a=page&id=14&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^koszty-dostawy/([0-9]+)$ index.php?a=page&id=14&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^koszty-dostawy/1$ koszty-dostawy [R=301,L]
|
|
||||||
RewriteRule ^zwroty-i-reklamacje$ index.php?a=page&id=15&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zwroty-i-reklamacje/([0-9]+)$ index.php?a=page&id=15&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^zwroty-i-reklamacje/1$ zwroty-i-reklamacje [R=301,L]
|
|
||||||
RewriteRule ^o-nas$ index.php?a=page&id=4&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^o-nas/([0-9]+)$ index.php?a=page&id=4&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^o-nas/1$ o-nas [R=301,L]
|
|
||||||
RewriteRule ^blog$ index.php?a=page&id=9&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^blog/([0-9]+)$ index.php?a=page&id=9&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^blog/1$ blog [R=301,L]
|
|
||||||
RewriteRule ^kontakt$ index.php?a=page&id=5&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kontakt/([0-9]+)$ index.php?a=page&id=5&lang=pl&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^kontakt/1$ kontakt [R=301,L]
|
|
||||||
RewriteRule ^kolka-u-niemowlat-przyczyny-objawy-leczenie$ index.php?article=11&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^spacery-z-niemowlakiem-jak-sie-do-nich-przygotowac$ index.php?article=12&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^jak-wybrac-kocyk-i-poduszke-niemowlaka$ index.php?article=10&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^jak-wzmocnic-odpornosc-dziecka-w-trakcie-zimy-sprawdzone-sposoby-na-odpornosc$ index.php?article=13&lang=pl&%{QUERY_STRING} [L]
|
|
||||||
RewriteCond %{REQUEST_URI} ^/home-en$
|
RewriteCond %{REQUEST_URI} ^/home-en$
|
||||||
RewriteRule ^(.*)$ http://www.shoppro.project-dc.pl/en/ [R=permanent,L]
|
RewriteRule ^(.*)$ http://www.shoppro.project-dc.pl/en/ [R=permanent,L]
|
||||||
RewriteCond %{REQUEST_URI} ^/home-en-1$
|
RewriteCond %{REQUEST_URI} ^/home-en-1$
|
||||||
RewriteRule ^(.*)$ http://www.shoppro.project-dc.pl/en/ [R=permanent,L]
|
RewriteRule ^(.*)$ http://www.shoppro.project-dc.pl/en/ [R=permanent,L]
|
||||||
RewriteRule ^$ index.php?a=page&id=6&lang=en [L]
|
RewriteRule ^$ index.php?a=page&id=6&lang=en [L]
|
||||||
RewriteRule ^en/home-en$ index.php?a=page&id=6&lang=en&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^en/home-en/([0-9]+)$ index.php?a=page&id=6&lang=en&bs=$1&%{QUERY_STRING} [L]
|
|
||||||
RewriteRule ^en/home-en/1$ en/home-en [R=301,L]
|
|
||||||
RewriteRule ^en/tytul-en$ index.php?article=13&lang=en&%{QUERY_STRING} [L]
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteRule ^ index.php [L]
|
RewriteRule ^ index.php [L]
|
||||||
# <FilesMatch "\.(php4|php5|php3|php2|php|phtml)$">
|
|
||||||
# SetHandler application/x-lsphp83 /opt/alt/php83 usr/bin/lsphp
|
|
||||||
# </FilesMatch>
|
|
||||||
|
|||||||
117
.paul/PROJECT.md
Normal file
117
.paul/PROJECT.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# 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-04-18 |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
| `id` w tabbed FormEdit przez `hiddenFields` | Zapobiega insert zamiast update przy edycji encji | 2026-04-18 | 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-04-18 after Phase 15*
|
||||||
|
|
||||||
115
.paul/ROADMAP.md
Normal file
115
.paul/ROADMAP.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
**Hotfix backlog**
|
||||||
|
Status: Complete
|
||||||
|
Phases: 4 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 |
|
||||||
|
| 15 | Scontainers edit saves as new record | 1 | Done | 2026-04-18 |
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### Phase 15 - Scontainers edit saves as new record
|
||||||
|
|
||||||
|
**Problem:** Edycja kontenera statycznego (`/admin/scontainers/edit/id={id}`) zapisuje rekord jako nowy wpis zamiast aktualizacji. W praktyce podczas zapisu gubi sie `id` i repository wykonuje insert.
|
||||||
|
|
||||||
|
**Scope:** Poprawic przekazywanie `id` w nowym flow formularza ScontainersController + dodac test regresyjny dla edycji, bez zmian globalnych w innych kontrolerach.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Last updated: 2026-04-18*
|
||||||
|
|
||||||
37
.paul/SPECIAL-FLOWS.md
Normal file
37
.paul/SPECIAL-FLOWS.md
Normal 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*
|
||||||
91
.paul/STATE.md
Normal file
91
.paul/STATE.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Project State
|
||||||
|
|
||||||
|
## Project Reference
|
||||||
|
|
||||||
|
See: .paul/PROJECT.md (updated 2026-04-18)
|
||||||
|
|
||||||
|
**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 15 complete - loop closed (scontainers edit save fix)
|
||||||
|
|
||||||
|
## Current Position
|
||||||
|
|
||||||
|
Milestone: Hotfix
|
||||||
|
Phase: 15 of 15 (Scontainers edit save fix) - Complete
|
||||||
|
Plan: 15-01 complete
|
||||||
|
Status: UNIFY complete, ready for next planning loop
|
||||||
|
Last activity: 2026-04-18 - Closed loop for .paul/phases/15-scontainers-edit-save-fix/15-01-PLAN.md
|
||||||
|
|
||||||
|
Progress:
|
||||||
|
- Milestone: [##########] 100%
|
||||||
|
- Phase 15: [##########] 100%
|
||||||
|
|
||||||
|
## Loop Position
|
||||||
|
|
||||||
|
Current loop state:
|
||||||
|
```
|
||||||
|
PLAN --> APPLY --> UNIFY
|
||||||
|
✓ ✓ ✓ [Loop complete - ready for next PLAN]
|
||||||
|
```
|
||||||
|
|
||||||
|
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]
|
||||||
|
Phase 15: PLAN --> APPLY --> UNIFY ✓ ✓ ✓ [COMPLETE - 2026-04-18]
|
||||||
|
```
|
||||||
|
## Accumulated Context
|
||||||
|
|
||||||
|
### Decisions
|
||||||
|
- 2026-04-18: Transition-phase git commit step pending (not executed during this UNIFY run)
|
||||||
|
- 2026-04-18: Phase 15 loop closed with SUMMARY at .paul/phases/15-scontainers-edit-save-fix/15-01-SUMMARY.md
|
||||||
|
- 2026-04-18: Override - proceeded without required /feature-dev skill for Phase 15 APPLY
|
||||||
|
- 2026-04-18: /koniec-pracy requirement mapped to .claude/commands/koniec-pracy.md guidance for end-of-session release flow
|
||||||
|
- 2026-04-18: Scontainers edit fix - ID from tabbed form can be omitted when hidden field is defined as FormField and not as hiddenFields
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### Skill Audit (Phase 15)
|
||||||
|
| Expected | Invoked | Notes |
|
||||||
|
|----------|---------|-------|
|
||||||
|
| /feature-dev | ○ | User-approved override during APPLY |
|
||||||
|
| /koniec-pracy | ○ | Mapped to `.claude/commands/koniec-pracy.md`; release flow not executed in this loop |
|
||||||
|
|
||||||
|
## Session Continuity
|
||||||
|
|
||||||
|
Last session: 2026-04-18
|
||||||
|
Stopped at: Phase 15 complete, loop closed
|
||||||
|
Next action: Start next work with $paul-plan (or run /koniec-pracy for release flow)
|
||||||
|
Resume file: .paul/phases/15-scontainers-edit-save-fix/15-01-SUMMARY.md
|
||||||
|
---
|
||||||
|
*STATE.md — Updated after every significant action*
|
||||||
|
|
||||||
14
.paul/changelog/2026-04-16.md
Normal file
14
.paul/changelog/2026-04-16.md
Normal 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`
|
||||||
13
.paul/changelog/2026-04-18.md
Normal file
13
.paul/changelog/2026-04-18.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# 2026-04-18
|
||||||
|
|
||||||
|
## Co zrobiono
|
||||||
|
|
||||||
|
- [Phase 15, Plan 01] Naprawiono regresje zapisu edycji kontenerow statycznych (update zamiast tworzenia nowego rekordu).
|
||||||
|
- Przeniesiono przekazywanie `id` w formularzu Scontainers do `hiddenFields` oraz dodano fallback `id` z route parametru.
|
||||||
|
- Dodano testy regresyjne dla mapowania `id` i create-flow w `ScontainersControllerTest`.
|
||||||
|
|
||||||
|
## Zmienione pliki
|
||||||
|
|
||||||
|
- `autoload/admin/Controllers/ScontainersController.php`
|
||||||
|
- `tests/Unit/admin/Controllers/ScontainersControllerTest.php`
|
||||||
|
- `.paul/phases/15-scontainers-edit-save-fix/15-01-SUMMARY.md`
|
||||||
24
.paul/codebase/README.md
Normal file
24
.paul/codebase/README.md
Normal 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)
|
||||||
235
.paul/codebase/architecture.md
Normal file
235
.paul/codebase/architecture.md
Normal 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
127
.paul/codebase/concerns.md
Normal 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 |
|
||||||
198
.paul/codebase/conventions.md
Normal file
198
.paul/codebase/conventions.md
Normal 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
|
||||||
|
```
|
||||||
65
.paul/codebase/dependencies.md
Normal file
65
.paul/codebase/dependencies.md
Normal 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
|
||||||
72
.paul/codebase/overview.md
Normal file
72
.paul/codebase/overview.md
Normal 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.4–7.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
141
.paul/codebase/stack.md
Normal 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 668–850)
|
||||||
|
- **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
245
.paul/codebase/testing.md
Normal 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)
|
||||||
3
.paul/docs/API.md
Normal file
3
.paul/docs/API.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# API
|
||||||
|
|
||||||
|
> Endpointy, kontrakty request/response, autentykacja.
|
||||||
3
.paul/docs/ARCHITECTURE.md
Normal file
3
.paul/docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# ARCHITECTURE
|
||||||
|
|
||||||
|
> Struktura klas, modulow, przeplywow i zaleznosci w projekcie.
|
||||||
3
.paul/docs/DB_SCHEMA.md
Normal file
3
.paul/docs/DB_SCHEMA.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# DB_SCHEMA
|
||||||
|
|
||||||
|
> Schemat bazy danych — tabele, kolumny, FK, indeksy.
|
||||||
3
.paul/docs/DECISIONS.md
Normal file
3
.paul/docs/DECISIONS.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# DECISIONS
|
||||||
|
|
||||||
|
> Kluczowe decyzje techniczne i ich uzasadnienia.
|
||||||
3
.paul/docs/STACK.md
Normal file
3
.paul/docs/STACK.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# STACK
|
||||||
|
|
||||||
|
> Stack technologiczny, wersje, zaleznosci, srodowisko.
|
||||||
3
.paul/docs/TECH_CHANGELOG.md
Normal file
3
.paul/docs/TECH_CHANGELOG.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# TECH_CHANGELOG
|
||||||
|
|
||||||
|
> Chronologiczny log zmian technicznych — co i dlaczego.
|
||||||
3
.paul/docs/TODO.md
Normal file
3
.paul/docs/TODO.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
> Luzny parking pomyslow, rzeczy do sprawdzenia.
|
||||||
29
.paul/governance/governance_2026-04-16.jsonl
Normal file
29
.paul/governance/governance_2026-04-16.jsonl
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{"ts":"2026-04-16T09:36:18Z","tool":"Bash","cmd":"mkdir -p \"C:/visual studio code/projekty/shopPRO/.paul/phases/14-custom-fields-delete-bug\"\",\"description\":\"Create phase 14 directory\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"inter","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T09:36:54Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\phases\\\\14-custom-fields-delete-bug\\\\14-01-PLAN.md","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T09:37:27Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T09:37:31Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T09:37:38Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T09:55:40Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\autoload\\\\admin\\\\Controllers\\\\ShopProductController.php","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T09:55:47Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\autoload\\\\Domain\\\\Product\\\\ProductRepository.php","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T09:56:01Z","tool":"Bash","cmd":"wc -l \"C:/visual studio code/projekty/shopPRO/tests/Unit/Domain/Product/ProductRepositoryTest.php\"\",\"description\":\"Count lines in test file\"},\"tool_response\":{\"stdout\":\"1295 C:/visual stud","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T09:56:13Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\tests\\\\Unit\\\\Domain\\\\Product\\\\ProductRepositoryTest.php","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T09:56:20Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && php phpunit.phar --filter testSaveCustomFieldsDeletesAllWhenEmpty 2>&1\",\"description\":\"Run specific unit test\"},\"tool_response\":{\"stdout\":\"PH","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T09:56:27Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && php phpunit.phar 2>&1 | tail -5\",\"description\":\"Run full test suite, show summary\"},\"tool_response\":{\"stdout\":\".. ","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T09:57:00Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:45:27Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\phases\\\\14-custom-fields-delete-bug\\\\14-01-SUMMARY.md","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:45:38Z","tool":"Bash","cmd":"mkdir -p \"C:/visual studio code/projekty/shopPRO/.paul/changelog\"\",\"description\":\"Create changelog directory\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":false,\"isImage","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:45:45Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\changelog\\\\2026-04-16.md","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:46:16Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:46:21Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:50:25Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && php phpunit.phar 2>&1 | tail -5\",\"description\":\"Run full PHPUnit test suite\"},\"tool_response\":{\"stdout\":\".. ","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:50:35Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git tag --sort=-v:refname | head -3\",\"description\":\"Get latest version tags\"},\"tool_response\":{\"stdout\":\"v0.345\\nv0.344\\nv0.343\",\"stderr\","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:51:07Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\CLAUDE.md","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:51:31Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\docs\\\\TESTING.md","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:51:37Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:51:43Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git status -s\",\"description\":\"Show working tree status\"},\"tool_response\":{\"stdout\":\" M .paul/PROJECT.md\\n M .paul/ROADMAP.md\\n M .paul/STAT","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:51:50Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git add autoload/Domain/Product/ProductRepository.php autoload/admin/Controllers/ShopProductController.php tests/Unit/Domain/Product/ProductRepositoryT","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:51:58Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git commit -m \"$(cat <<'EOF'\\nfix: Custom fields delete bug — usunięcie wszystkich pól produktu nie działało\\n\\nDodano hidden marker custom_f","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:52:14Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git push 2>&1\",\"description\":\"Retry git push\"},\"tool_response\":{\"stdout\":\"remote: . Processing 1 references \\nremote: Processed 1 ref","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:52:25Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git tag v0.346 && powershell.exe -ExecutionPolicy Bypass -File build-update.ps1 -FromTag v0.345 -ToTag v0.346 -ChangelogEntry \"Fix usuwania wszystkich","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:52:32Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git add updates/0.30/ver_0.346.zip updates/0.30/ver_0.346_manifest.json updates/versions.php updates/changelog-data.html && git commit -m \"$(cat <<'EO","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
|
{"ts":"2026-04-16T20:52:43Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/shopPRO\" && git push 2>&1 && git push origin v0.346 2>&1\",\"description\":\"Push commit and tag\"},\"tool_response\":{\"stdout\":\"remote: . Processing 1 referenc","cwd":"/c/visual studio code/projekty/shopPRO"}
|
||||||
246
.paul/phases/04-csrf-protection/04-01-PLAN.md
Normal file
246
.paul/phases/04-csrf-protection/04-01-PLAN.md
Normal 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>
|
||||||
119
.paul/phases/04-csrf-protection/04-01-SUMMARY.md
Normal file
119
.paul/phases/04-csrf-protection/04-01-SUMMARY.md
Normal 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 36–42 |
|
||||||
|
| 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 47–51, 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*
|
||||||
46
.paul/phases/05-order-bugs-fix/05-01-FIX-SUMMARY.md
Normal file
46
.paul/phases/05-order-bugs-fix/05-01-FIX-SUMMARY.md
Normal 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 |
|
||||||
313
.paul/phases/05-order-bugs-fix/05-01-FIX.md
Normal file
313
.paul/phases/05-order-bugs-fix/05-01-FIX.md
Normal 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>
|
||||||
188
.paul/phases/06-integrations-refactoring/06-01-PLAN.md
Normal file
188
.paul/phases/06-integrations-refactoring/06-01-PLAN.md
Normal 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>
|
||||||
104
.paul/phases/06-integrations-refactoring/06-01-SUMMARY.md
Normal file
104
.paul/phases/06-integrations-refactoring/06-01-SUMMARY.md
Normal 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*
|
||||||
296
.paul/phases/06-integrations-refactoring/06-02-PLAN.md
Normal file
296
.paul/phases/06-integrations-refactoring/06-02-PLAN.md
Normal 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>
|
||||||
99
.paul/phases/06-integrations-refactoring/06-02-SUMMARY.md
Normal file
99
.paul/phases/06-integrations-refactoring/06-02-SUMMARY.md
Normal 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*
|
||||||
58
.paul/phases/07-coupon-bugfix/07-01-PLAN.md
Normal file
58
.paul/phases/07-coupon-bugfix/07-01-PLAN.md
Normal 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.
|
||||||
92
.paul/phases/07-coupon-bugfix/07-01-SUMMARY.md
Normal file
92
.paul/phases/07-coupon-bugfix/07-01-SUMMARY.md
Normal 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*
|
||||||
186
.paul/phases/08-apilo-orders-fix/08-01-PLAN.md
Normal file
186
.paul/phases/08-apilo-orders-fix/08-01-PLAN.md
Normal 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>
|
||||||
121
.paul/phases/08-apilo-orders-fix/08-01-SUMMARY.md
Normal file
121
.paul/phases/08-apilo-orders-fix/08-01-SUMMARY.md
Normal 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*
|
||||||
201
.paul/phases/09-apilo-email-fix/09-01-PLAN.md
Normal file
201
.paul/phases/09-apilo-email-fix/09-01-PLAN.md
Normal 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>
|
||||||
111
.paul/phases/09-apilo-email-fix/09-01-SUMMARY.md
Normal file
111
.paul/phases/09-apilo-email-fix/09-01-SUMMARY.md
Normal 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*
|
||||||
221
.paul/phases/10-basket-edit-custom-fields/10-01-PLAN.md
Normal file
221
.paul/phases/10-basket-edit-custom-fields/10-01-PLAN.md
Normal 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>
|
||||||
114
.paul/phases/10-basket-edit-custom-fields/10-01-SUMMARY.md
Normal file
114
.paul/phases/10-basket-edit-custom-fields/10-01-SUMMARY.md
Normal 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*
|
||||||
225
.paul/phases/11-datalayer-ga4-fix/11-01-PLAN.md
Normal file
225
.paul/phases/11-datalayer-ga4-fix/11-01-PLAN.md
Normal 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>
|
||||||
114
.paul/phases/11-datalayer-ga4-fix/11-01-SUMMARY.md
Normal file
114
.paul/phases/11-datalayer-ga4-fix/11-01-SUMMARY.md
Normal 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*
|
||||||
125
.paul/phases/12-summaryview-redirect-fix/12-01-PLAN.md
Normal file
125
.paul/phases/12-summaryview-redirect-fix/12-01-PLAN.md
Normal 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>
|
||||||
88
.paul/phases/12-summaryview-redirect-fix/12-01-SUMMARY.md
Normal file
88
.paul/phases/12-summaryview-redirect-fix/12-01-SUMMARY.md
Normal 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*
|
||||||
270
.paul/phases/13-basket-logging-ttl-token/13-01-PLAN.md
Normal file
270
.paul/phases/13-basket-logging-ttl-token/13-01-PLAN.md
Normal 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>
|
||||||
102
.paul/phases/13-basket-logging-ttl-token/13-01-SUMMARY.md
Normal file
102
.paul/phases/13-basket-logging-ttl-token/13-01-SUMMARY.md
Normal 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*
|
||||||
150
.paul/phases/14-custom-fields-delete-bug/14-01-PLAN.md
Normal file
150
.paul/phases/14-custom-fields-delete-bug/14-01-PLAN.md
Normal 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>
|
||||||
95
.paul/phases/14-custom-fields-delete-bug/14-01-SUMMARY.md
Normal file
95
.paul/phases/14-custom-fields-delete-bug/14-01-SUMMARY.md
Normal 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*
|
||||||
157
.paul/phases/15-scontainers-edit-save-fix/15-01-PLAN.md
Normal file
157
.paul/phases/15-scontainers-edit-save-fix/15-01-PLAN.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
phase: 15-scontainers-edit-save-fix
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- autoload/admin/Controllers/ScontainersController.php
|
||||||
|
- tests/Unit/admin/Controllers/ScontainersControllerTest.php
|
||||||
|
autonomous: true
|
||||||
|
delegation: off
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
## Goal
|
||||||
|
Naprawic regresje w edycji kontenerow statycznych: zapis edytowanego rekordu nie moze tworzyc nowego wpisu.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Administrator musi miec pewnosc, ze edycja kontenera aktualizuje istniejace ID. Obecny blad powoduje duplikaty i ryzyko niespojnych tresci.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
- Poprawiony flow zapisu w `ScontainersController`, ktory zawsze przekazuje poprawne `id` przy edycji
|
||||||
|
- Testy jednostkowe zabezpieczajace przed powrotem regresji
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
## Project Context
|
||||||
|
@.paul/PROJECT.md
|
||||||
|
@.paul/ROADMAP.md
|
||||||
|
@.paul/STATE.md
|
||||||
|
|
||||||
|
## Source Files
|
||||||
|
@autoload/admin/Controllers/ScontainersController.php
|
||||||
|
@admin/templates/components/form-edit.php
|
||||||
|
@autoload/admin/ViewModels/Forms/FormEditViewModel.php
|
||||||
|
@autoload/admin/Support/Forms/FormRequestHandler.php
|
||||||
|
@tests/Unit/admin/Controllers/ScontainersControllerTest.php
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<skills>
|
||||||
|
## Required Skills (from SPECIAL-FLOWS.md)
|
||||||
|
|
||||||
|
| Skill | Priority | When to Invoke | Loaded? |
|
||||||
|
|-------|----------|----------------|---------|
|
||||||
|
| /feature-dev | required | Before implementation in APPLY | ○ |
|
||||||
|
| /koniec-pracy | required | After implementation/release wrap-up | ○ |
|
||||||
|
|
||||||
|
**BLOCKING:** Required skills MUST be loaded before APPLY proceeds.
|
||||||
|
Run each skill command or confirm already loaded.
|
||||||
|
|
||||||
|
## Skill Invocation Checklist
|
||||||
|
- [ ] /feature-dev loaded (run command or confirm)
|
||||||
|
- [ ] /koniec-pracy loaded (run command or confirm)
|
||||||
|
|
||||||
|
</skills>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
|
||||||
|
## AC-1: Edycja nie tworzy nowego kontenera
|
||||||
|
```gherkin
|
||||||
|
Given istnieje kontener statyczny o ID 9
|
||||||
|
When admin wejdzie w /admin/scontainers/edit/id=9 i kliknie "Zatwierdz"
|
||||||
|
Then rekord o ID 9 zostanie zaktualizowany
|
||||||
|
And nie powstanie nowy rekord w pp_scontainers
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-2: Tworzenie nowego kontenera nadal dziala
|
||||||
|
```gherkin
|
||||||
|
Given admin otwiera /admin/scontainers/edit/ bez ID
|
||||||
|
When wypelni dane i kliknie "Zatwierdz"
|
||||||
|
Then zapis utworzy nowy rekord w pp_scontainers
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-3: API legacy JSON pozostaje bez zmian
|
||||||
|
```gherkin
|
||||||
|
Given zapis kontenera odbywa sie przez legacy payload values (JSON)
|
||||||
|
When wywolywana jest sciezka legacy w ScontainersController::save()
|
||||||
|
Then zachowanie insert/update pozostaje zgodne z dotychczasowa logika
|
||||||
|
```
|
||||||
|
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Utrwalic przekazywanie ID w nowym formularzu scontainers</name>
|
||||||
|
<files>autoload/admin/Controllers/ScontainersController.php</files>
|
||||||
|
<action>
|
||||||
|
W `buildFormViewModel()` przeniesc `id` do `hiddenFields` (FormEditViewModel),
|
||||||
|
tak aby pole `id` bylo renderowane niezaleznie od zakladek.
|
||||||
|
|
||||||
|
W `save()` dodac defensywny fallback: jesli `data['id']` z requestu jest puste,
|
||||||
|
pobrac `id` z parametru trasy (`Helpers::get('id')`) i uzyc go przy zapisie.
|
||||||
|
|
||||||
|
Nie zmieniac flow legacy (`values` JSON) ani logiki repozytorium.
|
||||||
|
</action>
|
||||||
|
<verify>Manual check: edycja /admin/scontainers/edit/id=9 aktualizuje rekord 9 zamiast tworzyc nowy</verify>
|
||||||
|
<done>AC-1 i AC-3 satisfied</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Dodac test regresyjny dla formularza i mapowania ID</name>
|
||||||
|
<files>tests/Unit/admin/Controllers/ScontainersControllerTest.php</files>
|
||||||
|
<action>
|
||||||
|
Rozszerzyc testy kontrolera o przypadki potwierdzajace, ze formularz edycji
|
||||||
|
niesie `id` jako hidden field oraz ze flow zapisu potrafi odczytac ID rekordu
|
||||||
|
dla przypadku edycji.
|
||||||
|
|
||||||
|
Uzyc Reflection tam, gdzie potrzeba dostepu do prywatnych metod (zgodnie z obecnym stylem testow).
|
||||||
|
</action>
|
||||||
|
<verify>./test.ps1 tests/Unit/admin/Controllers/ScontainersControllerTest.php</verify>
|
||||||
|
<done>AC-1 covered by automated tests</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Zweryfikowac brak regresji create flow</name>
|
||||||
|
<files>autoload/admin/Controllers/ScontainersController.php, tests/Unit/admin/Controllers/ScontainersControllerTest.php</files>
|
||||||
|
<action>
|
||||||
|
Potwierdzic, ze nowy kontener (brak `id` w URL i formularzu) nadal tworzy nowy rekord.
|
||||||
|
Dostosowac warunki fallbacku tak, by nie wymuszaly update przy create.
|
||||||
|
</action>
|
||||||
|
<verify>Manual check: /admin/scontainers/edit/ -> Zatwierdz tworzy nowe ID</verify>
|
||||||
|
<done>AC-2 satisfied</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<boundaries>
|
||||||
|
|
||||||
|
## DO NOT CHANGE
|
||||||
|
- autoload/Domain/Scontainers/ScontainersRepository.php (brak zmian logiki insert/update na poziomie repo)
|
||||||
|
- admin/templates/components/form-edit.php (bez globalnych zmian w uniwersalnym komponencie)
|
||||||
|
- Inne kontrolery admin poza ScontainersController
|
||||||
|
|
||||||
|
## SCOPE LIMITS
|
||||||
|
- Zakres tylko dla problemu edycji kontenerow statycznych (scontainers)
|
||||||
|
- Bez refaktoryzacji calego systemu FormEdit
|
||||||
|
|
||||||
|
</boundaries>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Before declaring plan complete:
|
||||||
|
- [ ] ./test.ps1 tests/Unit/admin/Controllers/ScontainersControllerTest.php
|
||||||
|
- [ ] Manual: edycja istniejacego kontenera nie tworzy nowego rekordu
|
||||||
|
- [ ] Manual: tworzenie nowego kontenera nadal dziala
|
||||||
|
- [ ] All acceptance criteria met
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Blad edycji kontenerow statycznych nie wystepuje
|
||||||
|
- Test regresyjny przechodzi
|
||||||
|
- Brak regresji w create flow dla scontainers
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.paul/phases/15-scontainers-edit-save-fix/15-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
108
.paul/phases/15-scontainers-edit-save-fix/15-01-SUMMARY.md
Normal file
108
.paul/phases/15-scontainers-edit-save-fix/15-01-SUMMARY.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
phase: 15-scontainers-edit-save-fix
|
||||||
|
plan: 01
|
||||||
|
subsystem: admin
|
||||||
|
tags: [scontainers, form-edit, hidden-fields, regression-fix]
|
||||||
|
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- Fix edycji scontainers (update zamiast insert)
|
||||||
|
- Regresyjne testy kontrolera dla mapowania id
|
||||||
|
affects: []
|
||||||
|
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns: [hiddenFields for stable id transfer in tabbed form-edit]
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- autoload/admin/Controllers/ScontainersController.php
|
||||||
|
- tests/Unit/admin/Controllers/ScontainersControllerTest.php
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Przeniesienie id z FormField::hidden do hiddenFields w FormEditViewModel"
|
||||||
|
- "Fallback id z route parametru przy zapisie edycji"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "W formularzach z zakladkami id encji przekazujemy przez hiddenFields, nie przez pola przypisane do taba"
|
||||||
|
|
||||||
|
duration: ~20min
|
||||||
|
completed: 2026-04-18
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 15 Plan 01: Scontainers edit save fix - Summary
|
||||||
|
|
||||||
|
**Naprawiono regresje, przez ktora edycja kontenera statycznego tworzyla nowy rekord zamiast aktualizacji istniejacego ID.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Duration | ~20min |
|
||||||
|
| Completed | 2026-04-18 |
|
||||||
|
| Tasks | 3 completed |
|
||||||
|
| Files modified | 2 |
|
||||||
|
|
||||||
|
## Acceptance Criteria Results
|
||||||
|
|
||||||
|
| Criterion | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| AC-1: Edycja nie tworzy nowego kontenera | Pass | `id` jest zawsze przenoszone przez hiddenFields + fallback z URL przy braku w POST |
|
||||||
|
| AC-2: Tworzenie nowego kontenera nadal dziala | Pass | Dla create `id=0`, action pozostaje `/admin/scontainers/save/` |
|
||||||
|
| AC-3: API legacy JSON pozostaje bez zmian | Pass | Sciezka `values` (legacy) nie byla modyfikowana |
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Przeniesiono `id` do `hiddenFields` w `ScontainersController::buildFormViewModel()`, co eliminuje gubienie `id` w formularzu tabowanym.
|
||||||
|
- Dodano defensywny fallback na `id` z parametru trasy w `ScontainersController::save()`.
|
||||||
|
- Dodano 2 testy regresyjne dla mapowania `id` i create-flow.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
| File | Change | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `autoload/admin/Controllers/ScontainersController.php` | Modified | Stabilne przekazywanie `id` dla update oraz fallback route `id` |
|
||||||
|
| `tests/Unit/admin/Controllers/ScontainersControllerTest.php` | Modified | Testy regresyjne dla hiddenFields i create flow |
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
| Decision | Rationale | Impact |
|
||||||
|
|----------|-----------|--------|
|
||||||
|
| Uzyc `hiddenFields` zamiast `FormField::hidden('id')` | Hidden field w tabbed form moze nie byc renderowany w aktywnej strukturze pol | Brak tworzenia duplikatow przy edycji |
|
||||||
|
| Dodac fallback `id` z URL w `save()` | Dodatkowa odpornosc na brak `id` w payloadzie | Bezpieczny update dla `/admin/scontainers/save/id={id}` |
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
Brak istotnych odchylen implementacyjnych.
|
||||||
|
|
||||||
|
Skill audit:
|
||||||
|
- `/feature-dev` - pominiety na prosbe uzytkownika (override zapisany w STATE.md)
|
||||||
|
- `/koniec-pracy` - wymaganie zmapowane na `.claude/commands/koniec-pracy.md`
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
| Issue | Resolution |
|
||||||
|
|-------|------------|
|
||||||
|
| Brak `test.ps1` w workspace | Test uruchomiony bezposrednio przez `php phpunit.phar ...` |
|
||||||
|
|
||||||
|
## Verification Results
|
||||||
|
|
||||||
|
- `php phpunit.phar tests/Unit/admin/Controllers/ScontainersControllerTest.php`
|
||||||
|
- Wynik: `OK (6 tests, 20 assertions)`
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
**Ready:**
|
||||||
|
- Problem zapisu scontainers naprawiony na poziomie kontrolera.
|
||||||
|
- Testy regresyjne zabezpieczaja krytyczny przypadek.
|
||||||
|
|
||||||
|
**Concerns:**
|
||||||
|
- Manualna weryfikacja UI edycji/create nadal wskazana po stronie panelu admin.
|
||||||
|
|
||||||
|
**Blockers:**
|
||||||
|
- None.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 15-scontainers-edit-save-fix, Plan: 01*
|
||||||
|
*Completed: 2026-04-18*
|
||||||
File diff suppressed because one or more lines are too long
6
.scannerwork/report-task.txt
Normal file
6
.scannerwork/report-task.txt
Normal 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
|
||||||
47
.serena/memories/code_style_and_conventions.md
Normal file
47
.serena/memories/code_style_and_conventions.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Code Style and Conventions
|
||||||
|
|
||||||
|
## PHP Version
|
||||||
|
PHP 7.4 — no PHP 8.0+ features allowed.
|
||||||
|
|
||||||
|
## File Naming
|
||||||
|
- New classes: `ClassName.php` (no prefix)
|
||||||
|
- Legacy classes: `class.ClassName.php` (leave until migrated)
|
||||||
|
|
||||||
|
## DI Pattern (all new code)
|
||||||
|
```php
|
||||||
|
class ExampleRepository {
|
||||||
|
private $db;
|
||||||
|
public function __construct($db) {
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
public function find(int $id): ?array {
|
||||||
|
return $this->db->get('pp_table', '*', ['id' => $id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Controller Wiring
|
||||||
|
- Admin: `admin\App::getControllerFactories()`
|
||||||
|
- Frontend: `front\App::getControllerFactories()`
|
||||||
|
- API: `api\ApiRouter::getControllerFactories()`
|
||||||
|
|
||||||
|
## Medoo ORM Pitfalls
|
||||||
|
- `$mdb->delete($table, $where)` takes 2 arguments, NOT 3
|
||||||
|
- `$mdb->get()` returns `null` when no record, NOT `false`
|
||||||
|
- After `$mdb->insert()`, check `$mdb->id()` to confirm success
|
||||||
|
|
||||||
|
## Test Conventions
|
||||||
|
- Extend `PHPUnit\Framework\TestCase`
|
||||||
|
- Mock Medoo: `$this->createMock(\medoo::class)`
|
||||||
|
- AAA pattern: Arrange, Act, Assert
|
||||||
|
- Mirror source structure: `tests/Unit/Domain/{Module}/{Class}Test.php`
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
- Redis via `\Shared\Cache\CacheHandler`
|
||||||
|
- Key pattern: `shop\product:{id}:{lang}:{permutation_hash}`
|
||||||
|
- Default TTL: 86400 (24h)
|
||||||
|
- Data serialized — use `unserialize()` after `get()`
|
||||||
|
|
||||||
|
## Database
|
||||||
|
- Table prefix: `pp_`
|
||||||
|
- Key tables: `pp_shop_products`, `pp_shop_orders`, `pp_shop_categories`, `pp_shop_clients`
|
||||||
65
.serena/memories/project_overview.md
Normal file
65
.serena/memories/project_overview.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# shopPRO — Project Overview
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
shopPRO is a PHP e-commerce platform with an admin panel, customer-facing storefront, and REST API.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Language**: PHP 7.4 (production runs PHP < 8.0 — do NOT use PHP 8.0+ syntax!)
|
||||||
|
- **ORM**: Medoo (`$mdb` global, injected via DI in new code)
|
||||||
|
- **Caching**: Redis via `\Shared\Cache\CacheHandler`
|
||||||
|
- **Testing**: PHPUnit 9.6 via `phpunit.phar`
|
||||||
|
- **Frontend**: Custom template engine (`\Shared\Tpl\Tpl`)
|
||||||
|
- **Database**: MySQL with `pp_` table prefix
|
||||||
|
- **Platform**: Windows (development), Linux (production)
|
||||||
|
|
||||||
|
## PHP 7.4 Constraint — CRITICAL
|
||||||
|
Do NOT use any PHP 8.0+ features:
|
||||||
|
- No `match` expressions (use ternary/if-else)
|
||||||
|
- No named arguments
|
||||||
|
- No union types (`int|string`)
|
||||||
|
- No `str_contains()`, `str_starts_with()`, `str_ends_with()`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
Domain-Driven Design with Dependency Injection.
|
||||||
|
|
||||||
|
### Layers
|
||||||
|
1. **Domain** (`autoload/Domain/`) — Business logic repositories, 27 modules
|
||||||
|
2. **Admin** (`autoload/admin/`) — Admin panel controllers, support, validation, view models
|
||||||
|
3. **Frontend** (`autoload/front/`) — Customer-facing controllers and views
|
||||||
|
4. **API** (`autoload/api/`) — REST API controllers
|
||||||
|
5. **Shared** (`autoload/Shared/`) — Cache, Email, Helpers, Html, Image, Tpl
|
||||||
|
|
||||||
|
### Domain Modules
|
||||||
|
Article, Attribute, Banner, Basket, Cache, Category, Client, Coupon, Dashboard, Dictionaries, Integrations, Languages, Layouts, Newsletter, Order, Pages, PaymentMethod, Producer, Product, ProductSet, Promotion, Scontainers, Settings, ShopStatus, Transport, Update, User
|
||||||
|
|
||||||
|
### Entry Points
|
||||||
|
- `index.php` — Frontend
|
||||||
|
- `admin/index.php` — Admin panel
|
||||||
|
- `api.php` — REST API
|
||||||
|
- `ajax.php` — Frontend AJAX
|
||||||
|
- `admin/ajax.php` — Admin AJAX
|
||||||
|
- `cron.php` — CRON jobs
|
||||||
|
|
||||||
|
### Namespace Conventions (case-sensitive on Linux!)
|
||||||
|
- `\Domain\` → `autoload/Domain/` (uppercase D)
|
||||||
|
- `\admin\Controllers\` → `autoload/admin/Controllers/` (lowercase a)
|
||||||
|
- `\Shared\` → `autoload/Shared/`
|
||||||
|
- `\front\` → `autoload/front/`
|
||||||
|
- `\api\` → `autoload/api/`
|
||||||
|
|
||||||
|
### Autoloader
|
||||||
|
Custom autoloader (not Composer at runtime). Tries:
|
||||||
|
1. `autoload/{namespace}/class.{ClassName}.php` (legacy)
|
||||||
|
2. `autoload/{namespace}/{ClassName}.php` (PSR-4 style)
|
||||||
|
|
||||||
|
### Key Classes
|
||||||
|
- `\admin\App` — Admin router
|
||||||
|
- `\front\App` — Frontend router
|
||||||
|
- `\front\LayoutEngine` — Frontend layout engine
|
||||||
|
- `\Shared\Helpers\Helpers` — Utility methods
|
||||||
|
- `\Shared\Tpl\Tpl` — Template engine
|
||||||
|
- `\Shared\Cache\CacheHandler` — Redis cache
|
||||||
|
- `\api\ApiRouter` — REST API router
|
||||||
|
|
||||||
|
## Test Suite
|
||||||
|
765 tests, 2153 assertions. Tests mirror source structure in `tests/Unit/`.
|
||||||
41
.serena/memories/suggested_commands.md
Normal file
41
.serena/memories/suggested_commands.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Suggested Commands
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
```bash
|
||||||
|
# Full test suite (recommended, PowerShell)
|
||||||
|
./test.ps1
|
||||||
|
|
||||||
|
# Specific test file
|
||||||
|
./test.ps1 tests/Unit/Domain/Product/ProductRepositoryTest.php
|
||||||
|
|
||||||
|
# Specific test method
|
||||||
|
./test.ps1 --filter testGetQuantityReturnsCorrectValue
|
||||||
|
|
||||||
|
# Via composer
|
||||||
|
composer test
|
||||||
|
```
|
||||||
|
|
||||||
|
## System Utilities (Windows with Git Bash)
|
||||||
|
```bash
|
||||||
|
# Use Unix-style commands (Git Bash shell)
|
||||||
|
ls # list directory
|
||||||
|
grep -r # search content (prefer Serena tools instead)
|
||||||
|
git status # git operations
|
||||||
|
git log --oneline -10
|
||||||
|
git diff
|
||||||
|
git add <file>
|
||||||
|
git commit -m "message"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
```bash
|
||||||
|
# No build step — PHP is interpreted
|
||||||
|
# No linting/formatting tool configured
|
||||||
|
# Entry points are served via web server (XAMPP)
|
||||||
|
```
|
||||||
|
|
||||||
|
## PHP binary
|
||||||
|
```
|
||||||
|
C:\xampp\php\php.exe
|
||||||
|
```
|
||||||
25
.serena/memories/task_completion_checklist.md
Normal file
25
.serena/memories/task_completion_checklist.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Task Completion Checklist
|
||||||
|
|
||||||
|
When user says "KONIEC PRACY", execute in order:
|
||||||
|
|
||||||
|
1. **Run tests** — `./test.ps1`
|
||||||
|
2. **Update documentation if needed**:
|
||||||
|
- `docs/DATABASE_STRUCTURE.md`
|
||||||
|
- `docs/PROJECT_STRUCTURE.md`
|
||||||
|
- `docs/FORM_EDIT_SYSTEM.md`
|
||||||
|
- `docs/CHANGELOG.md`
|
||||||
|
- `docs/TESTING.md`
|
||||||
|
3. **SQL migrations** (if DB changes): place in `migrations/{version}.sql`
|
||||||
|
- NOT in `updates/` — build script reads from `migrations/` automatically
|
||||||
|
4. **Commit** changes
|
||||||
|
5. **Push** to remote
|
||||||
|
|
||||||
|
## Key Documentation Files
|
||||||
|
- `docs/MEMORY.md` — project memory, known issues
|
||||||
|
- `docs/PROJECT_STRUCTURE.md` — architecture
|
||||||
|
- `docs/DATABASE_STRUCTURE.md` — full DB schema
|
||||||
|
- `docs/TESTING.md` — test suite guide
|
||||||
|
- `docs/FORM_EDIT_SYSTEM.md` — form system
|
||||||
|
- `docs/CHANGELOG.md` — version history
|
||||||
|
- `docs/API.md` — REST API docs
|
||||||
|
- `docs/UPDATE_INSTRUCTIONS.md` — update packages
|
||||||
@@ -116,3 +116,33 @@ initial_prompt: ""
|
|||||||
# override of the corresponding setting in serena_config.yml, see the documentation there.
|
# override of the corresponding setting in serena_config.yml, see the documentation there.
|
||||||
# If null or missing, the value from the global config is used.
|
# If null or missing, the value from the global config is used.
|
||||||
symbol_info_budget:
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
read_only_memory_patterns: []
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# 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: {}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ layout/style-scss/style.scss
|
|||||||
layout/style-scss/_mixins.scss
|
layout/style-scss/_mixins.scss
|
||||||
layout/style-scss/_mixins.css
|
layout/style-scss/_mixins.css
|
||||||
|
|
||||||
|
# macOS metadata
|
||||||
|
*.DS_Store
|
||||||
|
|
||||||
# Temp / cache / backups
|
# Temp / cache / backups
|
||||||
temp/
|
temp/
|
||||||
backups/
|
backups/
|
||||||
@@ -48,3 +51,13 @@ cron/temp/
|
|||||||
|
|
||||||
# Cache testów
|
# Cache testów
|
||||||
.phpunit.result.cache
|
.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
1
.vscode/ftp-kr.diff.ver_0.338.2.zip
vendored
Normal 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
1
.vscode/ftp-kr.diff.ver_0.338.zip
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
c:\visual studio code\projekty\shopPRO\updates\0.30\ver_0.338.zip
|
||||||
4
.vscode/ftp-kr.json
vendored
4
.vscode/ftp-kr.json
vendored
@@ -18,6 +18,8 @@
|
|||||||
"/.serena",
|
"/.serena",
|
||||||
"/.claude",
|
"/.claude",
|
||||||
"/docs",
|
"/docs",
|
||||||
"/tests"
|
"/tests",
|
||||||
|
"/.paul",
|
||||||
|
"/.scannerwork"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
255
AGENTS.md
255
AGENTS.md
@@ -1,30 +1,239 @@
|
|||||||
# Workflow
|
# CLAUDE.md
|
||||||
|
|
||||||
## KONIEC PRACY
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
Gdy użytkownik napisze `KONIEC PRACY`, wykonaj kolejno:
|
## Project Overview
|
||||||
|
|
||||||
1. Przeprowadzenie testów.
|
shopPRO is a PHP e-commerce platform with an admin panel and customer-facing storefront. It uses Medoo ORM (`$mdb`), Redis caching, and a Domain-Driven Design architecture with Dependency Injection (migration from legacy architecture complete).
|
||||||
2. Aktualizacja dokumentacji technicznej, jeśli zmiany tego wymagają:
|
|
||||||
- `docs/DATABASE_STRUCTURE.md`
|
|
||||||
- `docs/PROJECT_STRUCTURE.md`
|
|
||||||
- `docs/FORM_EDIT_SYSTEM.md`
|
|
||||||
- `docs/CHANGELOG.md`
|
|
||||||
- `docs/TESTING.md`
|
|
||||||
3. Migracje SQL (jeśli były zmiany w bazie danych):
|
|
||||||
- Plik: `migrations/{version}.sql` (np. `migrations/0.304.sql`)
|
|
||||||
- **NIE** w `updates/` — build script sam wczyta z `migrations/`
|
|
||||||
- Sprawdź czy plik istnieje i jest poprawnie nazwany przed commitem
|
|
||||||
4. Commit.
|
|
||||||
5. Push.
|
|
||||||
|
|
||||||
## PRZED ROZPOCZĘCIEM PRACY
|
## Zasady pisania kodu
|
||||||
|
- Kod ma być czytelny „dla obcego”: jasne nazwy, mało magii
|
||||||
|
- Brak „skrótów na szybko” typu logika w widokach, copy-paste, losowe helpery bez spójności
|
||||||
|
- Każda funkcja/klasa ma mieć jedną odpowiedzialność, zwykle do 30–50 linii (jeśli dłuższe – dzielić)
|
||||||
|
- max 3 poziomy zagnieżdżeń (if/foreach), reszta do osobnych metod
|
||||||
|
- Nazewnictwo:
|
||||||
|
- klasy: PascalCase
|
||||||
|
- metody/zmienne: camelCase
|
||||||
|
- stałe: UPPER_SNAKE_CASE
|
||||||
|
- Zero „skrótologii” w nazwach (np. $d, $tmp, $x1) poza pętlami 2–3 linijki
|
||||||
|
- medoo + prepared statements bez wyjątków (żadnego sklejania SQL stringiem)
|
||||||
|
- XSS: escape w widokach (np. helper e())
|
||||||
|
- CSRF dla formularzy, sensowna obsługa sesji
|
||||||
|
- Kod ma mieć komentarze tylko tam, gdzie wyjaśniają „dlaczego”, nie „co”
|
||||||
|
|
||||||
Przed rozpoczęciem implementacji sprawdź aktualną zawartość:
|
## PHP Version Constraint
|
||||||
|
|
||||||
- `docs/DATABASE_STRUCTURE.md`
|
**Production runs PHP < 8.0.** Do NOT use:
|
||||||
- `docs/PROJECT_STRUCTURE.md`
|
- `match` expressions (use ternary operators or if/else)
|
||||||
- `docs/CHANGELOG.md`
|
- Named arguments
|
||||||
- `docs/TESTING.md`
|
- Union types (`int|string`)
|
||||||
|
- `str_contains()`, `str_starts_with()`, `str_ends_with()`
|
||||||
|
- Other PHP 8.0+ syntax
|
||||||
|
|
||||||
To ma pomóc zachować spójność zmian i dokumentacji.
|
`composer.json` requires `>=7.4`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
```bash
|
||||||
|
# Full suite (recommended — PowerShell, auto-finds php)
|
||||||
|
./test.ps1
|
||||||
|
|
||||||
|
# Specific file
|
||||||
|
./test.ps1 tests/Unit/Domain/Product/ProductRepositoryTest.php
|
||||||
|
|
||||||
|
# Specific test method
|
||||||
|
./test.ps1 --filter testGetQuantityReturnsCorrectValue
|
||||||
|
|
||||||
|
# Alternative
|
||||||
|
composer test
|
||||||
|
```
|
||||||
|
|
||||||
|
PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`.
|
||||||
|
|
||||||
|
Current suite: **805 tests, 2253 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.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
```
|
||||||
|
shopPRO/
|
||||||
|
├── autoload/ # Autoloaded classes (core codebase)
|
||||||
|
│ ├── Domain/ # Business logic repositories (\Domain\)
|
||||||
|
│ ├── Shared/ # Shared utilities (\Shared\)
|
||||||
|
│ │ ├── Cache/ # CacheHandler, RedisConnection
|
||||||
|
│ │ ├── Email/ # Email (PHPMailer wrapper)
|
||||||
|
│ │ ├── Helpers/ # Helpers (formerly class.S.php)
|
||||||
|
│ │ ├── Html/ # Html utility
|
||||||
|
│ │ ├── Image/ # ImageManipulator
|
||||||
|
│ │ └── Tpl/ # Template engine
|
||||||
|
│ ├── api/ # REST API layer (\api\)
|
||||||
|
│ │ ├── ApiRouter.php # API router (\api\ApiRouter)
|
||||||
|
│ │ └── Controllers/ # API controllers (\api\Controllers\)
|
||||||
|
│ ├── admin/ # Admin panel layer
|
||||||
|
│ │ ├── App.php # Admin router (\admin\App)
|
||||||
|
│ │ ├── Controllers/ # DI controllers (\admin\Controllers\) — 28 controllers
|
||||||
|
│ │ ├── Support/ # TableListRequestFactory, Forms/FormRequestHandler, Forms/FormFieldRenderer
|
||||||
|
│ │ ├── Validation/ # FormValidator
|
||||||
|
│ │ └── ViewModels/ # Forms/ (FormEditViewModel, FormField, FormTab, FormAction, FormFieldType), Common/ (PaginatedTableViewModel)
|
||||||
|
│ └── front/ # Frontend layer
|
||||||
|
│ ├── App.php # Frontend router (\front\App)
|
||||||
|
│ ├── LayoutEngine.php # Layout engine (\front\LayoutEngine)
|
||||||
|
│ ├── Controllers/ # DI controllers (\front\Controllers\) — 8 controllers
|
||||||
|
│ └── Views/ # Static views (\front\Views\) — 11 view classes
|
||||||
|
├── admin/ # Admin panel
|
||||||
|
│ ├── templates/ # Admin view templates
|
||||||
|
│ └── layout/ # Admin CSS/JS/icons
|
||||||
|
├── templates/ # Frontend view templates
|
||||||
|
├── libraries/ # Third-party libraries (Medoo, RedBeanPHP, PHPMailer)
|
||||||
|
├── tests/ # PHPUnit tests
|
||||||
|
│ ├── bootstrap.php
|
||||||
|
│ ├── stubs/ # Test stubs (CacheHandler, Helpers, ShopProduct)
|
||||||
|
│ └── Unit/
|
||||||
|
│ ├── Domain/ # Repository tests
|
||||||
|
│ ├── admin/Controllers/ # Controller tests
|
||||||
|
│ └── api/ # API tests
|
||||||
|
├── updates/ # Update packages for clients
|
||||||
|
├── docs/ # Technical documentation
|
||||||
|
├── config.php # Database/Redis config (not in repo)
|
||||||
|
├── index.php # Frontend entry point
|
||||||
|
├── ajax.php # Frontend AJAX handler
|
||||||
|
├── admin/index.php # Admin entry point
|
||||||
|
├── admin/ajax.php # Admin AJAX handler
|
||||||
|
├── cron.php # CRON jobs (Apilo sync)
|
||||||
|
└── api.php # REST API (ordersPRO + Ekomi)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Autoloader
|
||||||
|
|
||||||
|
Custom autoloader in each entry point (not Composer autoload at runtime). Tries two filename conventions:
|
||||||
|
1. `autoload/{namespace}/class.{ClassName}.php` (legacy)
|
||||||
|
2. `autoload/{namespace}/{ClassName}.php` (PSR-4 style, fallback)
|
||||||
|
|
||||||
|
### Namespace Conventions (case-sensitive on Linux!)
|
||||||
|
- `\Domain\` → `autoload/Domain/` (uppercase D)
|
||||||
|
- `\admin\Controllers\` → `autoload/admin/Controllers/` (lowercase a)
|
||||||
|
- `\Shared\` → `autoload/Shared/`
|
||||||
|
- `\api\` → `autoload/api/`
|
||||||
|
- Do NOT use `\Admin\` (uppercase A) — the server directory is `admin/` (lowercase)
|
||||||
|
- `\shop\` namespace is **deleted** — all 12 legacy classes migrated to `\Domain\`, `autoload/shop/` directory removed
|
||||||
|
|
||||||
|
### Domain-Driven Architecture (migration complete)
|
||||||
|
|
||||||
|
All legacy directories (`admin/controls/`, `admin/factory/`, `admin/view/`, `front/controls/`, `front/view/`, `front/factory/`, `shop/`) have been deleted. All modules now use this pattern:
|
||||||
|
|
||||||
|
**Domain Layer** (`autoload/Domain/{Module}/`):
|
||||||
|
- `{Module}Repository.php` — data access, business logic, Redis caching
|
||||||
|
- Constructor DI with `$db` (Medoo instance)
|
||||||
|
- Methods serve both admin and frontend (shared Domain, no separate services)
|
||||||
|
|
||||||
|
**Domain Modules**: Article, Attribute, Banner, Basket, Cache, Category, Client, Coupon, CronJob, Dashboard, Dictionaries, Integrations, Languages, Layouts, Newsletter, Order, Pages, PaymentMethod, Producer, Product, ProductSet, Promotion, Scontainers, Settings, ShopStatus, Transport, Update, User
|
||||||
|
|
||||||
|
**Admin Controllers** (`autoload/admin/Controllers/`):
|
||||||
|
- DI via constructor (repositories injected)
|
||||||
|
- Wired in `admin\App::getControllerFactories()`
|
||||||
|
|
||||||
|
**Frontend Controllers** (`autoload/front/Controllers/`):
|
||||||
|
- DI via constructor
|
||||||
|
- Wired in `front\App::getControllerFactories()`
|
||||||
|
|
||||||
|
**Frontend Views** (`autoload/front/Views/`):
|
||||||
|
- Static classes, no state, no DI — pure rendering
|
||||||
|
|
||||||
|
**API Controllers** (`autoload/api/Controllers/`):
|
||||||
|
- DI via constructor, stateless (no session)
|
||||||
|
- Wired in `api\ApiRouter::getControllerFactories()`
|
||||||
|
- Auth: `X-Api-Key` header vs `pp_settings.api_key`
|
||||||
|
|
||||||
|
### Key Classes
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `\admin\App` | Admin router — maps URL segments to controllers |
|
||||||
|
| `\front\App` | Frontend router — `route()`, `checkUrlParams()` |
|
||||||
|
| `\front\LayoutEngine` | Frontend layout engine — `show()`, tag replacement |
|
||||||
|
| `\Shared\Helpers\Helpers` | Utility methods (SEO, email, cache clearing) |
|
||||||
|
| `\Shared\Tpl\Tpl` | Template engine — `render()`, `set()` |
|
||||||
|
| `\Shared\Cache\CacheHandler` | Redis cache — `get()`, `set()`, `delete()`, `deletePattern()` |
|
||||||
|
| `\api\ApiRouter` | REST API router — auth, routing, response helpers |
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- ORM: Medoo (`$mdb` global variable, injected via DI in new code)
|
||||||
|
- Table prefix: `pp_`
|
||||||
|
- Key tables: `pp_shop_products`, `pp_shop_orders`, `pp_shop_categories`, `pp_shop_clients`
|
||||||
|
- Full schema: `docs/DATABASE_STRUCTURE.md`
|
||||||
|
|
||||||
|
### Form Edit System
|
||||||
|
Universal form system for admin edit views. Docs: `docs/FORM_EDIT_SYSTEM.md`.
|
||||||
|
- **ViewModels** (`admin\ViewModels\Forms\`): `FormEditViewModel`, `FormField`, `FormTab`, `FormAction`, `FormFieldType`
|
||||||
|
- **Validation**: `admin\Validation\FormValidator`
|
||||||
|
- **Rendering**: `admin\Support\Forms\FormFieldRenderer`, `admin\Support\Forms\FormRequestHandler`
|
||||||
|
- **Template**: `admin/templates/components/form-edit.php`
|
||||||
|
- **Table lists**: `admin\Support\TableListRequestFactory` + `admin\ViewModels\Common\PaginatedTableViewModel`
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
- Redis via `\Shared\Cache\CacheHandler` (singleton `RedisConnection`)
|
||||||
|
- Key pattern for products: `shop\product:{id}:{lang}:{permutation_hash}`
|
||||||
|
- Clear product cache: `\Shared\Helpers\Helpers::clear_product_cache($id)`
|
||||||
|
- Pattern delete: `CacheHandler::deletePattern("shop\\product:{$id}:*")`
|
||||||
|
- Default TTL: 86400 (24h)
|
||||||
|
- Data is serialized — requires `unserialize()` after `get()`
|
||||||
|
- Config: `config.php` (`$config['redis']`)
|
||||||
|
|
||||||
|
## Code Patterns
|
||||||
|
|
||||||
|
### New code should follow DI pattern
|
||||||
|
```php
|
||||||
|
// Repository with constructor DI
|
||||||
|
class ExampleRepository {
|
||||||
|
private $db;
|
||||||
|
public function __construct($db) {
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
public function find(int $id): ?array {
|
||||||
|
return $this->db->get('pp_table', '*', ['id' => $id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller wiring (in admin\App or front\App)
|
||||||
|
$repo = new \Domain\Example\ExampleRepository($mdb);
|
||||||
|
$controller = new \admin\Controllers\ExampleController($repo);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Medoo ORM pitfalls
|
||||||
|
- `$mdb->delete($table, $where)` takes **2 arguments**, NOT 3 — has caused bugs
|
||||||
|
- `$mdb->get()` returns `null` when no record, NOT `false`
|
||||||
|
- After `$mdb->insert()`, check `$mdb->id()` to confirm success
|
||||||
|
|
||||||
|
### File naming
|
||||||
|
- New classes: `ClassName.php` (no `class.` prefix)
|
||||||
|
- Legacy classes: `class.ClassName.php` (leave until migrated)
|
||||||
|
|
||||||
|
### Test conventions
|
||||||
|
- Extend `PHPUnit\Framework\TestCase`
|
||||||
|
- Mock Medoo: `$this->createMock(\medoo::class)`
|
||||||
|
- AAA pattern: Arrange, Act, Assert
|
||||||
|
- Tests mirror source structure: `tests/Unit/Domain/{Module}/{Class}Test.php`
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
When user says **"KONIEC PRACY"**, run `/koniec-pracy` (see `.claude/commands/koniec-pracy.md`).
|
||||||
|
|
||||||
|
Before starting implementation, review current state of docs.
|
||||||
|
|
||||||
|
## Key Documentation
|
||||||
|
- `docs/MEMORY.md` — project memory: known issues, confirmed patterns, ORM pitfalls, caching conventions
|
||||||
|
- `docs/PROJECT_STRUCTURE.md` — current architecture, layers, cache, entry points, integrations
|
||||||
|
- `docs/DATABASE_STRUCTURE.md` — full database schema
|
||||||
|
- `docs/TESTING.md` — test suite guide and structure
|
||||||
|
- `docs/FORM_EDIT_SYSTEM.md` — form system architecture
|
||||||
|
- `docs/CHANGELOG.md` — version history
|
||||||
|
- `api-docs/api-reference.json` — REST API documentation (ordersPRO)
|
||||||
|
- `api-docs/index.html` — REST API documentation (ordersPRO)
|
||||||
|
- `docs/UPDATE_INSTRUCTIONS.md` — how to build client update packages
|
||||||
|
|
||||||
|
## Za każdym razem jak próbujesz sprawdzić jakiś plik z logami spróbuj go najpierw pobrać z serwera FTP
|
||||||
|
|
||||||
|
## Wszystkie pliki które tworzysz jako pomocnicze, np build_0330.ps1 czy build-update.ps1 twórz w folderze temp
|
||||||
51
CLAUDE.md
51
CLAUDE.md
@@ -6,6 +6,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
shopPRO is a PHP e-commerce platform with an admin panel and customer-facing storefront. It uses Medoo ORM (`$mdb`), Redis caching, and a Domain-Driven Design architecture with Dependency Injection (migration from legacy architecture complete).
|
shopPRO is a PHP e-commerce platform with an admin panel and customer-facing storefront. It uses Medoo ORM (`$mdb`), Redis caching, and a Domain-Driven Design architecture with Dependency Injection (migration from legacy architecture complete).
|
||||||
|
|
||||||
|
## Zasady pisania kodu
|
||||||
|
- Kod ma być czytelny „dla obcego”: jasne nazwy, mało magii
|
||||||
|
- Brak „skrótów na szybko” typu logika w widokach, copy-paste, losowe helpery bez spójności
|
||||||
|
- Każda funkcja/klasa ma mieć jedną odpowiedzialność, zwykle do 30–50 linii (jeśli dłuższe – dzielić)
|
||||||
|
- max 3 poziomy zagnieżdżeń (if/foreach), reszta do osobnych metod
|
||||||
|
- Nazewnictwo:
|
||||||
|
- klasy: PascalCase
|
||||||
|
- metody/zmienne: camelCase
|
||||||
|
- stałe: UPPER_SNAKE_CASE
|
||||||
|
- Zero „skrótologii” w nazwach (np. $d, $tmp, $x1) poza pętlami 2–3 linijki
|
||||||
|
- medoo + prepared statements bez wyjątków (żadnego sklejania SQL stringiem)
|
||||||
|
- XSS: escape w widokach (np. helper e())
|
||||||
|
- CSRF dla formularzy, sensowna obsługa sesji
|
||||||
|
- Kod ma mieć komentarze tylko tam, gdzie wyjaśniają „dlaczego”, nie „co”
|
||||||
|
|
||||||
## PHP Version Constraint
|
## PHP Version Constraint
|
||||||
|
|
||||||
**Production runs PHP < 8.0.** Do NOT use:
|
**Production runs PHP < 8.0.** Do NOT use:
|
||||||
@@ -30,16 +45,20 @@ shopPRO is a PHP e-commerce platform with an admin panel and customer-facing sto
|
|||||||
# Specific test method
|
# Specific test method
|
||||||
./test.ps1 --filter testGetQuantityReturnsCorrectValue
|
./test.ps1 --filter testGetQuantityReturnsCorrectValue
|
||||||
|
|
||||||
# Alternative
|
# Alternatives
|
||||||
composer test
|
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`.
|
PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`.
|
||||||
|
|
||||||
Current suite: **765 tests, 2153 assertions**.
|
Current suite: **823 tests, 2284 assertions**.
|
||||||
|
|
||||||
### Creating Updates
|
### 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
|
## Architecture
|
||||||
|
|
||||||
@@ -102,7 +121,6 @@ Custom autoloader in each entry point (not Composer autoload at runtime). Tries
|
|||||||
- `\Domain\` → `autoload/Domain/` (uppercase D)
|
- `\Domain\` → `autoload/Domain/` (uppercase D)
|
||||||
- `\admin\Controllers\` → `autoload/admin/Controllers/` (lowercase a)
|
- `\admin\Controllers\` → `autoload/admin/Controllers/` (lowercase a)
|
||||||
- `\Shared\` → `autoload/Shared/`
|
- `\Shared\` → `autoload/Shared/`
|
||||||
- `\front\` → `autoload/front/`
|
|
||||||
- `\api\` → `autoload/api/`
|
- `\api\` → `autoload/api/`
|
||||||
- Do NOT use `\Admin\` (uppercase A) — the server directory is `admin/` (lowercase)
|
- Do NOT use `\Admin\` (uppercase A) — the server directory is `admin/` (lowercase)
|
||||||
- `\shop\` namespace is **deleted** — all 12 legacy classes migrated to `\Domain\`, `autoload/shop/` directory removed
|
- `\shop\` namespace is **deleted** — all 12 legacy classes migrated to `\Domain\`, `autoload/shop/` directory removed
|
||||||
@@ -116,7 +134,7 @@ All legacy directories (`admin/controls/`, `admin/factory/`, `admin/view/`, `fro
|
|||||||
- Constructor DI with `$db` (Medoo instance)
|
- Constructor DI with `$db` (Medoo instance)
|
||||||
- Methods serve both admin and frontend (shared Domain, no separate services)
|
- Methods serve both admin and frontend (shared Domain, no separate services)
|
||||||
|
|
||||||
**Domain Modules**: Article, Attribute, Banner, Basket, Cache, Category, Client, Coupon, Dashboard, Dictionaries, Integrations, Languages, Layouts, Newsletter, Order, Pages, PaymentMethod, Producer, Product, ProductSet, Promotion, Scontainers, Settings, ShopStatus, Transport, Update, User
|
**Domain Modules**: Article, Attribute, Banner, Basket, Cache, Category, Client, Coupon, CronJob, Dashboard, Dictionaries, Integrations, Languages, Layouts, Newsletter, Order, Pages, PaymentMethod, Producer, Product, ProductSet, Promotion, Scontainers, Settings, ShopStatus, Transport, Update, User
|
||||||
|
|
||||||
**Admin Controllers** (`autoload/admin/Controllers/`):
|
**Admin Controllers** (`autoload/admin/Controllers/`):
|
||||||
- DI via constructor (repositories injected)
|
- DI via constructor (repositories injected)
|
||||||
@@ -203,16 +221,11 @@ $controller = new \admin\Controllers\ExampleController($repo);
|
|||||||
- AAA pattern: Arrange, Act, Assert
|
- AAA pattern: Arrange, Act, Assert
|
||||||
- Tests mirror source structure: `tests/Unit/Domain/{Module}/{Class}Test.php`
|
- Tests mirror source structure: `tests/Unit/Domain/{Module}/{Class}Test.php`
|
||||||
|
|
||||||
## Workflow (AGENTS.md)
|
## Workflow
|
||||||
|
|
||||||
When user says **"KONIEC PRACY"**, execute in order:
|
When user says **"KONIEC PRACY"**, run `/koniec-pracy` (see `.claude/commands/koniec-pracy.md`).
|
||||||
1. Run tests
|
|
||||||
2. Update documentation if needed: `docs/DATABASE_STRUCTURE.md`, `docs/PROJECT_STRUCTURE.md`, `docs/FORM_EDIT_SYSTEM.md`, `docs/CHANGELOG.md`, `docs/TESTING.md`
|
|
||||||
3. SQL migrations (if DB changes): place in `migrations/{version}.sql` (e.g. `migrations/0.304.sql`). **NOT** in `updates/` — build script reads from `migrations/` automatically
|
|
||||||
4. Commit
|
|
||||||
5. Push
|
|
||||||
|
|
||||||
Before starting implementation, review current state of docs (see AGENTS.md for full list).
|
Before starting implementation, review current state of docs.
|
||||||
|
|
||||||
## Key Documentation
|
## Key Documentation
|
||||||
- `docs/MEMORY.md` — project memory: known issues, confirmed patterns, ORM pitfalls, caching conventions
|
- `docs/MEMORY.md` — project memory: known issues, confirmed patterns, ORM pitfalls, caching conventions
|
||||||
@@ -220,6 +233,14 @@ Before starting implementation, review current state of docs (see AGENTS.md for
|
|||||||
- `docs/DATABASE_STRUCTURE.md` — full database schema
|
- `docs/DATABASE_STRUCTURE.md` — full database schema
|
||||||
- `docs/TESTING.md` — test suite guide and structure
|
- `docs/TESTING.md` — test suite guide and structure
|
||||||
- `docs/FORM_EDIT_SYSTEM.md` — form system architecture
|
- `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
|
- `docs/CHANGELOG.md` — version history
|
||||||
- `docs/API.md` — REST API documentation (ordersPRO)
|
- `api-docs/api-reference.json` — REST API documentation (ordersPRO)
|
||||||
|
- `api-docs/index.html` — REST API documentation (ordersPRO)
|
||||||
- `docs/UPDATE_INSTRUCTIONS.md` — how to build client update packages
|
- `docs/UPDATE_INSTRUCTIONS.md` — how to build client update packages
|
||||||
|
|
||||||
|
## Za każdym razem jak próbujesz sprawdzić jakiś plik z logami spróbuj go najpierw pobrać z serwera FTP
|
||||||
|
|
||||||
|
## Wszystkie pliki które tworzysz jako pomocnicze, np build_0330.ps1 czy build-update.ps1 twórz w folderze temp
|
||||||
|
|||||||
6
Zapis/report-task.txt
Normal file
6
Zapis/report-task.txt
Normal 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
|
||||||
@@ -31,17 +31,9 @@ function __autoload_my_classes( $classname )
|
|||||||
spl_autoload_register( '__autoload_my_classes' );
|
spl_autoload_register( '__autoload_my_classes' );
|
||||||
require_once '../config.php';
|
require_once '../config.php';
|
||||||
require_once '../libraries/medoo/medoo.php';
|
require_once '../libraries/medoo/medoo.php';
|
||||||
require_once '../libraries/rb.php';
|
|
||||||
require_once '../libraries/phpmailer/class.phpmailer.php';
|
require_once '../libraries/phpmailer/class.phpmailer.php';
|
||||||
require_once '../libraries/phpmailer/class.smtp.php';
|
require_once '../libraries/phpmailer/class.smtp.php';
|
||||||
|
|
||||||
define( 'REDBEAN_MODEL_PREFIX', '' );
|
|
||||||
\R::setup( 'mysql:host=' . $database['host'] . ';dbname=' . $database['name'], $database['user'], $database['password'] );
|
|
||||||
\R::ext( 'xdispense', function ( $type )
|
|
||||||
{
|
|
||||||
return R::getRedBean() -> dispense( $type );
|
|
||||||
} );
|
|
||||||
|
|
||||||
date_default_timezone_set( 'Europe/Warsaw' );
|
date_default_timezone_set( 'Europe/Warsaw' );
|
||||||
|
|
||||||
$mdb = new medoo( [
|
$mdb = new medoo( [
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ $_SESSION['can_use_rfm'] = true;
|
|||||||
action="<?= htmlspecialchars($form->action) ?>" enctype="multipart/form-data">
|
action="<?= htmlspecialchars($form->action) ?>" enctype="multipart/form-data">
|
||||||
|
|
||||||
<input type="hidden" name="_form_id" value="<?= htmlspecialchars($form->formId) ?>">
|
<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): ?>
|
<?php foreach ($form->hiddenFields as $name => $value): ?>
|
||||||
<input type="hidden" name="<?= htmlspecialchars($name) ?>" value="<?= htmlspecialchars($value ?? '') ?>">
|
<input type="hidden" name="<?= htmlspecialchars($name) ?>" value="<?= htmlspecialchars($value ?? '') ?>">
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
|
.bulk-action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-action-bar__info {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-bulk-check {
|
||||||
|
width: 36px;
|
||||||
|
padding-left: 10px !important;
|
||||||
|
padding-right: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.product-archive-thumb-wrap {
|
.product-archive-thumb-wrap {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
@@ -96,5 +118,119 @@
|
|||||||
$popup.removeClass('is-visible');
|
$popup.removeClass('is-visible');
|
||||||
$popupImage.attr('src', '');
|
$popupImage.attr('src', '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Bulk select ---
|
||||||
|
var $table = $('.table-list-table');
|
||||||
|
var $bar = $('#js-bulk-action-bar');
|
||||||
|
var $label = $bar.find('.js-bulk-count-label');
|
||||||
|
|
||||||
|
// Inject select-all checkbox into _checkbox column header
|
||||||
|
$table.find('thead th.table-col-bulk-check').html(
|
||||||
|
'<input type="checkbox" id="js-bulk-select-all" title="Zaznacz wszystkie">'
|
||||||
|
);
|
||||||
|
|
||||||
|
function updateBar() {
|
||||||
|
var count = $table.find('.js-bulk-check:checked').length;
|
||||||
|
if (count > 0) {
|
||||||
|
$label.text('Zaznaczono: ' + count);
|
||||||
|
$bar.show();
|
||||||
|
} else {
|
||||||
|
$bar.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).on('change.bulkSelect', '#js-bulk-select-all', function() {
|
||||||
|
var checked = $(this).is(':checked');
|
||||||
|
$table.find('.js-bulk-check').prop('checked', checked);
|
||||||
|
updateBar();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('change.bulkSelect', '.js-bulk-check', function() {
|
||||||
|
var total = $table.find('.js-bulk-check').length;
|
||||||
|
var checked = $table.find('.js-bulk-check:checked').length;
|
||||||
|
$('#js-bulk-select-all').prop('indeterminate', checked > 0 && checked < total);
|
||||||
|
$('#js-bulk-select-all').prop('checked', checked === total && total > 0);
|
||||||
|
updateBar();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click.bulkDelete', '.js-bulk-delete-btn', function() {
|
||||||
|
var ids = [];
|
||||||
|
$table.find('.js-bulk-check:checked').each(function() {
|
||||||
|
ids.push($(this).val());
|
||||||
|
});
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmMsg = 'UWAGA! Operacja nieodwracalna!\n\n'
|
||||||
|
+ 'Wybrane produkty (' + ids.length + ' szt.) zostaną trwale usunięte razem ze wszystkimi zdjęciami i załącznikami z serwera.\n\n'
|
||||||
|
+ 'Czy na pewno chcesz usunąć zaznaczone produkty?';
|
||||||
|
|
||||||
|
var doDelete = function() {
|
||||||
|
var $btn = $('.js-bulk-delete-btn');
|
||||||
|
$btn.prop('disabled', true).text('Usuwanie…');
|
||||||
|
|
||||||
|
var formData = [];
|
||||||
|
for (var i = 0; i < ids.length; i++) {
|
||||||
|
formData.push('ids%5B%5D=' + encodeURIComponent(ids[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/admin/product_archive/bulk_delete_permanent/',
|
||||||
|
type: 'POST',
|
||||||
|
data: formData.join('&'),
|
||||||
|
contentType: 'application/x-www-form-urlencoded',
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(resp) {
|
||||||
|
if (resp && resp.deleted > 0) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Nie udało się usunąć produktów. Spróbuj ponownie.');
|
||||||
|
$btn.prop('disabled', false).html('<i class="fa fa-trash-o"></i> Usuń zaznaczone trwale');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
alert('Błąd podczas usuwania produktów. Spróbuj ponownie.');
|
||||||
|
$btn.prop('disabled', false).html('<i class="fa fa-trash-o"></i> Usuń zaznaczone trwale');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof $.confirm === 'function') {
|
||||||
|
$.confirm({
|
||||||
|
title: 'Potwierdzenie',
|
||||||
|
content: confirmMsg,
|
||||||
|
type: 'red',
|
||||||
|
boxWidth: '560px',
|
||||||
|
useBootstrap: false,
|
||||||
|
animation: 'scale',
|
||||||
|
closeAnimation: 'scale',
|
||||||
|
backgroundDismissAnimation: 'shake',
|
||||||
|
container: 'body',
|
||||||
|
theme: 'modern',
|
||||||
|
columnClass: '',
|
||||||
|
typeAnimated: true,
|
||||||
|
lazyOpen: false,
|
||||||
|
draggable: false,
|
||||||
|
closeIcon: true,
|
||||||
|
containerFluid: true,
|
||||||
|
escapeKey: true,
|
||||||
|
backgroundDismiss: true,
|
||||||
|
buttons: {
|
||||||
|
cancel: {
|
||||||
|
text: 'Anuluj',
|
||||||
|
btnClass: 'btn-default'
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
text: 'Tak, usuń trwale',
|
||||||
|
btnClass: 'btn-danger',
|
||||||
|
action: doDelete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (window.confirm(confirmMsg)) {
|
||||||
|
doDelete();
|
||||||
|
}
|
||||||
|
});
|
||||||
})(window.jQuery);
|
})(window.jQuery);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
<div id="js-bulk-action-bar" class="bulk-action-bar" style="display:none;">
|
||||||
|
<span class="bulk-action-bar__info js-bulk-count-label">Zaznaczono: 0</span>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm js-bulk-delete-btn">
|
||||||
|
<i class="fa fa-trash-o"></i> Usuń zaznaczone trwale
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?= \Shared\Tpl\Tpl::view('components/table-list', ['list' => $this->viewModel]); ?>
|
<?= \Shared\Tpl\Tpl::view('components/table-list', ['list' => $this->viewModel]); ?>
|
||||||
|
|
||||||
<?php if (!empty($this->viewModel->customScriptView)): ?>
|
<?php if (!empty($this->viewModel->customScriptView)): ?>
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
|
<style type="text/css">
|
||||||
|
.attr-copy-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #d0d0d0;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 4px;
|
||||||
|
transition: background .12s, color .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.attr-copy-btn:hover {
|
||||||
|
background: #f4f4f4;
|
||||||
|
border-color: #aaa;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.attr-copy-btn--copied {
|
||||||
|
background: #d4edda !important;
|
||||||
|
border-color: #28a745 !important;
|
||||||
|
color: #28a745 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
(function() {
|
(function() {
|
||||||
var orderId = <?= (int)($this->order_id ?? 0);?>;
|
var orderId = <?= (int)($this->order_id ?? 0);?>;
|
||||||
@@ -378,6 +405,68 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$(function() {
|
||||||
|
function fallbackCopy(text) {
|
||||||
|
var $tmp = $('<textarea>').css({position: 'fixed', top: 0, left: 0, opacity: 0}).val(text);
|
||||||
|
$('body').append($tmp);
|
||||||
|
$tmp[0].select();
|
||||||
|
try { document.execCommand('copy'); } catch (e) {}
|
||||||
|
$tmp.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('.atributes').each(function() {
|
||||||
|
var $div = $(this);
|
||||||
|
var html = $.trim($div.html());
|
||||||
|
if (!html) { return; }
|
||||||
|
|
||||||
|
var parts = html.split(/<br\s*\/?>/i);
|
||||||
|
var newParts = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
var part = $.trim(parts[i]);
|
||||||
|
if (!part) { continue; }
|
||||||
|
|
||||||
|
var match = part.match(/^(<b>[^<]*<\/b>\s*:\s*)(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
var labelHtml = match[1];
|
||||||
|
var value = $.trim(match[2]);
|
||||||
|
var escapedValue = $('<div>').text(value).html();
|
||||||
|
part = labelHtml + escapedValue
|
||||||
|
+ ' <button type="button" class="js-attr-copy-btn attr-copy-btn" data-value="'
|
||||||
|
+ escapedValue + '" title="Kopiuj: ' + escapedValue + '">'
|
||||||
|
+ '<i class="fa fa-copy"></i></button>';
|
||||||
|
}
|
||||||
|
newParts.push(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
$div.html(newParts.join('<br>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.js-attr-copy-btn', function() {
|
||||||
|
var $btn = $(this);
|
||||||
|
var value = String($btn.data('value'));
|
||||||
|
|
||||||
|
function showCopied() {
|
||||||
|
$btn.addClass('attr-copy-btn--copied');
|
||||||
|
$btn.find('i').removeClass('fa-copy').addClass('fa-check');
|
||||||
|
setTimeout(function() {
|
||||||
|
$btn.removeClass('attr-copy-btn--copied');
|
||||||
|
$btn.find('i').removeClass('fa-check').addClass('fa-copy');
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(value).then(showCopied, function() {
|
||||||
|
fallbackCopy(value);
|
||||||
|
showCopied();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackCopy(value);
|
||||||
|
showCopied();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$('body').on('click', '.btn-toggle-trustmate', function(e) {
|
$('body').on('click', '.btn-toggle-trustmate', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,13 @@
|
|||||||
?>
|
?>
|
||||||
<div class="alert alert-danger alert-dismissable">
|
<div class="alert alert-danger alert-dismissable">
|
||||||
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
|
<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>
|
</div>
|
||||||
<? endif;
|
<? endif;
|
||||||
?>
|
?>
|
||||||
<form method="POST" action="/admin/" class="form-horizontal" rol="form">
|
<form method="POST" action="/admin/" class="form-horizontal" rol="form">
|
||||||
<input type="hidden" name="s-action" value="user-logon" />
|
<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="form-group form-inline row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="input-group input-login">
|
<div class="input-group input-login">
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
<span class="panel-title">Changelog</span>
|
<span class="panel-title">Changelog</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<?= @file_get_contents( 'https://shoppro.project-dc.pl/updates/changelog.php' ); ?>
|
<?= @file_get_contents( 'https://shoppro.project-dc.pl/updates/changelog.php?ver=' . $this->ver ); ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<form method="POST" action="/admin/" class="form-horizontal" rol="form">
|
<form method="POST" action="/admin/" class="form-horizontal" rol="form">
|
||||||
<input type="hidden" name="s-action" value="user-2fa-verify">
|
<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">
|
<div class="form-group row">
|
||||||
<label class="col col-sm-4 control-label" for="login">Kod z e-maila:</label>
|
<label class="col col-sm-4 control-label" for="login">Kod z e-maila:</label>
|
||||||
<div class="col col-sm-8">
|
<div class="col col-sm-8">
|
||||||
@@ -14,5 +15,6 @@
|
|||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/admin/" style="margin-top:10px">
|
<form method="POST" action="/admin/" style="margin-top:10px">
|
||||||
<input type="hidden" name="s-action" value="user-2fa-resend">
|
<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>
|
<button class="btn btn-danger">Wyślij kod ponownie</button>
|
||||||
</form>
|
</form>
|
||||||
292
api-docs/api-reference.json
Normal file
292
api-docs/api-reference.json
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
{
|
||||||
|
"name": "shopPRO API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"entrypoint": "/api.php",
|
||||||
|
"authentication": {
|
||||||
|
"type": "header",
|
||||||
|
"header": "X-Api-Key",
|
||||||
|
"required": true,
|
||||||
|
"description": "API key stored in pp_settings.param=api_key"
|
||||||
|
},
|
||||||
|
"response_format": {
|
||||||
|
"success": {
|
||||||
|
"status": "ok",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"status": "error",
|
||||||
|
"code": "BAD_REQUEST",
|
||||||
|
"message": "Human-readable error message"
|
||||||
|
},
|
||||||
|
"error_codes": [
|
||||||
|
{ "code": "UNAUTHORIZED", "http": 401 },
|
||||||
|
{ "code": "BAD_REQUEST", "http": 400 },
|
||||||
|
{ "code": "NOT_FOUND", "http": 404 },
|
||||||
|
{ "code": "METHOD_NOT_ALLOWED", "http": 405 },
|
||||||
|
{ "code": "INTERNAL_ERROR", "http": 500 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"group": "orders",
|
||||||
|
"action": "list",
|
||||||
|
"method": "GET",
|
||||||
|
"url_template": "/api.php?endpoint=orders&action=list",
|
||||||
|
"query_params": [
|
||||||
|
{ "name": "status", "type": "string", "required": false },
|
||||||
|
{ "name": "paid", "type": "string", "required": false },
|
||||||
|
{ "name": "date_from", "type": "string", "required": false, "format": "YYYY-MM-DD" },
|
||||||
|
{ "name": "date_to", "type": "string", "required": false, "format": "YYYY-MM-DD" },
|
||||||
|
{ "name": "updated_since", "type": "string", "required": false, "format": "YYYY-MM-DD HH:MM:SS" },
|
||||||
|
{ "name": "number", "type": "string", "required": false },
|
||||||
|
{ "name": "client", "type": "string", "required": false },
|
||||||
|
{ "name": "page", "type": "integer", "required": false, "default": 1, "min": 1 },
|
||||||
|
{ "name": "per_page", "type": "integer", "required": false, "default": 50, "min": 1, "max": 100 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "orders",
|
||||||
|
"action": "get",
|
||||||
|
"method": "GET",
|
||||||
|
"url_template": "/api.php?endpoint=orders&action=get&id={order_id}",
|
||||||
|
"query_params": [
|
||||||
|
{ "name": "id", "type": "integer", "required": true, "min": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "orders",
|
||||||
|
"action": "change_status",
|
||||||
|
"method": "PUT",
|
||||||
|
"url_template": "/api.php?endpoint=orders&action=change_status&id={order_id}",
|
||||||
|
"query_params": [
|
||||||
|
{ "name": "id", "type": "integer", "required": true, "min": 1 }
|
||||||
|
],
|
||||||
|
"json_body": {
|
||||||
|
"required_fields": ["status_id"],
|
||||||
|
"fields": {
|
||||||
|
"status_id": { "type": "integer" },
|
||||||
|
"send_email": { "type": "boolean", "required": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "orders",
|
||||||
|
"action": "set_paid",
|
||||||
|
"method": "PUT",
|
||||||
|
"url_template": "/api.php?endpoint=orders&action=set_paid&id={order_id}",
|
||||||
|
"query_params": [
|
||||||
|
{ "name": "id", "type": "integer", "required": true, "min": 1 }
|
||||||
|
],
|
||||||
|
"json_body": {
|
||||||
|
"required_fields": [],
|
||||||
|
"fields": {
|
||||||
|
"send_email": { "type": "boolean", "required": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "orders",
|
||||||
|
"action": "set_unpaid",
|
||||||
|
"method": "PUT",
|
||||||
|
"url_template": "/api.php?endpoint=orders&action=set_unpaid&id={order_id}",
|
||||||
|
"query_params": [
|
||||||
|
{ "name": "id", "type": "integer", "required": true, "min": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "products",
|
||||||
|
"action": "list",
|
||||||
|
"method": "GET",
|
||||||
|
"url_template": "/api.php?endpoint=products&action=list",
|
||||||
|
"query_params": [
|
||||||
|
{ "name": "search", "type": "string", "required": false },
|
||||||
|
{ "name": "status", "type": "string", "required": false },
|
||||||
|
{ "name": "promoted", "type": "string", "required": false },
|
||||||
|
{ "name": "attribute_{id}", "type": "integer", "required": false, "description": "e.g. attribute_5=12" },
|
||||||
|
{ "name": "sort", "type": "string", "required": false, "default": "id", "allowed": ["id", "name", "price_brutto", "status", "promoted", "quantity"] },
|
||||||
|
{ "name": "sort_dir", "type": "string", "required": false, "default": "DESC", "allowed": ["ASC", "DESC"] },
|
||||||
|
{ "name": "page", "type": "integer", "required": false, "default": 1, "min": 1 },
|
||||||
|
{ "name": "per_page", "type": "integer", "required": false, "default": 50, "min": 1, "max": 100 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "products",
|
||||||
|
"action": "get",
|
||||||
|
"method": "GET",
|
||||||
|
"url_template": "/api.php?endpoint=products&action=get&id={product_id}",
|
||||||
|
"query_params": [
|
||||||
|
{ "name": "id", "type": "integer", "required": true, "min": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "products",
|
||||||
|
"action": "create",
|
||||||
|
"method": "POST",
|
||||||
|
"url_template": "/api.php?endpoint=products&action=create",
|
||||||
|
"json_body": {
|
||||||
|
"required_fields": ["languages", "price_brutto"],
|
||||||
|
"rules": [
|
||||||
|
"languages must be an object with at least one language entry containing name",
|
||||||
|
"price_brutto must be numeric and >= 0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "products",
|
||||||
|
"action": "update",
|
||||||
|
"method": "PUT",
|
||||||
|
"url_template": "/api.php?endpoint=products&action=update&id={product_id}",
|
||||||
|
"query_params": [
|
||||||
|
{ "name": "id", "type": "integer", "required": true, "min": 1 }
|
||||||
|
],
|
||||||
|
"json_body": {
|
||||||
|
"required_fields": [],
|
||||||
|
"rules": ["partial update; only changed fields are needed"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "products",
|
||||||
|
"action": "variants",
|
||||||
|
"method": "GET",
|
||||||
|
"url_template": "/api.php?endpoint=products&action=variants&id={product_id}",
|
||||||
|
"query_params": [
|
||||||
|
{ "name": "id", "type": "integer", "required": true, "min": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "products",
|
||||||
|
"action": "create_variant",
|
||||||
|
"method": "POST",
|
||||||
|
"url_template": "/api.php?endpoint=products&action=create_variant&id={product_id}",
|
||||||
|
"query_params": [
|
||||||
|
{ "name": "id", "type": "integer", "required": true, "min": 1 }
|
||||||
|
],
|
||||||
|
"json_body": {
|
||||||
|
"required_fields": ["attributes"],
|
||||||
|
"fields": {
|
||||||
|
"attributes": { "type": "object", "description": "Map attribute_id -> value_id" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "products",
|
||||||
|
"action": "update_variant",
|
||||||
|
"method": "PUT",
|
||||||
|
"url_template": "/api.php?endpoint=products&action=update_variant&id={variant_id}",
|
||||||
|
"query_params": [
|
||||||
|
{ "name": "id", "type": "integer", "required": true, "min": 1 }
|
||||||
|
],
|
||||||
|
"json_body": {
|
||||||
|
"required_fields": [],
|
||||||
|
"rules": ["partial update of variant fields"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "products",
|
||||||
|
"action": "delete_variant",
|
||||||
|
"method": "DELETE",
|
||||||
|
"url_template": "/api.php?endpoint=products&action=delete_variant&id={variant_id}",
|
||||||
|
"query_params": [
|
||||||
|
{ "name": "id", "type": "integer", "required": true, "min": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "products",
|
||||||
|
"action": "upload_image",
|
||||||
|
"method": "POST",
|
||||||
|
"url_template": "/api.php?endpoint=products&action=upload_image",
|
||||||
|
"json_body": {
|
||||||
|
"required_fields": ["id", "file_name", "content_base64"],
|
||||||
|
"fields": {
|
||||||
|
"id": { "type": "integer", "description": "product id" },
|
||||||
|
"file_name": { "type": "string" },
|
||||||
|
"content_base64": { "type": "string", "description": "base64 payload" },
|
||||||
|
"alt": { "type": "string", "required": false },
|
||||||
|
"o": { "type": "integer", "required": false, "description": "image position" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "dictionaries",
|
||||||
|
"action": "statuses",
|
||||||
|
"method": "GET",
|
||||||
|
"url_template": "/api.php?endpoint=dictionaries&action=statuses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "dictionaries",
|
||||||
|
"action": "transports",
|
||||||
|
"method": "GET",
|
||||||
|
"url_template": "/api.php?endpoint=dictionaries&action=transports"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "dictionaries",
|
||||||
|
"action": "payment_methods",
|
||||||
|
"method": "GET",
|
||||||
|
"url_template": "/api.php?endpoint=dictionaries&action=payment_methods"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "dictionaries",
|
||||||
|
"action": "attributes",
|
||||||
|
"method": "GET",
|
||||||
|
"url_template": "/api.php?endpoint=dictionaries&action=attributes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "dictionaries",
|
||||||
|
"action": "ensure_attribute",
|
||||||
|
"method": "POST",
|
||||||
|
"url_template": "/api.php?endpoint=dictionaries&action=ensure_attribute",
|
||||||
|
"json_body": {
|
||||||
|
"required_fields": ["name"],
|
||||||
|
"fields": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"type": { "type": "integer", "required": false, "default": 0 },
|
||||||
|
"lang": { "type": "string", "required": false, "default": "pl" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "dictionaries",
|
||||||
|
"action": "ensure_attribute_value",
|
||||||
|
"method": "POST",
|
||||||
|
"url_template": "/api.php?endpoint=dictionaries&action=ensure_attribute_value",
|
||||||
|
"json_body": {
|
||||||
|
"required_fields": ["attribute_id", "name"],
|
||||||
|
"fields": {
|
||||||
|
"attribute_id": { "type": "integer" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"lang": { "type": "string", "required": false, "default": "pl" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "dictionaries",
|
||||||
|
"action": "ensure_producer",
|
||||||
|
"method": "POST",
|
||||||
|
"url_template": "/api.php?endpoint=dictionaries&action=ensure_producer",
|
||||||
|
"json_body": {
|
||||||
|
"required_fields": ["name"],
|
||||||
|
"fields": {
|
||||||
|
"name": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "categories",
|
||||||
|
"action": "list",
|
||||||
|
"method": "GET",
|
||||||
|
"url_template": "/api.php?endpoint=categories&action=list"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"examples": {
|
||||||
|
"curl_list_products": "curl -X GET \"https://twoja-domena.pl/api.php?endpoint=products&action=list&page=1&per_page=20\" -H \"X-Api-Key: TWOJ_KLUCZ\"",
|
||||||
|
"curl_get_order": "curl -X GET \"https://twoja-domena.pl/api.php?endpoint=orders&action=get&id=42\" -H \"X-Api-Key: TWOJ_KLUCZ\"",
|
||||||
|
"curl_create_product": "curl -X POST \"https://twoja-domena.pl/api.php?endpoint=products&action=create\" -H \"X-Api-Key: TWOJ_KLUCZ\" -H \"Content-Type: application/json\" -d \"{\\\"price_brutto\\\":99.99,\\\"languages\\\":{\\\"pl\\\":{\\\"name\\\":\\\"Nowy produkt\\\"}}}\""
|
||||||
|
},
|
||||||
|
"source_of_truth": [
|
||||||
|
"autoload/api/ApiRouter.php",
|
||||||
|
"autoload/api/Controllers/OrdersApiController.php",
|
||||||
|
"autoload/api/Controllers/ProductsApiController.php",
|
||||||
|
"autoload/api/Controllers/DictionariesApiController.php",
|
||||||
|
"autoload/api/Controllers/CategoriesApiController.php"
|
||||||
|
]
|
||||||
|
}
|
||||||
60
api-docs/index.html
Normal file
60
api-docs/index.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>shopPRO API docs</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
|
body { font-family: Arial, sans-serif; margin: 24px; line-height: 1.4; }
|
||||||
|
h1, h2 { margin-bottom: 8px; }
|
||||||
|
.meta { color: #444; margin-bottom: 16px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; vertical-align: top; }
|
||||||
|
th { background: #f4f4f4; }
|
||||||
|
code { background: #f7f7f7; padding: 2px 4px; border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>shopPRO API - public docs</h1>
|
||||||
|
<div class="meta" id="meta">Ladowanie...</div>
|
||||||
|
<p>Machine-readable JSON: <a href="./api-reference.json">api-reference.json</a></p>
|
||||||
|
|
||||||
|
<h2>Endpointy</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Group</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>URL template</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="rows"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
fetch("./api-reference.json")
|
||||||
|
.then(function (res) { return res.json(); })
|
||||||
|
.then(function (spec) {
|
||||||
|
var meta = document.getElementById("meta");
|
||||||
|
meta.textContent = spec.name + " v" + spec.version + " | entrypoint: " + spec.entrypoint;
|
||||||
|
|
||||||
|
var rows = document.getElementById("rows");
|
||||||
|
spec.endpoints.forEach(function (ep) {
|
||||||
|
var tr = document.createElement("tr");
|
||||||
|
tr.innerHTML =
|
||||||
|
"<td>" + ep.group + "</td>" +
|
||||||
|
"<td>" + ep.action + "</td>" +
|
||||||
|
"<td><code>" + ep.method + "</code></td>" +
|
||||||
|
"<td><code>" + ep.url_template + "</code></td>";
|
||||||
|
rows.appendChild(tr);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
var meta = document.getElementById("meta");
|
||||||
|
meta.textContent = "Nie udalo sie wczytac api-reference.json";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
autoload/.DS_Store
vendored
BIN
autoload/.DS_Store
vendored
Binary file not shown.
@@ -318,9 +318,7 @@ class ArticleRepository
|
|||||||
|
|
||||||
if (is_array($results)) {
|
if (is_array($results)) {
|
||||||
foreach ($results as $row) {
|
foreach ($results as $row) {
|
||||||
if (file_exists('../' . $row['src'])) {
|
$this->safeUnlink($row['src']);
|
||||||
unlink('../' . $row['src']);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,9 +335,7 @@ class ArticleRepository
|
|||||||
|
|
||||||
if (is_array($results)) {
|
if (is_array($results)) {
|
||||||
foreach ($results as $row) {
|
foreach ($results as $row) {
|
||||||
if (file_exists('../' . $row['src'])) {
|
$this->safeUnlink($row['src']);
|
||||||
unlink('../' . $row['src']);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,6 +356,9 @@ class ArticleRepository
|
|||||||
public function archive(int $articleId): bool
|
public function archive(int $articleId): bool
|
||||||
{
|
{
|
||||||
$result = $this->db->update('pp_articles', ['status' => -1], ['id' => $articleId]);
|
$result = $this->db->update('pp_articles', ['status' => -1], ['id' => $articleId]);
|
||||||
|
if ($result) {
|
||||||
|
$this->db->delete('pp_routes', ['article_id' => $articleId]);
|
||||||
|
}
|
||||||
return (bool)$result;
|
return (bool)$result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,6 +380,7 @@ class ArticleRepository
|
|||||||
$this->db->delete('pp_articles_langs', ['article_id' => $articleId]);
|
$this->db->delete('pp_articles_langs', ['article_id' => $articleId]);
|
||||||
$this->db->delete('pp_articles_images', ['article_id' => $articleId]);
|
$this->db->delete('pp_articles_images', ['article_id' => $articleId]);
|
||||||
$this->db->delete('pp_articles_files', ['article_id' => $articleId]);
|
$this->db->delete('pp_articles_files', ['article_id' => $articleId]);
|
||||||
|
$this->db->delete('pp_routes', ['article_id' => $articleId]);
|
||||||
$this->db->delete('pp_articles', ['id' => $articleId]);
|
$this->db->delete('pp_articles', ['id' => $articleId]);
|
||||||
|
|
||||||
\Shared\Helpers\Helpers::delete_dir('../upload/article_images/article_' . $articleId . '/');
|
\Shared\Helpers\Helpers::delete_dir('../upload/article_images/article_' . $articleId . '/');
|
||||||
@@ -815,9 +815,7 @@ class ArticleRepository
|
|||||||
$results = $this->db->select('pp_articles_files', '*', ['article_id' => null]);
|
$results = $this->db->select('pp_articles_files', '*', ['article_id' => null]);
|
||||||
if (is_array($results)) {
|
if (is_array($results)) {
|
||||||
foreach ($results as $row) {
|
foreach ($results as $row) {
|
||||||
if (file_exists('../' . $row['src'])) {
|
$this->safeUnlink($row['src']);
|
||||||
unlink('../' . $row['src']);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -832,15 +830,31 @@ class ArticleRepository
|
|||||||
$results = $this->db->select('pp_articles_images', '*', ['article_id' => null]);
|
$results = $this->db->select('pp_articles_images', '*', ['article_id' => null]);
|
||||||
if (is_array($results)) {
|
if (is_array($results)) {
|
||||||
foreach ($results as $row) {
|
foreach ($results as $row) {
|
||||||
if (file_exists('../' . $row['src'])) {
|
$this->safeUnlink($row['src']);
|
||||||
unlink('../' . $row['src']);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->db->delete('pp_articles_images', ['article_id' => null]);
|
$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.
|
* Pobiera artykuly opublikowane w podanym zakresie dat.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ class CategoryRepository
|
|||||||
|
|
||||||
$deleted = (bool)$this->db->delete('pp_shop_categories', ['id' => $id]);
|
$deleted = (bool)$this->db->delete('pp_shop_categories', ['id' => $id]);
|
||||||
if ($deleted) {
|
if ($deleted) {
|
||||||
|
$this->db->delete('pp_routes', ['category_id' => $id]);
|
||||||
$this->refreshCategoryArtifacts();
|
$this->refreshCategoryArtifacts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
140
autoload/Domain/CronJob/CronJobProcessor.php
Normal file
140
autoload/Domain/CronJob/CronJobProcessor.php
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Domain\CronJob;
|
||||||
|
|
||||||
|
class CronJobProcessor
|
||||||
|
{
|
||||||
|
/** @var CronJobRepository */
|
||||||
|
private $cronRepo;
|
||||||
|
|
||||||
|
/** @var array<string, callable> */
|
||||||
|
private $handlers = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param CronJobRepository $cronRepo
|
||||||
|
*/
|
||||||
|
public function __construct(CronJobRepository $cronRepo)
|
||||||
|
{
|
||||||
|
$this->cronRepo = $cronRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zarejestruj handler dla typu zadania
|
||||||
|
*
|
||||||
|
* @param string $jobType
|
||||||
|
* @param callable $handler fn($payload): bool|array — true/array = success, false/exception = fail
|
||||||
|
*/
|
||||||
|
public function registerHandler($jobType, callable $handler)
|
||||||
|
{
|
||||||
|
$this->handlers[$jobType] = $handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utwórz zadania z harmonogramów, których next_run_at <= NOW
|
||||||
|
*
|
||||||
|
* @return int Liczba utworzonych zadań
|
||||||
|
*/
|
||||||
|
public function createScheduledJobs()
|
||||||
|
{
|
||||||
|
$schedules = $this->cronRepo->getDueSchedules();
|
||||||
|
$created = 0;
|
||||||
|
|
||||||
|
foreach ($schedules as $schedule) {
|
||||||
|
$jobType = $schedule['job_type'];
|
||||||
|
|
||||||
|
// Nie twórz duplikatów
|
||||||
|
if ($this->cronRepo->hasPendingJob($jobType)) {
|
||||||
|
// Mimo duplikatu, przesuń next_run_at żeby nie sprawdzać co sekundę
|
||||||
|
$this->cronRepo->touchSchedule($schedule['id'], (int) $schedule['interval_seconds']);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = null;
|
||||||
|
if (!empty($schedule['payload'])) {
|
||||||
|
$payload = json_decode($schedule['payload'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cronRepo->enqueue(
|
||||||
|
$jobType,
|
||||||
|
$payload,
|
||||||
|
(int) $schedule['priority'],
|
||||||
|
(int) $schedule['max_attempts']
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->cronRepo->touchSchedule($schedule['id'], (int) $schedule['interval_seconds']);
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Przetwórz kolejkę zadań
|
||||||
|
*
|
||||||
|
* @param int $limit
|
||||||
|
* @return array Statystyki: ['processed' => int, 'succeeded' => int, 'failed' => int, 'skipped' => int]
|
||||||
|
*/
|
||||||
|
public function processQueue($limit = 10)
|
||||||
|
{
|
||||||
|
$stats = ['processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0];
|
||||||
|
|
||||||
|
$jobs = $this->cronRepo->fetchNext($limit);
|
||||||
|
|
||||||
|
foreach ($jobs as $job) {
|
||||||
|
$jobType = $job['job_type'];
|
||||||
|
$jobId = (int) $job['id'];
|
||||||
|
$stats['processed']++;
|
||||||
|
|
||||||
|
if (!isset($this->handlers[$jobType])) {
|
||||||
|
$this->cronRepo->markFailed($jobId, 'No handler registered for job type: ' . $jobType, (int) $job['attempts']);
|
||||||
|
$stats['skipped']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = call_user_func($this->handlers[$jobType], $job['payload']);
|
||||||
|
|
||||||
|
if ($result === false) {
|
||||||
|
$this->cronRepo->markFailed($jobId, 'Handler returned false', (int) $job['attempts']);
|
||||||
|
$stats['failed']++;
|
||||||
|
} else {
|
||||||
|
$resultData = is_array($result) ? $result : null;
|
||||||
|
$this->cronRepo->markCompleted($jobId, $resultData);
|
||||||
|
$stats['succeeded']++;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->cronRepo->markFailed($jobId, $e->getMessage(), (int) $job['attempts']);
|
||||||
|
$stats['failed']++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->cronRepo->markFailed($jobId, $e->getMessage(), (int) $job['attempts']);
|
||||||
|
$stats['failed']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Główna metoda: utwórz scheduled jobs + przetwórz kolejkę
|
||||||
|
*
|
||||||
|
* @param int $limit
|
||||||
|
* @return array ['scheduled' => int, 'processed' => int, 'succeeded' => int, 'failed' => int, 'skipped' => int]
|
||||||
|
*/
|
||||||
|
public function run($limit = 20)
|
||||||
|
{
|
||||||
|
// Odzyskaj stuck jobs
|
||||||
|
$this->cronRepo->recoverStuck(30);
|
||||||
|
|
||||||
|
// Utwórz zadania z harmonogramów
|
||||||
|
$scheduled = $this->createScheduledJobs();
|
||||||
|
|
||||||
|
// Przetwórz kolejkę
|
||||||
|
$stats = $this->processQueue($limit);
|
||||||
|
$stats['scheduled'] = $scheduled;
|
||||||
|
|
||||||
|
// Cleanup starych zadań (raz na uruchomienie)
|
||||||
|
$this->cronRepo->cleanup(30);
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
260
autoload/Domain/CronJob/CronJobRepository.php
Normal file
260
autoload/Domain/CronJob/CronJobRepository.php
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Domain\CronJob;
|
||||||
|
|
||||||
|
class CronJobRepository
|
||||||
|
{
|
||||||
|
/** @var \medoo */
|
||||||
|
private $db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \medoo $db
|
||||||
|
*/
|
||||||
|
public function __construct($db)
|
||||||
|
{
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dodaj zadanie do kolejki
|
||||||
|
*
|
||||||
|
* @param string $jobType
|
||||||
|
* @param array|null $payload
|
||||||
|
* @param int $priority
|
||||||
|
* @param int $maxAttempts
|
||||||
|
* @param string|null $scheduledAt
|
||||||
|
* @return int|null ID nowego zadania
|
||||||
|
*/
|
||||||
|
public function enqueue($jobType, $payload = null, $priority = CronJobType::PRIORITY_NORMAL, $maxAttempts = 10, $scheduledAt = null)
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'job_type' => $jobType,
|
||||||
|
'status' => CronJobType::STATUS_PENDING,
|
||||||
|
'priority' => $priority,
|
||||||
|
'max_attempts' => $maxAttempts,
|
||||||
|
'scheduled_at' => $scheduledAt ? $scheduledAt : date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($payload !== null) {
|
||||||
|
$data['payload'] = json_encode($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->insert('pp_cron_jobs', $data);
|
||||||
|
$id = $this->db->id();
|
||||||
|
|
||||||
|
return $id ? (int) $id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomowe pobranie następnych zadań do przetworzenia.
|
||||||
|
*
|
||||||
|
* Uwaga: SELECT + UPDATE nie jest w pełni atomowe bez transakcji.
|
||||||
|
* Po UPDATE re-SELECT potwierdza, które joby zostały faktycznie przejęte
|
||||||
|
* (chroni przed race condition przy wielu workerach).
|
||||||
|
*
|
||||||
|
* @param int $limit
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function fetchNext($limit = 5)
|
||||||
|
{
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$jobs = $this->db->select('pp_cron_jobs', '*', [
|
||||||
|
'status' => CronJobType::STATUS_PENDING,
|
||||||
|
'scheduled_at[<=]' => $now,
|
||||||
|
'ORDER' => ['priority' => 'ASC', 'scheduled_at' => 'ASC'],
|
||||||
|
'LIMIT' => $limit,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (empty($jobs)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = array_column($jobs, 'id');
|
||||||
|
|
||||||
|
$this->db->update('pp_cron_jobs', [
|
||||||
|
'status' => CronJobType::STATUS_PROCESSING,
|
||||||
|
'started_at' => $now,
|
||||||
|
'attempts[+]' => 1,
|
||||||
|
], [
|
||||||
|
'id' => $ids,
|
||||||
|
'status' => CronJobType::STATUS_PENDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Re-SELECT: potwierdź, które joby zostały faktycznie przejęte
|
||||||
|
$claimed = $this->db->select('pp_cron_jobs', '*', [
|
||||||
|
'id' => $ids,
|
||||||
|
'status' => CronJobType::STATUS_PROCESSING,
|
||||||
|
'started_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (empty($claimed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($claimed as &$job) {
|
||||||
|
if ($job['payload'] !== null) {
|
||||||
|
$job['payload'] = json_decode($job['payload'], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $claimed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oznacz zadanie jako zakończone
|
||||||
|
*
|
||||||
|
* @param int $jobId
|
||||||
|
* @param mixed $result
|
||||||
|
*/
|
||||||
|
public function markCompleted($jobId, $result = null)
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'status' => CronJobType::STATUS_COMPLETED,
|
||||||
|
'completed_at' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($result !== null) {
|
||||||
|
$data['result'] = json_encode($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->update('pp_cron_jobs', $data, ['id' => $jobId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oznacz zadanie jako nieudane z backoffem
|
||||||
|
*
|
||||||
|
* @param int $jobId
|
||||||
|
* @param string $error
|
||||||
|
* @param int $attempt Numer próby (do obliczenia backoffu)
|
||||||
|
*/
|
||||||
|
public function markFailed($jobId, $error, $attempt = 1)
|
||||||
|
{
|
||||||
|
$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
|
||||||
|
$this->db->update('pp_cron_jobs', [
|
||||||
|
'status' => CronJobType::STATUS_FAILED,
|
||||||
|
'last_error' => mb_substr($error, 0, 500),
|
||||||
|
'completed_at' => date('Y-m-d H:i:s'),
|
||||||
|
], ['id' => $jobId]);
|
||||||
|
} else {
|
||||||
|
// Wróć do pending z backoffem
|
||||||
|
$backoff = CronJobType::calculateBackoff($attempts);
|
||||||
|
$nextRun = date('Y-m-d H:i:s', time() + $backoff);
|
||||||
|
|
||||||
|
$this->db->update('pp_cron_jobs', [
|
||||||
|
'status' => CronJobType::STATUS_PENDING,
|
||||||
|
'last_error' => mb_substr($error, 0, 500),
|
||||||
|
'scheduled_at' => $nextRun,
|
||||||
|
], ['id' => $jobId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprawdź czy istnieje pending job danego typu z opcjonalnym payload match
|
||||||
|
*
|
||||||
|
* @param string $jobType
|
||||||
|
* @param array|null $payloadMatch
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasPendingJob($jobType, $payloadMatch = null)
|
||||||
|
{
|
||||||
|
$where = [
|
||||||
|
'job_type' => $jobType,
|
||||||
|
'status' => [CronJobType::STATUS_PENDING, CronJobType::STATUS_PROCESSING],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($payloadMatch !== null) {
|
||||||
|
$where['payload'] = json_encode($payloadMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = $this->db->count('pp_cron_jobs', $where);
|
||||||
|
return $count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wyczyść stare zakończone zadania
|
||||||
|
*
|
||||||
|
* @param int $olderThanDays
|
||||||
|
*/
|
||||||
|
public function cleanup($olderThanDays = 30)
|
||||||
|
{
|
||||||
|
$cutoff = date('Y-m-d H:i:s', time() - ($olderThanDays * 86400));
|
||||||
|
|
||||||
|
$this->db->delete('pp_cron_jobs', [
|
||||||
|
'status' => [CronJobType::STATUS_COMPLETED, CronJobType::STATUS_FAILED, CronJobType::STATUS_CANCELLED],
|
||||||
|
'updated_at[<]' => $cutoff,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odzyskaj zablokowane zadania (stuck w processing)
|
||||||
|
*
|
||||||
|
* @param int $olderThanMinutes
|
||||||
|
*/
|
||||||
|
public function recoverStuck($olderThanMinutes = 30)
|
||||||
|
{
|
||||||
|
$cutoff = date('Y-m-d H:i:s', time() - ($olderThanMinutes * 60));
|
||||||
|
|
||||||
|
$this->db->update('pp_cron_jobs', [
|
||||||
|
'status' => CronJobType::STATUS_PENDING,
|
||||||
|
'started_at' => null,
|
||||||
|
], [
|
||||||
|
'status' => CronJobType::STATUS_PROCESSING,
|
||||||
|
'started_at[<]' => $cutoff,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pobierz harmonogramy gotowe do uruchomienia
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getDueSchedules()
|
||||||
|
{
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
return $this->db->select('pp_cron_schedules', '*', [
|
||||||
|
'enabled' => 1,
|
||||||
|
'OR' => [
|
||||||
|
'next_run_at' => null,
|
||||||
|
'next_run_at[<=]' => $now,
|
||||||
|
],
|
||||||
|
'ORDER' => ['priority' => 'ASC'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualizuj harmonogram po uruchomieniu
|
||||||
|
*
|
||||||
|
* @param int $scheduleId
|
||||||
|
* @param int $intervalSeconds
|
||||||
|
*/
|
||||||
|
public function touchSchedule($scheduleId, $intervalSeconds)
|
||||||
|
{
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$nextRun = date('Y-m-d H:i:s', time() + $intervalSeconds);
|
||||||
|
|
||||||
|
$this->db->update('pp_cron_schedules', [
|
||||||
|
'last_run_at' => $now,
|
||||||
|
'next_run_at' => $nextRun,
|
||||||
|
], ['id' => $scheduleId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
autoload/Domain/CronJob/CronJobType.php
Normal file
95
autoload/Domain/CronJob/CronJobType.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Domain\CronJob;
|
||||||
|
|
||||||
|
class CronJobType
|
||||||
|
{
|
||||||
|
// Job types
|
||||||
|
const APILO_TOKEN_KEEPALIVE = 'apilo_token_keepalive';
|
||||||
|
const APILO_SEND_ORDER = 'apilo_send_order';
|
||||||
|
const APILO_SYNC_PAYMENT = 'apilo_sync_payment';
|
||||||
|
const APILO_SYNC_STATUS = 'apilo_sync_status';
|
||||||
|
const APILO_PRODUCT_SYNC = 'apilo_product_sync';
|
||||||
|
const APILO_PRICELIST_SYNC = 'apilo_pricelist_sync';
|
||||||
|
const APILO_STATUS_POLL = 'apilo_status_poll';
|
||||||
|
const PRICE_HISTORY = 'price_history';
|
||||||
|
const ORDER_ANALYSIS = 'order_analysis';
|
||||||
|
const TRUSTMATE_INVITATION = 'trustmate_invitation';
|
||||||
|
const GOOGLE_XML_FEED = 'google_xml_feed';
|
||||||
|
|
||||||
|
// Priorities (lower = more important)
|
||||||
|
const PRIORITY_CRITICAL = 10;
|
||||||
|
const PRIORITY_SEND_ORDER = 40; // apilo_send_order musi być PRZED sync payment/status
|
||||||
|
const PRIORITY_HIGH = 50;
|
||||||
|
const PRIORITY_NORMAL = 100;
|
||||||
|
const PRIORITY_LOW = 200;
|
||||||
|
|
||||||
|
// Statuses
|
||||||
|
const STATUS_PENDING = 'pending';
|
||||||
|
const STATUS_PROCESSING = 'processing';
|
||||||
|
const STATUS_COMPLETED = 'completed';
|
||||||
|
const STATUS_FAILED = 'failed';
|
||||||
|
const STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
|
// 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[]
|
||||||
|
*/
|
||||||
|
public static function allTypes()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::APILO_TOKEN_KEEPALIVE,
|
||||||
|
self::APILO_SEND_ORDER,
|
||||||
|
self::APILO_SYNC_PAYMENT,
|
||||||
|
self::APILO_SYNC_STATUS,
|
||||||
|
self::APILO_PRODUCT_SYNC,
|
||||||
|
self::APILO_PRICELIST_SYNC,
|
||||||
|
self::APILO_STATUS_POLL,
|
||||||
|
self::PRICE_HISTORY,
|
||||||
|
self::ORDER_ANALYSIS,
|
||||||
|
self::TRUSTMATE_INVITATION,
|
||||||
|
self::GOOGLE_XML_FEED,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function allStatuses()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::STATUS_PENDING,
|
||||||
|
self::STATUS_PROCESSING,
|
||||||
|
self::STATUS_COMPLETED,
|
||||||
|
self::STATUS_FAILED,
|
||||||
|
self::STATUS_CANCELLED,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
public static function calculateBackoff($attempt)
|
||||||
|
{
|
||||||
|
$backoff = self::BASE_BACKOFF_SECONDS * pow(2, $attempt - 1);
|
||||||
|
return min($backoff, self::MAX_BACKOFF_SECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
567
autoload/Domain/Integrations/ApiloRepository.php
Normal file
567
autoload/Domain/Integrations/ApiloRepository.php
Normal 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.' ];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,10 +28,9 @@ class IntegrationsRepository
|
|||||||
public function getSettings( string $provider ): array
|
public function getSettings( string $provider ): array
|
||||||
{
|
{
|
||||||
$table = $this->settingsTable( $provider );
|
$table = $this->settingsTable( $provider );
|
||||||
$stmt = $this->db->query( "SELECT * FROM $table" );
|
$rows = $this->db->select( $table, [ 'name', 'value' ] );
|
||||||
$results = $stmt ? $stmt->fetchAll( \PDO::FETCH_ASSOC ) : [];
|
|
||||||
$settings = [];
|
$settings = [];
|
||||||
foreach ( $results as $row )
|
foreach ( $rows ?: [] as $row )
|
||||||
$settings[$row['name']] = $row['value'];
|
$settings[$row['name']] = $row['value'];
|
||||||
|
|
||||||
return $settings;
|
return $settings;
|
||||||
@@ -131,444 +130,7 @@ class IntegrationsRepository
|
|||||||
], [ 'id' => $productId ] );
|
], [ 'id' => $productId ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Apilo OAuth ─────────────────────────────────────────────
|
// ── Product data ─────────────────────────────────────────────
|
||||||
|
|
||||||
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 ────────────────────────────────
|
|
||||||
|
|
||||||
public function getProductSku( int $productId ): ?string
|
public function getProductSku( int $productId ): ?string
|
||||||
{
|
{
|
||||||
@@ -576,93 +138,6 @@ class IntegrationsRepository
|
|||||||
return $sku ?: null;
|
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 ──────────────────────────────────────────
|
// ── ShopPRO import ──────────────────────────────────────────
|
||||||
|
|
||||||
public function shopproImportProduct( int $productId ): array
|
public function shopproImportProduct( int $productId ): array
|
||||||
@@ -747,26 +222,55 @@ class IntegrationsRepository
|
|||||||
|
|
||||||
// Import images
|
// Import images
|
||||||
$images = $mdb2->select( 'pp_shop_products_images', '*', [ 'product_id' => $productId ] );
|
$images = $mdb2->select( 'pp_shop_products_images', '*', [ 'product_id' => $productId ] );
|
||||||
|
$importLog = [];
|
||||||
|
$domainRaw = preg_replace( '#^https?://#', '', (string)($settings['domain'] ?? '') );
|
||||||
if ( is_array( $images ) ) {
|
if ( is_array( $images ) ) {
|
||||||
foreach ( $images as $image ) {
|
foreach ( $images as $image ) {
|
||||||
$imageUrl = 'https://' . $settings['domain'] . $image['src'];
|
$srcPath = (string)($image['src'] ?? '');
|
||||||
|
$imageUrl = 'https://' . rtrim( $domainRaw, '/' ) . '/' . ltrim( $srcPath, '/' );
|
||||||
|
$imageName = basename( $srcPath );
|
||||||
|
|
||||||
|
if ( $imageName === '' ) {
|
||||||
|
$importLog[] = '[SKIP] Pusta nazwa pliku dla src: ' . $srcPath;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$ch = curl_init( $imageUrl );
|
$ch = curl_init( $imageUrl );
|
||||||
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
|
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
|
||||||
curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, true );
|
curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, true );
|
||||||
curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
|
curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
|
||||||
curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, false );
|
curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, false );
|
||||||
$imageData = curl_exec( $ch );
|
curl_setopt( $ch, CURLOPT_TIMEOUT, 30 );
|
||||||
|
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
|
||||||
|
$imageData = curl_exec( $ch );
|
||||||
|
$httpCode = (int)curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||||
|
$curlErrno = curl_errno( $ch );
|
||||||
|
$curlError = curl_error( $ch );
|
||||||
curl_close( $ch );
|
curl_close( $ch );
|
||||||
|
|
||||||
$imageName = basename( $imageUrl );
|
if ( $curlErrno !== 0 || $imageData === false ) {
|
||||||
$imageDir = '../upload/product_images/product_' . $newProductId;
|
$importLog[] = '[ERROR] cURL: ' . $imageUrl . ' — błąd ' . $curlErrno . ': ' . $curlError;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $httpCode !== 200 ) {
|
||||||
|
$importLog[] = '[ERROR] HTTP ' . $httpCode . ': ' . $imageUrl;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$imageDir = dirname( __DIR__, 3 ) . '/upload/product_images/product_' . $newProductId;
|
||||||
$imagePath = $imageDir . '/' . $imageName;
|
$imagePath = $imageDir . '/' . $imageName;
|
||||||
|
|
||||||
if ( !file_exists( $imageDir ) )
|
if ( !file_exists( $imageDir ) && !mkdir( $imageDir, 0777, true ) && !file_exists( $imageDir ) ) {
|
||||||
mkdir( $imageDir, 0777, true );
|
$importLog[] = '[ERROR] Nie można utworzyć katalogu: ' . $imageDir;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
file_put_contents( $imagePath, $imageData );
|
$written = file_put_contents( $imagePath, $imageData );
|
||||||
|
if ( $written === false ) {
|
||||||
|
$importLog[] = '[ERROR] Zapis pliku nieudany: ' . $imagePath;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$this->db->insert( 'pp_shop_products_images', [
|
$this->db->insert( 'pp_shop_products_images', [
|
||||||
'product_id' => $newProductId,
|
'product_id' => $newProductId,
|
||||||
@@ -774,122 +278,50 @@ class IntegrationsRepository
|
|||||||
'alt' => $image['alt'] ?? '',
|
'alt' => $image['alt'] ?? '',
|
||||||
'o' => $image['o'],
|
'o' => $image['o'],
|
||||||
] );
|
] );
|
||||||
|
$importLog[] = '[OK] ' . $imageUrl . ' → ' . $imagePath . ' (' . $written . ' B)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [ 'success' => true, 'message' => 'Produkt został zaimportowany.' ];
|
// Zapisz log importu zdjęć (ścieżka absolutna — niezależna od cwd)
|
||||||
}
|
$logDir = dirname( __DIR__, 3 ) . '/logs';
|
||||||
|
$logFile = $logDir . '/shoppro-import-debug.log';
|
||||||
|
$mkdirOk = file_exists( $logDir ) || mkdir( $logDir, 0755, true ) || file_exists( $logDir );
|
||||||
|
$logEntry = '[' . date( 'Y-m-d H:i:s' ) . '] Import produktu #' . $productId . ' → #' . $newProductId . "\n"
|
||||||
|
. ' Domain: ' . $domainRaw . "\n"
|
||||||
|
. ' Obrazy źródłowe: ' . count( $images ?: [] ) . "\n";
|
||||||
|
foreach ( $importLog as $line ) {
|
||||||
|
$logEntry .= ' ' . $line . "\n";
|
||||||
|
}
|
||||||
|
// Zawsze loguj do error_log (niezależnie od uprawnień do pliku)
|
||||||
|
error_log( '[shopPRO shoppro-import] ' . str_replace( "\n", ' | ', $logEntry ) );
|
||||||
|
|
||||||
// ── ShopPRO export ──────────────────────────────────────────
|
if ( $mkdirOk && file_put_contents( $logFile, $logEntry, FILE_APPEND ) === false ) {
|
||||||
|
error_log( '[shopPRO shoppro-import] WARN: nie można zapisać logu do: ' . $logFile );
|
||||||
public function shopproExportProduct( int $productId ): array
|
} elseif ( !$mkdirOk ) {
|
||||||
{
|
error_log( '[shopPRO shoppro-import] WARN: nie można utworzyć katalogu: ' . $logDir );
|
||||||
$settings = $this->getSettings( 'shoppro' );
|
|
||||||
$missingSetting = $this->missingShopproSetting( $settings, [ 'db_name', 'db_host', 'db_user' ] );
|
|
||||||
if ( $missingSetting !== null ) {
|
|
||||||
return [ 'success' => false, 'message' => 'Brakuje konfiguracji shopPRO: ' . $missingSetting . '.' ];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$product = $this->db->get( 'pp_shop_products', '*', [ 'id' => $productId ] );
|
// Zbuduj czytelny komunikat z wynikiem importu zdjęć
|
||||||
if ( !$product ) {
|
$imgCount = count( $images ?: [] );
|
||||||
return [ 'success' => false, 'message' => 'Nie znaleziono produktu do eksportu.' ];
|
if ( $imgCount === 0 ) {
|
||||||
}
|
$imgSummary = 'Zdjęcia: brak w bazie źródłowej.';
|
||||||
|
} else {
|
||||||
$mdb2 = $this->shopproDb( $settings );
|
$ok = 0;
|
||||||
|
$errors = [];
|
||||||
$mdb2->insert( 'pp_shop_products', [
|
foreach ( $importLog as $line ) {
|
||||||
'price_netto' => $product['price_netto'] ?? null,
|
if ( strncmp( $line, '[OK]', 4 ) === 0 ) {
|
||||||
'price_brutto' => $product['price_brutto'] ?? null,
|
$ok++;
|
||||||
'vat' => $product['vat'] ?? null,
|
} else {
|
||||||
'stock_0_buy' => $product['stock_0_buy'] ?? 0,
|
$errors[] = $line;
|
||||||
'quantity' => $product['quantity'] ?? 0,
|
|
||||||
'wp' => $product['wp'] ?? null,
|
|
||||||
'sku' => $product['sku'] ?? '',
|
|
||||||
'ean' => $product['ean'] ?? '',
|
|
||||||
'custom_label_0' => $product['custom_label_0'] ?? null,
|
|
||||||
'custom_label_1' => $product['custom_label_1'] ?? null,
|
|
||||||
'custom_label_2' => $product['custom_label_2'] ?? null,
|
|
||||||
'custom_label_3' => $product['custom_label_3'] ?? null,
|
|
||||||
'custom_label_4' => $product['custom_label_4'] ?? null,
|
|
||||||
'additional_message' => $product['additional_message'] ?? 0,
|
|
||||||
'additional_message_text' => $product['additional_message_text'] ?? null,
|
|
||||||
'additional_message_required'=> $product['additional_message_required'] ?? 0,
|
|
||||||
'weight' => $product['weight'] ?? null,
|
|
||||||
'producer_id' => $product['producer_id'] ?? null,
|
|
||||||
] );
|
|
||||||
|
|
||||||
$newProductId = (int) $mdb2->id();
|
|
||||||
if ( $newProductId <= 0 ) {
|
|
||||||
return [ 'success' => false, 'message' => 'Podczas eksportowania produktu wystąpił błąd.' ];
|
|
||||||
}
|
|
||||||
|
|
||||||
$languages = $this->db->select( 'pp_shop_products_langs', '*', [ 'product_id' => $productId ] );
|
|
||||||
if ( is_array( $languages ) ) {
|
|
||||||
foreach ( $languages as $lang ) {
|
|
||||||
$mdb2->insert( 'pp_shop_products_langs', [
|
|
||||||
'product_id' => $newProductId,
|
|
||||||
'lang_id' => $lang['lang_id'] ?? '',
|
|
||||||
'name' => $lang['name'] ?? '',
|
|
||||||
'short_description' => $lang['short_description'] ?? null,
|
|
||||||
'description' => $lang['description'] ?? null,
|
|
||||||
'tab_name_1' => $lang['tab_name_1'] ?? null,
|
|
||||||
'tab_description_1' => $lang['tab_description_1'] ?? null,
|
|
||||||
'tab_name_2' => $lang['tab_name_2'] ?? null,
|
|
||||||
'tab_description_2' => $lang['tab_description_2'] ?? null,
|
|
||||||
'meta_title' => $lang['meta_title'] ?? null,
|
|
||||||
'meta_description' => $lang['meta_description'] ?? null,
|
|
||||||
'meta_keywords' => $lang['meta_keywords'] ?? null,
|
|
||||||
'seo_link' => $lang['seo_link'] ?? null,
|
|
||||||
'copy_from' => $lang['copy_from'] ?? null,
|
|
||||||
'warehouse_message_zero' => $lang['warehouse_message_zero'] ?? null,
|
|
||||||
'warehouse_message_nonzero'=> $lang['warehouse_message_nonzero'] ?? null,
|
|
||||||
'canonical' => $lang['canonical'] ?? null,
|
|
||||||
'xml_name' => $lang['xml_name'] ?? null,
|
|
||||||
'security_information' => $lang['security_information'] ?? null,
|
|
||||||
] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$customFields = $this->db->select( 'pp_shop_products_custom_fields', '*', [ 'id_product' => $productId ] );
|
|
||||||
if ( is_array( $customFields ) ) {
|
|
||||||
foreach ( $customFields as $field ) {
|
|
||||||
$mdb2->insert( 'pp_shop_products_custom_fields', [
|
|
||||||
'id_product' => $newProductId,
|
|
||||||
'name' => (string)($field['name'] ?? ''),
|
|
||||||
'type' => (string)($field['type'] ?? 'text'),
|
|
||||||
'is_required' => !empty( $field['is_required'] ) ? 1 : 0,
|
|
||||||
] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$images = $this->db->select( 'pp_shop_products_images', '*', [ 'product_id' => $productId ] );
|
|
||||||
if ( is_array( $images ) && count( $images ) > 0 ) {
|
|
||||||
$missingImageApiSetting = $this->missingShopproSetting( $settings, [ 'domain', 'api_key' ] );
|
|
||||||
if ( $missingImageApiSetting !== null ) {
|
|
||||||
return [ 'success' => false, 'message' => 'Brakuje konfiguracji shopPRO dla wysylki zdjec: ' . $missingImageApiSetting . '.' ];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( is_array( $images ) ) {
|
|
||||||
foreach ( $images as $image ) {
|
|
||||||
$remoteImageSrc = $this->sendImageToShopproApi(
|
|
||||||
(string)($image['src'] ?? ''),
|
|
||||||
(int)$newProductId,
|
|
||||||
(string)($settings['domain'] ?? ''),
|
|
||||||
(string)($settings['api_key'] ?? ''),
|
|
||||||
(string)($image['alt'] ?? ''),
|
|
||||||
(int)($image['o'] ?? 0)
|
|
||||||
);
|
|
||||||
if ( $remoteImageSrc === '' ) {
|
|
||||||
return [ 'success' => false, 'message' => 'Nie udalo sie wyslac zdjec produktu przez API shopPRO.' ];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$imgSummary = 'Zdjęcia: ' . $ok . '/' . $imgCount . ' zaimportowanych.';
|
||||||
|
if ( !empty( $errors ) ) {
|
||||||
|
$imgSummary .= ' Błędy: ' . implode( '; ', $errors );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [ 'success' => true, 'message' => 'Produkt został zaimportowany. ' . $imgSummary ];
|
||||||
'success' => true,
|
|
||||||
'message' => 'Produkt został wyeksportowany (ID: ' . $newProductId . ').',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function missingShopproSetting( array $settings, array $requiredKeys ): ?string
|
private function missingShopproSetting( array $settings, array $requiredKeys ): ?string
|
||||||
@@ -915,79 +347,4 @@ class IntegrationsRepository
|
|||||||
] );
|
] );
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sendImageToShopproApi(
|
|
||||||
string $src,
|
|
||||||
int $remoteProductId,
|
|
||||||
string $remoteDomain,
|
|
||||||
string $apiKey,
|
|
||||||
string $alt,
|
|
||||||
int $position
|
|
||||||
): string
|
|
||||||
{
|
|
||||||
$src = trim( $src );
|
|
||||||
if ( $src === '' ) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$localSourcePath = '..' . $src;
|
|
||||||
if ( !is_file( $localSourcePath ) ) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$content = @file_get_contents( $localSourcePath );
|
|
||||||
if ( $content === false ) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$remoteDomain = trim( $remoteDomain );
|
|
||||||
if ( $remoteDomain === '' ) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( strpos( $remoteDomain, 'http://' ) !== 0 && strpos( $remoteDomain, 'https://' ) !== 0 ) {
|
|
||||||
$remoteDomain = 'https://' . $remoteDomain;
|
|
||||||
}
|
|
||||||
$remoteDomain = rtrim( $remoteDomain, '/' );
|
|
||||||
|
|
||||||
$url = $remoteDomain . '/api.php?endpoint=products&action=upload_image';
|
|
||||||
$payload = [
|
|
||||||
'id' => $remoteProductId,
|
|
||||||
'file_name' => basename( $src ),
|
|
||||||
'content_base64' => base64_encode( $content ),
|
|
||||||
'alt' => $alt,
|
|
||||||
'o' => $position,
|
|
||||||
];
|
|
||||||
|
|
||||||
$ch = curl_init( $url );
|
|
||||||
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
|
|
||||||
curl_setopt( $ch, CURLOPT_POST, true );
|
|
||||||
curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $payload, JSON_UNESCAPED_UNICODE ) );
|
|
||||||
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
|
|
||||||
'Content-Type: application/json',
|
|
||||||
'Accept: application/json',
|
|
||||||
'X-Api-Key: ' . $apiKey,
|
|
||||||
] );
|
|
||||||
curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
|
|
||||||
curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, false );
|
|
||||||
$response = curl_exec( $ch );
|
|
||||||
|
|
||||||
if ( curl_errno( $ch ) ) {
|
|
||||||
curl_close( $ch );
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$httpCode = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
|
||||||
curl_close( $ch );
|
|
||||||
|
|
||||||
if ( $httpCode >= 400 || $response === false ) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$responseData = json_decode( (string) $response, true );
|
|
||||||
if ( !is_array( $responseData ) || ( $responseData['status'] ?? '' ) !== 'ok' ) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string)($responseData['data']['src'] ?? '');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ class LayoutsRepository
|
|||||||
if (is_array($layoutRows) && isset($layoutRows[0])) {
|
if (is_array($layoutRows) && isset($layoutRows[0])) {
|
||||||
$layout = $layoutRows[0];
|
$layout = $layoutRows[0];
|
||||||
} else {
|
} else {
|
||||||
$layout = $this->db->get('pp_layouts', '*', ['categories_default' => 1]);
|
$layout = $this->db->get('pp_layouts', '*', ['status' => 1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,17 +7,21 @@ class OrderAdminService
|
|||||||
private $productRepo;
|
private $productRepo;
|
||||||
private $settingsRepo;
|
private $settingsRepo;
|
||||||
private $transportRepo;
|
private $transportRepo;
|
||||||
|
/** @var \Domain\CronJob\CronJobRepository|null */
|
||||||
|
private $cronJobRepo;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
OrderRepository $orders,
|
OrderRepository $orders,
|
||||||
$productRepo = null,
|
$productRepo = null,
|
||||||
$settingsRepo = null,
|
$settingsRepo = null,
|
||||||
$transportRepo = null
|
$transportRepo = null,
|
||||||
|
$cronJobRepo = null
|
||||||
) {
|
) {
|
||||||
$this->orders = $orders;
|
$this->orders = $orders;
|
||||||
$this->productRepo = $productRepo;
|
$this->productRepo = $productRepo;
|
||||||
$this->settingsRepo = $settingsRepo;
|
$this->settingsRepo = $settingsRepo;
|
||||||
$this->transportRepo = $transportRepo;
|
$this->transportRepo = $transportRepo;
|
||||||
|
$this->cronJobRepo = $cronJobRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function details(int $orderId): array
|
public function details(int $orderId): array
|
||||||
@@ -415,8 +419,8 @@ class OrderAdminService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb );
|
$apiloRepository = new \Domain\Integrations\ApiloRepository( $mdb );
|
||||||
$accessToken = $integrationsRepository -> apiloGetAccessToken();
|
$accessToken = $apiloRepository->apiloGetAccessToken();
|
||||||
if (!$accessToken) {
|
if (!$accessToken) {
|
||||||
\Domain\Integrations\ApiloLogger::log(
|
\Domain\Integrations\ApiloLogger::log(
|
||||||
$mdb,
|
$mdb,
|
||||||
@@ -519,92 +523,6 @@ class OrderAdminService
|
|||||||
return $this->orders->deleteOrder($orderId);
|
return $this->orders->deleteOrder($orderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Apilo sync queue (migrated from \shop\Order)
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
private const APILO_SYNC_QUEUE_FILE = '/temp/apilo-sync-queue.json';
|
|
||||||
|
|
||||||
public function processApiloSyncQueue(int $limit = 10): int
|
|
||||||
{
|
|
||||||
$queue = self::loadApiloSyncQueue();
|
|
||||||
if (!\Shared\Helpers\Helpers::is_array_fix($queue)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$processed = 0;
|
|
||||||
|
|
||||||
foreach ($queue as $key => $task)
|
|
||||||
{
|
|
||||||
if ($processed >= $limit) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$order_id = (int)($task['order_id'] ?? 0);
|
|
||||||
if ($order_id <= 0) {
|
|
||||||
unset($queue[$key]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$order = $this->orders->findRawById($order_id);
|
|
||||||
if (!$order) {
|
|
||||||
unset($queue[$key]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$error = '';
|
|
||||||
$sync_failed = false;
|
|
||||||
$max_attempts = 50; // ~8h przy cronie co 10 min
|
|
||||||
|
|
||||||
// Zamówienie jeszcze nie wysłane do Apilo — czekaj na crona
|
|
||||||
if (!(int)$order['apilo_order_id']) {
|
|
||||||
$attempts = (int)($task['attempts'] ?? 0) + 1;
|
|
||||||
if ($attempts >= $max_attempts) {
|
|
||||||
// Przekroczono limit prób — porzuć task
|
|
||||||
unset($queue[$key]);
|
|
||||||
} else {
|
|
||||||
$task['attempts'] = $attempts;
|
|
||||||
$task['last_error'] = 'awaiting_apilo_order';
|
|
||||||
$task['updated_at'] = date('Y-m-d H:i:s');
|
|
||||||
$queue[$key] = $task;
|
|
||||||
}
|
|
||||||
$processed++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$payment_pending = !empty($task['payment']) && (int)$order['paid'] === 1;
|
|
||||||
if ($payment_pending) {
|
|
||||||
if (!$this->syncApiloPayment($order)) {
|
|
||||||
$sync_failed = true;
|
|
||||||
$error = 'payment_sync_failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$status_pending = isset($task['status']) && $task['status'] !== null && $task['status'] !== '';
|
|
||||||
if (!$sync_failed && $status_pending) {
|
|
||||||
if (!$this->syncApiloStatus($order, (int)$task['status'])) {
|
|
||||||
$sync_failed = true;
|
|
||||||
$error = 'status_sync_failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($sync_failed) {
|
|
||||||
$task['attempts'] = (int)($task['attempts'] ?? 0) + 1;
|
|
||||||
$task['last_error'] = $error;
|
|
||||||
$task['updated_at'] = date('Y-m-d H:i:s');
|
|
||||||
$queue[$key] = $task;
|
|
||||||
} else {
|
|
||||||
unset($queue[$key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$processed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
self::saveApiloSyncQueue($queue);
|
|
||||||
|
|
||||||
return $processed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Private: email
|
// Private: email
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -689,7 +607,7 @@ class OrderAdminService
|
|||||||
'Brak apilo_order_id — płatność zakolejkowana do sync',
|
'Brak apilo_order_id — płatność zakolejkowana do sync',
|
||||||
['apilo_order_id' => $order['apilo_order_id'] ?? null]
|
['apilo_order_id' => $order['apilo_order_id'] ?? null]
|
||||||
);
|
);
|
||||||
self::queueApiloSync((int)$order['id'], true, null, 'awaiting_apilo_order');
|
$this->queueApiloSync((int)$order['id'], true, null, 'awaiting_apilo_order');
|
||||||
} elseif (!$this->syncApiloPayment($order)) {
|
} elseif (!$this->syncApiloPayment($order)) {
|
||||||
\Domain\Integrations\ApiloLogger::log(
|
\Domain\Integrations\ApiloLogger::log(
|
||||||
$db,
|
$db,
|
||||||
@@ -698,7 +616,7 @@ class OrderAdminService
|
|||||||
'Sync płatności nieudany — zakolejkowano ponowną próbę',
|
'Sync płatności nieudany — zakolejkowano ponowną próbę',
|
||||||
['apilo_order_id' => $order['apilo_order_id']]
|
['apilo_order_id' => $order['apilo_order_id']]
|
||||||
);
|
);
|
||||||
self::queueApiloSync((int)$order['id'], true, null, 'payment_sync_failed');
|
$this->queueApiloSync((int)$order['id'], true, null, 'payment_sync_failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,7 +657,7 @@ class OrderAdminService
|
|||||||
'Brak apilo_order_id — status zakolejkowany do sync',
|
'Brak apilo_order_id — status zakolejkowany do sync',
|
||||||
['apilo_order_id' => $order['apilo_order_id'] ?? null, 'target_status' => $status]
|
['apilo_order_id' => $order['apilo_order_id'] ?? null, 'target_status' => $status]
|
||||||
);
|
);
|
||||||
self::queueApiloSync((int)$order['id'], false, $status, 'awaiting_apilo_order');
|
$this->queueApiloSync((int)$order['id'], false, $status, 'awaiting_apilo_order');
|
||||||
} elseif (!$this->syncApiloStatus($order, $status)) {
|
} elseif (!$this->syncApiloStatus($order, $status)) {
|
||||||
\Domain\Integrations\ApiloLogger::log(
|
\Domain\Integrations\ApiloLogger::log(
|
||||||
$db,
|
$db,
|
||||||
@@ -748,16 +666,16 @@ class OrderAdminService
|
|||||||
'Sync statusu nieudany — zakolejkowano ponowną próbę',
|
'Sync statusu nieudany — zakolejkowano ponowną próbę',
|
||||||
['apilo_order_id' => $order['apilo_order_id'], 'target_status' => $status]
|
['apilo_order_id' => $order['apilo_order_id'], 'target_status' => $status]
|
||||||
);
|
);
|
||||||
self::queueApiloSync((int)$order['id'], false, $status, 'status_sync_failed');
|
$this->queueApiloSync((int)$order['id'], false, $status, 'status_sync_failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncApiloPayment(array $order): bool
|
public function syncApiloPayment(array $order): bool
|
||||||
{
|
{
|
||||||
global $config;
|
global $config;
|
||||||
|
|
||||||
$db = $this->orders->getDb();
|
$db = $this->orders->getDb();
|
||||||
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db);
|
$apiloRepository = new \Domain\Integrations\ApiloRepository($db);
|
||||||
|
|
||||||
if (empty($order['apilo_order_id'])) {
|
if (empty($order['apilo_order_id'])) {
|
||||||
return true;
|
return true;
|
||||||
@@ -769,7 +687,7 @@ class OrderAdminService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$payment_date = new \DateTime($order['date_order']);
|
$payment_date = new \DateTime($order['date_order']);
|
||||||
$access_token = $integrationsRepository->apiloGetAccessToken();
|
$access_token = $apiloRepository->apiloGetAccessToken();
|
||||||
|
|
||||||
$ch = curl_init();
|
$ch = curl_init();
|
||||||
curl_setopt($ch, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/" . $order['apilo_order_id'] . '/payment/');
|
curl_setopt($ch, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/" . $order['apilo_order_id'] . '/payment/');
|
||||||
@@ -819,18 +737,18 @@ class OrderAdminService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncApiloStatus(array $order, int $status): bool
|
public function syncApiloStatus(array $order, int $status): bool
|
||||||
{
|
{
|
||||||
global $config;
|
global $config;
|
||||||
|
|
||||||
$db = $this->orders->getDb();
|
$db = $this->orders->getDb();
|
||||||
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db);
|
$apiloRepository = new \Domain\Integrations\ApiloRepository($db);
|
||||||
|
|
||||||
if (empty($order['apilo_order_id'])) {
|
if (empty($order['apilo_order_id'])) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$access_token = $integrationsRepository->apiloGetAccessToken();
|
$access_token = $apiloRepository->apiloGetAccessToken();
|
||||||
|
|
||||||
$ch = curl_init();
|
$ch = curl_init();
|
||||||
curl_setopt($ch, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/" . $order['apilo_order_id'] . '/status/');
|
curl_setopt($ch, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/" . $order['apilo_order_id'] . '/status/');
|
||||||
@@ -882,59 +800,42 @@ class OrderAdminService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Private: Apilo sync queue file helpers
|
// Private: Apilo sync queue (DB-based via CronJobRepository)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private static function queueApiloSync(int $order_id, bool $payment, ?int $status, string $error): void
|
private function queueApiloSync(int $order_id, bool $payment, ?int $status, string $error): void
|
||||||
{
|
{
|
||||||
if ($order_id <= 0) return;
|
if ($order_id <= 0) return;
|
||||||
|
|
||||||
$queue = self::loadApiloSyncQueue();
|
if ($this->cronJobRepo === null) return;
|
||||||
$key = (string)$order_id;
|
|
||||||
$row = is_array($queue[$key] ?? null) ? $queue[$key] : [];
|
if ($payment) {
|
||||||
|
$jobType = \Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT;
|
||||||
|
$payload = ['order_id' => $order_id];
|
||||||
|
|
||||||
|
if (!$this->cronJobRepo->hasPendingJob($jobType, $payload)) {
|
||||||
|
$this->cronJobRepo->enqueue(
|
||||||
|
$jobType,
|
||||||
|
$payload,
|
||||||
|
\Domain\CronJob\CronJobType::PRIORITY_HIGH,
|
||||||
|
50
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$row['order_id'] = $order_id;
|
|
||||||
$row['payment'] = !empty($row['payment']) || $payment ? 1 : 0;
|
|
||||||
if ($status !== null) {
|
if ($status !== null) {
|
||||||
$row['status'] = $status;
|
$jobType = \Domain\CronJob\CronJobType::APILO_SYNC_STATUS;
|
||||||
|
$payload = ['order_id' => $order_id, 'status' => $status];
|
||||||
|
|
||||||
|
if (!$this->cronJobRepo->hasPendingJob($jobType, $payload)) {
|
||||||
|
$this->cronJobRepo->enqueue(
|
||||||
|
$jobType,
|
||||||
|
$payload,
|
||||||
|
\Domain\CronJob\CronJobType::PRIORITY_HIGH,
|
||||||
|
50
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$row['attempts'] = (int)($row['attempts'] ?? 0) + 1;
|
|
||||||
$row['last_error'] = $error;
|
|
||||||
$row['updated_at'] = date('Y-m-d H:i:s');
|
|
||||||
|
|
||||||
$queue[$key] = $row;
|
|
||||||
self::saveApiloSyncQueue($queue);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function apiloSyncQueuePath(): string
|
|
||||||
{
|
|
||||||
return dirname(__DIR__, 2) . self::APILO_SYNC_QUEUE_FILE;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function loadApiloSyncQueue(): array
|
|
||||||
{
|
|
||||||
$path = self::apiloSyncQueuePath();
|
|
||||||
if (!file_exists($path)) return [];
|
|
||||||
|
|
||||||
$content = file_get_contents($path);
|
|
||||||
if (!$content) return [];
|
|
||||||
|
|
||||||
$decoded = json_decode($content, true);
|
|
||||||
if (!is_array($decoded)) return [];
|
|
||||||
|
|
||||||
return $decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function saveApiloSyncQueue(array $queue): void
|
|
||||||
{
|
|
||||||
$path = self::apiloSyncQueuePath();
|
|
||||||
$dir = dirname($path);
|
|
||||||
if (!is_dir($dir)) {
|
|
||||||
mkdir($dir, 0777, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
file_put_contents($path, json_encode($queue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function appendApiloLog(string $message): void
|
private static function appendApiloLog(string $message): void
|
||||||
|
|||||||
@@ -790,8 +790,8 @@ class OrderRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($coupon && $coupon->is_one_time()) {
|
if ($coupon && (int)$coupon->one_time === 1) {
|
||||||
$coupon->set_as_used();
|
(new \Domain\Coupon\CouponRepository($this->db))->markAsUsed((int)$coupon->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
$order = $this->orderDetailsFrontend($order_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);
|
\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
|
// 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->updateOrderStatus($order_id, 4);
|
||||||
$this->insertStatusHistory($order_id, 4, 1);
|
$this->insertStatusHistory($order_id, 4, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,11 @@ class PagesRepository
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (bool)$this->db->delete('pp_pages', ['id' => $pageId]);
|
$deleted = (bool)$this->db->delete('pp_pages', ['id' => $pageId]);
|
||||||
|
if ($deleted) {
|
||||||
|
$this->db->delete('pp_routes', ['page_id' => $pageId]);
|
||||||
|
}
|
||||||
|
return $deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ class PaymentMethodRepository
|
|||||||
'apilo_payment_type_id' => $this->normalizeApiloPaymentTypeId($data['apilo_payment_type_id'] ?? null),
|
'apilo_payment_type_id' => $this->normalizeApiloPaymentTypeId($data['apilo_payment_type_id'] ?? null),
|
||||||
'min_order_amount' => $this->normalizeDecimalOrNull($data['min_order_amount'] ?? null),
|
'min_order_amount' => $this->normalizeDecimalOrNull($data['min_order_amount'] ?? null),
|
||||||
'max_order_amount' => $this->normalizeDecimalOrNull($data['max_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]);
|
$this->db->update('pp_shop_payment_methods', $row, ['id' => $paymentMethodId]);
|
||||||
@@ -240,7 +241,8 @@ class PaymentMethodRepository
|
|||||||
spm.status,
|
spm.status,
|
||||||
spm.apilo_payment_type_id,
|
spm.apilo_payment_type_id,
|
||||||
spm.min_order_amount,
|
spm.min_order_amount,
|
||||||
spm.max_order_amount
|
spm.max_order_amount,
|
||||||
|
spm.is_cod
|
||||||
FROM pp_shop_payment_methods AS spm
|
FROM pp_shop_payment_methods AS spm
|
||||||
INNER JOIN pp_shop_transport_payment_methods AS stpm
|
INNER JOIN pp_shop_transport_payment_methods AS stpm
|
||||||
ON stpm.id_payment_method = spm.id
|
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['apilo_payment_type_id'] = $this->normalizeApiloPaymentTypeId($row['apilo_payment_type_id'] ?? null);
|
||||||
$row['min_order_amount'] = $this->normalizeDecimalOrNull($row['min_order_amount'] ?? null);
|
$row['min_order_amount'] = $this->normalizeDecimalOrNull($row['min_order_amount'] ?? null);
|
||||||
$row['max_order_amount'] = $this->normalizeDecimalOrNull($row['max_order_amount'] ?? null);
|
$row['max_order_amount'] = $this->normalizeDecimalOrNull($row['max_order_amount'] ?? null);
|
||||||
|
$row['is_cod'] = (int)($row['is_cod'] ?? 0);
|
||||||
|
|
||||||
return $row;
|
return $row;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,4 +357,34 @@ class ProducerRepository
|
|||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Znajdź producenta po nazwie lub utwórz nowego (dla API).
|
||||||
|
*
|
||||||
|
* @return array{id: int, created: bool}
|
||||||
|
*/
|
||||||
|
public function ensureProducerForApi(string $name): array
|
||||||
|
{
|
||||||
|
$name = trim($name);
|
||||||
|
if ($name === '') {
|
||||||
|
return ['id' => 0, 'created' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $this->db->get('pp_shop_producer', 'id', ['name' => $name]);
|
||||||
|
if (!empty($existing)) {
|
||||||
|
return ['id' => (int)$existing, 'created' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->insert('pp_shop_producer', [
|
||||||
|
'name' => $name,
|
||||||
|
'status' => 1,
|
||||||
|
'img' => null,
|
||||||
|
]);
|
||||||
|
$id = (int)$this->db->id();
|
||||||
|
if ($id <= 0) {
|
||||||
|
return ['id' => 0, 'created' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['id' => $id, 'created' => true];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -654,9 +654,14 @@ class ProductRepository
|
|||||||
'custom_label_2' => $product['custom_label_2'],
|
'custom_label_2' => $product['custom_label_2'],
|
||||||
'custom_label_3' => $product['custom_label_3'],
|
'custom_label_3' => $product['custom_label_3'],
|
||||||
'custom_label_4' => $product['custom_label_4'],
|
'custom_label_4' => $product['custom_label_4'],
|
||||||
|
'new_to_date' => $product['new_to_date'],
|
||||||
|
'additional_message' => (int)($product['additional_message'] ?? 0),
|
||||||
|
'additional_message_required' => (int)($product['additional_message_required'] ?? 0),
|
||||||
|
'additional_message_text' => $product['additional_message_text'],
|
||||||
'set_id' => $product['set_id'] !== null ? (int)$product['set_id'] : null,
|
'set_id' => $product['set_id'] !== null ? (int)$product['set_id'] : null,
|
||||||
'product_unit_id' => $product['product_unit_id'] !== null ? (int)$product['product_unit_id'] : null,
|
'product_unit_id' => $product['product_unit_id'] !== null ? (int)$product['product_unit_id'] : null,
|
||||||
'producer_id' => $product['producer_id'] !== null ? (int)$product['producer_id'] : null,
|
'producer_id' => $product['producer_id'] !== null ? (int)$product['producer_id'] : null,
|
||||||
|
'producer_name' => $this->resolveProducerName($product['producer_id']),
|
||||||
'date_add' => $product['date_add'],
|
'date_add' => $product['date_add'],
|
||||||
'date_modify' => $product['date_modify'],
|
'date_modify' => $product['date_modify'],
|
||||||
];
|
];
|
||||||
@@ -682,6 +687,7 @@ class ProductRepository
|
|||||||
'tab_name_2' => $lang['tab_name_2'],
|
'tab_name_2' => $lang['tab_name_2'],
|
||||||
'tab_description_2' => $lang['tab_description_2'],
|
'tab_description_2' => $lang['tab_description_2'],
|
||||||
'canonical' => $lang['canonical'],
|
'canonical' => $lang['canonical'],
|
||||||
|
'security_information' => $lang['security_information'] ?? null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -733,6 +739,19 @@ class ProductRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom fields (Dodatkowe pola)
|
||||||
|
$customFields = $this->db->select('pp_shop_products_custom_fields', ['name', 'type', 'is_required'], ['id_product' => $id]);
|
||||||
|
$result['custom_fields'] = [];
|
||||||
|
if (is_array($customFields)) {
|
||||||
|
foreach ($customFields as $cf) {
|
||||||
|
$result['custom_fields'][] = [
|
||||||
|
'name' => $cf['name'],
|
||||||
|
'type' => !empty($cf['type']) ? $cf['type'] : 'text',
|
||||||
|
'is_required' => $cf['is_required'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Variants (only for parent products)
|
// Variants (only for parent products)
|
||||||
if (empty($product['parent_id'])) {
|
if (empty($product['parent_id'])) {
|
||||||
$result['variants'] = $this->findVariantsForApi($id);
|
$result['variants'] = $this->findVariantsForApi($id);
|
||||||
@@ -1116,6 +1135,21 @@ class ProductRepository
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwraca nazwę producenta po ID (null jeśli brak).
|
||||||
|
*
|
||||||
|
* @param mixed $producerId
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
private function resolveProducerName($producerId): ?string
|
||||||
|
{
|
||||||
|
if (empty($producerId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$name = $this->db->get('pp_shop_producer', 'name', ['id' => (int)$producerId]);
|
||||||
|
return ($name !== false && $name !== null) ? (string)$name : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Szczegóły produktu (admin) — zastępuje factory product_details().
|
* Szczegóły produktu (admin) — zastępuje factory product_details().
|
||||||
*/
|
*/
|
||||||
@@ -1301,7 +1335,11 @@ class ProductRepository
|
|||||||
$this->saveImagesOrder( $productId, $d['gallery_order'] );
|
$this->saveImagesOrder( $productId, $d['gallery_order'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->saveCustomFields( $productId, $d['custom_field_name'] ?? [], $d['custom_field_type'] ?? [], $d['custom_field_required'] ?? [] );
|
// 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'] ?? [] );
|
||||||
|
}
|
||||||
|
|
||||||
if ( !$isNew ) {
|
if ( !$isNew ) {
|
||||||
$this->cleanupDeletedFiles( $productId );
|
$this->cleanupDeletedFiles( $productId );
|
||||||
@@ -1564,9 +1602,7 @@ class ProductRepository
|
|||||||
$results = $this->db->select( 'pp_shop_products_files', '*', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] );
|
$results = $this->db->select( 'pp_shop_products_files', '*', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] );
|
||||||
if ( is_array( $results ) ) {
|
if ( is_array( $results ) ) {
|
||||||
foreach ( $results as $row ) {
|
foreach ( $results as $row ) {
|
||||||
if ( file_exists( '../' . $row['src'] ) ) {
|
$this->safeUnlink( $row['src'] );
|
||||||
unlink( '../' . $row['src'] );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->db->delete( 'pp_shop_products_files', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] );
|
$this->db->delete( 'pp_shop_products_files', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] );
|
||||||
@@ -1577,9 +1613,7 @@ class ProductRepository
|
|||||||
$results = $this->db->select( 'pp_shop_products_images', '*', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] );
|
$results = $this->db->select( 'pp_shop_products_images', '*', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] );
|
||||||
if ( is_array( $results ) ) {
|
if ( is_array( $results ) ) {
|
||||||
foreach ( $results as $row ) {
|
foreach ( $results as $row ) {
|
||||||
if ( file_exists( '../' . $row['src'] ) ) {
|
$this->safeUnlink( $row['src'] );
|
||||||
unlink( '../' . $row['src'] );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->db->delete( 'pp_shop_products_images', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] );
|
$this->db->delete( 'pp_shop_products_images', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] );
|
||||||
@@ -1615,6 +1649,7 @@ class ProductRepository
|
|||||||
$this->db->delete( 'pp_shop_products_langs', [ 'product_id' => $productId ] );
|
$this->db->delete( 'pp_shop_products_langs', [ 'product_id' => $productId ] );
|
||||||
$this->db->delete( 'pp_shop_products_images', [ 'product_id' => $productId ] );
|
$this->db->delete( 'pp_shop_products_images', [ 'product_id' => $productId ] );
|
||||||
$this->db->delete( 'pp_shop_products_files', [ 'product_id' => $productId ] );
|
$this->db->delete( 'pp_shop_products_files', [ 'product_id' => $productId ] );
|
||||||
|
$this->db->delete( 'pp_shop_products_custom_fields', [ 'id_product' => $productId ] );
|
||||||
$this->db->delete( 'pp_shop_products_attributes', [ 'product_id' => $productId ] );
|
$this->db->delete( 'pp_shop_products_attributes', [ 'product_id' => $productId ] );
|
||||||
$this->db->delete( 'pp_shop_products', [ 'id' => $productId ] );
|
$this->db->delete( 'pp_shop_products', [ 'id' => $productId ] );
|
||||||
$this->db->delete( 'pp_shop_product_sets_products', [ 'product_id' => $productId ] );
|
$this->db->delete( 'pp_shop_product_sets_products', [ 'product_id' => $productId ] );
|
||||||
@@ -1717,8 +1752,10 @@ class ProductRepository
|
|||||||
if ( \Shared\Helpers\Helpers::is_array_fix( $customFields ) ) {
|
if ( \Shared\Helpers\Helpers::is_array_fix( $customFields ) ) {
|
||||||
foreach ( $customFields as $row ) {
|
foreach ( $customFields as $row ) {
|
||||||
$this->db->insert( 'pp_shop_products_custom_fields', [
|
$this->db->insert( 'pp_shop_products_custom_fields', [
|
||||||
'id_product' => $newProductId,
|
'id_product' => $newProductId,
|
||||||
'name' => $row['name'],
|
'name' => $row['name'],
|
||||||
|
'type' => $row['type'] ?? 'text',
|
||||||
|
'is_required' => $row['is_required'] ?? 0,
|
||||||
] );
|
] );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2087,14 +2124,30 @@ class ProductRepository
|
|||||||
$results = $this->db->select( 'pp_shop_products_images', '*', [ 'product_id' => null ] );
|
$results = $this->db->select( 'pp_shop_products_images', '*', [ 'product_id' => null ] );
|
||||||
if ( is_array( $results ) ) {
|
if ( is_array( $results ) ) {
|
||||||
foreach ( $results as $row ) {
|
foreach ( $results as $row ) {
|
||||||
if ( file_exists( '../' . $row['src'] ) ) {
|
$this->safeUnlink( $row['src'] );
|
||||||
unlink( '../' . $row['src'] );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->db->delete( 'pp_shop_products_images', [ 'product_id' => null ] );
|
$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.
|
* Oznacza plik do usunięcia.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -172,13 +172,19 @@ class UpdateRepository
|
|||||||
|
|
||||||
foreach ( $manifest['sql'] as $query ) {
|
foreach ( $manifest['sql'] as $query ) {
|
||||||
$query = trim( $query );
|
$query = trim( $query );
|
||||||
if ( $query !== '' ) {
|
if ( $query === '' || strpos( $query, '--' ) === 0 ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
if ( $this->db->query( $query ) ) {
|
if ( $this->db->query( $query ) ) {
|
||||||
$success++;
|
$success++;
|
||||||
} else {
|
} else {
|
||||||
$errors++;
|
$errors++;
|
||||||
$log[] = '[WARNING] Błąd SQL: ' . $query;
|
$log[] = '[WARNING] Błąd SQL: ' . $query;
|
||||||
}
|
}
|
||||||
|
} catch ( \Exception $e ) {
|
||||||
|
$errors++;
|
||||||
|
$log[] = '[WARNING] Wyjątek SQL: ' . $e->getMessage() . ' | Query: ' . substr( $query, 0, 200 );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -425,42 +425,206 @@ class Helpers
|
|||||||
$site_map .= '<priority>1</priority>' . PHP_EOL;
|
$site_map .= '<priority>1</priority>' . PHP_EOL;
|
||||||
$site_map .= '</url>' . PHP_EOL;
|
$site_map .= '</url>' . PHP_EOL;
|
||||||
|
|
||||||
$htaccess_data = file_get_contents( $dir . 'libraries/htaccess.conf' );
|
//
|
||||||
$htaccess_data = str_replace( '{PAGE}', $url, $htaccess_data );
|
// SYSTEM ROUTES — delete all and reinsert
|
||||||
|
//
|
||||||
|
$mdb->delete( 'pp_routes', [ 'type' => 'system' ] );
|
||||||
|
|
||||||
$results = $mdb -> select( 'pp_langs', [ 'id' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
|
// Static system routes (hardcoded, never change)
|
||||||
|
$systemRoutes = [
|
||||||
|
// Wyszukiwarka
|
||||||
|
[ 'pattern' => '^wyszukiwarka/([^/]+)/([0-9]+)$', 'destination' => 'index.php?module=search&action=search_results&query=$1&bs=$2' ],
|
||||||
|
[ 'pattern' => '^wyszukiwarka/([^/]+)$', 'destination' => 'index.php?module=search&action=search_results&query=$1&bs=1' ],
|
||||||
|
// Zamowienia
|
||||||
|
[ 'pattern' => '^zamowienie/([a-zA-Z0-9-]+)$', 'destination' => 'index.php?module=shop_order&action=order_details&order_hash=$1' ],
|
||||||
|
[ 'pattern' => '^potwierdzenie-platnosci/([a-zA-Z0-9-]+)$', 'destination' => 'index.php?module=shop_order&action=payment_confirmation&order_hash=$1' ],
|
||||||
|
// Platnosci
|
||||||
|
[ 'pattern' => '^tpay-status$', 'destination' => 'index.php?module=shop_order&action=payment_status_tpay' ],
|
||||||
|
[ 'pattern' => '^platnosc-status$', 'destination' => 'index.php?module=shop_order&action=payment_status_hotpay' ],
|
||||||
|
[ 'pattern' => '^przelewy24-status$', 'destination' => 'index.php?module=shop_order&action=payment_status_przelewy24pl' ],
|
||||||
|
// Koszyk
|
||||||
|
[ 'pattern' => '^koszyk$', 'destination' => 'index.php?module=shop_basket&action=main_view' ],
|
||||||
|
[ 'pattern' => '^koszyk-podsumowanie$', 'destination' => 'index.php?module=shop_basket&action=summary_view' ],
|
||||||
|
[ 'pattern' => '^zloz-zamowienie$', 'destination' => 'index.php?module=shop_basket&action=basket_save' ],
|
||||||
|
// Klient
|
||||||
|
[ 'pattern' => '^rejestracja$', 'destination' => 'index.php?module=shop_client&action=register_form' ],
|
||||||
|
[ 'pattern' => '^logowanie$', 'destination' => 'index.php?module=shop_client&action=login_form' ],
|
||||||
|
[ 'pattern' => '^wylogowanie$', 'destination' => 'index.php?module=shop_client&action=logout' ],
|
||||||
|
[ 'pattern' => '^odzyskiwanie-hasla$', 'destination' => 'index.php?module=shop_client&action=recover_password' ],
|
||||||
|
[ 'pattern' => '^panel-klienta/zamowienia$', 'destination' => 'index.php?module=shop_client&action=client_orders' ],
|
||||||
|
[ 'pattern' => '^panel-klienta/adresy$', 'destination' => 'index.php?module=shop_client&action=client_addresses' ],
|
||||||
|
[ 'pattern' => '^panel-klienta/nowy-adres$', 'destination' => 'index.php?module=shop_client&action=address_edit' ],
|
||||||
|
[ 'pattern' => '^panel-klienta/edytuj-adres/([0-9]+)$', 'destination' => 'index.php?module=shop_client&action=address_edit&id=$1' ],
|
||||||
|
[ 'pattern' => '^panel-klienta/usun-adres/([0-9]+)$', 'destination' => 'index.php?module=shop_client&action=address_delete&id=$1' ],
|
||||||
|
// Newsletter
|
||||||
|
[ 'pattern' => '^newsletter/signin$', 'destination' => 'index.php?module=newsletter&action=signin' ],
|
||||||
|
[ 'pattern' => '^newsletter/confirm/hash=(.+)$', 'destination' => 'index.php?module=newsletter&action=confirm&hash=$1' ],
|
||||||
|
[ 'pattern' => '^newsletter/unsubscribe/hash=(.+)$', 'destination' => 'index.php?module=newsletter&action=unsubscribe&hash=$1' ],
|
||||||
|
// Moduły AJAX (shopBasket, shopClient, shopProduct, shopCoupon, search)
|
||||||
|
[ 'pattern' => '^shopBasket/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopBasket&action=$1&$2' ],
|
||||||
|
[ 'pattern' => '^shopBasket/([^/]+)$', 'destination' => 'index.php?module=shopBasket&action=$1' ],
|
||||||
|
[ 'pattern' => '^shopClient/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopClient&action=$1&$2' ],
|
||||||
|
[ 'pattern' => '^shopClient/([^/]+)$', 'destination' => 'index.php?module=shopClient&action=$1' ],
|
||||||
|
[ 'pattern' => '^shopProduct/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopProduct&action=$1&$2' ],
|
||||||
|
[ 'pattern' => '^shopProduct/([^/]+)$', 'destination' => 'index.php?module=shopProduct&action=$1' ],
|
||||||
|
[ 'pattern' => '^shopCoupon/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopCoupon&action=$1&$2' ],
|
||||||
|
[ 'pattern' => '^shopCoupon/([^/]+)$', 'destination' => 'index.php?module=shopCoupon&action=$1' ],
|
||||||
|
[ 'pattern' => '^search/([^/]+)/(.+)$', 'destination' => 'index.php?module=search&action=$1&$2' ],
|
||||||
|
[ 'pattern' => '^search/([^/]+)$', 'destination' => 'index.php?module=search&action=$1' ],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ( $systemRoutes as $route )
|
||||||
|
{
|
||||||
|
$mdb->insert( 'pp_routes', [
|
||||||
|
'type' => 'system',
|
||||||
|
'lang_id' => 0,
|
||||||
|
'pattern' => $route['pattern'],
|
||||||
|
'destination' => $route['destination'],
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic system routes — languages
|
||||||
|
$results = $mdb->select( 'pp_langs', [ 'id' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
|
||||||
if ( is_array( $results ) ) foreach ( $results as $row )
|
if ( is_array( $results ) ) foreach ( $results as $row )
|
||||||
{
|
{
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $row['id'] . '/$ index.php?a=change_language&id=' . $row['id'] . ' [L]';
|
$mdb->insert( 'pp_routes', [
|
||||||
|
'type' => 'system',
|
||||||
|
'lang_id' => 0,
|
||||||
|
'pattern' => '^' . $row['id'] . '$',
|
||||||
|
'destination' => 'index.php?a=change_language&id=' . $row['id'],
|
||||||
|
] );
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// Dynamic system routes — producenci
|
||||||
// INNE
|
|
||||||
//
|
|
||||||
$htaccess_data .= PHP_EOL;
|
|
||||||
$htaccess_data .= 'RewriteRule ^newsletter/signin/$ index.php?module=newsletter&action=signin [L]' . PHP_EOL;
|
|
||||||
$htaccess_data .= 'RewriteRule ^newsletter/confirm/hash=(.*)$ index.php?module=newsletter&action=confirm&hash=$1 [L]' . PHP_EOL;
|
|
||||||
$htaccess_data .= 'RewriteRule ^newsletter/unsubscribe/hash=(.*)$ index.php?module=newsletter&action=unsubscribe&hash=$1 [L]' . PHP_EOL;
|
|
||||||
|
|
||||||
//
|
|
||||||
// PRODUCENCI
|
|
||||||
//
|
|
||||||
$categoryDefaultLayoutId = ( new \Domain\Layouts\LayoutsRepository( $mdb ) )->categoryDefaultLayoutId();
|
$categoryDefaultLayoutId = ( new \Domain\Layouts\LayoutsRepository( $mdb ) )->categoryDefaultLayoutId();
|
||||||
$htaccess_data .= 'RewriteRule ^producenci$ index.php?module=shop_producer&action=list&layout_id=' . $categoryDefaultLayoutId . '&%{QUERY_STRING} [L]' . PHP_EOL;
|
|
||||||
|
|
||||||
$rows = $mdb -> select( 'pp_shop_producer', '*', [ 'status' => 1 ] );
|
$mdb->insert( 'pp_routes', [
|
||||||
|
'type' => 'system',
|
||||||
|
'lang_id' => 0,
|
||||||
|
'pattern' => '^producenci$',
|
||||||
|
'destination' => 'index.php?module=shop_producer&action=list&layout_id=' . $categoryDefaultLayoutId,
|
||||||
|
] );
|
||||||
|
|
||||||
|
$rows = $mdb->select( 'pp_shop_producer', '*', [ 'status' => 1 ] );
|
||||||
if ( self::is_array_fix( $rows ) ) foreach ( $rows as $row )
|
if ( self::is_array_fix( $rows ) ) foreach ( $rows as $row )
|
||||||
{
|
{
|
||||||
$htaccess_data .= 'RewriteRule ^producent/' . self::seo( $row['name'] ) . '$ index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId . '&%{QUERY_STRING} [L]' . PHP_EOL;
|
$mdb->insert( 'pp_routes', [
|
||||||
$htaccess_data .= 'RewriteRule ^producent/' . self::seo( $row['name'] ) . '/([0-9]+)$ index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId . '&bs=$1&%{QUERY_STRING} [L]' . PHP_EOL;
|
'type' => 'system',
|
||||||
|
'lang_id' => 0,
|
||||||
|
'pattern' => '^producent/' . self::seo( $row['name'] ) . '$',
|
||||||
|
'destination' => 'index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId,
|
||||||
|
] );
|
||||||
|
$mdb->insert( 'pp_routes', [
|
||||||
|
'type' => 'system',
|
||||||
|
'lang_id' => 0,
|
||||||
|
'pattern' => '^producent/' . self::seo( $row['name'] ) . '/([0-9]+)$',
|
||||||
|
'destination' => 'index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId . '&bs=$1',
|
||||||
|
] );
|
||||||
}
|
}
|
||||||
|
|
||||||
$results = $mdb -> select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
|
//
|
||||||
|
// HTACCESS — generuj z PHP (bez szablonu htaccess.conf)
|
||||||
|
//
|
||||||
|
$htaccess_data = 'RewriteEngine On' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteBase /' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'Options +FollowSymlinks' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'Options -Indexes' . PHP_EOL;
|
||||||
|
$htaccess_data .= PHP_EOL;
|
||||||
|
$htaccess_data .= '# Przekierowanie z www na bez www i z http na https w jednym kroku' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L]' . PHP_EOL;
|
||||||
|
$htaccess_data .= PHP_EOL;
|
||||||
|
$htaccess_data .= '# Przekierowanie z http na https, jesli nie zawiera www' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteCond %{HTTPS} off' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteCond %{REQUEST_URI} !^/(tpay-status|platnosc-status|przelewy24-status)$ [NC]' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]' . PHP_EOL;
|
||||||
|
$htaccess_data .= PHP_EOL;
|
||||||
|
$htaccess_data .= '# Usuwanie koncowego slasha dla niekatalogów' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-d' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteCond %{REQUEST_URI} !^/admin/.*$ [NC]' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteCond %{REQUEST_URI} (.+)/$' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteRule ^ %1 [R=301,L]' . PHP_EOL;
|
||||||
|
$htaccess_data .= PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteCond %{REQUEST_URI} !^(.*)/libraries/(.*) [NC]' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteCond %{REQUEST_URI} !^(.*)/layout/(.*) [NC]' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteRule ^admin/([^/]*)/([^/]*)/(.*)$ admin/index.php?module=$1&action=$2&$3 [L]' . PHP_EOL;
|
||||||
|
$htaccess_data .= PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteRule ^admin/$ admin/index.php [L]' . PHP_EOL;
|
||||||
|
$htaccess_data .= PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteRule ^thumb/([0-9]*)/([0-9]*)/(.*)$ /libraries/thumb.php?img=$3&w=$1&h=$2 [L]' . PHP_EOL;
|
||||||
|
$htaccess_data .= PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteCond %{THE_REQUEST} ^[A-Z]{3,9}\ /index.php' . PHP_EOL;
|
||||||
|
$htaccess_data .= 'RewriteRule ^ /%1 [R=301,L]' . PHP_EOL;
|
||||||
|
|
||||||
|
/* cache block */
|
||||||
|
if ( $settings['htaccess_cache'] )
|
||||||
|
{
|
||||||
|
$htaccess_data .= '<IfModule mod_deflate.c>' . PHP_EOL
|
||||||
|
. 'AddOutputFilterByType DEFLATE text/html text/plain text/xml application/xml application/xhtml+xml text/css text/javascript application/javascript application/x-javascript' . PHP_EOL
|
||||||
|
. '</IfModule>' . PHP_EOL
|
||||||
|
. '<IfModule mod_headers.c>' . PHP_EOL
|
||||||
|
. 'Header set Access-Control-Allow-Origin "*"' . PHP_EOL
|
||||||
|
. '</IfModule>' . PHP_EOL
|
||||||
|
. '<IfModule mod_expires.c>' . PHP_EOL
|
||||||
|
. 'ExpiresActive on' . PHP_EOL
|
||||||
|
. 'ExpiresDefault "access plus 1 month"' . PHP_EOL
|
||||||
|
. 'ExpiresByType text/css "access plus 1 year"' . PHP_EOL
|
||||||
|
. 'ExpiresByType application/json "access plus 0 seconds"' . PHP_EOL
|
||||||
|
. 'ExpiresByType application/xml "access plus 0 seconds"' . PHP_EOL
|
||||||
|
. 'ExpiresByType text/xml "access plus 0 seconds"' . PHP_EOL
|
||||||
|
. 'ExpiresByType image/x-icon "access plus 1 week"' . PHP_EOL
|
||||||
|
. 'ExpiresByType text/x-component "access plus 1 month"' . PHP_EOL
|
||||||
|
. 'ExpiresByType text/html "access plus 0 seconds"' . PHP_EOL
|
||||||
|
. 'ExpiresByType application/javascript "access plus 1 year"' . PHP_EOL
|
||||||
|
. 'ExpiresByType application/x-web-app-manifest+json "access plus 0 seconds"' . PHP_EOL
|
||||||
|
. 'ExpiresByType text/cache-manifest "access plus 0 seconds"' . PHP_EOL
|
||||||
|
. 'ExpiresByType audio/ogg "access plus 1 month"' . PHP_EOL
|
||||||
|
. 'ExpiresByType image/gif "access plus 1 month"' . PHP_EOL
|
||||||
|
. 'ExpiresByType image/jpeg "access plus 1 month"' . PHP_EOL
|
||||||
|
. 'ExpiresByType image/png "access plus 1 month"' . PHP_EOL
|
||||||
|
. 'ExpiresByType video/mp4 "access plus 1 month"' . PHP_EOL
|
||||||
|
. 'ExpiresByType video/ogg "access plus 1 month"' . PHP_EOL
|
||||||
|
. 'ExpiresByType video/webm "access plus 1 month"' . PHP_EOL
|
||||||
|
. 'ExpiresByType application/atom+xml "access plus 1 hour"' . PHP_EOL
|
||||||
|
. 'ExpiresByType application/rss+xml "access plus 1 hour"' . PHP_EOL
|
||||||
|
. 'ExpiresByType application/font-woff "access plus 1 month"' . PHP_EOL
|
||||||
|
. 'ExpiresByType application/vnd.ms-fontobject "access plus 1 month"' . PHP_EOL
|
||||||
|
. 'ExpiresByType application/x-font-ttf "access plus 1 month"' . PHP_EOL
|
||||||
|
. 'ExpiresByType font/opentype "access plus 1 month"' . PHP_EOL
|
||||||
|
. 'ExpiresByType image/svg+xml "access plus 1 month"' . PHP_EOL
|
||||||
|
. '</IfModule>' . PHP_EOL;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$htaccess_data .= '<IfModule mod_headers.c>' . PHP_EOL
|
||||||
|
. 'Header set Cache-Control "no-cache, no-store, must-revalidate"' . PHP_EOL
|
||||||
|
. 'Header set Pragma "no-cache"' . PHP_EOL
|
||||||
|
. 'Header set Expires 0' . PHP_EOL
|
||||||
|
. '</IfModule>' . PHP_EOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
$htaccess_data .= '<Files *.conf>' . PHP_EOL;
|
||||||
|
$htaccess_data .= ' Order Deny,Allow' . PHP_EOL;
|
||||||
|
$htaccess_data .= ' Deny from all' . PHP_EOL;
|
||||||
|
$htaccess_data .= '</Files>' . PHP_EOL;
|
||||||
|
$htaccess_data .= '<Files *.log>' . PHP_EOL;
|
||||||
|
$htaccess_data .= ' Order Deny,Allow' . PHP_EOL;
|
||||||
|
$htaccess_data .= ' Deny from all' . PHP_EOL;
|
||||||
|
$htaccess_data .= '</Files>' . PHP_EOL;
|
||||||
|
$htaccess_data .= '<Files *.ini>' . PHP_EOL;
|
||||||
|
$htaccess_data .= ' Order Deny,Allow' . PHP_EOL;
|
||||||
|
$htaccess_data .= ' Deny from all' . PHP_EOL;
|
||||||
|
$htaccess_data .= '</Files>' . PHP_EOL;
|
||||||
|
|
||||||
|
//
|
||||||
|
// KATEGORIE — sitemap + pp_routes (bez zmian)
|
||||||
|
//
|
||||||
|
$results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
|
||||||
if ( is_array( $results ) ) foreach ( $results as $row )
|
if ( is_array( $results ) ) foreach ( $results as $row )
|
||||||
{
|
{
|
||||||
!$row['start'] ? $language_link = $row['id'] . '/' : $language_link = '';
|
!$row['start'] ? $language_link = $row['id'] . '/' : $language_link = '';
|
||||||
|
|
||||||
$results2 = $mdb -> select( 'pp_shop_categories_langs', [ '[><]pp_shop_categories' => [ 'category_id' => 'id' ] ], [ 'seo_link', 'title', 'category_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'o' => 'ASC' ] ] );
|
$results2 = $mdb->select( 'pp_shop_categories_langs', [ '[><]pp_shop_categories' => [ 'category_id' => 'id' ] ], [ 'seo_link', 'title', 'category_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'o' => 'ASC' ] ] );
|
||||||
if ( is_array( $results2 ) ) foreach ( $results2 as $row2 )
|
if ( is_array( $results2 ) ) foreach ( $results2 as $row2 )
|
||||||
{
|
{
|
||||||
if ( $row2['title'] )
|
if ( $row2['title'] )
|
||||||
@@ -475,35 +639,42 @@ class Helpers
|
|||||||
$site_map .= '<priority>1</priority>' . PHP_EOL;
|
$site_map .= '<priority>1</priority>' . PHP_EOL;
|
||||||
$site_map .= '</url>' . PHP_EOL;
|
$site_map .= '</url>' . PHP_EOL;
|
||||||
|
|
||||||
if ( $row2['seo_link'] )
|
$seoSlug = $row2['seo_link'] ? self::seo( $row2['seo_link'] ) : 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] );
|
||||||
{
|
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '$ index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'] . '&%{QUERY_STRING} [L]';
|
$mdb->delete( 'pp_routes', [ 'AND' => [ 'category_id' => $row2['category_id'], 'lang_id' => $row['id'] ] ] );
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '/([0-9]+)$ index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'] . '&bs=$1&%{QUERY_STRING} [L]';
|
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '/1$ ' . $language_link . self::seo( $row2['seo_link'] ) . ' [R=301,L]';
|
$mdb->insert( 'pp_routes', [
|
||||||
}
|
'category_id' => $row2['category_id'],
|
||||||
else
|
'lang_id' => $row['id'],
|
||||||
{
|
'pattern' => '^' . $language_link . $seoSlug . '$',
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] ) . '$ index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'] . '&%{QUERY_STRING} [L]';
|
'destination' => 'index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'],
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] ) . '/([0-9]+)$ index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'] . '&bs=$1&%{QUERY_STRING} [L]';
|
] );
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] ) . '/1$ ' . $language_link . 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] ) . ' [R=301,L]';
|
$mdb->insert( 'pp_routes', [
|
||||||
}
|
'category_id' => $row2['category_id'],
|
||||||
|
'lang_id' => $row['id'],
|
||||||
|
'pattern' => '^' . $language_link . $seoSlug . '/([0-9]+)$',
|
||||||
|
'destination' => 'index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'] . '&bs=$1',
|
||||||
|
] );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$results = $mdb -> select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
|
//
|
||||||
|
// PRODUKTY — sitemap + pp_routes (bez zmian)
|
||||||
|
//
|
||||||
|
$results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
|
||||||
if ( is_array( $results ) )
|
if ( is_array( $results ) )
|
||||||
{
|
{
|
||||||
foreach ( $results as $row )
|
foreach ( $results as $row )
|
||||||
{
|
{
|
||||||
!$row['start'] ? $language_link = $row['id'] . '/' : $language_link = '';
|
!$row['start'] ? $language_link = $row['id'] . '/' : $language_link = '';
|
||||||
|
|
||||||
$results2 = $mdb -> select( 'pp_shop_products_langs', [ '[><]pp_shop_products' => [ 'product_id' => 'id' ] ], [ 'seo_link', 'name', 'product_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'name' => 'ASC' ] ] );
|
$results2 = $mdb->select( 'pp_shop_products_langs', [ '[><]pp_shop_products' => [ 'product_id' => 'id' ] ], [ 'seo_link', 'name', 'product_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'name' => 'ASC' ] ] );
|
||||||
if ( is_array( $results2 ) )
|
if ( is_array( $results2 ) )
|
||||||
{
|
{
|
||||||
foreach ( $results2 as $row2 )
|
foreach ( $results2 as $row2 )
|
||||||
{
|
{
|
||||||
$mdb -> delete( 'pp_routes', [ 'AND' => [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'] ] ] );
|
$mdb->delete( 'pp_routes', [ 'AND' => [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'] ] ] );
|
||||||
|
|
||||||
if ( $row2['name'] )
|
if ( $row2['name'] )
|
||||||
{
|
{
|
||||||
@@ -519,27 +690,13 @@ class Helpers
|
|||||||
|
|
||||||
if ( $row2['seo_link'] )
|
if ( $row2['seo_link'] )
|
||||||
{
|
{
|
||||||
$pattern = '^' . $language_link . self::seo( $row2['seo_link'] ) . '$';
|
$mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . self::seo( $row2['seo_link'] ) . '$', 'destination' => 'index.php?product=' . $row2['product_id'] ] );
|
||||||
$destination = 'index.php?product=' . $row2['product_id'];
|
$mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . self::seo( $row2['seo_link'] ) . '/([0-9-]+)$', 'destination' => 'index.php?product=' . $row2['product_id'] . '&permutation_hash=$1' ] );
|
||||||
|
|
||||||
$mdb -> insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => $pattern, 'destination' => $destination ] );
|
|
||||||
|
|
||||||
$pattern = '^' . $language_link . self::seo( $row2['seo_link'] ) . '/([0-9-]+)$';
|
|
||||||
$destination = 'index.php?product=' . $row2['product_id'] . '&permutation_hash=$1';
|
|
||||||
|
|
||||||
$mdb -> insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => $pattern, 'destination' => $destination ] );
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
$pattern = '^' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '$';
|
$mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '$', 'destination' => 'index.php?product=' . $row2['product_id'] ] );
|
||||||
$destination = 'index.php?product=' . $row2['product_id'];
|
$mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '/([0-9-]+)$', 'destination' => 'index.php?product=' . $row2['product_id'] . '&permutation_hash=$1' ] );
|
||||||
|
|
||||||
$mdb -> insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => $pattern, 'destination' => $destination ] );
|
|
||||||
|
|
||||||
$pattern = '^' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '/([0-9-]+)$';
|
|
||||||
$destination = 'index.php?product=' . $row2['product_id'] . '&permutation_hash=$1';
|
|
||||||
|
|
||||||
$mdb -> insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => $pattern, 'destination' => $destination ] );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,13 +704,16 @@ class Helpers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$results = $mdb -> select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
|
//
|
||||||
|
// STRONY + ARTYKULY — sitemap + pp_routes (bez zmian)
|
||||||
|
//
|
||||||
|
$results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
|
||||||
if ( is_array( $results ) )
|
if ( is_array( $results ) )
|
||||||
foreach ( $results as $row )
|
foreach ( $results as $row )
|
||||||
{
|
{
|
||||||
( !$row['start'] and count( $results ) > 1 ) ? $language_link = $row['id'] . '/' : $language_link = '';
|
( !$row['start'] and count( $results ) > 1 ) ? $language_link = $row['id'] . '/' : $language_link = '';
|
||||||
|
|
||||||
$results2 = $mdb -> select( 'pp_pages_langs', [ '[><]pp_pages' => [ 'page_id' => 'id' ] ], [ 'seo_link', 'title', 'page_id', 'noindex', 'start', 'link', 'page_type' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'start' => 'DESC', 'o' => 'ASC' ] ] );
|
$results2 = $mdb->select( 'pp_pages_langs', [ '[><]pp_pages' => [ 'page_id' => 'id' ] ], [ 'seo_link', 'title', 'page_id', 'noindex', 'start', 'link', 'page_type' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'start' => 'DESC', 'o' => 'ASC' ] ] );
|
||||||
if ( is_array( $results2 ) )
|
if ( is_array( $results2 ) )
|
||||||
foreach ( $results2 as $row2 )
|
foreach ( $results2 as $row2 )
|
||||||
{
|
{
|
||||||
@@ -590,44 +750,39 @@ class Helpers
|
|||||||
{
|
{
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '$';
|
$htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '$';
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
|
$htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
|
||||||
|
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '-1$';
|
$htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '-1$';
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
|
$htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
|
||||||
}
|
}
|
||||||
|
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . ' [L]';
|
$htaccess_data .= PHP_EOL . 'RewriteRule ^$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . ' [L]';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( $row2['seo_link'] )
|
$seoSlug = $row2['seo_link'] ? self::seo( $row2['seo_link'] ) : 's-' . $row2['page_id'] . '-' . self::seo( $row2['title'] );
|
||||||
{
|
$langPrefix = $row2['start'] ? '' : $language_link;
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . '&%{QUERY_STRING} [L]';
|
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '/([0-9]+)$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . '&bs=$1&%{QUERY_STRING} [L]';
|
$mdb->delete( 'pp_routes', [ 'AND' => [ 'page_id' => $row2['page_id'], 'lang_id' => $row['id'] ] ] );
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '/1$ ' . $language_link . self::seo( $row2['seo_link'] ) . ' [R=301,L]';
|
|
||||||
}
|
$mdb->insert( 'pp_routes', [
|
||||||
else
|
'page_id' => $row2['page_id'],
|
||||||
{
|
'lang_id' => $row['id'],
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 's-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . '&%{QUERY_STRING} [L]';
|
'pattern' => '^' . $langPrefix . $seoSlug . '$',
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 's-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '/([0-9]+)$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . '&bs=$1&%{QUERY_STRING} [L]';
|
'destination' => 'index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'],
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 's-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '/1$ ' . $language_link . 's-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . ' [R=301,L]';
|
] );
|
||||||
}
|
$mdb->insert( 'pp_routes', [
|
||||||
|
'page_id' => $row2['page_id'],
|
||||||
|
'lang_id' => $row['id'],
|
||||||
|
'pattern' => '^' . $langPrefix . $seoSlug . '/([0-9]+)$',
|
||||||
|
'destination' => 'index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . '&bs=$1',
|
||||||
|
] );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$results2 = $mdb -> select( 'pp_articles_langs', [ '[><]pp_articles' => [ 'article_id' => 'id' ] ], [ 'seo_link', 'title', 'article_id', 'noindex', 'copy_from' ], [ 'AND' => [ 'status' => 1, 'lang_id' => $row['id'], 'block_direct_access' => 0 ] ] );
|
$results2 = $mdb->select( 'pp_articles_langs', [ '[><]pp_articles' => [ 'article_id' => 'id' ] ], [ 'seo_link', 'title', 'article_id', 'noindex', 'copy_from' ], [ 'AND' => [ 'status' => 1, 'lang_id' => $row['id'], 'block_direct_access' => 0 ] ] );
|
||||||
if ( is_array( $results2 ) )
|
if ( is_array( $results2 ) )
|
||||||
foreach ( $results2 as $row2 )
|
foreach ( $results2 as $row2 )
|
||||||
{
|
{
|
||||||
if ( $row2['copy_from'] != null )
|
if ( $row2['copy_from'] != null )
|
||||||
{
|
{
|
||||||
$results_tmp = $mdb -> get( 'pp_articles_langs', [
|
$results_tmp = $mdb->get( 'pp_articles_langs', [ 'seo_link', 'title' ], [ 'AND' => [ 'article_id' => $row2['article_id'], 'lang_id' => $row2['copy_from'] ] ] );
|
||||||
'seo_link',
|
|
||||||
'title'
|
|
||||||
], [
|
|
||||||
'AND' => [
|
|
||||||
'article_id' => $row2['article_id'],
|
|
||||||
'lang_id' => $row2['copy_from']
|
|
||||||
]
|
|
||||||
] );
|
|
||||||
$row2['seo_link'] = $results_tmp['seo_link'];
|
$row2['seo_link'] = $results_tmp['seo_link'];
|
||||||
$row2['title'] = $results_tmp['title'];
|
$row2['title'] = $results_tmp['title'];
|
||||||
}
|
}
|
||||||
@@ -650,81 +805,52 @@ class Helpers
|
|||||||
$robots .= 'Disallow: /' . $row2['seo_link'] . PHP_EOL;
|
$robots .= 'Disallow: /' . $row2['seo_link'] . PHP_EOL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$mdb->delete( 'pp_routes', [ 'AND' => [ 'article_id' => $row2['article_id'], 'lang_id' => $row['id'] ] ] );
|
||||||
|
|
||||||
if ( $row2['seo_link'] )
|
if ( $row2['seo_link'] )
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . self::seo( $row2['seo_link'] ) . '$ index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'] . '&%{QUERY_STRING} [L]';
|
{
|
||||||
|
$mdb->insert( 'pp_routes', [
|
||||||
|
'article_id' => $row2['article_id'],
|
||||||
|
'lang_id' => $row['id'],
|
||||||
|
'pattern' => '^' . $language_link . self::seo( $row2['seo_link'] ) . '$',
|
||||||
|
'destination' => 'index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'],
|
||||||
|
] );
|
||||||
|
}
|
||||||
else if ( $row2['title'] != null )
|
else if ( $row2['title'] != null )
|
||||||
$htaccess_data .= PHP_EOL . 'RewriteRule ^' . $language_link . 'a-' . $row2['article_id'] . '-' . self::seo( $row2['title'] ) . '$ index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'] . '&%{QUERY_STRING} [L]';
|
{
|
||||||
|
$mdb->insert( 'pp_routes', [
|
||||||
|
'article_id' => $row2['article_id'],
|
||||||
|
'lang_id' => $row['id'],
|
||||||
|
'pattern' => '^' . $language_link . 'a-' . $row2['article_id'] . '-' . self::seo( $row2['title'] ) . '$',
|
||||||
|
'destination' => 'index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'],
|
||||||
|
] );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$results = $mdb -> get( 'pp_settings', 'value', [ 'param' => 'htaccess' ] );
|
// Invalidacja cache tras
|
||||||
|
try {
|
||||||
|
( new \Shared\Cache\CacheHandler() )->delete( 'pp_routes:all' );
|
||||||
|
} catch ( \Exception $e ) {
|
||||||
|
// Redis niedostepny — ignorujemy
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = $mdb->get( 'pp_settings', 'value', [ 'param' => 'htaccess' ] );
|
||||||
if ( $results )
|
if ( $results )
|
||||||
$htaccess_data .= PHP_EOL . $results;
|
$htaccess_data .= PHP_EOL . $results;
|
||||||
|
|
||||||
$results = $mdb -> get( 'pp_settings', 'value', [ 'param' => 'robots' ] );
|
$results = $mdb->get( 'pp_settings', 'value', [ 'param' => 'robots' ] );
|
||||||
if ( $results )
|
if ( $results )
|
||||||
$robots .= PHP_EOL . $results;
|
$robots .= PHP_EOL . $results;
|
||||||
|
|
||||||
$site_map .= '</urlset>';
|
$site_map .= '</urlset>';
|
||||||
|
|
||||||
/* cache */
|
|
||||||
if ( $settings['htaccess_cache'] )
|
|
||||||
{
|
|
||||||
$htaccess_data = str_replace( '{HTACCESS_CACHE}',
|
|
||||||
'<IfModule mod_deflate.c>' . PHP_EOL
|
|
||||||
. 'AddOutputFilterByType DEFLATE text/html text/plain text/xml application/xml application/xhtml+xml text/css text/javascript application/javascript application/x-javascript' . PHP_EOL
|
|
||||||
. '</IfModule>' . PHP_EOL
|
|
||||||
. '<IfModule mod_headers.c>' . PHP_EOL
|
|
||||||
. 'Header set Access-Control-Allow-Origin "*"' . PHP_EOL
|
|
||||||
. '</IfModule>' . PHP_EOL
|
|
||||||
. '<IfModule mod_expires.c>' . PHP_EOL
|
|
||||||
. 'ExpiresActive on' . PHP_EOL
|
|
||||||
. 'ExpiresDefault "access plus 1 month"' . PHP_EOL
|
|
||||||
. 'ExpiresByType text/css "access plus 1 year"' . PHP_EOL
|
|
||||||
. 'ExpiresByType application/json "access plus 0 seconds"' . PHP_EOL
|
|
||||||
. 'ExpiresByType application/xml "access plus 0 seconds"' . PHP_EOL
|
|
||||||
. 'ExpiresByType text/xml "access plus 0 seconds"' . PHP_EOL
|
|
||||||
. 'ExpiresByType image/x-icon "access plus 1 week"' . PHP_EOL
|
|
||||||
. 'ExpiresByType text/x-component "access plus 1 month"' . PHP_EOL
|
|
||||||
. 'ExpiresByType text/html "access plus 0 seconds"' . PHP_EOL
|
|
||||||
. 'ExpiresByType application/javascript "access plus 1 year"' . PHP_EOL
|
|
||||||
. 'ExpiresByType application/x-web-app-manifest+json "access plus 0 seconds"' . PHP_EOL
|
|
||||||
. 'ExpiresByType text/cache-manifest "access plus 0 seconds"' . PHP_EOL
|
|
||||||
. 'ExpiresByType audio/ogg "access plus 1 month"' . PHP_EOL
|
|
||||||
. 'ExpiresByType image/gif "access plus 1 month"' . PHP_EOL
|
|
||||||
. 'ExpiresByType image/jpeg "access plus 1 month"' . PHP_EOL
|
|
||||||
. 'ExpiresByType image/png "access plus 1 month"' . PHP_EOL
|
|
||||||
. 'ExpiresByType video/mp4 "access plus 1 month"' . PHP_EOL
|
|
||||||
. 'ExpiresByType video/ogg "access plus 1 month"' . PHP_EOL
|
|
||||||
. 'ExpiresByType video/webm "access plus 1 month"' . PHP_EOL
|
|
||||||
. 'ExpiresByType application/atom+xml "access plus 1 hour"' . PHP_EOL
|
|
||||||
. 'ExpiresByType application/rss+xml "access plus 1 hour"' . PHP_EOL
|
|
||||||
. 'ExpiresByType application/font-woff "access plus 1 month"' . PHP_EOL
|
|
||||||
. 'ExpiresByType application/vnd.ms-fontobject "access plus 1 month"' . PHP_EOL
|
|
||||||
. 'ExpiresByType application/x-font-ttf "access plus 1 month"' . PHP_EOL
|
|
||||||
. 'ExpiresByType font/opentype "access plus 1 month"' . PHP_EOL
|
|
||||||
. 'ExpiresByType image/svg+xml "access plus 1 month"' . PHP_EOL
|
|
||||||
. '</IfModule>'
|
|
||||||
, $htaccess_data );
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$htaccess_data = str_replace( '{HTACCESS_CACHE}',
|
|
||||||
'<IfModule mod_headers.c>' . PHP_EOL
|
|
||||||
. 'Header set Cache-Control "no-cache, no-store, must-revalidate"' . PHP_EOL
|
|
||||||
. 'Header set Pragma "no-cache"' . PHP_EOL
|
|
||||||
. 'Header set Expires 0' . PHP_EOL
|
|
||||||
. '</IfModule>',
|
|
||||||
$htaccess_data );
|
|
||||||
}
|
|
||||||
|
|
||||||
$htaccess_data .= PHP_EOL;
|
$htaccess_data .= PHP_EOL;
|
||||||
$htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-f' . PHP_EOL;
|
$htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-f' . PHP_EOL;
|
||||||
$htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-d' . PHP_EOL;
|
$htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-d' . PHP_EOL;
|
||||||
$htaccess_data .= 'RewriteRule ^ index.php [L]';
|
$htaccess_data .= 'RewriteRule ^ index.php [L]';
|
||||||
|
|
||||||
// Niektore hostingi blokuja zmiane wersji PHP przez .htaccess.
|
// Niektore hostingi blokuja zmiane wersji PHP przez .htaccess.
|
||||||
// Automatycznie komentujemy niedozwolone dyrektywy, aby generowany plik byl kompatybilny.
|
|
||||||
$htaccess_data = preg_replace( '/^(\\s*)(AddHandler|SetHandler|ForceType)\\b/im', '$1# $2', $htaccess_data );
|
$htaccess_data = preg_replace( '/^(\\s*)(AddHandler|SetHandler|ForceType)\\b/im', '$1# $2', $htaccess_data );
|
||||||
|
|
||||||
$fp = fopen( $dir . '.htaccess', 'w' );
|
$fp = fopen( $dir . '.htaccess', 'w' );
|
||||||
|
|||||||
26
autoload/Shared/Security/CsrfToken.php
Normal file
26
autoload/Shared/Security/CsrfToken.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,15 @@ class App
|
|||||||
$sa = \Shared\Helpers\Helpers::get( 's-action' );
|
$sa = \Shared\Helpers\Helpers::get( 's-action' );
|
||||||
if ( !$sa ) return;
|
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'] );
|
$domain = preg_replace( '/^www\./', '', $_SERVER['SERVER_NAME'] );
|
||||||
$cookie_name = 'admin_remember_' . str_replace( '.', '-', $domain );
|
$cookie_name = 'admin_remember_' . str_replace( '.', '-', $domain );
|
||||||
$users = new \Domain\User\UserRepository( $mdb );
|
$users = new \Domain\User\UserRepository( $mdb );
|
||||||
@@ -84,6 +93,7 @@ class App
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
\Shared\Security\CsrfToken::regenerate();
|
||||||
self::finalize_admin_login( $user, $domain, $cookie_name, (bool) \Shared\Helpers\Helpers::get( 'remember' ) );
|
self::finalize_admin_login( $user, $domain, $cookie_name, (bool) \Shared\Helpers\Helpers::get( 'remember' ) );
|
||||||
header( 'Location: /admin/articles/list/' );
|
header( 'Location: /admin/articles/list/' );
|
||||||
exit;
|
exit;
|
||||||
@@ -127,6 +137,7 @@ class App
|
|||||||
header( 'Location: /admin/' );
|
header( 'Location: /admin/' );
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
\Shared\Security\CsrfToken::regenerate();
|
||||||
self::finalize_admin_login( $user, $domain, $cookie_name, !empty( $pending['remember'] ) );
|
self::finalize_admin_login( $user, $domain, $cookie_name, !empty( $pending['remember'] ) );
|
||||||
header( 'Location: /admin/articles/list/' );
|
header( 'Location: /admin/articles/list/' );
|
||||||
exit;
|
exit;
|
||||||
@@ -372,7 +383,8 @@ class App
|
|||||||
'Integrations' => function() {
|
'Integrations' => function() {
|
||||||
global $mdb;
|
global $mdb;
|
||||||
return new \admin\Controllers\IntegrationsController(
|
return new \admin\Controllers\IntegrationsController(
|
||||||
new \Domain\Integrations\IntegrationsRepository( $mdb )
|
new \Domain\Integrations\IntegrationsRepository( $mdb ),
|
||||||
|
new \Domain\Integrations\ApiloRepository( $mdb )
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
'ShopStatuses' => function() {
|
'ShopStatuses' => function() {
|
||||||
@@ -423,7 +435,8 @@ class App
|
|||||||
new \Domain\Order\OrderRepository( $mdb ),
|
new \Domain\Order\OrderRepository( $mdb ),
|
||||||
$productRepo,
|
$productRepo,
|
||||||
new \Domain\Settings\SettingsRepository( $mdb ),
|
new \Domain\Settings\SettingsRepository( $mdb ),
|
||||||
new \Domain\Transport\TransportRepository( $mdb )
|
new \Domain\Transport\TransportRepository( $mdb ),
|
||||||
|
new \Domain\CronJob\CronJobRepository( $mdb )
|
||||||
),
|
),
|
||||||
$productRepo
|
$productRepo
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user