Files
orderPRO/.paul/phases/129-order-user-notes/129-01-PLAN.md
Jacek Pyziak 48351b5f36 feat(129): order user notes module
CRUD notatek autorskich operatora per zamowienie z badge [N] na liscie
zamowien. Reuse istniejacej tabeli `order_notes` przez nowy
`note_type='user'` z `user_id` (FK->users SET NULL) i `author_name`
(snapshot). Sekcja `#notes` w "Wiadomosci i zalaczniki" w
`/orders/{id}` z inline edit form + delete przez
`OrderProAlerts.confirm`. Autoryzacja DB-level
(`WHERE user_id = :user_id`, rowCount=0 ⇒ 403) — bez admin override
(brak systemu rol w aplikacji).

- Migracja `20260514_000116_*.sql` (ADD COLUMN user_id + author_name +
  FK + indeks `idx_order_notes_type_order`); idempotentne z DDL
  no-op fallback.
- `OrderNotesService` (CRUD + walidacja body ≤ 2000 znakow); subquery
  `user_notes_count` w paginate; badge HTML w `toTableRow()`.
- 3 routy POST /orders/{id}/notes(/update|/delete).
- SCSS module `_order-notes.scss` + vanilla JS `order-notes.js`
  (inline edit toggle + delete confirm; idempotent guard).
- 9 kluczy i18n PL; PROJECT.md + ROADMAP.md + tech_changelog.md +
  db_schema.md zaktualizowane.

Follow-up: `php bin/migrate.php` + manualny smoke test (autor vs inny
user + badge na /orders/list).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:20:05 +02:00

