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:
40
src/Modules/Notifications/NotificationApiController.php
Normal file
40
src/Modules/Notifications/NotificationApiController.php
Normal 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()]);
|
||||
}
|
||||
}
|
||||
58
src/Modules/Notifications/NotificationController.php
Normal file
58
src/Modules/Notifications/NotificationController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
127
src/Modules/Notifications/NotificationRepository.php
Normal file
127
src/Modules/Notifications/NotificationRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user