feat(117): smsplanet integration settings

This commit is contained in:
2026-05-12 13:18:41 +02:00
parent 09f9ca798d
commit bcbb35bc6b
22 changed files with 1392 additions and 22 deletions

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use AppCorexceptionsIntegrationConfigException;
use App\Core\Exceptions\IntegrationConfigException;
final class IntegrationSecretCipher
{

View File

@@ -23,7 +23,8 @@ final class IntegrationsHubController
private readonly InpostIntegrationRepository $inpost,
private readonly ShopproIntegrationsRepository $shoppro,
private readonly FakturowniaIntegrationRepository $fakturownia,
private readonly HostedSmsIntegrationRepository $hostedSms
private readonly HostedSmsIntegrationRepository $hostedSms,
private readonly SmsplanetIntegrationRepository $smsplanet
) {
}
@@ -37,6 +38,7 @@ final class IntegrationsHubController
$this->buildShopproRow(),
$this->buildFakturowniaRow(),
$this->buildHostedSmsRow(),
$this->buildSmsplanetRow(),
];
$html = $this->template->render('settings/integrations', [
@@ -242,4 +244,33 @@ final class IntegrationsHubController
];
}
/**
* @return array<string, mixed>
*/
private function buildSmsplanetRow(): array
{
$settings = $this->smsplanet->getSettings();
$authMethod = (string) ($settings['auth_method'] ?? 'token');
$isConfigured = !empty($settings['sender'])
&& (
($authMethod === 'token' && !empty($settings['has_api_token']))
|| ($authMethod === 'key_password' && !empty($settings['has_api_key']) && !empty($settings['has_api_password']))
);
return [
'provider' => $this->translator->get('settings.integrations_hub.providers.smsplanet'),
'instance' => 'SMSPLANET',
'authorization_status' => $isConfigured
? $this->translator->get('settings.integrations_hub.status.configured')
: $this->translator->get('settings.integrations_hub.status.not_configured'),
'secret_status' => $isConfigured
? $this->translator->get('settings.integrations_hub.status.saved')
: $this->translator->get('settings.integrations_hub.status.missing'),
'is_active' => !empty($settings['is_active']),
'last_test_at' => trim((string) ($settings['last_test_at'] ?? '')),
'configure_url' => '/settings/integrations/smsplanet',
'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'),
];
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\SslCertificateResolver;
final class SmsplanetApiClient
{
private const API_URL = 'https://api2.smsplanet.pl/sms';
private const AUTH_TOKEN = 'token';
public function __construct(private readonly int $timeoutSeconds = 15)
{
}
/**
* @param array<string, mixed> $credentials
* @return array{ok: bool, http_code: int, message: string, message_id: string}
*/
public function sendSms(array $credentials, string $phone, string $message): array
{
$payload = [
'from' => trim((string) ($credentials['sender'] ?? '')),
'to' => trim($phone),
'msg' => $message,
];
if (!empty($credentials['clear_polish'])) {
$payload['clear_polish'] = '1';
}
if (!empty($credentials['transactional'])) {
$payload['transactional'] = '1';
}
$headers = [];
if (($credentials['auth_method'] ?? '') === self::AUTH_TOKEN) {
$headers[] = 'Authorization: Bearer ' . trim((string) ($credentials['api_token'] ?? ''));
} else {
$payload['key'] = trim((string) ($credentials['api_key'] ?? ''));
$payload['password'] = (string) ($credentials['api_password'] ?? '');
}
[$body, $httpCode, $curlError] = $this->postForm($payload, $headers);
if ($curlError !== null) {
return [
'ok' => false,
'http_code' => $httpCode,
'message' => 'Blad polaczenia: ' . $curlError,
'message_id' => '',
];
}
return $this->parseResponse($body, $httpCode);
}
/**
* @param array<string, string> $payload
* @param array<int, string> $extraHeaders
* @return array{0: string, 1: int, 2: ?string}
*/
private function postForm(array $payload, array $extraHeaders): array
{
$ch = curl_init(self::API_URL);
if ($ch === false) {
return ['', 0, 'Nie udalo sie zainicjowac cURL.'];
}
$headers = array_merge([
'Accept: application/json',
'Content-Type: application/x-www-form-urlencoded; charset=UTF-8',
'User-Agent: orderPRO/1.0',
], $extraHeaders);
$options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($payload),
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => $headers,
];
$caPath = SslCertificateResolver::resolve();
if ($caPath !== null) {
$options[CURLOPT_CAINFO] = $caPath;
}
curl_setopt_array($ch, $options);
$rawBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
unset($ch);
if ($rawBody === false) {
return ['', $httpCode, $curlError !== '' ? $curlError : 'Brak odpowiedzi z API.'];
}
return [(string) $rawBody, $httpCode, null];
}
/**
* @return array{ok: bool, http_code: int, message: string, message_id: string}
*/
private function parseResponse(string $body, int $httpCode): array
{
$decoded = json_decode(ltrim($body, "\xEF\xBB\xBF \t\n\r\0\x0B"), true);
if (!is_array($decoded)) {
return [
'ok' => false,
'http_code' => $httpCode,
'message' => 'Niepoprawna odpowiedz JSON SMSPLANET: ' . substr(trim(strip_tags($body)), 0, 180),
'message_id' => '',
];
}
$messageId = trim((string) ($decoded['messageId'] ?? ''));
if ($httpCode >= 200 && $httpCode < 300 && $messageId !== '') {
return [
'ok' => true,
'http_code' => $httpCode,
'message' => 'messageId: ' . $messageId,
'message_id' => $messageId,
];
}
$errorCode = trim((string) ($decoded['errorCode'] ?? ''));
$errorMessage = trim((string) ($decoded['errorMsg'] ?? ''));
if ($errorMessage === '') {
$errorMessage = 'HTTP ' . $httpCode;
}
if ($errorCode !== '') {
$errorMessage = 'errorCode ' . $errorCode . ': ' . $errorMessage;
}
return [
'ok' => false,
'http_code' => $httpCode,
'message' => $errorMessage,
'message_id' => '',
];
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Http\RedirectPathResolver;
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;
use Throwable;
final class SmsplanetIntegrationController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly SmsplanetIntegrationRepository $repository,
private readonly SmsplanetApiClient $apiClient,
private readonly IntegrationsRepository $integrations
) {
}
public function index(Request $request): Response
{
$html = $this->template->render('settings/smsplanet', [
'title' => $this->translator->get('settings.smsplanet.title'),
'activeMenu' => 'settings',
'activeSettings' => 'integrations',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'settings' => $this->repository->getSettings(),
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
'testMessage' => (string) Flash::get('smsplanet_test', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(Request $request): Response
{
$redirectTo = $this->resolveRedirect($request);
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
try {
$this->repository->saveSettings([
'auth_method' => (string) $request->input('auth_method', ''),
'api_token' => (string) $request->input('api_token', ''),
'api_key' => (string) $request->input('api_key', ''),
'api_password' => (string) $request->input('api_password', ''),
'sender' => (string) $request->input('sender', ''),
'clear_polish' => $request->input('clear_polish', ''),
'transactional' => $request->input('transactional', ''),
'is_active' => $request->input('is_active', ''),
]);
Flash::set('settings_success', $this->translator->get('settings.smsplanet.flash.saved'));
} catch (Throwable $exception) {
Flash::set(
'settings_error',
$this->translator->get('settings.smsplanet.flash.save_failed') . ' ' . $exception->getMessage()
);
}
return Response::redirect($redirectTo);
}
public function test(Request $request): Response
{
$redirectTo = $this->resolveRedirect($request);
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
try {
$phone = $this->validatePhone((string) $request->input('phone', ''));
$message = $this->validateMessage((string) $request->input('message', ''));
$credentials = $this->repository->getCredentials();
if ($credentials === null) {
throw new IntegrationConfigException('Najpierw zapisz kompletna i aktywna konfiguracje SMSPLANET.');
}
$result = $this->apiClient->sendSms($credentials, $phone, $message);
$this->integrations->updateTestResult(
$credentials['integration_id'],
$result['ok'] ? 'ok' : 'fail',
(int) $result['http_code'],
(string) $result['message']
);
if ($result['ok']) {
Flash::set('smsplanet_test', $this->translator->get('settings.smsplanet.flash.test_success', [
'message_id' => (string) $result['message_id'],
]));
} else {
Flash::set('settings_error', $this->translator->get('settings.smsplanet.flash.test_failed') . ' ' . $result['message']);
}
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.smsplanet.flash.test_failed') . ' ' . $exception->getMessage());
}
return Response::redirect($redirectTo);
}
private function resolveRedirect(Request $request): string
{
return RedirectPathResolver::resolve(
(string) $request->input('return_to', '/settings/integrations/smsplanet'),
['/settings/integrations'],
'/settings/integrations/smsplanet'
);
}
private function validatePhone(string $value): string
{
$phone = preg_replace('/[\s+\-()]/', '', trim($value)) ?? '';
if (preg_match('/^\d{8,15}$/', $phone) !== 1) {
throw new IntegrationConfigException('Podaj numer telefonu w formacie 600111222 albo 48600111222.');
}
return $phone;
}
private function validateMessage(string $value): string
{
$message = trim($value);
if ($message === '') {
throw new IntegrationConfigException('Podaj tresc testowego SMS.');
}
if (strlen($message) > 918) {
throw new IntegrationConfigException('Tresc testowego SMS nie moze przekraczac 918 znakow.');
}
return $message;
}
}

View File

@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Support\StringHelper;
use PDO;
use Throwable;
final class SmsplanetIntegrationRepository
{
private const INTEGRATION_TYPE = 'smsplanet';
private const INTEGRATION_NAME = 'SMSPLANET';
private const INTEGRATION_BASE_URL = 'https://api2.smsplanet.pl/sms';
private const AUTH_TOKEN = 'token';
private const AUTH_KEY_PASSWORD = 'key_password';
private readonly IntegrationsRepository $integrations;
private readonly IntegrationSecretCipher $cipher;
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
) {
$this->integrations = new IntegrationsRepository($this->pdo);
$this->cipher = new IntegrationSecretCipher($this->secret);
}
/**
* @return array<string, mixed>
*/
public function getSettings(): array
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$row = $this->fetchRow() ?? [];
$integration = $this->integrations->findById($integrationId);
return [
'integration_id' => $integrationId,
'auth_method' => $this->normalizeAuthMethod((string) ($row['auth_method'] ?? '')),
'sender' => trim((string) ($row['sender'] ?? '')),
'clear_polish' => !empty($row['clear_polish']),
'transactional' => !empty($row['transactional']),
'has_api_token' => $this->hasEncryptedValue($row['api_token_encrypted'] ?? null),
'has_api_key' => $this->hasEncryptedValue($row['api_key_encrypted'] ?? null),
'has_api_password' => $this->hasEncryptedValue($row['api_password_encrypted'] ?? null),
'is_active' => (int) ($integration['is_active'] ?? 1) === 1,
'last_test_status' => trim((string) ($integration['last_test_status'] ?? '')),
'last_test_http_code' => isset($integration['last_test_http_code']) ? (int) $integration['last_test_http_code'] : null,
'last_test_message' => trim((string) ($integration['last_test_message'] ?? '')),
'last_test_at' => trim((string) ($integration['last_test_at'] ?? '')),
'updated_at' => trim((string) ($row['updated_at'] ?? '')),
];
}
/**
* @param array<string, mixed> $payload
*/
public function saveSettings(array $payload): void
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$row = $this->fetchRequiredRow();
$authMethod = $this->normalizeAuthMethod((string) ($payload['auth_method'] ?? ''));
$sender = $this->validateSender((string) ($payload['sender'] ?? ''));
$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->updateIntegrationActive($integrationId, !empty($payload['is_active']));
}
/**
* @return array{
* integration_id: int,
* auth_method: string,
* api_token: string,
* api_key: string,
* api_password: string,
* sender: string,
* clear_polish: bool,
* transactional: bool
* }|null
*/
public function getCredentials(): ?array
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$row = $this->fetchRow();
$integration = $this->integrations->findById($integrationId);
if ($row === null || (int) ($integration['is_active'] ?? 0) !== 1) {
return null;
}
$authMethod = $this->normalizeAuthMethod((string) ($row['auth_method'] ?? ''));
$sender = trim((string) ($row['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)) {
return null;
}
return [
'integration_id' => $integrationId,
'auth_method' => $authMethod,
'api_token' => $apiToken,
'api_key' => $apiKey,
'api_password' => $apiPassword,
'sender' => $sender,
'clear_polish' => !empty($row['clear_polish']),
'transactional' => !empty($row['transactional']),
];
}
private function ensureBaseIntegration(): int
{
return $this->integrations->ensureIntegration(
self::INTEGRATION_TYPE,
self::INTEGRATION_NAME,
self::INTEGRATION_BASE_URL,
15,
true
);
}
private function ensureRow(): void
{
$integrationId = $this->ensureBaseIntegration();
$statement = $this->pdo->prepare(
'INSERT INTO smsplanet_integration_settings (id, integration_id, created_at, updated_at)
VALUES (1, :integration_id, NOW(), NOW())
ON DUPLICATE KEY UPDATE integration_id = VALUES(integration_id), updated_at = VALUES(updated_at)'
);
$statement->execute(['integration_id' => $integrationId]);
}
/**
* @return array<string, mixed>|null
*/
private function fetchRow(): ?array
{
try {
$statement = $this->pdo->prepare('SELECT * FROM smsplanet_integration_settings WHERE id = 1 LIMIT 1');
$statement->execute();
$row = $statement->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return null;
}
return is_array($row) ? $row : null;
}
/**
* @return array<string, mixed>
*/
private function fetchRequiredRow(): array
{
$row = $this->fetchRow();
if ($row === null) {
throw new IntegrationConfigException('Brak rekordu konfiguracji SMSPLANET.');
}
return $row;
}
private function normalizeAuthMethod(string $value): string
{
return $value === self::AUTH_KEY_PASSWORD ? self::AUTH_KEY_PASSWORD : self::AUTH_TOKEN;
}
private function validateSender(string $value): string
{
$sender = trim($value);
if ($sender === '' || strlen($sender) > 32) {
throw new IntegrationConfigException('Podaj pole nadawcy SMSPLANET (maks. 32 znaki).');
}
return $sender;
}
/**
* @param array<string, mixed> $row
*/
private function resolveTokenEncrypted(array $row, string $newToken): ?string
{
$token = trim($newToken);
if ($token !== '') {
return $this->cipher->encrypt($token);
}
return StringHelper::nullableString((string) ($row['api_token_encrypted'] ?? ''));
}
/**
* @param array<string, mixed> $row
*/
private function resolveKeyEncrypted(array $row, string $newKey): ?string
{
$key = trim($newKey);
if ($key !== '') {
return $this->cipher->encrypt($key);
}
return StringHelper::nullableString((string) ($row['api_key_encrypted'] ?? ''));
}
/**
* @param array<string, mixed> $row
*/
private function resolvePasswordEncrypted(array $row, string $newPassword): ?string
{
$password = trim($newPassword);
if ($password !== '') {
return $this->cipher->encrypt($password);
}
return StringHelper::nullableString((string) ($row['api_password_encrypted'] ?? ''));
}
private function validateCredentials(
string $authMethod,
?string $tokenEncrypted,
?string $keyEncrypted,
?string $passwordEncrypted
): void {
if ($authMethod === self::AUTH_TOKEN && ($tokenEncrypted === null || $tokenEncrypted === '')) {
throw new IntegrationConfigException('Podaj token Bearer SMSPLANET.');
}
if ($authMethod !== self::AUTH_KEY_PASSWORD) {
return;
}
if ($keyEncrypted === null || $keyEncrypted === '') {
throw new IntegrationConfigException('Podaj klucz API SMSPLANET.');
}
if ($passwordEncrypted === null || $passwordEncrypted === '') {
throw new IntegrationConfigException('Podaj haslo API SMSPLANET.');
}
}
/**
* @param array<string, mixed> $payload
*/
private function updateSettingsRow(
string $authMethod,
?string $tokenEncrypted,
?string $keyEncrypted,
?string $passwordEncrypted,
string $sender,
array $payload
): void {
$statement = $this->pdo->prepare(
'UPDATE smsplanet_integration_settings
SET auth_method = :auth_method,
api_token_encrypted = :api_token_encrypted,
api_key_encrypted = :api_key_encrypted,
api_password_encrypted = :api_password_encrypted,
sender = :sender,
clear_polish = :clear_polish,
transactional = :transactional,
updated_at = NOW()
WHERE id = 1'
);
$statement->execute([
'auth_method' => $authMethod,
'api_token_encrypted' => $tokenEncrypted,
'api_key_encrypted' => $keyEncrypted,
'api_password_encrypted' => $passwordEncrypted,
'sender' => $sender,
'clear_polish' => !empty($payload['clear_polish']) ? 1 : 0,
'transactional' => !empty($payload['transactional']) ? 1 : 0,
]);
}
private function updateIntegrationActive(int $integrationId, bool $isActive): void
{
$statement = $this->pdo->prepare(
'UPDATE integrations
SET is_active = :is_active,
updated_at = NOW()
WHERE id = :id AND type = :type'
);
$statement->execute([
'id' => $integrationId,
'type' => self::INTEGRATION_TYPE,
'is_active' => $isActive ? 1 : 0,
]);
}
private function hasEncryptedValue(mixed $value): bool
{
return trim((string) $value) !== '';
}
private function decryptValue(string $encrypted): string
{
$value = StringHelper::nullableString($encrypted);
if ($value === null) {
return '';
}
return trim((string) $this->cipher->decrypt($value));
}
private function hasCompleteCredentials(
string $authMethod,
string $sender,
string $apiToken,
string $apiKey,
string $apiPassword
): bool {
if ($sender === '') {
return false;
}
if ($authMethod === self::AUTH_TOKEN) {
return $apiToken !== '';
}
return $apiKey !== '' && $apiPassword !== '';
}
}