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;
}
}

View File

@@ -22,6 +22,9 @@ use App\Modules\Automation\AutomationService;
use App\Modules\Settings\ShopproApiClient;
use App\Modules\Settings\ShopproIntegrationsRepository;
use App\Modules\Shipments\ShipmentPackageRepository;
use App\Modules\Sms\SmsConversationService;
use App\Modules\Sms\SmsMessageRepository;
use Throwable;
final class OrdersController
{
@@ -41,7 +44,9 @@ final class OrdersController
private readonly ?ShopproIntegrationsRepository $shopproIntegrations = null,
private readonly ?AutomationService $automation = null,
private readonly ?InvoiceRepository $invoiceRepo = null,
private readonly ?InvoiceConfigRepository $invoiceConfigRepo = null
private readonly ?InvoiceConfigRepository $invoiceConfigRepo = null,
private readonly ?SmsMessageRepository $smsMessages = null,
private readonly ?SmsConversationService $smsConversation = null
) {
}
@@ -247,6 +252,9 @@ final class OrdersController
$flashError = (string) Flash::get('order.error', '');
$customerRiskInfo = $this->buildCustomerRiskInfo($order, $orderId);
$smsMessages = $this->smsMessages !== null ? $this->smsMessages->findByOrderId($orderId) : [];
$smsPhone = $this->resolveSmsPhone($order, $addresses);
$smsDefaultFooterConfigured = $this->smsConversation !== null && $this->smsConversation->hasDefaultFooter();
$html = $this->template->render('orders/show', [
'title' => $this->translator->get('orders.details.title') . ' #' . $orderId,
@@ -279,11 +287,50 @@ final class OrdersController
'emailTemplates' => $emailTemplates,
'emailMailboxes' => $emailMailboxes,
'customerRiskInfo' => $customerRiskInfo,
'smsMessages' => $smsMessages,
'smsPhone' => $smsPhone,
'smsDefaultFooterConfigured' => $smsDefaultFooterConfigured,
], 'layouts/app');
return Response::html($html);
}
public function sendSms(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$redirectTo = '/orders/' . $orderId . '?tab=sms';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('order.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
if ($orderId <= 0 || $this->smsConversation === null) {
Flash::set('order.error', 'Modul SMS nie jest dostepny.');
return Response::redirect($redirectTo);
}
try {
$user = $this->auth->user();
$userId = is_array($user) ? (int) ($user['id'] ?? 0) : 0;
$result = $this->smsConversation->sendFromOrder(
$orderId,
(string) $request->input('phone', ''),
(string) $request->input('message', ''),
$userId > 0 ? $userId : null
);
if ($result['ok']) {
Flash::set('order.success', 'SMS zostal wyslany.');
} else {
Flash::set('order.error', 'Nie udalo sie wyslac SMS: ' . $result['message']);
}
} catch (Throwable $exception) {
Flash::set('order.error', 'Nie udalo sie wyslac SMS: ' . $exception->getMessage());
}
return Response::redirect($redirectTo);
}
/**
* Sklada informacje o historii zwrotow klienta biezacego zamowienia.
*
@@ -315,6 +362,32 @@ final class OrdersController
];
}
/**
* @param array<string, mixed> $order
* @param array<int, array<string, mixed>> $addresses
*/
private function resolveSmsPhone(array $order, array $addresses): string
{
$buyerPhone = trim((string) ($order['buyer_phone'] ?? ''));
if ($buyerPhone !== '') {
return $buyerPhone;
}
foreach (['customer', 'delivery', 'invoice'] as $wantedType) {
foreach ($addresses as $address) {
if ((string) ($address['address_type'] ?? '') !== $wantedType) {
continue;
}
$phone = trim((string) ($address['phone'] ?? ''));
if ($phone !== '') {
return $phone;
}
}
}
return '';
}
private function composeCustomerRiskText(int $count, string $email, string $phone, string $name): string
{
if ($count <= 0) {

View File

@@ -16,6 +16,8 @@ use Throwable;
final class SmsplanetIntegrationController
{
private const MAX_SMS_LENGTH = 918;
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
@@ -58,6 +60,9 @@ final class SmsplanetIntegrationController
'api_key' => (string) $request->input('api_key', ''),
'api_password' => (string) $request->input('api_password', ''),
'sender' => (string) $request->input('sender', ''),
'sender_mode' => (string) $request->input('sender_mode', ''),
'sender_phone' => (string) $request->input('sender_phone', ''),
'default_footer' => (string) $request->input('default_footer', ''),
'clear_polish' => $request->input('clear_polish', ''),
'transactional' => $request->input('transactional', ''),
'is_active' => $request->input('is_active', ''),
@@ -89,7 +94,10 @@ final class SmsplanetIntegrationController
throw new IntegrationConfigException('Najpierw zapisz kompletna i aktywna konfiguracje SMSPLANET.');
}
$result = $this->apiClient->sendSms($credentials, $phone, $message);
$finalMessage = $this->buildFinalMessage($message, (string) ($credentials['default_footer'] ?? ''));
$this->validateFinalMessageLength($finalMessage);
$result = $this->apiClient->sendSms($credentials, $phone, $finalMessage);
$this->integrations->updateTestResult(
$credentials['integration_id'],
$result['ok'] ? 'ok' : 'fail',
@@ -136,10 +144,36 @@ final class SmsplanetIntegrationController
if ($message === '') {
throw new IntegrationConfigException('Podaj tresc testowego SMS.');
}
if (strlen($message) > 918) {
if ($this->messageLength($message) > self::MAX_SMS_LENGTH) {
throw new IntegrationConfigException('Tresc testowego SMS nie moze przekraczac 918 znakow.');
}
return $message;
}
private function buildFinalMessage(string $message, string $defaultFooter): string
{
$footer = trim(str_replace(["\r\n", "\r"], "\n", $defaultFooter));
if ($footer === '') {
return $message;
}
return rtrim($message) . "\n\n" . $footer;
}
private function validateFinalMessageLength(string $message): void
{
if ($this->messageLength($message) > self::MAX_SMS_LENGTH) {
throw new IntegrationConfigException('Tresc testowego SMS ze stopka nie moze przekraczac 918 znakow.');
}
}
private function messageLength(string $value): int
{
if (function_exists('mb_strlen')) {
return mb_strlen($value, 'UTF-8');
}
return strlen($value);
}
}

View File

@@ -15,6 +15,9 @@ final class SmsplanetIntegrationRepository
private const INTEGRATION_BASE_URL = 'https://api2.smsplanet.pl/sms';
private const AUTH_TOKEN = 'token';
private const AUTH_KEY_PASSWORD = 'key_password';
private const SENDER_MODE_TEXT = 'text';
private const SENDER_MODE_PHONE = 'phone';
private const MAX_DEFAULT_FOOTER_LENGTH = 300;
private readonly IntegrationsRepository $integrations;
private readonly IntegrationSecretCipher $cipher;
@@ -41,6 +44,9 @@ final class SmsplanetIntegrationRepository
'integration_id' => $integrationId,
'auth_method' => $this->normalizeAuthMethod((string) ($row['auth_method'] ?? '')),
'sender' => trim((string) ($row['sender'] ?? '')),
'sender_mode' => $this->normalizeSenderMode((string) ($row['sender_mode'] ?? '')),
'sender_phone' => trim((string) ($row['sender_phone'] ?? '')),
'default_footer' => $this->normalizeDefaultFooter((string) ($row['default_footer'] ?? '')),
'clear_polish' => !empty($row['clear_polish']),
'transactional' => !empty($row['transactional']),
'has_api_token' => $this->hasEncryptedValue($row['api_token_encrypted'] ?? null),
@@ -66,12 +72,25 @@ final class SmsplanetIntegrationRepository
$authMethod = $this->normalizeAuthMethod((string) ($payload['auth_method'] ?? ''));
$sender = $this->validateSender((string) ($payload['sender'] ?? ''));
$senderMode = $this->normalizeSenderMode((string) ($payload['sender_mode'] ?? ''));
$senderPhone = $this->validateSenderPhone((string) ($payload['sender_phone'] ?? ''), $senderMode);
$defaultFooter = $this->validateDefaultFooter((string) ($payload['default_footer'] ?? ''));
$tokenEncrypted = $this->resolveTokenEncrypted($row, (string) ($payload['api_token'] ?? ''));
$keyEncrypted = $this->resolveKeyEncrypted($row, (string) ($payload['api_key'] ?? ''));
$passwordEncrypted = $this->resolvePasswordEncrypted($row, (string) ($payload['api_password'] ?? ''));
$this->validateCredentials($authMethod, $tokenEncrypted, $keyEncrypted, $passwordEncrypted);
$this->updateSettingsRow($authMethod, $tokenEncrypted, $keyEncrypted, $passwordEncrypted, $sender, $payload);
$this->updateSettingsRow(
$authMethod,
$tokenEncrypted,
$keyEncrypted,
$passwordEncrypted,
$sender,
$senderMode,
$senderPhone,
$defaultFooter,
$payload
);
$this->updateIntegrationActive($integrationId, !empty($payload['is_active']));
}
@@ -83,6 +102,9 @@ final class SmsplanetIntegrationRepository
* api_key: string,
* api_password: string,
* sender: string,
* sender_mode: string,
* sender_phone: string,
* default_footer: string,
* clear_polish: bool,
* transactional: bool
* }|null
@@ -99,11 +121,14 @@ final class SmsplanetIntegrationRepository
$authMethod = $this->normalizeAuthMethod((string) ($row['auth_method'] ?? ''));
$sender = trim((string) ($row['sender'] ?? ''));
$senderMode = $this->normalizeSenderMode((string) ($row['sender_mode'] ?? ''));
$senderPhone = trim((string) ($row['sender_phone'] ?? ''));
$apiSender = $senderMode === self::SENDER_MODE_PHONE ? $senderPhone : $sender;
$apiToken = $this->decryptValue((string) ($row['api_token_encrypted'] ?? ''));
$apiKey = $this->decryptValue((string) ($row['api_key_encrypted'] ?? ''));
$apiPassword = $this->decryptValue((string) ($row['api_password_encrypted'] ?? ''));
if (!$this->hasCompleteCredentials($authMethod, $sender, $apiToken, $apiKey, $apiPassword)) {
if (!$this->hasCompleteCredentials($authMethod, $apiSender, $apiToken, $apiKey, $apiPassword)) {
return null;
}
@@ -113,7 +138,10 @@ final class SmsplanetIntegrationRepository
'api_token' => $apiToken,
'api_key' => $apiKey,
'api_password' => $apiPassword,
'sender' => $sender,
'sender' => $apiSender,
'sender_mode' => $senderMode,
'sender_phone' => $senderPhone,
'default_footer' => $this->normalizeDefaultFooter((string) ($row['default_footer'] ?? '')),
'clear_polish' => !empty($row['clear_polish']),
'transactional' => !empty($row['transactional']),
];
@@ -175,6 +203,11 @@ final class SmsplanetIntegrationRepository
return $value === self::AUTH_KEY_PASSWORD ? self::AUTH_KEY_PASSWORD : self::AUTH_TOKEN;
}
private function normalizeSenderMode(string $value): string
{
return $value === self::SENDER_MODE_PHONE ? self::SENDER_MODE_PHONE : self::SENDER_MODE_TEXT;
}
private function validateSender(string $value): string
{
$sender = trim($value);
@@ -185,6 +218,38 @@ final class SmsplanetIntegrationRepository
return $sender;
}
private function validateSenderPhone(string $value, string $senderMode): ?string
{
$phone = preg_replace('/[\s+\-()]/', '', trim($value)) ?? '';
if ($senderMode !== self::SENDER_MODE_PHONE) {
return $phone !== '' ? substr($phone, 0, 32) : null;
}
if (preg_match('/^\d{8,15}$/', $phone) !== 1) {
throw new IntegrationConfigException('Podaj numer 2WAY w formacie 600111222 albo 48600111222.');
}
return $phone;
}
private function normalizeDefaultFooter(string $value): string
{
return trim(str_replace(["\r\n", "\r"], "\n", $value));
}
private function validateDefaultFooter(string $value): ?string
{
$footer = $this->normalizeDefaultFooter($value);
if ($footer === '') {
return null;
}
if ($this->messageLength($footer) > self::MAX_DEFAULT_FOOTER_LENGTH) {
throw new IntegrationConfigException('Stopka SMSPLANET nie moze przekraczac 300 znakow.');
}
return $footer;
}
/**
* @param array<string, mixed> $row
*/
@@ -255,6 +320,9 @@ final class SmsplanetIntegrationRepository
?string $keyEncrypted,
?string $passwordEncrypted,
string $sender,
string $senderMode,
?string $senderPhone,
?string $defaultFooter,
array $payload
): void {
$statement = $this->pdo->prepare(
@@ -264,6 +332,9 @@ final class SmsplanetIntegrationRepository
api_key_encrypted = :api_key_encrypted,
api_password_encrypted = :api_password_encrypted,
sender = :sender,
sender_mode = :sender_mode,
sender_phone = :sender_phone,
default_footer = :default_footer,
clear_polish = :clear_polish,
transactional = :transactional,
updated_at = NOW()
@@ -275,6 +346,9 @@ final class SmsplanetIntegrationRepository
'api_key_encrypted' => $keyEncrypted,
'api_password_encrypted' => $passwordEncrypted,
'sender' => $sender,
'sender_mode' => $senderMode,
'sender_phone' => $senderPhone,
'default_footer' => $defaultFooter,
'clear_polish' => !empty($payload['clear_polish']) ? 1 : 0,
'transactional' => !empty($payload['transactional']) ? 1 : 0,
]);
@@ -327,4 +401,13 @@ final class SmsplanetIntegrationRepository
return $apiKey !== '' && $apiPassword !== '';
}
private function messageLength(string $value): int
{
if (function_exists('mb_strlen')) {
return mb_strlen($value, 'UTF-8');
}
return strlen($value);
}
}

