feat: Implement SMS sending functionality with KavehNegar and Rangineh providers

- Add SendSmsMessage class for encapsulating SMS message data.
- Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS.
- Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates.
- Develop SendSmsHandler for handling SMS sending messages.
- Create SmsService to manage SMS dispatching and logging.
- Add UserProfileController for managing user profiles with CRUD operations.
- Implement UserProfile entity and repository for user profile data management.
- Update symfony.lock and bootstrap.php for project dependencies and environment setup.
This commit is contained in:
hamed
2026-06-09 22:00:34 +03:30
commit de1a78a235
222 changed files with 36388 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Shared\Constant;
class ErrorCodes
{
// Auth
public const ERR_AUTH_001 = 'ERR_AUTH_001';
public const ERR_AUTH_002 = 'ERR_AUTH_002';
public const ERR_AUTH_003 = 'ERR_AUTH_003';
public const ERR_AUTH_004 = 'ERR_AUTH_004';
public const ERR_AUTH_005 = 'ERR_AUTH_005';
public const ERR_AUTH_006 = 'ERR_AUTH_006';
// Validation
public const ERR_VALIDATION_001 = 'ERR_VALIDATION_001';
public const ERR_VALIDATION_002 = 'ERR_VALIDATION_002';
// Not Found
public const ERR_NOT_FOUND_001 = 'ERR_NOT_FOUND_001';
// Conflict
public const ERR_CONFLICT_001 = 'ERR_CONFLICT_001';
// Forbidden
public const ERR_FORBIDDEN_001 = 'ERR_FORBIDDEN_001';
// Payment
public const ERR_PAYMENT_001 = 'ERR_PAYMENT_001';
public const ERR_PAYMENT_002 = 'ERR_PAYMENT_002';
public const ERR_PAYMENT_003 = 'ERR_PAYMENT_003';
// Appointment
public const ERR_APPOINTMENT_001 = 'ERR_APPOINTMENT_001';
public const ERR_APPOINTMENT_002 = 'ERR_APPOINTMENT_002';
// File
public const ERR_FILE_001 = 'ERR_FILE_001';
public const ERR_FILE_002 = 'ERR_FILE_002';
// SMS
public const ERR_SMS_001 = 'ERR_SMS_001';
public const ERR_SMS_002 = 'ERR_SMS_002';
public const ERR_SMS_003 = 'ERR_SMS_003';
// Secretary
public const ERR_SECRETARY_001 = 'ERR_SECRETARY_001';
// Rate Limit
public const ERR_RATE_LIMIT_001 = 'ERR_RATE_LIMIT_001';
public static function message(string $code): string
{
return match ($code) {
self::ERR_AUTH_001 => 'توکن JWT منقضی شده یا نامعتبر است',
self::ERR_AUTH_002 => 'کد OTP نامعتبر است',
self::ERR_AUTH_003 => 'کد OTP منقضی شده است',
self::ERR_AUTH_004 => 'تعداد تلاش‌های OTP به حد مجاز رسیده است',
self::ERR_AUTH_005 => 'نام کاربری یا رمز عبور اشتباه است',
self::ERR_AUTH_006 => 'این نوع حساب فقط از طریق کد OTP وارد می‌شود',
self::ERR_VALIDATION_001 => 'ورودی نامعتبر است',
self::ERR_VALIDATION_002 => 'فیلد الزامی وارد نشده است',
self::ERR_NOT_FOUND_001 => 'منبع درخواستی یافت نشد',
self::ERR_FORBIDDEN_001 => 'دسترسی به این منبع مجاز نیست',
self::ERR_PAYMENT_001 => 'درگاه پرداخت در دسترس نیست',
self::ERR_PAYMENT_002 => 'مبلغ پرداخت نامعتبر است',
self::ERR_PAYMENT_003 => 'وضعیت نوبت برای پرداخت مناسب نیست',
self::ERR_APPOINTMENT_001 => 'اسلات انتخاب‌شده در دسترس نیست',
self::ERR_APPOINTMENT_002 => 'نوبت قابل لغو نیست',
self::ERR_FILE_001 => 'فرمت فایل مجاز نیست',
self::ERR_FILE_002 => 'حجم فایل بیش از حد مجاز است (حداکثر 5MB)',
self::ERR_SMS_001 => 'موجودی پیامک کافی نیست',
self::ERR_SMS_002 => 'متغیر نامعتبر در تمپلیت',
self::ERR_SMS_003 => 'تمپلیت قبلاً ارسال شده است',
self::ERR_SECRETARY_001 => 'پلن فعلی اجازه منشی بیشتر را نمی‌دهد',
self::ERR_CONFLICT_001 => 'تداخل: منبع در حال استفاده است یا قبلاً تغییر کرده است',
self::ERR_RATE_LIMIT_001 => 'درخواست‌های زیاد. لطفاً بعداً تلاش کنید',
default => 'خطای ناشناخته',
};
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Shared\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
abstract class BaseController extends AbstractController
{
protected function success(mixed $data, int $status = 200, array $meta = []): JsonResponse
{
$response = ['success' => true, 'data' => $data];
if (!empty($meta)) {
$response['meta'] = $meta;
}
return new JsonResponse($response, $status);
}
protected function paginated(mixed $data, int $total, int $page, int $limit): JsonResponse
{
return $this->success($data, 200, [
'totalRecords' => $total,
'totalPages' => (int) ceil($total / max($limit, 1)),
'currentPage' => $page,
]);
}
protected function error(string $code, string $message, int $status = 400, ?string $field = null): JsonResponse
{
$err = ['code' => $code, 'message' => $message];
if ($field !== null) {
$err['field'] = $field;
}
return new JsonResponse(['success' => false, 'data' => null, 'errors' => [$err]], $status);
}
protected function validationError(array $violations): JsonResponse
{
$errors = [];
foreach ($violations as $field => $messages) {
foreach ((array) $messages as $message) {
$errors[] = [
'code' => 'ERR_VALIDATION_001',
'field' => $field,
'message' => $message,
];
}
}
return new JsonResponse(['success' => false, 'data' => null, 'errors' => $errors], 422);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Shared\Controller;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
class HealthController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly CacheInterface $cache,
) {}
#[Route('/health', methods: ['GET'])]
public function __invoke(): JsonResponse
{
$checks = [];
$status = 'ok';
try {
$this->em->getConnection()->executeQuery('SELECT 1');
$checks['database'] = 'ok';
} catch (\Throwable) {
$checks['database'] = 'error';
$status = 'degraded';
}
try {
$item = $this->cache->getItem('health_check');
$checks['redis'] = 'ok';
} catch (\Throwable) {
$checks['redis'] = 'error';
$status = 'degraded';
}
return new JsonResponse([
'status' => $status,
'checks' => $checks,
'timestamp' => time(),
], $status === 'ok' ? 200 : 503);
}
}
@@ -0,0 +1,123 @@
<?php
namespace App\Shared\EventSubscriber;
use App\Shared\Exception\AppException;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class ExceptionSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly TokenStorageInterface $tokenStorage,
) {}
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if ($exception instanceof AppException) {
$err = ['code' => $exception->getErrorCode(), 'message' => $exception->getMessage()];
if ($exception->getField()) {
$err['field'] = $exception->getField();
}
$event->setResponse(new JsonResponse(
['success' => false, 'data' => null, 'errors' => [$err]],
$exception->getHttpStatus()
));
return;
}
if ($exception instanceof TooManyRequestsHttpException) {
$headers = [];
$retryAfter = $exception->getHeaders()['Retry-After'] ?? null;
if ($retryAfter !== null) {
$headers['Retry-After'] = $retryAfter;
}
$event->setResponse(new JsonResponse(
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_RATE_LIMIT_001', 'message' => 'درخواست‌های زیاد. لطفاً بعداً تلاش کنید']]],
429,
$headers
));
return;
}
if ($exception instanceof NotFoundHttpException) {
$event->setResponse(new JsonResponse(
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_NOT_FOUND_001', 'message' => 'منبع درخواستی یافت نشد']]],
404
));
return;
}
if ($exception instanceof AccessDeniedHttpException) {
$event->setResponse(new JsonResponse(
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_FORBIDDEN_001', 'message' => 'دسترسی به این منبع مجاز نیست']]],
403
));
return;
}
if ($exception instanceof UnauthorizedHttpException) {
$event->setResponse(new JsonResponse(
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_AUTH_001', 'message' => 'احراز هویت لازم است']]],
401
));
return;
}
// Security exceptions not yet wrapped into HttpException
if ($exception instanceof AccessDeniedException) {
$token = $this->tokenStorage->getToken();
$isAuthenticated = $token !== null && $token->getUser() !== null;
if (!$isAuthenticated) {
$event->setResponse(new JsonResponse(
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_AUTH_001', 'message' => 'احراز هویت لازم است']]],
401
));
} else {
$event->setResponse(new JsonResponse(
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_FORBIDDEN_001', 'message' => 'دسترسی به این منبع مجاز نیست']]],
403
));
}
return;
}
if ($exception instanceof AuthenticationException) {
$event->setResponse(new JsonResponse(
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_AUTH_001', 'message' => 'احراز هویت لازم است']]],
401
));
return;
}
// Generic fallback: never leak stack traces or internal details in API responses
$this->logger->error('Unhandled exception', [
'exception' => $exception,
'path' => $event->getRequest()->getPathInfo(),
]);
$event->setResponse(new JsonResponse(
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_INTERNAL_001', 'message' => 'خطای داخلی سرور']]],
500
));
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::EXCEPTION => ['onKernelException', 10]];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Shared\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class SecurityHeadersSubscriber implements EventSubscriberInterface
{
public function onKernelResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$response = $event->getResponse();
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
if ($event->getRequest()->isSecure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
if (str_starts_with($event->getRequest()->getPathInfo(), '/api')) {
$response->headers->set('Content-Security-Policy', "default-src 'none'");
}
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::RESPONSE => 'onKernelResponse'];
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Shared\Exception;
use App\Shared\Constant\ErrorCodes;
class AppException extends \RuntimeException
{
public function __construct(
private readonly string $errorCode,
?string $message = null,
private readonly int $httpStatus = 400,
private readonly ?string $field = null,
) {
parent::__construct($message ?? ErrorCodes::message($errorCode));
}
public function getErrorCode(): string { return $this->errorCode; }
public function getHttpStatus(): int { return $this->httpStatus; }
public function getField(): ?string { return $this->field; }
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Shared\Message;
class SendSmsMessage
{
public function __construct(
public readonly string $mobile,
public readonly string $message,
public readonly ?int $smsLogId = null,
) {}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Shared\Service;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class FileValidatorService
{
private const ALLOWED_SIGNATURES = [
'image/jpeg' => ["\xFF\xD8\xFF"],
'image/png' => ["\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"],
'image/webp' => ["RIFF"],
];
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
public function __construct(private readonly int $maxSizeBytes = 5_242_880) {}
public function validateUploadedFile(UploadedFile $file): string
{
if ($file->getSize() > $this->maxSizeBytes) {
throw new AppException(ErrorCodes::ERR_FILE_002, null, 422);
}
$binaryContent = (string) file_get_contents($file->getPathname());
return $this->validate($binaryContent, $file->getClientOriginalName());
}
public function validate(string $binaryContent, string $claimedFilename): string
{
if (strlen($binaryContent) > $this->maxSizeBytes) {
throw new AppException(ErrorCodes::ERR_FILE_002, null, 422);
}
$detected = false;
foreach (self::ALLOWED_SIGNATURES as $signatures) {
foreach ($signatures as $sig) {
if (str_starts_with($binaryContent, $sig)) {
$detected = true;
break 2;
}
}
}
if (!$detected) {
throw new AppException(ErrorCodes::ERR_FILE_001, null, 422);
}
return $this->sanitizeFilename($claimedFilename);
}
public function sanitizeFilename(string $filename): string
{
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', basename($filename));
if (empty($safeName) || str_contains($safeName, '..')) {
throw new AppException(ErrorCodes::ERR_FILE_001, null, 422);
}
$ext = strtolower(pathinfo($safeName, PATHINFO_EXTENSION));
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
throw new AppException(ErrorCodes::ERR_FILE_001, null, 422);
}
return $safeName;
}
public function detectMimeType(string $filePath): string
{
$handle = fopen($filePath, 'rb');
$header = fread($handle, 12);
fclose($handle);
foreach (self::ALLOWED_SIGNATURES as $mime => $signatures) {
foreach ($signatures as $sig) {
if (str_starts_with($header, $sig)) {
return $mime;
}
}
}
throw new AppException(ErrorCodes::ERR_FILE_001, 'نوع فایل پشتیبانی نمی‌شود', 422);
}
}