296 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 129-order-user-notes
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- database/migrations/20260514_000116_extend_order_notes_user_authored.sql
- src/Modules/Orders/OrdersRepository.php
- src/Modules/Orders/OrdersController.php
- src/Modules/Orders/OrderNotesService.php
- routes/web.php
- resources/views/orders/show.php
- resources/views/orders/list.php
- resources/lang/pl.php
- resources/scss/modules/_order-notes.scss
- resources/scss/app.scss
- public/assets/js/modules/order-notes.js
- resources/views/layouts/app.php
autonomous: true
delegation: auto
---
<objective>
## Goal
Wprowadzic moduł notatek użytkownika w zamówieniach: pełen CRUD (add/edit/delete tylko dla autora) w sekcji "Wiadomosci i zalaczniki" w szczegółach zamówienia (`/orders/{id}`), oraz licznik notatek `[N]` jako mały badge przy numerze zamówienia na liście (`/orders/list`).
## Purpose
Operator potrzebuje miejsca na własne adnotacje per zamówienie (uzgodnienia z klientem, ustalenia wewnętrzne, flagi do dalszej obsługi), niezależne od zaimportowanych notatek ze źródła. Badge na liście pozwala szybko zobaczyć które zamówienia mają adnotacje bez wchodzenia w szczegóły — analogicznie do licznika zwrotów klienta (Phase 106).
## Output
- Migracja rozszerzająca `order_notes` o `user_id` (FK→users SET NULL) + `author_name` (snapshot) + `body` (czytelny alias do TEXT) — z reuse istniejącej kolumny `comment` jako body i nowymi kolumnami; nowy `note_type='user'`.
- `OrderNotesService` z metodami `create/update/delete/listUserNotes/countUserNotesForOrders`.
- 3 routes: `POST /orders/{id}/notes`, `POST /orders/{id}/notes/{noteId}/update`, `POST /orders/{id}/notes/{noteId}/delete`.
- W sekcji "Wiadomosci i zalaczniki" w `show.php`: lista notatek użytkownika (data + autor + tresc + akcje edit/delete dla autora) + formularz dodawania; importowane notatki zachowane jako osobny blok wyżej (filtr po `note_type`).
- Badge `[N]` w komórce `order_ref` listy zamówień (neutralna kolorystyka, klasa `order-notes-badge`, link do `#notes` w szczegółach).
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
## Source Files (key spots)
@src/Modules/Orders/OrdersRepository.php
@src/Modules/Orders/OrdersController.php
@routes/web.php
@resources/views/orders/show.php
@resources/views/orders/list.php
@resources/lang/pl.php
## Reference Patterns
- Phase 106 Customer Return Badge — `customerReturnedCountSubquerySql()` w `OrdersRepository`, render `risk-return-badge` w `OrdersController::formatOrderRow()` (linia ~656659).
- Phase 124 SMS Templates Service — `SmsTemplatesService` jako wzorzec serwisu CRUD nad pojedynczą tabelą.
- Phase 113-115 toggle pattern — `invoice-requested-toggle.js` jako wzorzec wanilijowego JS POST-em z CSRF.
## Existing `order_notes` schema (draft 20260302_orders_schema_v1.sql)
Tabela już istnieje: `id`, `order_id`, `source_note_id`, `note_type`, `created_at_external`, `comment`, `payload_json`, `created_at`, `updated_at`, UNIQUE `(order_id, source_note_id)`. Obecnie używana tylko do notatek importowanych ze źródła (shopPRO/Allegro mappers; loadOrderNotes w OrdersRepository.php:596).
<clarifications>
- **Schemat DB** — Gdzie przechowywać notatki użytkownika?
→ Odpowiedź: Rozszerz `order_notes` o `user_id`+`author_name`+nowy `note_type='user'`.
- **Badge UI** — Jak ma wyglądać label z liczbą notatek na liście zamówień?
→ Odpowiedź: Mały badge `[N]` przy nr zamówienia (neutralna kolorystyka, klik scrolluje do sekcji notatek w szczegółach).
- **CRUD scope** — Co operator może robić z własnymi notatkami?
→ Odpowiedź: Pełny CRUD (add/edit/delete) — autor lub admin może edytować/usuwać. Brak systemu ról w aplikacji → implementacja: edit/delete dozwolone tylko gdy `note.user_id === session.user_id` (sam autor). Jeżeli operator chce uprawnienia globalne, odłożyć do osobnej fazy po wprowadzeniu ról.
- **Umiejscowienie** — Gdzie umieścić UI notatek w szczegółach zamówienia?
→ Odpowiedź: W sekcji "Wiadomosci i zalaczniki" (już istnieje w details panel, `resources/views/orders/show.php` linia ~449463). Tam dorzucamy listę notatek użytkownika + formularz dodawania. Importowane notatki ze źródła zachowujemy jako osobny mniejszy blok.
</clarifications>
</context>
<acceptance_criteria>
## AC-1: Migracja DB — kolumny user notes
```gherkin
Given baza zawiera tabelę `order_notes` ze starymi importowanymi rekordami (note_type IN ('shoppro','allegro','message'))
When uruchomię `php bin/migrate.php`
Then tabela `order_notes` ma nowe kolumny `user_id INT UNSIGNED NULL` (FKusers(id) ON DELETE SET NULL), `author_name VARCHAR(190) NULL`, oraz indeks `idx_order_notes_type_order (note_type, order_id)`. Istniejące rekordy mają `user_id=NULL`, `author_name=NULL`, `note_type` niezmieniony. Migracja jest idempotentna (re-run = no-op via `INFORMATION_SCHEMA` guard lub `IF NOT EXISTS`).
```
## AC-2: Tworzenie notatki użytkownika
```gherkin
Given zalogowany user (id=5, name="Jacek Pyziak") otwiera `/orders/1090`
When wpisuje treść w textarea formularza "Dodaj notatkę" i klika "Zapisz"
Then POST `/orders/1090/notes` z `_token` i `body` zapisuje wiersz `order_notes(order_id=1090, note_type='user', user_id=5, author_name='Jacek Pyziak', comment=<treść>, created_at=NOW())`, dodaje wpis `order_activity_log(event_type='note', summary='Dodano notatkę', actor_type='user', actor_name='Jacek Pyziak')`, flashuje sukces i przekierowuje do `/orders/1090#notes`.
```
## AC-3: Edycja i usuwanie tylko przez autora
```gherkin
Given notatka #42 ma user_id=5 i jest renderowana na `/orders/1090`
When zalogowany user id=5 klika "Edytuj" zmienia treść "Zapisz"
Then POST `/orders/1090/notes/42/update` aktualizuje `comment` i `updated_at`, lista re-renderuje się z nową treścią
When ten sam user id=5 klika "Usuń" potwierdza w `OrderProAlerts.confirm` z `danger:true`
Then POST `/orders/1090/notes/42/delete` usuwa rekord (DELETE WHERE id=42 AND user_id=5), flashuje sukces
When zalogowany user id=8 (inny niż autor) próbuje POST `/orders/1090/notes/42/update` lub `/delete`
Then odpowiedź HTTP 403 z komunikatem "Brak uprawnień tylko autor może edytować/usuwać notatkę" (flash danger), wiersz pozostaje nienaruszony
```
## AC-4: Lista notatek w sekcji "Wiadomosci i zalaczniki"
```gherkin
Given zamówienie 1090 ma 2 notatki użytkownika (autor=Jacek, daty 2026-05-14 10:00 i 2026-05-14 12:30) oraz 1 zaimportowaną z shopPRO (`note_type='shoppro'`)
When otwieram `/orders/1090` i scrolluję do "Wiadomosci i zalaczniki"
Then widzę:
1. Blok "Notatki" (id="notes"): 2 wpisy w kolejności desc po `created_at`, każdy z `data | autor` w nagłówku i treścią poniżej, oraz przyciskami "Edytuj"/"Usuń" tylko dla wpisów, których user_id == session.user_id
2. Inline formularz dodawania notatki (textarea + przycisk "Zapisz") z CSRF tokenem
3. Blok "Wiadomości ze źródła" (subtelny styl, mniejszy): 1 wpis shopPRO bez akcji edit/delete
```
## AC-5: Badge `[N]` na liście zamówień
```gherkin
Given zamówienie 1090 ma 2 user-notes, zamówienie 1091 ma 0
When otwieram `/orders/list`
Then przy nr zamówienia 1090 widzę mały badge `<span class="order-notes-badge" title="2 notatki">[2]</span>` jako link do `/orders/1090#notes` (neutralna kolorystyka — niebieskoszary tekst na jasnym tle, mniejszy niż badge zwrotów), badge przy 1091 jest ukryty (count=0 ⇒ pusty string).
```
## AC-6: Subquery licznika nie psuje paginacji/sortowania
```gherkin
Given lista `/orders/list` z 1000 zamówieniami filtrowana po statusie i sortowana
When wykonam paginację, filtrowanie i sortowanie
Then licznik `user_notes_count` jest wyliczany subquery (`SELECT COUNT(*) FROM order_notes WHERE order_id = o.id AND note_type = 'user'`) jako kolumna SELECT bez wpływu na WHERE/GROUP BY/ORDER. Czas wykonania zapytania pozostaje rozsądny dzięki indeksowi `idx_order_notes_type_order`.
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Migracja DB + extend `order_notes` o pola user-authored</name>
<files>database/migrations/20260514_000116_extend_order_notes_user_authored.sql, .paul/codebase/db_schema.md</files>
<action>
Utwórz migrację `20260514_000116_extend_order_notes_user_authored.sql`:
- `ALTER TABLE order_notes ADD COLUMN user_id INT UNSIGNED NULL AFTER note_type;`
- `ALTER TABLE order_notes ADD COLUMN author_name VARCHAR(190) NULL AFTER user_id;`
- `ALTER TABLE order_notes ADD CONSTRAINT order_notes_user_fk FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE;`
- `ALTER TABLE order_notes ADD INDEX idx_order_notes_type_order (note_type, order_id);`
- Każdy ADD owijaj w `INFORMATION_SCHEMA` guard (`SET @x = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE ...); SET @sql = IF(@x=0, 'ALTER TABLE...', 'SELECT 1'); PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;`) — wzorzec z istniejących migracji. UWAGA: ostatni guard musi być DDL no-op (`ALTER TABLE order_notes COMMENT='phase-129 idempotent'`) NIE `SELECT 1` (decyzja z Phase 115).
Następnie zaktualizuj `.paul/codebase/db_schema.md` (sekcja Orders → order_notes): dopisz tabelę z nowymi kolumnami i indeksem, opisz że `note_type='user'` oznacza notatki autorskie z `user_id`/`author_name`, a stare `note_type IN ('shoppro','allegro','message')` to importowane.
Avoid: zmiany w `comment`/`payload_json`/`source_note_id` (ochrona istniejących importów). UNIQUE `(order_id, source_note_id)` zostaje — user notes mają source_note_id=NULL, więc MySQL traktuje każdy NULL jako unique row.
</action>
<verify>php bin/migrate.php → migration logged; `DESCRIBE order_notes;` pokazuje nowe kolumny i FK; re-run migracji = no-op (idempotent guard).</verify>
<done>AC-1 satisfied: kolumny dodane, FK aktywny, indeks utworzony, schema doc zaktualizowany.</done>
</task>
<task type="auto">
<name>Task 2: OrderNotesService + repository extension + routes + Controller actions</name>
<files>src/Modules/Orders/OrderNotesService.php, src/Modules/Orders/OrdersRepository.php, src/Modules/Orders/OrdersController.php, routes/web.php</files>
<action>
1) Utwórz `src/Modules/Orders/OrderNotesService.php` (final class):
- `__construct(\PDO $pdo)`
- `listUserNotes(int $orderId): array``SELECT id, user_id, author_name, comment AS body, created_at, updated_at FROM order_notes WHERE order_id = :order_id AND note_type = 'user' ORDER BY created_at DESC, id DESC`
- `listImportedNotes(int $orderId): array` — stary `loadOrderNotes` logic, ale z filtrem `note_type != 'user'`
- `create(int $orderId, int $userId, string $authorName, string $body): int` — INSERT + zwraca lastInsertId; po INSERT wywołaj `OrderActivityLogService::log(orderId, 'note', 'Dodano notatkę', actorName=$authorName)` jeśli serwis istnieje (jeśli nie — INSERT do `order_activity_log` bezpośrednio przez PDO; pattern z Phase 56 OrderPaymentsService).
- `update(int $noteId, int $userId, string $body): bool` — UPDATE WHERE id=:id AND user_id=:user_id, zwraca `$stmt->rowCount() > 0`. Rzut `RuntimeException` z kodem 403 gdy rowCount=0 (nieautoryzowany lub nie istnieje).
- `delete(int $noteId, int $userId): bool` — DELETE WHERE id=:id AND user_id=:user_id; analogiczna obsługa autoryzacji.
- Walidacja `body`: trim, nie pusty (min 1 znak), max 2000 znaków (TEXT). Throw `InvalidArgumentException` gdy pusty.
2) `OrdersRepository.php`:
- Dodaj prywatną metodę `userNotesCountSubquerySql(string $orderAlias): string` zwracającą string `(SELECT COUNT(*) FROM order_notes WHERE order_id = ' . $orderAlias . '.id AND note_type = \'user\')` (wzorzec z `customerReturnedCountSubquerySql`).
- W `fetchOrdersForList()` (i innych metodach budujących SELECT dla listy) dodaj kolumnę `... AS user_notes_count` obok `customer_returned_count`.
- W `getOrderDetails()` doloż `user_notes_count` i `user_notes_list` (przez OrderNotesService — wstrzyknij go w konstruktorze, lub wczytaj inline analogicznie do `loadOrderNotes`). Zachowaj `loadOrderNotes` jako `loadImportedOrderNotes` (rename) lub dorzuć nową metodę `loadUserOrderNotes` filtrującą po `note_type='user'`.
3) `OrdersController.php`:
- Dodaj prywatne `$orderNotesService` w konstruktorze.
- Metoda `storeNote(Request $request): Response` — pobierz orderId z `$request->input('id')` (pattern Phase 108), userId z sesji (`$_SESSION['user']['id']`), authorName z sesji (`$_SESSION['user']['name']`), `body` z `$request->input('body')`. Walidacja CSRF. Wywołaj `OrderNotesService::create()`. Flash success/error, redirect `/orders/{id}#notes`.
- Metoda `updateNote(Request $request): Response` — params `id` (order) i `noteId`. CSRF + user authorization (przez return z service). Redirect `/orders/{id}#notes`.
- Metoda `deleteNote(Request $request): Response` — analogicznie.
- W `formatOrderRow()` (linia ~656): dodaj wyliczenie `$userNotesCount = max(0, (int) ($row['user_notes_count'] ?? 0));` i `$notesBadge = $userNotesCount >= 1 ? ' <a href="/orders/' . $orderId . '#notes" class="order-notes-badge" title="' . $userNotesCount . ' notatek">[' . $userNotesCount . ']</a>' : '';` — wklej w `order_ref` HTML obok `$returnedBadge`.
4) `routes/web.php` (po linii ~595, blok orders):
```php
$router->post('/orders/{id}/notes', [$ordersController, 'storeNote'], [$authMiddleware]);
$router->post('/orders/{id}/notes/{noteId}/update', [$ordersController, 'updateNote'], [$authMiddleware]);
$router->post('/orders/{id}/notes/{noteId}/delete', [$ordersController, 'deleteNote'], [$authMiddleware]);
```
Wstrzyknięcie `OrderNotesService` analogicznie do innych serwisów (sprawdź jak `SmsConversationService` lub `OrderPaymentsService` są instancjonowane — pattern factory w `Application.php`/`CronHandlerFactory.php` lub bezpośrednie `new` w routes).
Avoid: sklejania SQL z input; pomijania CSRF; mieszania `comment` (legacy text imported) z nowym body — używamy tej samej kolumny, ale w service zawsze filtrujemy po `note_type='user'`.
</action>
<verify>
`php -l` na wszystkich zmienionych plikach; `composer dump-autoload` jeśli trzeba; smoke ręczny po deploy: POST `/orders/{X}/notes` z curl (sesja + CSRF) → 302 + nowy wiersz w `order_notes`; UPDATE/DELETE jako inny user → 403 + flash danger.
</verify>
<done>AC-2, AC-3, AC-6 satisfied: CRUD działa, autoryzacja po `user_id` egzekwowana, subquery count w listingu bez wpływu na paginację.</done>
</task>
<task type="auto">
<name>Task 3: UI — sekcja notatek w show.php, badge na list.php, JS edit modal, SCSS, i18n</name>
<files>resources/views/orders/show.php, resources/views/orders/list.php, resources/lang/pl.php, resources/scss/modules/_order-notes.scss, resources/scss/app.scss, public/assets/js/modules/order-notes.js, resources/views/layouts/app.php</files>
<action>
1) `resources/views/orders/show.php` (sekcja "Wiadomosci i zalaczniki", linia ~449463):
- Zmień blok renderowania na 2 sub-listy:
a) `<div id="notes" class="order-user-notes">` — header "Notatki", iteracja po `$userNotesList` (passed z controllera). Każda notatka: `<div class="order-event order-event--user">` z `<div class="order-event__head">data | autor</div>`, `<div class="order-event__body">body</div>`, oraz `<div class="order-event__actions">` z przyciskami `Edytuj` / `Usuń` widocznymi gdy `(int)($note['user_id'] ?? 0) === $currentUserId`. Przycisk "Usuń" jako `<form method="post" action="/orders/{id}/notes/{noteId}/delete">` + ukryty submit + JS handler wywołujący `OrderProAlerts.confirm({title:'Usunąć notatkę?', message:'Tej operacji nie można cofnąć.', danger:true, onConfirm: function(){ form.submit(); }})` (pattern options-object — decyzja Phase 114).
- Pod listą: formularz `<form method="post" action="/orders/{id}/notes">` z CSRF `_token`, `<textarea name="body" maxlength="2000" required>`, przycisk "Zapisz".
b) `<div class="order-imported-notes">` — header "Wiadomości ze źródła", iteracja po `$importedNotesList`, render jak dotychczas (bez akcji).
- W górze widoku pobierz `$userNotesList = is_array($userNotes ?? null) ? $userNotes : [];`, `$importedNotesList = is_array($importedNotes ?? null) ? $importedNotes : [];`, `$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);`.
2) `resources/views/orders/list.php` — `order_ref` HTML już jest generowany w controllerze przez `formatOrderRow()`, więc badge wleci automatycznie. Jeśli list.php gdziekolwiek inline renderuje order ref, sprawdź i nie dubluj.
3) `resources/lang/pl.php` — dodaj klucze w sekcji `orders.details`:
```php
'notes_user_title' => 'Notatki',
'notes_user_empty' => 'Brak notatek.',
'notes_user_add_placeholder' => 'Wpisz notatkę...',
'notes_user_save' => 'Zapisz',
'notes_user_edit' => 'Edytuj',
'notes_user_delete' => 'Usuń',
'notes_user_confirm_delete' => 'Usunąć notatkę?',
'notes_imported_title' => 'Wiadomości ze źródła',
'notes_forbidden' => 'Brak uprawnień — tylko autor może edytować/usuwać notatkę.',
'notes_created' => 'Notatka dodana.',
'notes_updated' => 'Notatka zaktualizowana.',
'notes_deleted' => 'Notatka usunięta.',
```
4) `resources/scss/modules/_order-notes.scss` — utwórz nowy moduł:
- `.order-user-notes`, `.order-imported-notes`, `.order-event--user`, `.order-event__actions` (flex, gap 8px), `.order-event__actions .btn-icon` (mały rozmiar).
- `.order-notes-badge` — neutralny styl (np. `background: #eef2ff; color: #4338ca; padding: 1px 6px; border-radius: 10px; font-size: 11px; font-weight: 600; text-decoration: none; margin-left: 4px;`). Hover: `background: #e0e7ff;`. Subtelniej niż `.risk-return-badge` (czerwony, alertowy).
- Formularz dodawania notatki: `.order-note-form textarea { width: 100%; min-height: 60px; }`.
Następnie dodaj `@use 'modules/order-notes';` w `resources/scss/app.scss`. Build SCSS lub powiedz operatorowi by uruchomił `npm run build:css` / `php tools/build-scss.php` (sprawdź jaki jest build pipeline w projekcie).
5) `public/assets/js/modules/order-notes.js` — wanilijowy JS:
- Inline edit: klik "Edytuj" zamienia `order-event__body` na textarea + przyciski "Zapisz"/"Anuluj"; "Zapisz" POST `fetch('/orders/'+orderId+'/notes/'+noteId+'/update', {method:'POST', body: new FormData(form)})` → jeśli OK, reload listy AJAX-em lub `location.reload()` (najprostsze, pattern z inline-status-change.js).
- Klik "Usuń" — `OrderProAlerts.confirm({title:'Usunąć notatkę?', message:'Tej operacji nie można cofnąć.', danger:true, confirmLabel:'Usuń', onConfirm: function(){ form.submit(); }})`.
- Idempotent guard: `if (window.__orderNotesInit) return; window.__orderNotesInit = true;` (pattern Phase 114 confirm-delete.js).
6) `resources/views/layouts/app.php` — załącz nowy moduł JS (pattern z `invoice-requested-toggle.js`):
```php
<script src="/assets/js/modules/order-notes.js?ver=<?= filemtime(...) ?: 0 ?>"></script>
```
Avoid: natywnego `confirm()` (zakaz CLAUDE.md); inline styles (zakaz CLAUDE.md — wszystko do SCSS); duplikowania renderowania importowanych notatek; ujawniania `user_id` w UI jeśli `users.id` jest wrażliwe (nie jest — to wewnętrzny ID).
</action>
<verify>
Otwórz `/orders/{X}` → widać sekcję "Notatki" + form dodawania; dodaj notatkę → pojawia się w liście z datą i autorem. Spróbuj edit cudzej notatki (z innym session.user_id) → przyciski edit/delete niewidoczne, próba POST → 403. Otwórz `/orders/list` → badge `[N]` widoczny przy zamówieniach z notatkami. Sprawdź czy `risk-return-badge` (Phase 106) nadal działa obok.
</verify>
<done>AC-4, AC-5 satisfied: UI sekcji notatek działa w show.php, badge w list.php widoczny, akcje edit/delete poprawnie ograniczone do autora.</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `order_notes.comment`, `order_notes.source_note_id`, `order_notes.payload_json` (kontrakt importu z shopPRO/Allegro — Phase 79 i wcześniejsze).
- `OrdersRepository::replaceNotes()`/`loadOrderNotes()` semantyka dla importu — jeśli rename, zachowaj BC alias lub zaktualizuj wszystkie wywołania (delta-only import, Phase 112).
- `.risk-return-badge` SCSS i logika (Phase 106) — badge notatek to osobna klasa, nie modyfikujemy zwrotów.
- `OrderProAlerts.confirm` API — używamy options-object (Phase 114 decyzja).
## SCOPE LIMITS
- Brak mentions/@-tagowania userów.
- Brak załączników do notatek (tylko tekst).
- Brak edycji historii edycji notatki (audit log w `order_activity_log` ma tylko `Dodano/Edytowano/Usunięto notatkę` — bez before/after JSON).
- Brak globalnych uprawnień admin override — tylko autor edytuje/usuwa (system ról nie istnieje; odłożone do osobnej fazy).
- Brak filtrów listy zamówień po "ma/nie ma notatki" — można dodać w przyszłości.
- Brak emitowania eventu automatyzacji `note.created` — można dodać jako osobny plan jeśli operator chce.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php bin/migrate.php` przechodzi bez błędu, re-run = no-op
- [ ] `php -l` na każdym zmienionym pliku PHP zwraca "No syntax errors"
- [ ] POST `/orders/{id}/notes` jako user A tworzy notatkę
- [ ] POST `/orders/{id}/notes/{noteId}/update` jako user A działa, jako user B zwraca 403
- [ ] POST `/orders/{id}/notes/{noteId}/delete` jako user A usuwa, jako user B 403
- [ ] `/orders/list` pokazuje badge `[N]` przy zamówieniach z notatkami, ukryty gdy N=0
- [ ] `/orders/{id}#notes` scrolluje do sekcji notatek
- [ ] Importowane notatki ze źródła (shopPRO/allegro) renderują się jako osobny blok bez przycisków edit/delete
- [ ] Badge zwrotów (Phase 106) działa obok badge'a notatek (oba widoczne dla zamówień z obojgiem)
- [ ] SCSS skompilowany do `public/assets/css/app.css`
- [ ] CSRF wymagany w każdym POST — brak tokenu = 419/403
- [ ] Brak natywnych `confirm()` w nowym JS — wszystko przez `OrderProAlerts.confirm`
</verification>
<success_criteria>
- AC-1..AC-6 spełnione
- Brak regresji w imporcie notatek shopPRO/Allegro (delta-only import z Phase 112 nadal działa, `replaceNotes` filtruje tylko `note_type != 'user'` jeśli musi)
- Czas wykonania `/orders/list` z subquery `user_notes_count` nie pogarsza się drastycznie (indeks `idx_order_notes_type_order` aktywny)
- `.paul/codebase/db_schema.md` i `.paul/codebase/architecture.md` zaktualizowane o nowy serwis i kolumny
- `.paul/codebase/tech_changelog.md` ma wpis dla Phase 129
</success_criteria>
<output>
After completion, create `.paul/phases/129-order-user-notes/129-01-SUMMARY.md` z:
- Krótki opis co zostało zbudowane
- Decisions (np. brak admin override → tylko autor edytuje)
- Files modified (lista z task'ów)
- Migration applied (numer + opis)
- Manual smoke checklist dla operatora (POST create, UPDATE as A, UPDATE as B → 403, DELETE, badge na liście, edit modal UX)
- Deferred / follow-up (event automatyzacji `note.created`, filtr listy "ma notatki", admin override po wprowadzeniu ról)
</output>