---
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
---
## 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).
## 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 ~656–659).
- 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).
- **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 ~449–463). Tam dorzucamy listę notatek użytkownika + formularz dodawania. Importowane notatki ze źródła zachowujemy jako osobny mniejszy blok.
## 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` (FK→users(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=, 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 `[2]` 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`.
```
Task 1: Migracja DB + extend `order_notes` o pola user-authoreddatabase/migrations/20260514_000116_extend_order_notes_user_authored.sql, .paul/codebase/db_schema.md
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.
php bin/migrate.php → migration logged; `DESCRIBE order_notes;` pokazuje nowe kolumny i FK; re-run migracji = no-op (idempotent guard).AC-1 satisfied: kolumny dodane, FK aktywny, indeks utworzony, schema doc zaktualizowany.Task 2: OrderNotesService + repository extension + routes + Controller actionssrc/Modules/Orders/OrderNotesService.php, src/Modules/Orders/OrdersRepository.php, src/Modules/Orders/OrdersController.php, routes/web.php
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 ? ' [' . $userNotesCount . ']' : '';` — 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'`.
`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.
AC-2, AC-3, AC-6 satisfied: CRUD działa, autoryzacja po `user_id` egzekwowana, subquery count w listingu bez wpływu na paginację.Task 3: UI — sekcja notatek w show.php, badge na list.php, JS edit modal, SCSS, i18nresources/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
1) `resources/views/orders/show.php` (sekcja "Wiadomosci i zalaczniki", linia ~449–463):
- Zmień blok renderowania na 2 sub-listy:
a) `
` — header "Notatki", iteracja po `$userNotesList` (passed z controllera). Każda notatka: `
` z `
data | autor
`, `
body
`, oraz `
` z przyciskami `Edytuj` / `Usuń` widocznymi gdy `(int)($note['user_id'] ?? 0) === $currentUserId`. Przycisk "Usuń" jako `