feat(116): hostedsms integration settings
Phase 116 complete: - add HostedSMS settings with encrypted password storage - add SimpleAPI real test SMS flow and integrations hub row - document schema, architecture, changelog, and PAUL state Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
125
src/Modules/Settings/HostedSmsApiClient.php
Normal file
125
src/Modules/Settings/HostedSmsApiClient.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\Http\SslCertificateResolver;
|
||||
|
||||
final class HostedSmsApiClient
|
||||
{
|
||||
private const API_URL = 'https://api.hostedsms.pl/SimpleApi';
|
||||
|
||||
public function __construct(private readonly int $timeoutSeconds = 15)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, http_code: int, message: string, message_id: string}
|
||||
*/
|
||||
public function sendSms(
|
||||
string $userEmail,
|
||||
string $password,
|
||||
string $sender,
|
||||
string $phone,
|
||||
string $message,
|
||||
bool $convertMessageToGsm7
|
||||
): array {
|
||||
$payload = [
|
||||
'UserEmail' => trim($userEmail),
|
||||
'Password' => $password,
|
||||
'Sender' => trim($sender),
|
||||
'Phone' => trim($phone),
|
||||
'Message' => $message,
|
||||
];
|
||||
|
||||
if ($convertMessageToGsm7) {
|
||||
$payload['ConvertMessageToGSM7'] = 'true';
|
||||
}
|
||||
|
||||
[$body, $httpCode, $curlError] = $this->postForm($payload);
|
||||
if ($curlError !== null) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'http_code' => $httpCode,
|
||||
'message' => 'Blad polaczenia: ' . $curlError,
|
||||
'message_id' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$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 HostedSMS: ' . 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,
|
||||
];
|
||||
}
|
||||
|
||||
$errorMessage = trim((string) ($decoded['ErrorMessage'] ?? ''));
|
||||
if ($errorMessage === '') {
|
||||
$errorMessage = 'HTTP ' . $httpCode;
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'http_code' => $httpCode,
|
||||
'message' => $errorMessage,
|
||||
'message_id' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $payload
|
||||
* @return array{0: string, 1: int, 2: ?string}
|
||||
*/
|
||||
private function postForm(array $payload): array
|
||||
{
|
||||
$ch = curl_init(self::API_URL);
|
||||
if ($ch === false) {
|
||||
return ['', 0, 'Nie udalo sie zainicjowac cURL.'];
|
||||
}
|
||||
|
||||
$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 => [
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/x-www-form-urlencoded; charset=UTF-8',
|
||||
'User-Agent: orderPRO/1.0',
|
||||
],
|
||||
];
|
||||
|
||||
$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];
|
||||
}
|
||||
}
|
||||
154
src/Modules/Settings/HostedSmsIntegrationController.php
Normal file
154
src/Modules/Settings/HostedSmsIntegrationController.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?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 HostedSmsIntegrationController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly HostedSmsIntegrationRepository $repository,
|
||||
private readonly HostedSmsApiClient $apiClient,
|
||||
private readonly IntegrationsRepository $integrations
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$html = $this->template->render('settings/hostedsms', [
|
||||
'title' => $this->translator->get('settings.hostedsms.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('hostedsms_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([
|
||||
'user_email' => (string) $request->input('user_email', ''),
|
||||
'password' => (string) $request->input('password', ''),
|
||||
'sender' => (string) $request->input('sender', ''),
|
||||
'convert_message_to_gsm7' => $request->input('convert_message_to_gsm7', ''),
|
||||
'is_active' => $request->input('is_active', ''),
|
||||
]);
|
||||
Flash::set('settings_success', $this->translator->get('settings.hostedsms.flash.saved'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set(
|
||||
'settings_error',
|
||||
$this->translator->get('settings.hostedsms.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 konfiguracje HostedSMS.');
|
||||
}
|
||||
|
||||
$result = $this->apiClient->sendSms(
|
||||
$credentials['user_email'],
|
||||
$credentials['password'],
|
||||
$credentials['sender'],
|
||||
$phone,
|
||||
$message,
|
||||
$credentials['convert_message_to_gsm7']
|
||||
);
|
||||
|
||||
$status = $result['ok'] ? 'ok' : 'fail';
|
||||
$this->integrations->updateTestResult(
|
||||
$credentials['integration_id'],
|
||||
$status,
|
||||
(int) $result['http_code'],
|
||||
(string) $result['message']
|
||||
);
|
||||
|
||||
if ($result['ok']) {
|
||||
Flash::set('hostedsms_test', $this->translator->get('settings.hostedsms.flash.test_success', [
|
||||
'message_id' => (string) $result['message_id'],
|
||||
]));
|
||||
} else {
|
||||
Flash::set('settings_error', $this->translator->get('settings.hostedsms.flash.test_failed') . ' ' . $result['message']);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.hostedsms.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/hostedsms'),
|
||||
['/settings/integrations'],
|
||||
'/settings/integrations/hostedsms'
|
||||
);
|
||||
}
|
||||
|
||||
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 miedzynarodowym, np. 48xxxxxxxxx.');
|
||||
}
|
||||
|
||||
return $phone;
|
||||
}
|
||||
|
||||
private function validateMessage(string $value): string
|
||||
{
|
||||
$message = trim($value);
|
||||
if ($message === '') {
|
||||
throw new IntegrationConfigException('Podaj tresc testowego SMS.');
|
||||
}
|
||||
if (strlen($message) > 4000) {
|
||||
throw new IntegrationConfigException('Tresc SMS nie moze przekraczac 4000 znakow.');
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
208
src/Modules/Settings/HostedSmsIntegrationRepository.php
Normal file
208
src/Modules/Settings/HostedSmsIntegrationRepository.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?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 HostedSmsIntegrationRepository
|
||||
{
|
||||
private const INTEGRATION_TYPE = 'hostedsms';
|
||||
private const INTEGRATION_NAME = 'HostedSMS';
|
||||
private const INTEGRATION_BASE_URL = 'https://api.hostedsms.pl/SimpleApi';
|
||||
|
||||
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);
|
||||
$passwordEncrypted = $this->resolvePasswordEncrypted($row, $integration);
|
||||
|
||||
return [
|
||||
'integration_id' => $integrationId,
|
||||
'user_email' => trim((string) ($row['user_email'] ?? '')),
|
||||
'sender' => trim((string) ($row['sender'] ?? '')),
|
||||
'convert_message_to_gsm7' => !empty($row['convert_message_to_gsm7']),
|
||||
'has_password' => $passwordEncrypted !== null && $passwordEncrypted !== '',
|
||||
'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->fetchRow();
|
||||
if ($row === null) {
|
||||
throw new IntegrationConfigException('Brak rekordu konfiguracji HostedSMS.');
|
||||
}
|
||||
|
||||
$userEmail = trim((string) ($payload['user_email'] ?? ''));
|
||||
if (!filter_var($userEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new IntegrationConfigException('Podaj poprawny login e-mail HostedSMS.');
|
||||
}
|
||||
|
||||
$sender = trim((string) ($payload['sender'] ?? ''));
|
||||
if ($sender === '' || strlen($sender) > 32) {
|
||||
throw new IntegrationConfigException('Podaj nadpis Sender HostedSMS (maks. 32 znaki).');
|
||||
}
|
||||
|
||||
$currentEncrypted = $this->resolvePasswordEncrypted($row, $this->integrations->findById($integrationId));
|
||||
$password = trim((string) ($payload['password'] ?? ''));
|
||||
$nextEncrypted = $currentEncrypted;
|
||||
if ($password !== '') {
|
||||
$nextEncrypted = $this->cipher->encrypt($password);
|
||||
}
|
||||
|
||||
if ($nextEncrypted === null || $nextEncrypted === '') {
|
||||
throw new IntegrationConfigException('Podaj haslo HostedSMS.');
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE hostedsms_integration_settings
|
||||
SET user_email = :user_email,
|
||||
password_encrypted = :password_encrypted,
|
||||
sender = :sender,
|
||||
convert_message_to_gsm7 = :convert_message_to_gsm7,
|
||||
updated_at = NOW()
|
||||
WHERE id = 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'user_email' => $userEmail,
|
||||
'password_encrypted' => $nextEncrypted,
|
||||
'sender' => $sender,
|
||||
'convert_message_to_gsm7' => !empty($payload['convert_message_to_gsm7']) ? 1 : 0,
|
||||
]);
|
||||
|
||||
$this->updateIntegrationActive($integrationId, !empty($payload['is_active']));
|
||||
$this->integrations->updateApiKeyEncrypted($integrationId, $nextEncrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{integration_id: int, user_email: string, password: string, sender: string, convert_message_to_gsm7: bool}|null
|
||||
*/
|
||||
public function getCredentials(): ?array
|
||||
{
|
||||
$this->ensureRow();
|
||||
$integrationId = $this->ensureBaseIntegration();
|
||||
$row = $this->fetchRow();
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userEmail = trim((string) ($row['user_email'] ?? ''));
|
||||
$sender = trim((string) ($row['sender'] ?? ''));
|
||||
$encrypted = $this->resolvePasswordEncrypted($row, $this->integrations->findById($integrationId));
|
||||
|
||||
if ($userEmail === '' || $sender === '' || $encrypted === null || $encrypted === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$password = trim($this->cipher->decrypt($encrypted));
|
||||
if ($password === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'integration_id' => $integrationId,
|
||||
'user_email' => $userEmail,
|
||||
'password' => $password,
|
||||
'sender' => $sender,
|
||||
'convert_message_to_gsm7' => !empty($row['convert_message_to_gsm7']),
|
||||
];
|
||||
}
|
||||
|
||||
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 hostedsms_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 hostedsms_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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $row
|
||||
* @param array<string, mixed>|null $integration
|
||||
*/
|
||||
private function resolvePasswordEncrypted(?array $row, ?array $integration): ?string
|
||||
{
|
||||
$settingsValue = trim((string) ($row['password_encrypted'] ?? ''));
|
||||
if ($settingsValue !== '') {
|
||||
return $settingsValue;
|
||||
}
|
||||
|
||||
$baseValue = trim((string) ($integration['api_key_encrypted'] ?? ''));
|
||||
return StringHelper::nullableString($baseValue);
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,8 @@ final class IntegrationsHubController
|
||||
private readonly ApaczkaIntegrationRepository $apaczka,
|
||||
private readonly InpostIntegrationRepository $inpost,
|
||||
private readonly ShopproIntegrationsRepository $shoppro,
|
||||
private readonly FakturowniaIntegrationRepository $fakturownia
|
||||
private readonly FakturowniaIntegrationRepository $fakturownia,
|
||||
private readonly HostedSmsIntegrationRepository $hostedSms
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ final class IntegrationsHubController
|
||||
$this->buildInpostRow(),
|
||||
$this->buildShopproRow(),
|
||||
$this->buildFakturowniaRow(),
|
||||
$this->buildHostedSmsRow(),
|
||||
];
|
||||
|
||||
$html = $this->template->render('settings/integrations', [
|
||||
@@ -214,4 +216,30 @@ final class IntegrationsHubController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildHostedSmsRow(): array
|
||||
{
|
||||
$settings = $this->hostedSms->getSettings();
|
||||
$isConfigured = !empty($settings['user_email'])
|
||||
&& !empty($settings['sender'])
|
||||
&& !empty($settings['has_password']);
|
||||
|
||||
return [
|
||||
'provider' => $this->translator->get('settings.integrations_hub.providers.hostedsms'),
|
||||
'instance' => 'HostedSMS',
|
||||
'authorization_status' => $isConfigured
|
||||
? $this->translator->get('settings.integrations_hub.status.configured')
|
||||
: $this->translator->get('settings.integrations_hub.status.not_configured'),
|
||||
'secret_status' => !empty($settings['has_password'])
|
||||
? $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/hostedsms',
|
||||
'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user