View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\Modules\Sms;
use App\Core\Exceptions\IntegrationConfigException;
use App\Modules\Notifications\NotificationRepository;
use App\Modules\Settings\SmsplanetApiClient;
use App\Modules\Settings\SmsplanetIntegrationRepository;
final class SmsConversationService
{
private const MAX_SMS_LENGTH = 918;
public function __construct(
private readonly SmsMessageRepository $messages,
private readonly SmsplanetIntegrationRepository $settings,
private readonly SmsplanetApiClient $apiClient,
private readonly NotificationRepository $notifications
) {
}
/**
* @return array{ok: bool, message: string, message_id: string}
*/
public function sendFromOrder(int $orderId, string $toPhone, string $body, ?int $userId): array
{
$phone = $this->normalizePhone($toPhone);
$message = trim($body);
if ($orderId <= 0 || $phone === '' || $message === '') {
throw new IntegrationConfigException('Podaj numer telefonu i tresc SMS.');
}
$credentials = $this->settings->getCredentials();
if ($credentials === null) {
throw new IntegrationConfigException('Konfiguracja SMSPLANET jest niekompletna albo nieaktywna.');
}
$finalMessage = $this->buildFinalOutboundBody($message, (string) ($credentials['default_footer'] ?? ''));
if ($this->messageLength($finalMessage) > self::MAX_SMS_LENGTH) {
throw new IntegrationConfigException('Tresc SMS ze stopka nie moze przekraczac 918 znakow.');
}
$from = trim((string) ($credentials['sender'] ?? ''));
$result = $this->apiClient->sendSms($credentials, $phone, $finalMessage);
$this->messages->insert([
'direction' => 'outbound',
'provider' => 'smsplanet',
'order_id' => $orderId,
'from_phone' => $from,
'from_phone_normalized' => $this->normalizePhone($from),
'to_phone' => $phone,
'to_phone_normalized' => $this->normalizePhone($phone),
'body' => $finalMessage,
'message_id' => (string) $result['message_id'],
'status' => $result['ok'] ? 'sent' : 'failed',
'raw_payload' => $result,
'created_by' => $userId,
]);
return [
'ok' => (bool) $result['ok'],
'message' => (string) $result['message'],
'message_id' => (string) $result['message_id'],
];
}
/**
* @param array<string, mixed> $payload
* @return array{message_id: int, order_id: ?int}
*/
public function receiveSmsplanetWebhook(array $payload): array
{
$parsed = $this->parseSmsplanetPayload($payload);
$fromNormalized = $this->normalizePhone($parsed['from_phone']);
$toNormalized = $this->normalizePhone($parsed['to_phone']);
$orderId = $this->messages->findLatestOrderIdByPhones($this->phoneLookupVariants($fromNormalized));
$messageId = $this->messages->insert([
'direction' => 'inbound',
'provider' => 'smsplanet',
'order_id' => $orderId,
'from_phone' => $parsed['from_phone'],
'from_phone_normalized' => $fromNormalized,
'to_phone' => $parsed['to_phone'],
'to_phone_normalized' => $toNormalized,
'body' => $parsed['body'],
'message_id' => $parsed['message_id'],
'status' => 'received',
'raw_payload' => $payload,
]);
$this->notifications->create([
'type' => 'sms_inbound',
'title' => 'Nowy SMS od klienta',
'body' => $this->notificationBody($parsed['from_phone'], $parsed['body']),
'target_url' => $orderId !== null ? '/orders/' . $orderId . '?tab=sms' : '/notifications',
'related_order_id' => $orderId,
'related_sms_message_id' => $messageId,
]);
return ['message_id' => $messageId, 'order_id' => $orderId];
}
public function normalizePhone(string $phone): string
{
return preg_replace('/[^0-9]+/', '', trim($phone)) ?? '';
}
public function hasDefaultFooter(): bool
{
$settings = $this->settings->getSettings();
return trim((string) ($settings['default_footer'] ?? '')) !== '';
}
private function buildFinalOutboundBody(string $message, string $defaultFooter): string
{
$footer = trim(str_replace(["\r\n", "\r"], "\n", $defaultFooter));
if ($footer === '') {
return $message;
}
return rtrim($message) . "\n\n" . $footer;
}
/**
* @return array{from_phone: string, to_phone: string, body: string, message_id: string}
*/
private function parseSmsplanetPayload(array $payload): array
{
$from = $this->firstPayloadValue($payload, ['from', 'sender', 'msisdn', 'phone', 'originator']);
$to = $this->firstPayloadValue($payload, ['to', 'recipient', 'receiver', 'number']);
$body = $this->firstPayloadValue($payload, ['msg', 'message', 'body', 'text', 'content']);
if ($from === '' || $body === '') {
throw new IntegrationConfigException('Webhook SMSPLANET nie zawiera numeru nadawcy albo tresci.');
}
return [
'from_phone' => $from,
'to_phone' => $to,
'body' => $body,
'message_id' => $this->firstPayloadValue($payload, ['messageId', 'message_id', 'id', 'smsId']),
];
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $keys
*/
private function firstPayloadValue(array $payload, array $keys): string
{
foreach ($keys as $key) {
if (array_key_exists($key, $payload)) {
return trim((string) $payload[$key]);
}
}
return '';
}
/**
* @return array<int, string>
*/
private function phoneLookupVariants(string $normalized): array
{
if ($normalized === '') {
return [];
}
$variants = [$normalized];
if (str_starts_with($normalized, '48') && strlen($normalized) === 11) {
$variants[] = substr($normalized, 2);
} elseif (strlen($normalized) === 9) {
$variants[] = '48' . $normalized;
}
return $variants;
}
private function notificationBody(string $fromPhone, string $body): string
{
$shortBody = trim(preg_replace('/\s+/', ' ', $body) ?? $body);
if (strlen($shortBody) > 140) {
$shortBody = substr($shortBody, 0, 137) . '...';
}
return trim($fromPhone . ': ' . $shortBody);
}
private function messageLength(string $value): int
{
if (function_exists('mb_strlen')) {
return mb_strlen($value, 'UTF-8');
}
return strlen($value);
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Modules\Sms;
use PDO;
final class SmsMessageRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @param array<string, mixed> $data
*/
public function insert(array $data): int
{
$statement = $this->pdo->prepare(
'INSERT INTO sms_messages (
direction, provider, order_id, from_phone, from_phone_normalized,
to_phone, to_phone_normalized, body, message_id, status,
raw_payload_json, created_by, created_at, updated_at
) VALUES (
:direction, :provider, :order_id, :from_phone, :from_phone_normalized,
:to_phone, :to_phone_normalized, :body, :message_id, :status,
:raw_payload_json, :created_by, NOW(), NOW()
)'
);
$statement->execute([
'direction' => (string) $data['direction'],
'provider' => (string) ($data['provider'] ?? 'smsplanet'),
'order_id' => $this->nullableInt($data['order_id'] ?? null),
'from_phone' => (string) $data['from_phone'],
'from_phone_normalized' => (string) $data['from_phone_normalized'],
'to_phone' => (string) $data['to_phone'],
'to_phone_normalized' => (string) $data['to_phone_normalized'],
'body' => (string) $data['body'],
'message_id' => $this->nullableString((string) ($data['message_id'] ?? '')),
'status' => substr((string) ($data['status'] ?? 'received'), 0, 32),
'raw_payload_json' => $this->jsonOrNull($data['raw_payload'] ?? null),
'created_by' => $this->nullableInt($data['created_by'] ?? null),
]);
return (int) $this->pdo->lastInsertId();
}
/**
* @return array<int, array<string, mixed>>
*/
public function findByOrderId(int $orderId): array
{
if ($orderId <= 0) {
return [];
}
$statement = $this->pdo->prepare(
'SELECT *
FROM sms_messages
WHERE order_id = :order_id
ORDER BY created_at ASC, id ASC'
);
$statement->execute(['order_id' => $orderId]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @param array<int, string> $phones
*/
public function findLatestOrderIdByPhones(array $phones): ?int
{
$variants = array_values(array_unique(array_filter($phones, static fn (string $value): bool => $value !== '')));
if ($variants === []) {
return null;
}
$addressPlaceholders = [];
$params = [];
foreach ($variants as $index => $phone) {
$addressKey = 'address_phone' . $index;
$addressPlaceholders[] = ':' . $addressKey;
$params[$addressKey] = $phone;
}
$statement = $this->pdo->prepare(
'SELECT o.id
FROM orders o
WHERE EXISTS (
SELECT 1
FROM order_addresses a
WHERE a.order_id = o.id
AND REGEXP_REPLACE(COALESCE(a.phone, ""), "[^0-9]+", "") IN (' . implode(',', $addressPlaceholders) . ')
)
ORDER BY COALESCE(o.ordered_at, o.source_created_at, o.created_at) DESC, o.id DESC
LIMIT 1'
);
$statement->execute($params);
$value = $statement->fetchColumn();
$orderId = (int) $value;
return $orderId > 0 ? $orderId : null;
}
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;
}
private function jsonOrNull(mixed $value): ?string
{
if ($value === null) {
return null;
}
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return is_string($encoded) ? $encoded : null;
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Modules\Sms;
use App\Core\Http\Request;
use App\Core\Http\Response;
use Throwable;
final class SmsplanetWebhookController
{
public function __construct(private readonly SmsConversationService $service)
{
}
public function inbound(Request $request): Response
{
$payload = $this->payloadFromRequest($request);
try {
$this->service->receiveSmsplanetWebhook($payload);
return Response::html('OK');
} catch (Throwable $exception) {
return Response::json([
'ok' => false,
'message' => $exception->getMessage(),
], 422);
}
}
/**
* @return array<string, mixed>
*/
private function payloadFromRequest(Request $request): array
{
$payload = $request->all();
$message = $this->decodeMessagePayload($payload['message'] ?? null);
if ($message !== null) {
$payload = array_merge($payload, $message);
}
$rawBody = file_get_contents('php://input');
if (is_string($rawBody) && trim($rawBody) !== '') {
$decoded = json_decode($rawBody, true);
if (is_array($decoded)) {
$payload = array_merge($payload, $decoded);
}
}
if (is_string($rawBody) && trim($rawBody) !== '') {
$payload['_raw_body'] = $rawBody;
}
return $payload;
}
/**
* @return array<string, mixed>|null
*/
private function decodeMessagePayload(mixed $message): ?array
{
if (!is_string($message) || trim($message) === '') {
return null;
}
$decoded = json_decode($message, true);
return is_array($decoded) ? $decoded : null;
}
}