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:
2026-05-14 15:20:05 +02:00
parent c78ac335ee
commit 48351b5f36
20 changed files with 1261 additions and 25 deletions

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Modules\Orders;
use InvalidArgumentException;
use PDO;
use RuntimeException;
/**
* Phase 129-01: CRUD notatek autorskich operatora (note_type='user').
* Importowane notatki ze zrodla (shoppro/allegro/message) maja wlasne zarzadzanie
* w OrderImportRepository::replaceNotes() — ten serwis ich nie dotyka.
*/
final class OrderNotesService
{
private const NOTE_TYPE_USER = 'user';
private const BODY_MAX_LENGTH = 2000;
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function listUserNotes(int $orderId): array
{
if ($orderId <= 0) {
return [];
}
$stmt = $this->pdo->prepare(
'SELECT id, order_id, user_id, author_name, comment AS body, created_at, updated_at
FROM order_notes
WHERE order_id = :order_id AND note_type = :note_type
ORDER BY created_at DESC, id DESC'
);
$stmt->execute(['order_id' => $orderId, 'note_type' => self::NOTE_TYPE_USER]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<int, array<string, mixed>>
*/
public function listImportedNotes(int $orderId): array
{
if ($orderId <= 0) {
return [];
}
$stmt = $this->pdo->prepare(
'SELECT * FROM order_notes
WHERE order_id = :order_id AND note_type <> :note_type
ORDER BY created_at_external DESC, id DESC'
);
$stmt->execute(['order_id' => $orderId, 'note_type' => self::NOTE_TYPE_USER]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
public function countUserNotes(int $orderId): int
{
if ($orderId <= 0) {
return 0;
}
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM order_notes WHERE order_id = :order_id AND note_type = :note_type'
);
$stmt->execute(['order_id' => $orderId, 'note_type' => self::NOTE_TYPE_USER]);
return (int) $stmt->fetchColumn();
}
public function findById(int $noteId): ?array
{
if ($noteId <= 0) {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT id, order_id, user_id, author_name, comment AS body, created_at, updated_at, note_type
FROM order_notes WHERE id = :id LIMIT 1'
);
$stmt->execute(['id' => $noteId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
public function create(int $orderId, int $userId, string $authorName, string $body): int
{
$body = $this->sanitizeBody($body);
if ($orderId <= 0 || $userId <= 0) {
throw new InvalidArgumentException('Nieprawidlowe parametry notatki.');
}
$stmt = $this->pdo->prepare(
'INSERT INTO order_notes
(order_id, source_note_id, note_type, user_id, author_name, comment, created_at, updated_at)
VALUES
(:order_id, NULL, :note_type, :user_id, :author_name, :comment, NOW(), NOW())'
);
$stmt->execute([
'order_id' => $orderId,
'note_type' => self::NOTE_TYPE_USER,
'user_id' => $userId,
'author_name' => $authorName !== '' ? $authorName : null,
'comment' => $body,
]);
return (int) $this->pdo->lastInsertId();
}
/**
* @throws RuntimeException kod 403 gdy uzytkownik nie jest autorem notatki
*/
public function update(int $noteId, int $userId, string $body): void
{
$body = $this->sanitizeBody($body);
if ($noteId <= 0 || $userId <= 0) {
throw new InvalidArgumentException('Nieprawidlowe parametry notatki.');
}
$stmt = $this->pdo->prepare(
'UPDATE order_notes
SET comment = :comment, updated_at = NOW()
WHERE id = :id AND note_type = :note_type AND user_id = :user_id'
);
$stmt->execute([
'id' => $noteId,
'note_type' => self::NOTE_TYPE_USER,
'user_id' => $userId,
'comment' => $body,
]);
if ($stmt->rowCount() === 0) {
throw new RuntimeException('Brak uprawnien — tylko autor moze edytowac notatke.', 403);
}
}
/**
* @throws RuntimeException kod 403 gdy uzytkownik nie jest autorem notatki
*/
public function delete(int $noteId, int $userId): void
{
if ($noteId <= 0 || $userId <= 0) {
throw new InvalidArgumentException('Nieprawidlowe parametry notatki.');
}
$stmt = $this->pdo->prepare(
'DELETE FROM order_notes
WHERE id = :id AND note_type = :note_type AND user_id = :user_id'
);
$stmt->execute([
'id' => $noteId,
'note_type' => self::NOTE_TYPE_USER,
'user_id' => $userId,
]);
if ($stmt->rowCount() === 0) {
throw new RuntimeException('Brak uprawnien — tylko autor moze usunac notatke.', 403);
}
}
private function sanitizeBody(string $body): string
{
$body = trim($body);
if ($body === '') {
throw new InvalidArgumentException('Tresc notatki nie moze byc pusta.');
}
if (function_exists('mb_strlen') ? mb_strlen($body) > self::BODY_MAX_LENGTH : strlen($body) > self::BODY_MAX_LENGTH) {
throw new InvalidArgumentException('Tresc notatki przekracza ' . self::BODY_MAX_LENGTH . ' znakow.');
}
return $body;
}
}

View File

@@ -27,6 +27,7 @@ use App\Modules\Sms\SmsConversationService;
use App\Modules\Sms\SmsMessageRepository;
use App\Modules\Sms\SmsTemplateRepository;
use App\Modules\Sms\SmsVariableResolver;
use RuntimeException;
use Throwable;
final class OrdersController
@@ -52,7 +53,8 @@ final class OrdersController
private readonly ?SmsConversationService $smsConversation = null,
private readonly ?SmsTemplateRepository $smsTemplates = null,
private readonly ?SmsVariableResolver $smsVariableResolver = null,
private readonly ?CompanySettingsRepository $companySettingsRepo = null
private readonly ?CompanySettingsRepository $companySettingsRepo = null,
private readonly ?OrderNotesService $orderNotes = null
) {
}
@@ -203,6 +205,9 @@ final class OrdersController
$shipments = is_array($details['shipments'] ?? null) ? $details['shipments'] : [];
$documents = is_array($details['documents'] ?? null) ? $details['documents'] : [];
$notes = is_array($details['notes'] ?? null) ? $details['notes'] : [];
$userNotes = $this->orderNotes !== null ? $this->orderNotes->listUserNotes($orderId) : [];
$currentUser = $this->auth->user();
$currentUserId = is_array($currentUser) ? (int) ($currentUser['id'] ?? 0) : 0;
$history = is_array($details['status_history'] ?? null) ? $details['status_history'] : [];
$activityLog = is_array($details['activity_log'] ?? null) ? $details['activity_log'] : [];
$statusCode = (string) (($order['effective_status_id'] ?? '') !== '' ? $order['effective_status_id'] : ($order['status_code'] ?? ''));
@@ -279,6 +284,8 @@ final class OrdersController
'pendingPrintPackageIds' => $this->printJobRepo !== null ? $this->printJobRepo->pendingPackageIds() : [],
'documents' => $documents,
'notes' => $notes,
'userNotes' => $userNotes,
'currentUserId' => $currentUserId,
'history' => $resolvedHistory,
'activityLog' => $activityLog,
'statusLabel' => $this->statusLabel($statusCode, $statusLabelMap),
@@ -624,6 +631,136 @@ final class OrdersController
return Response::json(['success' => true, 'invoice_requested' => $value ? 1 : 0]);
}
public function storeNote(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$redirectTo = '/orders/' . $orderId . '#notes';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('order.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/orders/' . $orderId);
}
if ($orderId <= 0 || $this->orderNotes === null) {
Flash::set('order.error', 'Modul notatek nie jest dostepny.');
return Response::redirect('/orders/' . $orderId);
}
$user = $this->auth->user();
$userId = is_array($user) ? (int) ($user['id'] ?? 0) : 0;
$authorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : '';
if ($userId <= 0) {
Flash::set('order.error', 'Wymagane zalogowanie.');
return Response::redirect('/orders/' . $orderId);
}
try {
$this->orderNotes->create($orderId, $userId, $authorName, (string) $request->input('body', ''));
$this->orders->recordActivity(
$orderId,
'note',
'Dodano notatke',
null,
'user',
$authorName !== '' ? $authorName : null
);
Flash::set('order.success', 'Notatka dodana.');
} catch (\InvalidArgumentException $exception) {
Flash::set('order.error', $exception->getMessage());
} catch (Throwable $exception) {
Flash::set('order.error', 'Nie udalo sie dodac notatki: ' . $exception->getMessage());
}
return Response::redirect($redirectTo);
}
public function updateNote(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$noteId = max(0, (int) $request->input('noteId', 0));
$redirectTo = '/orders/' . $orderId . '#notes';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('order.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/orders/' . $orderId);
}
if ($orderId <= 0 || $noteId <= 0 || $this->orderNotes === null) {
Flash::set('order.error', 'Nieprawidlowe parametry.');
return Response::redirect('/orders/' . $orderId);
}
$user = $this->auth->user();
$userId = is_array($user) ? (int) ($user['id'] ?? 0) : 0;
$authorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : '';
if ($userId <= 0) {
Flash::set('order.error', 'Wymagane zalogowanie.');
return Response::redirect('/orders/' . $orderId);
}
try {
$this->orderNotes->update($noteId, $userId, (string) $request->input('body', ''));
$this->orders->recordActivity(
$orderId,
'note',
'Zaktualizowano notatke',
['note_id' => $noteId],
'user',
$authorName !== '' ? $authorName : null
);
Flash::set('order.success', 'Notatka zaktualizowana.');
} catch (RuntimeException $exception) {
Flash::set('order.error', $exception->getMessage());
} catch (\InvalidArgumentException $exception) {
Flash::set('order.error', $exception->getMessage());
} catch (Throwable $exception) {
Flash::set('order.error', 'Nie udalo sie zaktualizowac notatki: ' . $exception->getMessage());
}
return Response::redirect($redirectTo);
}
public function deleteNote(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$noteId = max(0, (int) $request->input('noteId', 0));
$redirectTo = '/orders/' . $orderId . '#notes';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('order.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/orders/' . $orderId);
}
if ($orderId <= 0 || $noteId <= 0 || $this->orderNotes === null) {
Flash::set('order.error', 'Nieprawidlowe parametry.');
return Response::redirect('/orders/' . $orderId);
}
$user = $this->auth->user();
$userId = is_array($user) ? (int) ($user['id'] ?? 0) : 0;
$authorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : '';
if ($userId <= 0) {
Flash::set('order.error', 'Wymagane zalogowanie.');
return Response::redirect('/orders/' . $orderId);
}
try {
$this->orderNotes->delete($noteId, $userId);
$this->orders->recordActivity(
$orderId,
'note',
'Usunieto notatke',
['note_id' => $noteId],
'user',
$authorName !== '' ? $authorName : null
);
Flash::set('order.success', 'Notatka usunieta.');
} catch (RuntimeException $exception) {
Flash::set('order.error', $exception->getMessage());
} catch (Throwable $exception) {
Flash::set('order.error', 'Nie udalo sie usunac notatki: ' . $exception->getMessage());
}
return Response::redirect($redirectTo);
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
@@ -658,6 +795,13 @@ final class OrdersController
? ' <span class="risk-return-badge" title="Klient nie odebral ' . $returnedCount . ' przesylek w historii">zwroty: ' . $returnedCount . '</span>'
: '';
$userNotesCount = max(0, (int) ($row['user_notes_count'] ?? 0));
$notesBadge = $userNotesCount >= 1
? ' <a href="/orders/' . (int) ($row['id'] ?? 0) . '#notes" class="order-notes-badge" title="' . $userNotesCount . ' '
. ($userNotesCount === 1 ? 'notatka' : ($userNotesCount < 5 ? 'notatki' : 'notatek')) . '">['
. $userNotesCount . ']</a>'
: '';
$previewBtn = '<button type="button" class="btn-icon js-order-preview-btn" data-order-id="' . (int) ($row['id'] ?? 0) . '" title="Podglad">'
. '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'
. '</button>';
@@ -667,7 +811,7 @@ final class OrdersController
'order_ref' => '<div class="orders-ref">'
. '<div class="orders-ref__main">' . $previewBtn . '<a href="/orders/' . (int) ($row['id'] ?? 0) . '">'
. htmlspecialchars($internalOrderNumber !== '' ? $internalOrderNumber : ('#' . (string) ($row['id'] ?? 0)), ENT_QUOTES, 'UTF-8')
. '</a></div>'
. '</a>' . $notesBadge . '</div>'
. '<div class="orders-ref__meta">'
. '<span>' . htmlspecialchars($integrationName !== '' ? $integrationName : $this->sourceLabel($source), ENT_QUOTES, 'UTF-8') . '</span>'
. '<span>ID: ' . htmlspecialchars($sourceOrderId !== '' ? $sourceOrderId : $externalOrderId, ENT_QUOTES, 'UTF-8') . '</span>'

View File

@@ -182,7 +182,8 @@ final class OrdersRepository
COALESCE(sh_agg.shipments_count, 0) AS shipments_count,
COALESCE(od_agg.documents_count, 0) AS documents_count,
ig.name AS integration_name,
' . $this->customerReturnedCountSubquerySql('o', 'a') . ' AS customer_returned_count
' . $this->customerReturnedCountSubquerySql('o', 'a') . ' AS customer_returned_count,
' . $this->userNotesCountSubquerySql('o') . ' AS user_notes_count
FROM orders o
LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code
@@ -246,6 +247,7 @@ final class OrdersRepository
'projects_done' => (int) ($row['projects_done'] ?? 0),
'projects_total' => (int) ($row['projects_total'] ?? 0),
'customer_returned_count' => max(0, (int) ($row['customer_returned_count'] ?? 0)),
'user_notes_count' => max(0, (int) ($row['user_notes_count'] ?? 0)),
];
}
@@ -595,7 +597,9 @@ final class OrdersRepository
*/
private function loadOrderNotes(int $orderId): array
{
$stmt = $this->pdo->prepare('SELECT * FROM order_notes WHERE order_id = :order_id ORDER BY created_at_external DESC, id DESC');
// Phase 129-01: zwraca tylko notatki importowane ze zrodla (note_type != 'user').
// Notatki autorskie operatora ladowane sa osobno przez OrderNotesService::listUserNotes().
$stmt = $this->pdo->prepare('SELECT * FROM order_notes WHERE order_id = :order_id AND note_type <> "user" ORDER BY created_at_external DESC, id DESC');
$stmt->execute(['order_id' => $orderId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
@@ -707,6 +711,16 @@ final class OrdersRepository
))';
}
/**
* Phase 129-01: subquery liczby notatek autorskich (note_type='user') dla zamowienia.
* Wspierane indeksem idx_order_notes_type_order (note_type, order_id).
*/
private function userNotesCountSubquerySql(string $orderAlias): string
{
return '(SELECT COUNT(*) FROM order_notes
WHERE order_id = ' . $orderAlias . '.id AND note_type = "user")';
}
private function effectiveStatusSql(string $orderAlias, string $mappingAlias): string
{
return 'CASE