feat(121+122): smsplanet conversation, notifications, default footer

Phase 121 — SMSPLANET Conversation + Notifications:
- migration 20260512_000110 adds smsplanet conversation + notifications tables
- src/Modules/Sms (SmsConversationService, SmsMessageRepository, SmsplanetWebhookController)
- src/Modules/Notifications (Repository, Controller, ApiController)
- order SMS tab, notification center, sender mode, inbound webhook
- public notifications.js + layouts/app.php integration

Phase 122 — SMSPLANET Default SMS Footer:
- migration 20260512_000111 adds smsplanet_integration_settings.default_footer
- footer appended to test SMS and order SMS, validated against 918 char limit
- settings textarea + compact order SMS note when footer configured

Bundled (could not split per-phase without hunk staging):
- routes/web.php (also carries Phase 118 fakturownia redirects)
- DOCS/{ARCHITECTURE,DB_SCHEMA,TECH_CHANGELOG}.md (118 + 121 + 122 entries)
- .paul/codebase/{architecture,db_schema,tech_changelog}.md (118 + 121 + 122)
- .paul/STATE.md, ROADMAP.md, changelog/2026-05-12.md (UNIFY closure)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-12 20:37:41 +02:00
parent 8f14851d85
commit 360eef128d
34 changed files with 2538 additions and 128 deletions

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Modules\Notifications;
use App\Core\Http\Request;
use App\Core\Http\Response;
final class NotificationApiController
{
public function __construct(private readonly NotificationRepository $repository)
{
}
public function unread(Request $request): Response
{
return Response::json([
'ok' => true,
'count' => $this->repository->unreadCount(),
'items' => array_map(
static fn (array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'type' => (string) ($row['type'] ?? ''),
'title' => (string) ($row['title'] ?? ''),
'body' => (string) ($row['body'] ?? ''),
'target_url' => (string) ($row['target_url'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
],
$this->repository->recentUnread(10)
),
]);
}
public function markRead(Request $request): Response
{
$this->repository->markRead(max(0, (int) $request->input('id', 0)));
return Response::json(['ok' => true, 'count' => $this->repository->unreadCount()]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Modules\Notifications;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
final class NotificationController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly NotificationRepository $repository
) {
}
public function index(Request $request): Response
{
$page = max(1, (int) $request->input('page', 1));
$result = $this->repository->paginate($page, 30);
$html = $this->template->render('notifications/index', [
'title' => $this->translator->get('notifications.title'),
'activeMenu' => 'notifications',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'notifications' => $result['items'],
'pagination' => $result,
'unreadCount' => $this->repository->unreadCount(),
], 'layouts/app');
return Response::html($html);
}
public function markRead(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::push('danger', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/notifications');
}
$id = max(0, (int) $request->input('id', 0));
if ($id > 0) {
$this->repository->markRead($id);
} else {
$this->repository->markAllRead();
}
return Response::redirect('/notifications');
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Modules\Notifications;
use PDO;
final class NotificationRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @param array<string, mixed> $data
*/
public function create(array $data): int
{
$statement = $this->pdo->prepare(
'INSERT INTO notifications (
type, title, body, target_url, related_order_id, related_sms_message_id, created_at
) VALUES (
:type, :title, :body, :target_url, :related_order_id, :related_sms_message_id, NOW()
)'
);
$statement->execute([
'type' => substr(trim((string) ($data['type'] ?? 'info')), 0, 64),
'title' => substr(trim((string) ($data['title'] ?? 'Powiadomienie')), 0, 190),
'body' => substr(trim((string) ($data['body'] ?? '')), 0, 500),
'target_url' => $this->nullableString((string) ($data['target_url'] ?? '')),
'related_order_id' => $this->nullableInt($data['related_order_id'] ?? null),
'related_sms_message_id' => $this->nullableInt($data['related_sms_message_id'] ?? null),
]);
return (int) $this->pdo->lastInsertId();
}
/**
* @return array{items: array<int, array<string, mixed>>, total: int, page: int, per_page: int}
*/
public function paginate(int $page = 1, int $perPage = 30): array
{
$safePage = max(1, $page);
$safePerPage = max(1, min(100, $perPage));
$offset = ($safePage - 1) * $safePerPage;
$total = (int) $this->pdo->query('SELECT COUNT(*) FROM notifications')->fetchColumn();
$statement = $this->pdo->prepare(
'SELECT *
FROM notifications
ORDER BY created_at DESC, id DESC
LIMIT :limit OFFSET :offset'
);
$statement->bindValue('limit', $safePerPage, PDO::PARAM_INT);
$statement->bindValue('offset', $offset, PDO::PARAM_INT);
$statement->execute();
$items = $statement->fetchAll(PDO::FETCH_ASSOC);
return [
'items' => is_array($items) ? $items : [],
'total' => $total,
'page' => $safePage,
'per_page' => $safePerPage,
];
}
public function unreadCount(): int
{
$statement = $this->pdo->prepare('SELECT COUNT(*) FROM notifications WHERE read_at IS NULL');
$statement->execute();
return (int) $statement->fetchColumn();
}
/**
* @return array<int, array<string, mixed>>
*/
public function recentUnread(int $limit = 10): array
{
$safeLimit = max(1, min(50, $limit));
$statement = $this->pdo->prepare(
'SELECT *
FROM notifications
WHERE read_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT :limit'
);
$statement->bindValue('limit', $safeLimit, PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
public function markRead(int $id): void
{
if ($id <= 0) {
return;
}
$statement = $this->pdo->prepare(
'UPDATE notifications
SET read_at = COALESCE(read_at, NOW())
WHERE id = :id'
);
$statement->execute(['id' => $id]);
}
public function markAllRead(): void
{
$this->pdo->prepare('UPDATE notifications SET read_at = NOW() WHERE read_at IS NULL')->execute();
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function nullableInt(mixed $value): ?int
{
$intValue = (int) $value;
return $intValue > 0 ? $intValue : null;
}
}