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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
198
src/Modules/Sms/SmsConversationService.php
Normal file
198
src/Modules/Sms/SmsConversationService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
130
src/Modules/Sms/SmsMessageRepository.php
Normal file
130
src/Modules/Sms/SmsMessageRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/Modules/Sms/SmsplanetWebhookController.php
Normal file
71
src/Modules/Sms/SmsplanetWebhookController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user