Refactor SMS sending to use KavehNegar VerifyLookup templates

- Removed SmsTextResolver dependency from multiple services and controllers.
- Introduced dispatchTemplate method in SmsService to handle SMS sending with templates.
- Updated existing SMS sending logic across various services (OtpService, PreRegistrationController, ClinicInvitationService, PaymentManager, SecretaryController, RepresentationActionController) to utilize the new dispatchTemplate method.
- Enhanced SmsMessageTemplate entity to include kavenegar_template and token_map fields.
- Created migration to add new fields to the sms_message_templates table and populate them with existing data.
- Updated SeedSmsMessageTemplatesCommand to handle new template structure.
- Added documentation for the new SMS template structure and usage.
This commit is contained in:
hamed
2026-07-05 11:20:53 +03:30
parent 828e3552c0
commit 969dc9651f
17 changed files with 480 additions and 102 deletions
@@ -26,7 +26,6 @@ class NotificationMobileController extends BaseController
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly SmsService $smsService,
private readonly \App\Sms\Service\SmsTextResolver $smsText,
) {}
// ── Request OTP ───────────────────────────────────────────────────────────
@@ -61,13 +60,10 @@ class NotificationMobileController extends BaseController
$this->em->flush();
// ارسال SMS
$message = $this->smsText->resolve(\App\Sms\Entity\SmsLog::TAG_NOTIFICATION_MOBILE, [
'code' => $otp->getOtpCode(),
]);
$this->smsService->dispatchAsync(
$this->smsService->dispatchTemplate(
\App\Sms\Entity\SmsLog::TAG_NOTIFICATION_MOBILE,
$mobile,
$message,
tag: \App\Sms\Entity\SmsLog::TAG_NOTIFICATION_MOBILE,
['code' => $otp->getOtpCode()],
);
return $this->success([
@@ -33,7 +33,6 @@ class PreRegistrationController extends BaseController
private readonly ClinicRepository $clinicRepo,
private readonly UserPasswordHasherInterface $hasher,
private readonly SmsService $sms,
private readonly \App\Sms\Service\SmsTextResolver $smsText,
private readonly LoggerInterface $logger,
) {}
@@ -155,15 +154,14 @@ class PreRegistrationController extends BaseController
$this->em->flush();
try {
$message = $this->smsText->resolve(\App\Sms\Entity\SmsLog::TAG_PRE_REGISTRATION, [
'username' => $preReg->getMobile(),
'password' => $password,
'link' => 'https://clinic-pro.ddev.site/admin',
]);
$this->sms->dispatchAsync(
$this->sms->dispatchTemplate(
\App\Sms\Entity\SmsLog::TAG_PRE_REGISTRATION,
$preReg->getMobile(),
$message,
tag: \App\Sms\Entity\SmsLog::TAG_PRE_REGISTRATION,
[
'username' => $preReg->getMobile(),
'password' => $password,
'link' => 'https://clinic-pro.ddev.site/admin',
],
);
} catch (\Throwable $e) {
$this->logger->warning('PreRegistration SMS failed', ['uuid' => $uuid, 'error' => $e->getMessage()]);
+2 -18
View File
@@ -7,7 +7,6 @@ use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Sms\Entity\SmsLog;
use App\Sms\Service\SmsService;
use App\Sms\Service\SmsTextResolver;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\Cache\CacheInterface;
@@ -16,11 +15,9 @@ class OtpService
public function __construct(
private readonly CacheInterface $cache,
private readonly SmsService $sms,
private readonly SmsTextResolver $smsText,
private readonly CityRepository $cityRepo,
private readonly int $otpTtl = 1200,
private readonly string $appEnv = 'dev',
private readonly ?string $otpTemplate = null,
) {}
private function key(string $uuid): string
@@ -72,21 +69,8 @@ class OtpService
$this->cache->save($item);
if ($this->appEnv !== 'dev') {
$site = $this->resolveSiteName($domain);
$message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code, 'site' => $site]);
if ($this->otpTemplate) {
// Kavenegar VerifyLookup: code has no spaces → token; site may contain spaces → token10.
$this->sms->dispatchAsync(
$mobile,
$message,
templateCode: $this->otpTemplate,
templateVars: ['token' => $code, 'token10' => $site],
tag: SmsLog::TAG_OTP,
);
} else {
$this->sms->dispatchAsync($mobile, $message, tag: SmsLog::TAG_OTP);
}
$site = $this->resolveSiteName($domain);
$this->sms->dispatchTemplate(SmsLog::TAG_OTP, $mobile, ['code' => $code, 'site' => $site]);
}
return $uuid;
@@ -17,7 +17,6 @@ class ClinicInvitationService
private readonly ClinicDoctorInvitationRepository $repo,
private readonly DoctorRepository $doctorRepo,
private readonly SmsService $smsService,
private readonly \App\Sms\Service\SmsTextResolver $smsText,
private readonly EntityManagerInterface $em,
private readonly string $appUrl,
) {}
@@ -113,11 +112,9 @@ class ClinicInvitationService
$clinicName = $clinic->getName() ?? 'کلینیک';
$link = rtrim($this->appUrl, '/') . '/clinic-invitation/' . $inv->getToken();
$message = $this->smsText->resolve(\App\Sms\Entity\SmsLog::TAG_CLINIC_INVITATION, [
$this->smsService->dispatchTemplate(\App\Sms\Entity\SmsLog::TAG_CLINIC_INVITATION, $inv->getMobile(), [
'clinic' => $clinicName,
'link' => $link,
]);
$this->smsService->dispatchAsync($inv->getMobile(), $message, tag: \App\Sms\Entity\SmsLog::TAG_CLINIC_INVITATION);
}
}
+3 -7
View File
@@ -16,7 +16,6 @@ use App\Representation\Service\JalaliDateService;
use App\Settlement\Service\CommissionService;
use App\Sms\Entity\SmsLog;
use App\Sms\Service\SmsService;
use App\Sms\Service\SmsTextResolver;
use App\Sms\Service\SmsWalletService;
use App\Subscription\Service\SubscriptionService;
use Doctrine\ORM\EntityManagerInterface;
@@ -38,7 +37,6 @@ final class PaymentManager
private readonly SubscriptionService $subscriptionService,
private readonly SmsWalletService $smsWalletService,
private readonly SmsService $smsService,
private readonly SmsTextResolver $smsText,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly CommissionService $commissionService,
@@ -314,23 +312,21 @@ final class PaymentManager
$mobile = $appointment->getPatientMobile();
if ($mobile) {
$when = $this->jalali->formatDateTime($appointment->getSlotStart());
$message = $this->smsText->resolve(SmsLog::TAG_PAYMENT, [
$when = $this->jalali->formatDateTime($appointment->getSlotStart());
$this->smsService->dispatchTemplate(SmsLog::TAG_PAYMENT, $mobile, [
'doctor' => $doctor->getName(),
'date' => $when,
]);
$this->smsService->dispatchAsync($mobile, $message, tag: SmsLog::TAG_PAYMENT);
}
// اعلان به شمارهٔ «اعلان نوبت» دکتر — فقط برای نوبت‌های پرداخت‌شدهٔ سایت (همین مسیر).
$notify = $doctor->getNotificationMobile();
if ($notify) {
$docMessage = $this->smsText->resolve(SmsLog::TAG_DOCTOR_APPOINTMENT, [
$this->smsService->dispatchTemplate(SmsLog::TAG_DOCTOR_APPOINTMENT, $notify, [
'patient' => $appointment->getPatientName() ?? '—',
'date' => $this->jalali->formatDateTime($appointment->getSlotStart(), false),
'time' => date('H:i', $appointment->getSlotStart()),
]);
$this->smsService->dispatchAsync($notify, $docMessage, tag: SmsLog::TAG_DOCTOR_APPOINTMENT);
}
}
@@ -310,10 +310,10 @@ class RepresentationActionController extends BaseController
// پیام خوش‌آمد به پزشک
$siteName = $this->configRepo->get('site_name') ?: 'کلینیک‌پرو';
$this->smsService->dispatchAsync(
$this->smsService->dispatchTemplate(
\App\Sms\Entity\SmsLog::TAG_WELCOME,
$mobile,
sprintf('دکتر %s عزیز، به %s خوش آمدید. پروفایل شما توسط نماینده ثبت شد.', $name, $siteName),
tag: \App\Sms\Entity\SmsLog::TAG_WELCOME,
['name' => $name, 'site' => $siteName],
);
return $this->success(['uuid' => $doctor->getUuid()], 201);
@@ -385,10 +385,10 @@ class RepresentationActionController extends BaseController
// پیام خوش‌آمد به مالک کلینیک
$siteName = $this->configRepo->get('site_name') ?: 'کلینیک‌پرو';
$this->smsService->dispatchAsync(
$this->smsService->dispatchTemplate(
\App\Sms\Entity\SmsLog::TAG_WELCOME,
$mobile,
sprintf('کلینیک %s به %s خوش آمد. پروفایل کلینیک شما توسط نماینده ثبت شد.', $name, $siteName),
tag: \App\Sms\Entity\SmsLog::TAG_WELCOME,
['name' => $name, 'site' => $siteName],
);
return $this->success([
@@ -12,7 +12,6 @@ use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Sms\Entity\SmsLog;
use App\Sms\Service\SmsService;
use App\Sms\Service\SmsTextResolver;
use App\Subscription\Service\SubscriptionService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -34,7 +33,6 @@ class SecretaryController extends BaseController
private readonly UserPasswordHasherInterface $hasher,
private readonly SubscriptionService $subscriptionService,
private readonly SmsService $smsService,
private readonly SmsTextResolver $smsText,
private readonly string $appUrl,
) {}
@@ -130,13 +128,11 @@ class SecretaryController extends BaseController
$link = rtrim($this->appUrl, '/') . '/login';
$message = $this->smsText->resolve(SmsLog::TAG_SECRETARY, [
$this->smsService->dispatchTemplate(SmsLog::TAG_SECRETARY, $mobile, [
'owner' => $ownerName,
'username' => $mobile,
'link' => $link,
]);
$this->smsService->dispatchAsync($mobile, $message, tag: SmsLog::TAG_SECRETARY);
}
#[Route('/api/v1/secretary/{uuid}', methods: ['GET'])]
@@ -23,19 +23,33 @@ class SeedSmsMessageTemplatesCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$created = 0;
$backfilled = 0;
foreach (SmsMessageTemplate::DEFAULTS as $tag => $def) {
if ($this->repo->findByTag($tag) !== null) {
$existing = $this->repo->findByTag($tag);
if ($existing !== null) {
if ($existing->getKavenegarTemplate() === null) {
$existing->setKavenegarTemplate($def['kavenegar_template']);
$existing->setTokenMap($def['token_map']);
$backfilled++;
}
continue;
}
$this->repo->save(
new SmsMessageTemplate($tag, $def['title'], $def['body'], $def['variables']),
new SmsMessageTemplate(
$tag,
$def['title'],
$def['body'],
$def['variables'],
$def['kavenegar_template'],
$def['token_map'],
),
false,
);
$created++;
}
$this->repo->getEntityManager()->flush();
$output->writeln(sprintf('Seeded %d sms message templates.', $created));
$output->writeln(sprintf('Seeded %d, backfilled %d sms message templates.', $created, $backfilled));
return Command::SUCCESS;
}
}
+21 -7
View File
@@ -41,11 +41,13 @@ class SmsMessageController extends BaseController
$items = [];
foreach (SmsMessageTemplate::DEFAULTS as $tag => $def) {
$items[] = $existing[$tag] ?? [
'tag' => $tag,
'title' => $def['title'],
'body' => $def['body'],
'variables' => $def['variables'],
'updated_at' => null,
'tag' => $tag,
'title' => $def['title'],
'body' => $def['body'],
'variables' => $def['variables'],
'kavenegar_template' => $def['kavenegar_template'],
'token_map' => $def['token_map'],
'updated_at' => null,
];
}
@@ -95,13 +97,25 @@ class SmsMessageController extends BaseController
);
}
$def = SmsMessageTemplate::DEFAULTS[$tag];
$tpl = $this->repo->findByTag($tag);
if ($tpl === null) {
$def = SmsMessageTemplate::DEFAULTS[$tag];
$tpl = new SmsMessageTemplate($tag, $def['title'], $body, $def['variables']);
$tpl = new SmsMessageTemplate(
$tag,
$def['title'],
$body,
$def['variables'],
$def['kavenegar_template'],
$def['token_map'],
);
} else {
$tpl->setBody($body);
}
if (array_key_exists('kavenegar_template', $data)) {
$name = trim((string) $data['kavenegar_template']);
$tpl->setKavenegarTemplate($name !== '' ? $name : null);
}
$this->repo->save($tpl);
return $this->success(['data' => $tpl->toArray()]);
+62 -19
View File
@@ -15,43 +15,66 @@ class SmsMessageTemplate
{
/**
* متن و متادیتای پیش‌فرض هر تگ — مرجع برای seed و fallback.
* @var array<string, array{title: string, body: string, variables: string[]}>
* kavenegar_template: نام تمپلت ساخته‌شده در پنل کاوه‌نگار (VerifyLookup).
* token_map: نگاشت متغیر منطقی → جایگاه کاوه‌نگار (token/token2/token3 بدون فاصله، token10/token20 با فاصله).
* @var array<string, array{title: string, body: string, variables: string[], kavenegar_template: string, token_map: array<string,string>}>
*/
public const DEFAULTS = [
SmsLog::TAG_OTP => [
'title' => 'کد تأیید ورود',
'body' => 'کد تأیید شما: {code}',
'variables' => ['code', 'site'],
'kavenegar_template' => 'clinicpro-otp',
'token_map' => ['code' => 'token', 'site' => 'token10'],
],
SmsLog::TAG_PAYMENT => [
'title' => 'تأیید پرداخت و نوبت',
'body' => 'نوبت شما با {doctor} در تاریخ {date} ثبت و تأیید شد.',
'variables' => ['doctor', 'date'],
'kavenegar_template' => 'clinicpro-payment',
'token_map' => ['doctor' => 'token10', 'date' => 'token20'],
],
SmsLog::TAG_CLINIC_INVITATION => [
'title' => 'دعوت پزشک به کلینیک',
'body' => "دکتر گرامی، کلینیک {clinic} شما را برای همکاری دعوت کرده است.\nبرای بررسی: {link}\nاین لینک تا ۷۲ ساعت معتبر است.",
'variables' => ['clinic', 'link'],
'kavenegar_template' => 'clinicpro-clinic-invite',
'token_map' => ['clinic' => 'token10', 'link' => 'token'],
],
SmsLog::TAG_PRE_REGISTRATION => [
'title' => 'پیش‌ثبت‌نام',
'body' => 'به کلینیک پرو خوش آمدید! شماره‌کاربری: {username} | رمز عبور: {password} | لینک ورود: {link}',
'variables' => ['username', 'password', 'link'],
'kavenegar_template' => 'clinicpro-pre-register',
'token_map' => ['username' => 'token', 'password' => 'token2', 'link' => 'token3'],
],
SmsLog::TAG_NOTIFICATION_MOBILE => [
'title' => 'تأیید شماره اعلان',
'body' => "کد تأیید شماره اعلان شما: {code}\nاعتبار: ۵ دقیقه",
'variables' => ['code'],
'kavenegar_template' => 'clinicpro-notify-code',
'token_map' => ['code' => 'token'],
],
SmsLog::TAG_SECRETARY => [
'title' => 'دعوت به‌عنوان منشی',
'body' => "شما به‌عنوان منشیِ {owner} در کلینیک‌پرو تعریف شدید.\nشماره‌کاربری: {username}\nلینک ورود: {link}",
'variables' => ['owner', 'username', 'link'],
'kavenegar_template' => 'clinicpro-secretary',
'token_map' => ['owner' => 'token10', 'username' => 'token', 'link' => 'token3'],
],
SmsLog::TAG_DOCTOR_APPOINTMENT => [
'title' => 'نوبت جدید (اعلان به پزشک)',
'body' => "نوبت جدید ثبت شد.\nبیمار: {patient}\nتاریخ: {date} ساعت {time}\n\nبرای مدیریت نوبت‌های خود به‌صورت رایگان به سایت clinic-pro.ir مراجعه کنید.\nکلینیک پرو",
'variables' => ['patient', 'date', 'time'],
'kavenegar_template' => 'clinicpro-doctor-appt',
'token_map' => ['patient' => 'token10', 'date' => 'token2', 'time' => 'token3'],
],
SmsLog::TAG_WELCOME => [
'title' => 'خوش‌آمدگویی',
'body' => '{name} عزیز، به {site} خوش آمدید. پروفایل شما توسط نماینده ثبت شد.',
'variables' => ['name', 'site'],
'kavenegar_template' => 'clinicpro-welcome',
'token_map' => ['name' => 'token10', 'site' => 'token20'],
],
];
@@ -72,36 +95,56 @@ class SmsMessageTemplate
#[ORM\Column(type: 'json')]
private array $variables = [];
#[ORM\Column(name: 'kavenegar_template', type: 'string', length: 100, nullable: true)]
private ?string $kavenegarTemplate = null;
#[ORM\Column(name: 'token_map', type: 'json')]
private array $tokenMap = [];
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $tag, string $title, string $body, array $variables = [])
{
$this->tag = $tag;
$this->title = $title;
$this->body = $body;
$this->variables = $variables;
$this->updatedAt = time();
public function __construct(
string $tag,
string $title,
string $body,
array $variables = [],
?string $kavenegarTemplate = null,
array $tokenMap = [],
) {
$this->tag = $tag;
$this->title = $title;
$this->body = $body;
$this->variables = $variables;
$this->kavenegarTemplate = $kavenegarTemplate;
$this->tokenMap = $tokenMap;
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getTag(): string { return $this->tag; }
public function getTitle(): string { return $this->title; }
public function getBody(): string { return $this->body; }
public function getVariables(): array { return $this->variables; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function getId(): ?int { return $this->id; }
public function getTag(): string { return $this->tag; }
public function getTitle(): string { return $this->title; }
public function getBody(): string { return $this->body; }
public function getVariables(): array { return $this->variables; }
public function getKavenegarTemplate(): ?string { return $this->kavenegarTemplate; }
public function getTokenMap(): array { return $this->tokenMap; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function setTitle(string $v): self { $this->title = $v; $this->updatedAt = time(); return $this; }
public function setBody(string $v): self { $this->body = $v; $this->updatedAt = time(); return $this; }
public function setKavenegarTemplate(?string $v): self { $this->kavenegarTemplate = $v; $this->updatedAt = time(); return $this; }
public function setTokenMap(array $v): self { $this->tokenMap = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
return [
'tag' => $this->tag,
'title' => $this->title,
'body' => $this->body,
'variables' => $this->variables,
'updated_at' => $this->updatedAt,
'tag' => $this->tag,
'title' => $this->title,
'body' => $this->body,
'variables' => $this->variables,
'kavenegar_template' => $this->kavenegarTemplate,
'token_map' => $this->tokenMap,
'updated_at' => $this->updatedAt,
];
}
}
+39 -4
View File
@@ -3,11 +3,13 @@
namespace App\Sms\Service;
use App\Sms\Entity\SmsLog;
use App\Sms\Entity\SmsMessageTemplate;
use App\Sms\Message\SendSmsMessage;
use App\Sms\Provider\KavehNegarProvider;
use App\Sms\Provider\RanginehProvider;
use App\Sms\Provider\SmsProviderInterface;
use App\Sms\Repository\SmsLogRepository;
use App\Sms\Repository\SmsMessageTemplateRepository;
use Symfony\Component\Messenger\MessageBusInterface;
class SmsService
@@ -15,10 +17,12 @@ class SmsService
private array $providers;
public function __construct(
private readonly KavehNegarProvider $kavenegar,
private readonly RanginehProvider $rangineh,
private readonly SmsLogRepository $logRepo,
private readonly MessageBusInterface $bus,
private readonly KavehNegarProvider $kavenegar,
private readonly RanginehProvider $rangineh,
private readonly SmsLogRepository $logRepo,
private readonly MessageBusInterface $bus,
private readonly SmsMessageTemplateRepository $messageTemplateRepo,
private readonly SmsTextResolver $textResolver,
) {
$this->providers = [
'kavenegar' => $kavenegar,
@@ -26,6 +30,37 @@ class SmsService
];
}
/**
* ارسال یک پیامک سیستمی از طریق تمپلت VerifyLookup کاوه‌نگار.
* فقط تگ + متغیرهای منطقی می‌گیرد؛ نام تمپلت و نگاشت token از SmsMessageTemplate (یا DEFAULTS) خوانده می‌شود.
*
* @param array<string,string|int> $vars متغیرهای منطقی (کلیدهای {...} بدنه)
*/
public function dispatchTemplate(string $tag, string $mobile, array $vars = [], string $provider = 'kavenegar'): void
{
$tpl = $this->messageTemplateRepo->findByTag($tag);
$kaveTemplate = $tpl?->getKavenegarTemplate()
?? (SmsMessageTemplate::DEFAULTS[$tag]['kavenegar_template'] ?? null);
$tokenMap = $tpl?->getTokenMap()
?: (SmsMessageTemplate::DEFAULTS[$tag]['token_map'] ?? []);
$message = $this->textResolver->resolve($tag, $vars);
if ($kaveTemplate === null || $tokenMap === []) {
$this->dispatchAsync($mobile, $message, $provider, tag: $tag);
return;
}
$slotVars = [];
foreach ($tokenMap as $logicalKey => $slot) {
if (array_key_exists($logicalKey, $vars)) {
$slotVars[$slot] = (string) $vars[$logicalKey];
}
}
$this->dispatchAsync($mobile, $message, $provider, templateVars: $slotVars, templateCode: $kaveTemplate, tag: $tag);
}
public function dispatchAsync(
string $mobile,
string $message,