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:
182
src/Modules/Orders/OrderNotesService.php
Normal file
182
src/Modules/Orders/OrderNotesService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user