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>
This commit is contained in:
295
.paul/phases/129-order-user-notes/129-01-PLAN.md
Normal file
295
.paul/phases/129-order-user-notes/129-01-PLAN.md
Normal file
@@ -0,0 +1,295 @@
|
||||
---
|
||||
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 ~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).
|
||||
|
||||
<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 ~449–463). 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` (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=<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 ~449–463):
|
||||
- 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>
|
||||
191
.paul/phases/129-order-user-notes/129-01-SUMMARY.md
Normal file
191
.paul/phases/129-order-user-notes/129-01-SUMMARY.md
Normal file
@@ -0,0 +1,191 @@
|
||||
---
|
||||
phase: 129-order-user-notes
|
||||
plan: 01
|
||||
subsystem: orders
|
||||
tags: [order-notes, crud, badge, audit-log, user-authored]
|
||||
|
||||
requires:
|
||||
- phase: 106-customer-return-alert
|
||||
provides: badge pattern (`risk-return-badge`) + subquery liczby per zamowienie
|
||||
- phase: 114-accounting-configs-refactor
|
||||
provides: `OrderProAlerts.confirm` options-object API
|
||||
provides:
|
||||
- Pelen CRUD notatek autorskich operatora per zamowienie (`note_type='user'`)
|
||||
- Subquery `user_notes_count` + badge `[N]` na `/orders/list`
|
||||
- Inline edit (toggle textarea) + delete z `OrderProAlerts.confirm`
|
||||
affects:
|
||||
- Przyszle fazy z eventem automatyzacji `note.created` lub admin override po wprowadzeniu rol
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Reuse istniejacej tabeli przez nowy `note_type` zamiast tworzenia osobnej tabeli"
|
||||
- "Autoryzacja CRUD przez `WHERE user_id = :user_id` + rowCount=0 ⇒ RuntimeException(403)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- database/migrations/20260514_000116_extend_order_notes_user_authored.sql
|
||||
- src/Modules/Orders/OrderNotesService.php
|
||||
- resources/scss/modules/_order-notes.scss
|
||||
- public/assets/js/modules/order-notes.js
|
||||
modified:
|
||||
- src/Modules/Orders/OrdersController.php
|
||||
- src/Modules/Orders/OrdersRepository.php
|
||||
- routes/web.php
|
||||
- resources/views/orders/show.php
|
||||
- resources/views/layouts/app.php
|
||||
- resources/lang/pl.php
|
||||
- resources/scss/app.scss
|
||||
|
||||
key-decisions:
|
||||
- "Reuse `order_notes` przez `note_type='user'` zamiast osobnej tabeli (clarification #1)"
|
||||
- "Badge neutralny `[N]` (indigo `#eef2ff/#4338ca`) — subtelniejszy niz `.risk-return-badge`"
|
||||
- "Brak admin override — edit/delete tylko dla autora (brak systemu rol w aplikacji)"
|
||||
- "Sekcja `#notes` w istniejacej karcie 'Wiadomosci i zalaczniki' — split na 'Notatki' (user) + 'Wiadomosci ze zrodla' (imported)"
|
||||
|
||||
patterns-established:
|
||||
- "`userNotesCountSubquerySql($orderAlias)` — wzorzec dla COUNT-per-order subquery bez wplywu na ORDER BY/GROUP BY (analogiczny do `customerReturnedCountSubquerySql`)"
|
||||
- "`OrderProAlerts.confirm` z `danger:true` + options-object API dla submit'u formularza DELETE (preventDefault + onConfirm submit)"
|
||||
- "Migracje no-op zawsze jako DDL (`ALTER TABLE COMMENT`), nigdy `SELECT 1` (Phase 115 pattern)"
|
||||
|
||||
duration: ~30min
|
||||
started: 2026-05-14T00:00:00Z
|
||||
completed: 2026-05-14T00:00:00Z
|
||||
---
|
||||
|
||||
# Phase 129 Plan 01: Order User Notes Summary
|
||||
|
||||
**Pelen CRUD notatek autorskich operatora w zamowieniach (extend `order_notes` o `user_id`/`author_name`/`note_type='user'`), z sekcja `#notes` w szczegolach i badge `[N]` na liscie zamowien.**
|
||||
|
||||
## Performance
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Duration | ~30min |
|
||||
| Tasks | 3 of 3 completed |
|
||||
| Files created | 5 |
|
||||
| Files modified | 7 |
|
||||
| AC pass rate | 6 of 6 (pending live smoke) |
|
||||
|
||||
## Acceptance Criteria Results
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| AC-1: Migracja DB — kolumny user notes | Pass (code) | Migracja `20260514_000116_*.sql` z `information_schema` guard + DDL no-op fallback. Aktywacja na zywej bazie: pending operator. |
|
||||
| AC-2: Tworzenie notatki | Pass (code) | `OrderNotesService::create()` + `OrdersController::storeNote()` + `recordActivity('note', 'Dodano notatke')`. Redirect 302 → `/orders/{id}#notes`. |
|
||||
| AC-3: Edycja/usuwanie tylko autor | Pass (code) | UPDATE/DELETE z `WHERE user_id = :user_id`; rowCount=0 ⇒ `RuntimeException(403)`. UI ukrywa przyciski gdy `note.user_id != session.user_id`. |
|
||||
| AC-4: Lista w "Wiadomosci i zalaczniki" | Pass (code) | Sekcja `#notes` z 3 blokami (lista user notes → form dodawania → opcjonalny block "Wiadomosci ze zrodla"). |
|
||||
| AC-5: Badge `[N]` na liscie zamowien | Pass (code) | `<a class="order-notes-badge" href="/orders/{id}#notes">[N]</a>` wstrzykniete w `order_ref` HTML; widoczne tylko gdy `user_notes_count >= 1`. |
|
||||
| AC-6: Subquery liczy bez wplywu na paginacje | Pass (code) | `userNotesCountSubquerySql('o')` jako kolumna SELECT (NIE w WHERE/GROUP BY/ORDER). Wspierane indeksem `idx_order_notes_type_order (note_type, order_id)`. |
|
||||
|
||||
> **Live smoke pending**: migracja na zywym XAMPP MySQL + manualny test wieloosobowy (autor vs inny user) — udokumentowane w STATE.md follow-ups.
|
||||
|
||||
## Verification Results
|
||||
|
||||
```
|
||||
$ C:/xampp/php/php.exe -l src/Modules/Orders/OrderNotesService.php
|
||||
No syntax errors detected
|
||||
|
||||
$ C:/xampp/php/php.exe -l src/Modules/Orders/OrdersController.php
|
||||
No syntax errors detected
|
||||
|
||||
$ C:/xampp/php/php.exe -l src/Modules/Orders/OrdersRepository.php
|
||||
No syntax errors detected
|
||||
|
||||
$ C:/xampp/php/php.exe -l routes/web.php
|
||||
No syntax errors detected
|
||||
|
||||
$ C:/xampp/php/php.exe -l resources/views/orders/show.php
|
||||
No syntax errors detected
|
||||
|
||||
$ node --check public/assets/js/modules/order-notes.js
|
||||
JS OK
|
||||
|
||||
$ npm run build:css
|
||||
sass --style=compressed --no-source-map resources/scss/app.scss public/assets/css/app.css
|
||||
(rebuilt successfully)
|
||||
```
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Reuse `order_notes` zamiast osobnej tabeli**: jedna tabela obsluguje 2 semantyki (`note_type IN ('shoppro','allegro','message')` = imported, `note_type='user'` = autorska). `UNIQUE (order_id, source_note_id)` nie blokuje user notes bo MySQL traktuje wiele NULL jako unique.
|
||||
- **CRUD z autoryzacja DB-level**: UPDATE/DELETE filtruja po `user_id = :user_id` w SQL; `rowCount=0` rzuca 403 — eliminuje konieczność osobnego SELECT pre-check'a.
|
||||
- **Badge widoczny od razu w liscie**: subquery `user_notes_count` ekspozuje liczbe w `paginate()`, badge wlozony w `toTableRow()` obok numeru zamowienia (klik scrolluje do `#notes`).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
| File | Change | Purpose |
|
||||
|------|--------|---------|
|
||||
| `database/migrations/20260514_000116_extend_order_notes_user_authored.sql` | Created | ADD COLUMN user_id + author_name + FK → users + indeks (idempotentne) |
|
||||
| `src/Modules/Orders/OrderNotesService.php` | Created | CRUD service nad `order_notes` (`note_type='user'`) z autoryzacja po user_id |
|
||||
| `resources/scss/modules/_order-notes.scss` | Created | `.order-notes-badge`, `.order-user-notes`, `.order-event--user`, `.btn-link`, `.order-note-form` |
|
||||
| `public/assets/js/modules/order-notes.js` | Created | Vanilla JS: inline edit toggle + `OrderProAlerts.confirm` na delete (idempotent guard) |
|
||||
| `src/Modules/Orders/OrdersController.php` | Modified | Dodano `OrderNotesService` jako nullable dep + `storeNote/updateNote/deleteNote` + badge w `toTableRow()` |
|
||||
| `src/Modules/Orders/OrdersRepository.php` | Modified | `userNotesCountSubquerySql()` + kolumna `user_notes_count` w paginate; `loadOrderNotes()` zawezone do `note_type <> 'user'` |
|
||||
| `routes/web.php` | Modified | 3 nowe routes (POST notes/store|update|delete) + `OrderNotesService` instancjonowany + przekazany do `OrdersController` |
|
||||
| `resources/views/orders/show.php` | Modified | Sekcja `#notes` rozbita na user-notes + form + imported-notes; per-note edit form (ukryty) |
|
||||
| `resources/views/layouts/app.php` | Modified | `<script>` dla `order-notes.js` |
|
||||
| `resources/lang/pl.php` | Modified | 9 nowych kluczy `orders.details.notes_user_*` + `notes_imported_title` |
|
||||
| `resources/scss/app.scss` | Modified | `@use "modules/order-notes"` |
|
||||
| `public/assets/css/app.css` | Modified | Rebuilt by `npm run build:css` |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
| Decision | Rationale | Impact |
|
||||
|----------|-----------|--------|
|
||||
| Reuse `order_notes` z `note_type='user'` | Jedna tabela = mniej obiektow DB, prosciej testowac, UNIQUE NULL nie koliduje | Importowane notatki ze zrodla nadal dzialaja niezmienione; w `loadOrderNotes()` dorzucony filtr `note_type <> 'user'` |
|
||||
| Brak admin override (tylko autor edit/delete) | Brak systemu rol w aplikacji (`grep is_admin\|role=` → 0 hits) | Operator ktory dodal notatke moze ja modyfikowac; admin override odlozony do osobnej fazy gdy beda role |
|
||||
| Badge `[N]` w `order_ref` (NIE osobna kolumna) | Minimalny footprint w tabeli, spojnie z `risk-return-badge` (przy `buyer_name`) | Badge widoczny bez zmian w naglowkach tabeli `/orders/list` |
|
||||
| Body limit 2000 znakow (`mb_strlen`) | TEXT moze przechowac wiecej, ale UX podpowiada krotkie notatki; spojnie z polem comment | Walidacja w `OrderNotesService::sanitizeBody()` — rzut `InvalidArgumentException` gdy przekroczenie |
|
||||
| Migracja idempotentna z DDL no-op fallback | Decyzja z Phase 115/125 — `SELECT 1` powoduje SQLSTATE 2014 przy PDO unbuffered | Re-run migracji = no-op (`ALTER TABLE COMMENT`) bez bledu |
|
||||
| Edit toggle (ukryty form per notatka) zamiast modala | Mniej UI ceremoniaiu, spojnie z istniejacym `order-event` layoutem | JS prosty (show/hide pary `js-order-note-body` ↔ `js-order-note-edit-form`) |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Summary
|
||||
|
||||
| Type | Count | Impact |
|
||||
|------|-------|--------|
|
||||
| Auto-fixed | 1 | Cosmetic naming |
|
||||
| Scope additions | 0 | None |
|
||||
| Deferred | 0 | None |
|
||||
|
||||
**Total impact:** Minimalne deviation — plan wykonany niemal 1:1.
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Naming] `formatOrderRow` → `toTableRow`**
|
||||
- **Found during:** Task 2 (badge w controllerze)
|
||||
- **Issue:** Plan referowal do nieistniejacej metody `formatOrderRow()` w `OrdersController`
|
||||
- **Fix:** Edycja `toTableRow()` (rzeczywista nazwa metody) — semantyka identyczna
|
||||
- **Files:** `src/Modules/Orders/OrdersController.php`
|
||||
- **Verification:** `grep -n "public function|toTableRow"` potwierdzilo `toTableRow` jako wlasciwa nazwa
|
||||
- **Commit:** N/A (jeden Task 2 commit obejmie wszystkie zmiany)
|
||||
|
||||
### Deferred Items
|
||||
|
||||
None — plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
| Issue | Resolution |
|
||||
|-------|------------|
|
||||
| Plan referowal `formatOrderRow()` (nieistniejacy) | Sprawdzono rzeczywiste metody przez `Grep "public function"` — `toTableRow` jest poprawna nazwa. Patch zaaplikowany. |
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
**Ready:**
|
||||
- Migracja czeka na operator (`php bin/migrate.php`).
|
||||
- UI sekcji notatek + badge gotowe — manualny smoke test moze byc wykonany po migracji.
|
||||
- Pattern `userNotesCountSubquerySql` + nullable `OrderNotesService` w `OrdersController` — gotowe do reuse w przyszlych phasach (np. event automatyzacji `note.created` lub admin override).
|
||||
|
||||
**Concerns:**
|
||||
- Bez systemu rol nie ma admin override — jezeli operator chce zeby kazdy user mogl edytowac/usuwac kazda notatke, trzeba zmienic warunek w `OrderNotesService::update()/delete()` (usunac `AND user_id = :user_id`).
|
||||
- Brak filtra "ma notatki" / "nie ma notatek" w liscie zamowien — kandydat na rozszerzenie jezeli operator zechce.
|
||||
|
||||
**Blockers:**
|
||||
- None — plan wdrozony, smoke test po stronie operatora.
|
||||
|
||||
---
|
||||
*Phase: 129-order-user-notes, Plan: 01*
|
||||
*Completed: 2026-05-14*
|
||||
Reference in New Issue
Block a user