feat: Add tagging system for SMS logs and templates

- Introduced a `tag` field in the `SmsLog` entity to categorize SMS messages.
- Updated the `SmsService` to handle the new `tag` parameter during SMS dispatch.
- Implemented a `SmsTextResolver` service to resolve SMS message templates based on tags.
- Created a new `SmsMessageTemplate` entity for editable SMS templates with placeholders.
- Added endpoints for managing SMS message templates in the admin panel.
- Enhanced existing SMS dispatching methods across various controllers to utilize the tagging system.
- Migrated the database to include the new `tag` field and created a seeding command for default SMS templates.
- Updated admin API to filter SMS logs by tag and include tag information in responses.
This commit is contained in:
hamed
2026-06-19 20:42:04 +03:30
parent da57c554c1
commit fa332f7fa1
22 changed files with 846 additions and 35 deletions
+211
View File
@@ -0,0 +1,211 @@
# تگ‌گذاری و لیست کامل پیامک‌های ارسالی
## پروژه
`clinicpro` (Backend SMS + Admin React SPA). کاملاً داخل همین پروژه است.
## زمینه
هر پیامکی که سیستم ارسال می‌کند در جدول `sms_logs` (Entity `SmsLog`) ذخیره و در تب «لاگ‌ها»ی صفحه‌ی `/admin/sms` (`SmsPage`) از طریق `GET /api/v1/admin/sms/logs` نمایش داده می‌شود. اما:
1. لاگ‌ها **تگ/نوع** ندارند — نمی‌توان فهمید یک پیامک مربوط به کدام بخش است (OTP ورود، تأیید پرداخت، دعوت کلینیک، پیش‌ثبت‌نام، تأیید موبایل اعلان، یا پیامکِ قالبیِ کاربرِ پنل).
2. مسیر ارسال یکدست نیست: بیشتر جاها از `SmsService::dispatchAsync(...)` استفاده می‌کنند، ولی `OtpService` مستقیماً `bus->dispatch(new SendSmsMessage(...))` صدا می‌زند.
هدف: همه‌ی پیامک‌های ارسالی با یک **تگ** ذخیره شوند (مثل `global` برای پیامک‌های سیستمیِ خودِ اپلیکیشن: OTP، تأیید پرداخت، و...) و در صفحه‌ی `/admin/sms` با ستون/فیلتر تگ دیده شوند.
## مشکل / هدف
افزودن فیلد `tag` به جریان ارسال و لاگ پیامک، تگ‌گذاری همه‌ی نقاط ارسال، نمایش/فیلتر تگ در پنل ادمین، و **ویرایش‌پذیر کردن متن همه‌ی پیامک‌ها از پنل** (متن‌های سیستمی که الان هاردکدند، بر اساس تگ قابل ویرایش شوند).
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Sms/Entity/SmsLog.php` | افزودن ستون `tag` (نیازمند migration) |
| `src/Sms/Message/SendSmsMessage.php` | DTO صف — افزودن `tag` |
| `src/Sms/Service/SmsService.php` | `dispatchAsync` و `sendNow` — عبور و ثبت `tag` |
| `src/Auth/Service/OtpService.php` | ارسال مستقیم OTP (باید از مسیر تگ‌دار رد شود) |
| `src/Payment/Controller/PaymentController.php` | پیامک تأیید پرداخت |
| `src/ClinicInvitation/Service/ClinicInvitationService.php` | پیامک دعوت پزشک به کلینیک |
| `src/Auth/Controller/PreRegistrationController.php` | پیامک پیش‌ثبت‌نام |
| `src/Auth/Controller/NotificationMobileController.php` | پیامک تأیید موبایلِ اعلان |
| `src/Admin/Controller/AdminApiController.php` | `smsLogs()` — افزودن `tag` به خروجی + فیلتر query |
| `src/Sms/Entity/SmsMessageTemplate.php` (جدید) | متن ویرایش‌پذیرِ سیستمی بر اساس تگ (یا فیلد جدید روی `SmsTemplate`) |
| `src/Sms/Service/SmsTextResolver.php` (جدید) | resolve متن بر اساس تگ + جایگزینی placeholder + fallback به متن هاردکد |
| `src/Sms/Controller/SmsMessageController.php` (جدید) | `GET/PATCH /api/v1/admin/sms/messages` |
| `src/Sms/Entity/SmsTemplate.php` | قالب کاربر — از قبل با `updateTemplate`/`setBody` ویرایش‌پذیر است (مرجع) |
| `assets/admin/pages/SmsPage.tsx` | تب «لاگ‌ها» (ستون/فیلتر تگ) + تب جدید «متن پیامک‌ها» (ویرایش) |
| `assets/admin/types/index.ts` | type `SmsLog` (+ `tag`) و type متن سیستمی |
| `docs/api/sms.md`, `docs/api/admin.md` | مستندسازی |
## وضعیت فعلی (کد واقعی)
`SmsLog` بدون tag:
```php
#[ORM\Column(type: 'text')] private string $message;
#[ORM\Column(type: 'string', length: 20)] private string $provider;
#[ORM\Column(type: 'boolean')] private bool $success;
#[ORM\Column(name: 'template_uuid', type: 'string', length: 36, nullable: true)] private ?string $templateUuid = null;
#[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt;
// constructor: __construct(string $mobile, string $message, string $provider, bool $success)
```
`SendSmsMessage` DTO:
```php
public function __construct(
public readonly string $mobile,
public readonly string $message,
public readonly string $provider = 'kavenegar',
public readonly ?string $templateUuid = null,
public readonly array $templateVars = [],
public readonly ?string $templateCode = null,
) {}
```
`SmsService` (ثبت لاگ بدون tag):
```php
public function dispatchAsync(string $mobile, string $message, string $provider = 'kavenegar',
?string $templateUuid = null, array $templateVars = [], ?string $templateCode = null): void
{
$this->bus->dispatch(new SendSmsMessage($mobile, $message, $provider, $templateUuid, $templateVars, $templateCode));
}
public function sendNow(SendSmsMessage $msg): bool
{
$provider = $this->resolveProvider($msg->provider);
$success = ($msg->templateCode !== null)
? $provider->sendTemplate($msg->mobile, $msg->templateCode, $msg->templateVars)
: $provider->send($msg->mobile, $msg->message);
$log = new SmsLog($msg->mobile, $msg->message, $provider->getName(), $success);
if ($msg->templateUuid) $log->setTemplateUuid($msg->templateUuid);
$this->logRepo->save($log);
return $success;
}
```
`OtpService` — مسیر مستقیم (بدون tag، بدون dispatchAsync):
```php
$this->bus->dispatch(new SendSmsMessage($mobile, "کد تأیید شما: {$code}"));
```
admin `smsLogs()` خروجی فعلی:
```php
->select('s.uuid, s.mobile, s.message, s.provider, s.success, s.createdAt')
// items: uuid, recipient(mobile), message, status(sent/failed), provider, sent_at
```
`SmsPage.tsx` تب‌ها: `'samples' | 'pending' | 'logs' | 'post-visit-review'`؛ تب logs از `/api/v1/admin/sms/logs` می‌خواند.
## وظایف
### ۱. افزودن `tag` به `SmsLog` + migration
ستون `tag` (string، طول ۳۰، nullable=false، پیش‌فرض `'global'`) به `SmsLog` اضافه کن؛ getter/setter و پارامتر constructor (با مقدار پیش‌فرض `'global'`):
```php
#[ORM\Column(type: 'string', length: 30, options: ['default' => 'global'])]
private string $tag = 'global';
public function __construct(string $mobile, string $message, string $provider, bool $success, string $tag = 'global')
{ /* ...; $this->tag = $tag; */ }
public function getTag(): string { return $this->tag; }
public function setTag(string $v): self { $this->tag = $v; return $this; }
```
سپس migration:
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
> ثابت‌های تگ را در یک جای واحد تعریف کن (مثلاً `SmsLog::TAG_GLOBAL = 'global'` و سایر تگ‌ها) تا رشته‌ی جادویی پخش نشود.
### ۲. عبور `tag` در DTO و SmsService
- در `SendSmsMessage` پارامتر `public readonly string $tag = 'global'` را اضافه کن (انتهای لیست، با پیش‌فرض).
- در `SmsService::dispatchAsync` پارامتر `string $tag = 'global'` اضافه و به `SendSmsMessage` پاس بده.
- در `SmsService::sendNow` هنگام ساخت `SmsLog`، `$msg->tag` را پاس بده:
```php
$log = new SmsLog($msg->mobile, $msg->message, $provider->getName(), $success, $msg->tag);
```
### ۳. تگ‌گذاری همه‌ی نقاط ارسال
برای هر caller یک تگ معنادار بده (به‌جای پیش‌فرض). تگ‌های پیشنهادی:
| caller | تگ |
|--------|----|
| `OtpService` (کد تأیید ورود) | `otp` |
| `PaymentController` (تأیید پرداخت) | `payment` |
| `ClinicInvitationService` (دعوت پزشک) | `clinic_invitation` |
| `PreRegistrationController` (پیش‌ثبت‌نام) | `pre_registration` |
| `NotificationMobileController` (تأیید موبایل اعلان) | `notification_mobile` |
| ارسال‌های قالبیِ کاربر پنل از `SmsController::send`/`sendViaTemplate` (پیامک‌های خود کاربر، نه سیستمی) | `user_template` |
| هر ارسال سیستمی دیگر بدون تگ مشخص | `global` (پیش‌فرض) |
- **مهم — `OtpService`:** الان مستقیم `bus->dispatch(new SendSmsMessage(...))` می‌زند. یا `SendSmsMessage(..., tag: 'otp')` بده، یا بهتر آن را به `SmsService::dispatchAsync($mobile, $message, tag: 'otp')` تبدیل کن تا مسیر یکدست شود.
- در `SmsController` (ارسال‌های کاربرِ پنل) هنگام لاگ، تگ `user_template` ست شود.
### ۴. admin endpoint — افزودن tag به خروجی + فیلتر
در `AdminApiController::smsLogs()`:
- `s.tag` را به `select` و به آیتم خروجی اضافه کن (`'tag' => $l['tag']`).
- پارامتر query اختیاری `tag` برای فیلتر:
```php
$tag = trim((string) $request->query->get('tag', ''));
if ($tag !== '') { $qb->andWhere('s.tag = :tag')->setParameter('tag', $tag); }
```
- ساختار `paginated()` حفظ شود.
### ۵. Admin SPA — ستون تگ + فیلتر در تب لاگ‌ها
در `SmsPage.tsx` (تب `logs`):
- ستون «تگ» به جدول لاگ‌ها اضافه کن (با برچسب فارسیِ خوانا — یک map از tag→label فارسی: `global`→«سیستمی»، `otp`→«کد تأیید»، `payment`→«پرداخت»، `clinic_invitation`→«دعوت کلینیک»، `pre_registration`→«پیش‌ثبت‌نام»، `notification_mobile`→«تأیید موبایل»، `user_template`→«قالب کاربر»).
- یک فیلتر کشویی تگ بالای جدول که `?tag=` را به query اضافه می‌کند.
- در `types/index.ts`، `SmsLog` فیلد `tag: string` بگیرد.
### ۶. ویرایش‌پذیر کردن متن همه‌ی پیامک‌ها
الان متن پیامک‌های **سیستمی** در کد هاردکد است و از پنل قابل ویرایش نیست — مثلاً:
```php
// OtpService.php
$this->bus->dispatch(new SendSmsMessage($mobile, "کد تأیید شما: {$code}"));
// PaymentController.php و ClinicInvitationService.php هم متن inline دارند
```
در مقابل، قالب‌های کاربر (`SmsTemplate`) از قبل با `PATCH /api/v1/sms/template/{uuid}` (`setBody`) ویرایش‌پذیرند. هدف: متن **هر پیامک سیستمی (بر اساس تگ)** هم از پنل قابل ویرایش شود.
راه‌حل — یک منبعِ متنِ ویرایش‌پذیر کلیددار با تگ:
- یک Entity سبک `SmsMessageTemplate` (یا استفاده از همان `SmsTemplate` با یک فیلد `tag` یکتا) که برای هر تگِ سیستمی یک رکورد دارد: `tag` (یکتا)، `title` (فارسی)، `body` (متن با placeholderها مثل `{code}`، `{amount}``variables` (لیست placeholderهای مجاز)، `updated_at`. migration لازم است.
- یک سرویس `SmsTextResolver::resolve(string $tag, array $vars): string` که body ویرایش‌شده‌ی همان تگ را از DB می‌گیرد، placeholderها را جایگزین می‌کند، و اگر رکوردی نبود به متنِ هاردکدِ پیش‌فرض fallback می‌کند (هیچ پیامکی بدون متن نماند).
- نقاط ارسال سیستمی (OtpService، PaymentController، ClinicInvitationService، PreRegistrationController، NotificationMobileController) به‌جای رشته‌ی inline، متن را از `SmsTextResolver::resolve('<tag>', [...vars])` بگیرند.
- **Seed/تأمین رکوردهای پیش‌فرض:** یک data fixture یا command که برای هر تگ سیستمی رکورد اولیه با متن فعلی بسازد (تا پنل از روز اول مقدار داشته باشد).
endpointهای ادمین برای ویرایش متن‌های سیستمی:
```
GET /api/v1/admin/sms/messages # لیست متن‌های سیستمی (tag/title/body/variables)
PATCH /api/v1/admin/sms/messages/{tag} # ویرایش body یک تگ
```
- هر دو `#[IsGranted('ROLE_ADMIN')]`؛ پاسخ‌ها با `success()`/`paginated()`.
- در ویرایش، اعتبارسنجی کن که فقط placeholderهای مجازِ همان تگ در body استفاده شده باشند (placeholder ناشناخته → خطای ۴۲۲).
Admin SPA — تب جدید «متن پیامک‌ها» در `SmsPage.tsx`:
- لیست متن‌های سیستمی بر اساس تگ (با `title` فارسی).
- فرم ویرایش `body` (React Hook Form + Zod) با نمایش placeholderهای مجاز هر تگ به‌صورت راهنما.
- ذخیره با `PATCH /api/v1/admin/sms/messages/{tag}` و invalidate کوئری.
> پیامک‌های قالبیِ کاربر (`SmsTemplate`، تگ `user_template`) از قبل ویرایش‌پذیرند — این بخش فقط برای متن‌های **سیستمی** (تگ‌های `otp`/`payment`/`clinic_invitation`/`pre_registration`/`notification_mobile`/`global`) است.
### ۷. مستندسازی
- `docs/api/sms.md`: فیلد `tag` در لاگ پیامک و مقادیر مجاز؛ و endpointهای جدید متن‌های سیستمی (`GET/PATCH /api/v1/admin/sms/messages`) با placeholderهای هر تگ.
- `docs/api/admin.md`: در `GET /api/v1/admin/sms/logs`، فیلد `tag` در پاسخ و پارامتر query `tag`.
## نکات مهم
- migration الزامی است (ستون جدید روی `sms_logs`). برای ردیف‌های موجود `default 'global'` اعمال شود تا NULL نشوند.
- پیش‌فرض همه‌جا `global` باشد تا اگر نقطه‌ای تگ‌گذاری نشد، پیامک باز هم لاگ و دسته‌بندی شود (هیچ پیامکی بی‌تگ نماند).
- مسیر صف (Messenger): چون `SendSmsMessage` فیلد جدید می‌گیرد، مطمئن شو پیام‌های در صف قدیمی مشکل deserialization ندارند (پیش‌فرض پارامتر این را پوشش می‌دهد).
- تاریخ‌ها Unix timestamp؛ خروجی admin با `paginated()`؛ در SPA: items از `data?.data`، total از `data?.meta?.totalRecords`.
- تگ‌ها را به‌صورت ثابت (const) در بک‌اند نگه‌دار و در فرانت map فارسی جدا داشته باش؛ رشته‌ی جادویی تکرار نشود.
- **متن ویرایش‌پذیر (وظیفه ۶):** `SmsTextResolver` باید همیشه fallback به متن هاردکد داشته باشد تا اگر ادمین متنی ثبت نکرده یا رکورد تگ نبود، پیامک با متن پیش‌فرض ارسال شود (هیچ پیامکی با متن خالی نرود). placeholderهای هر تگ ثابت‌اند؛ هنگام ویرایش فقط همان‌ها مجازند.
- همان تگِ ارسال = همان تگِ متنِ ویرایش‌پذیر = همان تگِ لاگ؛ این سه باید یکی باشند (یک منبع const مشترک).
- تست: بعد از migration، یک OTP بفرست (مثلاً `/api/v1/user/send-code`) و در `GET /api/v1/admin/sms/logs?tag=otp` ببین لاگ با تگ `otp` ثبت شده؛ یک ارسال بدون تگ → `global`؛ فیلتر تب لاگ‌ها در `/admin/sms` کار کند. سپس متنِ تگ `otp` را از `PATCH /api/v1/admin/sms/messages/otp` ویرایش کن و یک OTP دیگر بفرست تا متن جدید (با placeholder `{code}`) اعمال شود.
+107 -5
View File
@@ -1,13 +1,13 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon, XMarkIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
import { CheckIcon, XMarkIcon, TrashIcon, PlusIcon, PencilIcon } from '@heroicons/react/24/outline';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { SmsTemplate, SmsLog } from '../types';
import type { SmsTemplate, SmsLog, SmsMessageText } from '../types';
import { formatDate, formatDateTime } from '../lib/utils';
import DataTable, { Column } from '../components/ui/DataTable';
import StatusBadge from '../components/ui/StatusBadge';
@@ -21,7 +21,7 @@ const templateSchema = z.object({
});
type TemplateFormData = z.infer<typeof templateSchema>;
type Tab = 'samples' | 'pending' | 'logs' | 'post-visit-review';
type Tab = 'samples' | 'pending' | 'logs' | 'post-visit-review' | 'messages';
const STATUS_LOG_META: Record<string, { label: string; cls: string }> = {
sent: { label: 'ارسال شده', cls: 'green' },
@@ -29,6 +29,21 @@ const STATUS_LOG_META: Record<string, { label: string; cls: string }> = {
queued: { label: 'در صف', cls: 'amber' },
};
const TAG_LABELS: Record<string, string> = {
global: 'سیستمی',
otp: 'کد تأیید',
payment: 'پرداخت',
clinic_invitation: 'دعوت کلینیک',
pre_registration: 'پیش‌ثبت‌نام',
notification_mobile: 'تأیید موبایل',
user_template: 'قالب کاربر',
};
const TAG_FILTER_OPTIONS = [
{ value: '', label: 'همه تگ‌ها' },
...Object.entries(TAG_LABELS).map(([value, label]) => ({ value, label })),
];
export default function SmsPage() {
const qc = useQueryClient();
const [activeTab, setActiveTab] = useState<Tab>('samples');
@@ -40,6 +55,9 @@ export default function SmsPage() {
const [deleteTarget, setDeleteTarget] = useState<SmsTemplate | null>(null);
const [postVisitRejectId, setPostVisitRejectId] = useState<number | null>(null);
const [postVisitRejectReason, setPostVisitRejectReason] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [editMsg, setEditMsg] = useState<SmsMessageText | null>(null);
const [editBody, setEditBody] = useState('');
const limit = 15;
const sampleTemplatesQuery = useQuery({
@@ -60,12 +78,31 @@ export default function SmsPage() {
});
const logsQuery = useQuery({
queryKey: ['sms-logs', page],
queryKey: ['sms-logs', page, tagFilter],
queryFn: () =>
api.get<PaginatedResponse<SmsLog>>(`/api/v1/admin/sms/logs?page=${page}&limit=${limit}`),
api.get<PaginatedResponse<SmsLog>>(
`/api/v1/admin/sms/logs?page=${page}&limit=${limit}` + (tagFilter ? `&tag=${tagFilter}` : ''),
),
enabled: activeTab === 'logs',
});
const messagesQuery = useQuery({
queryKey: ['sms-messages'],
queryFn: () => api.get<ApiResponse<{ data: SmsMessageText[] }>>('/api/v1/admin/sms/messages'),
enabled: activeTab === 'messages',
});
const updateMessageMut = useMutation({
mutationFn: ({ tag, body }: { tag: string; body: string }) =>
api.patch<ApiResponse<SmsMessageText>>(`/api/v1/admin/sms/messages/${tag}`, { body }),
onSuccess: () => {
toast.success('متن پیامک به‌روزرسانی شد');
setEditMsg(null);
qc.invalidateQueries({ queryKey: ['sms-messages'] });
},
onError: (err: Error) => toast.error(err.message),
});
const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm<TemplateFormData>({
resolver: zodResolver(templateSchema),
});
@@ -171,6 +208,7 @@ export default function SmsPage() {
return <span className={`badge ${meta.cls}`}><span className="bdot" />{meta.label}</span>;
},
},
{ key: 'tag', header: 'تگ', render: (l) => <span className="badge gray"><span className="bdot" />{TAG_LABELS[l.tag] ?? l.tag}</span> },
{ key: 'provider', header: 'سرویس‌دهنده' },
{ key: 'sent_at', header: 'زمان ارسال', render: (l) => formatDateTime(l.sent_at) },
];
@@ -189,6 +227,7 @@ export default function SmsPage() {
{ key: 'pending', label: 'در انتظار تأیید', badge: pendingCount },
{ key: 'post-visit-review', label: 'متن پیامک ویزیت', badge: postVisitPendingCount },
{ key: 'logs', label: 'لاگ‌های ارسال' },
{ key: 'messages', label: 'متن پیامک‌ها' },
];
return (
@@ -338,6 +377,18 @@ export default function SmsPage() {
{activeTab === 'logs' && (
<>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'flex-end' }}>
<select
className="input"
style={{ maxWidth: 200 }}
value={tagFilter}
onChange={(e) => { setTagFilter(e.target.value); setPage(1); }}
>
{TAG_FILTER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<DataTable<SmsLog>
columns={logColumns}
data={logsQuery.data?.data ?? []}
@@ -352,9 +403,60 @@ export default function SmsPage() {
/>
</>
)}
{activeTab === 'messages' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{(((messagesQuery.data?.data as any)?.data ?? []) as SmsMessageText[]).map((m) => (
<div key={m.tag} className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<b style={{ fontSize: 14 }}>{m.title}</b>
<button className="btn ghost sm" onClick={() => { setEditMsg(m); setEditBody(m.body); }}>
<PencilIcon style={{ width: 14, height: 14 }} /> ویرایش
</button>
</div>
<div className="muted" style={{ fontSize: 13, lineHeight: 1.9, whiteSpace: 'pre-wrap', direction: 'rtl' }}>{m.body}</div>
{m.variables.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{m.variables.map((v) => <span key={v} className="chip" dir="ltr">{`{${v}}`}</span>)}
</div>
)}
</div>
))}
{messagesQuery.isLoading && <p className="muted">در حال بارگذاری...</p>}
</div>
)}
</div>
</div>
<Modal
open={editMsg !== null}
title={`ویرایش متن: ${editMsg?.title ?? ''}`}
onClose={() => setEditMsg(null)}
footer={
<>
<button className="btn ghost sm" onClick={() => setEditMsg(null)}>انصراف</button>
<button
className="btn primary sm"
disabled={updateMessageMut.isPending || !editBody.trim()}
onClick={() => editMsg && updateMessageMut.mutate({ tag: editMsg.tag, body: editBody })}
>
{updateMessageMut.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<div className="form-row">
<label>متن پیامک</label>
<textarea className="input" rows={5} dir="rtl" value={editBody} onChange={(e) => setEditBody(e.target.value)} />
</div>
{editMsg && editMsg.variables.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<span className="muted" style={{ fontSize: 12 }}>متغیرهای مجاز:</span>
{editMsg.variables.map((v) => <span key={v} className="chip" dir="ltr">{`{${v}}`}</span>)}
</div>
)}
</Modal>
<Modal open={addOpen} title="افزودن قالب نمونه" onClose={() => setAddOpen(false)}
footer={
<>
+9
View File
@@ -183,10 +183,19 @@ export interface SmsLog {
message: string;
status: 'queued' | 'sent' | 'failed';
provider: string;
tag: string;
sent_at: string | null;
created_at: string;
}
export interface SmsMessageText {
tag: string;
title: string;
body: string;
variables: string[];
updated_at: number | null;
}
export interface Province {
id: number;
uuid: string;
+10 -6
View File
@@ -719,7 +719,8 @@ List SMS send logs.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
| `limit` | integer | ❌ | Default: 15, max 100 |
| `tag` | string | ❌ | فیلتر بر اساس تگ: `global` \| `otp` \| `payment` \| `clinic_invitation` \| `pre_registration` \| `notification_mobile` \| `user_template` |
### Response `200`
```json
@@ -727,17 +728,20 @@ List SMS send logs.
"success": true,
"data": [
{
"id": 1,
"mobile": "09123456789",
"uuid": "...",
"recipient": "09123456789",
"message": "کد تأیید: 123456",
"status": "sent",
"provider": "kavenegar",
"success": true,
"created_at": 1717000000
"tag": "otp",
"sent_at": "2026-06-19T...",
"created_at": "2026-06-19T..."
}
],
"meta": { "totalRecords": 5000, "totalPages": 250, "currentPage": 1 }
"meta": { "totalRecords": 5000, "totalPages": 334, "currentPage": 1 }
}
```
> `tag` نوع پیامک را مشخص می‌کند؛ پیش‌فرض پیامک‌های سیستمیِ بی‌برچسب `global` است.
---
+65
View File
@@ -458,3 +458,68 @@ Updated template with `status: "rejected"`.
### GET /api/v1/admin/sms/wallet-report
**Permission:** `ROLE_ADMIN` — لیست همه کیف‌های پیامکی (paginated)
---
## متن ویرایش‌پذیر پیامک‌های سیستمی
متن پیامک‌های سیستمی (OTP، پرداخت، دعوت کلینیک، پیش‌ثبت‌نام، تأیید موبایل) از پنل قابل ویرایش است و بر اساس **تگ** کلیددار می‌شود. هر متن placeholderهای مجاز خود را دارد (مثل `{code}`، `{doctor}`، `{date}`). هنگام ارسال، `SmsTextResolver` متنِ ویرایش‌شده‌ی DB را می‌گیرد و placeholderها را جایگزین می‌کند؛ اگر رکوردی نبود به متن پیش‌فرض fallback می‌شود.
> تگ‌ها: `otp`، `payment`، `clinic_invitation`، `pre_registration`، `notification_mobile`. (پیامک قالبیِ کاربر با تگ `user_template` جداگانه از طریق `POST /api/v1/sms/template` مدیریت می‌شود.)
### GET `/api/v1/admin/sms/messages`
لیست همه‌ی متن‌های سیستمی (تگ‌هایی که هنوز رکورد ندارند با مقدار پیش‌فرض برگردانده می‌شوند).
**Permission:** `ROLE_ADMIN`
#### Response `200`
```json
{
"success": true,
"data": {
"data": [
{
"tag": "otp",
"title": "کد تأیید ورود",
"body": "کد تأیید شما: {code}",
"variables": ["code"],
"updated_at": 1718000000
}
]
}
}
```
### PATCH `/api/v1/admin/sms/messages/{tag}`
ویرایش متن یک پیامک سیستمی.
**Permission:** `ROLE_ADMIN`
#### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `tag` | string | یکی از تگ‌های سیستمی |
#### Request Body
```json
{ "body": "کد ورود شما: {code}" }
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `body` | string | ✅ | متن جدید؛ فقط placeholderهای مجازِ همان تگ پذیرفته می‌شود |
#### Response `200`
```json
{ "success": true, "data": { "data": { "tag": "otp", "title": "...", "body": "...", "variables": ["code"], "updated_at": 1718000123 } } }
```
#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | تگ ناشناخته |
| `ERR_VALIDATION_002` | 422 | متن خالی |
| `ERR_VALIDATION_001` | 422 | placeholder نامعتبر (خارج از متغیرهای مجاز تگ) |
> **Command:** `php bin/console app:seed-sms-message-templates` رکوردهای پیش‌فرض را برای تگ‌هایی که هنوز ندارند می‌سازد.
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260619121047 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE sms_logs ADD tag VARCHAR(30) DEFAULT \'global\' NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE sms_logs DROP tag');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260619170105 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE sms_message_templates (id INT AUTO_INCREMENT NOT NULL, tag VARCHAR(30) NOT NULL, title VARCHAR(100) NOT NULL, body LONGTEXT NOT NULL, variables JSON NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_FE35EE97389B783 (tag), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE sms_message_templates');
}
}
+7 -1
View File
@@ -1264,12 +1264,17 @@ class AdminApiController extends BaseController
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$tag = trim((string) $request->query->get('tag', ''));
$qb = $this->em->createQueryBuilder()
->select('s.uuid, s.mobile, s.message, s.provider, s.success, s.createdAt')
->select('s.uuid, s.mobile, s.message, s.provider, s.success, s.tag, s.createdAt')
->from(SmsLog::class, 's')
->orderBy('s.createdAt', 'DESC');
if ($tag !== '') {
$qb->andWhere('s.tag = :tag')->setParameter('tag', $tag);
}
$total = (clone $qb)->select('COUNT(s.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
@@ -1281,6 +1286,7 @@ class AdminApiController extends BaseController
'message' => $l['message'],
'status' => $l['success'] ? 'sent' : 'failed',
'provider' => $l['provider'],
'tag' => $l['tag'],
'sent_at' => date('c', (int) $l['createdAt']),
'created_at' => date('c', (int) $l['createdAt']),
], $rows);
@@ -26,6 +26,7 @@ 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 ───────────────────────────────────────────────────────────
@@ -60,9 +61,13 @@ 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(
$mobile,
"کد تأیید شماره اعلان شما: {$otp->getOtpCode()}\nاعتبار: ۵ دقیقه"
$message,
tag: \App\Sms\Entity\SmsLog::TAG_NOTIFICATION_MOBILE,
);
return $this->success([
@@ -33,6 +33,7 @@ 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,
) {}
@@ -154,13 +155,15 @@ 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(
$preReg->getMobile(),
sprintf(
'به کلینیک پرو خوش آمدید! شماره‌کاربری: %s | رمز عبور: %s | لینک ورود: https://clinic-pro.ddev.site/admin',
$preReg->getMobile(),
$password
)
$message,
tag: \App\Sms\Entity\SmsLog::TAG_PRE_REGISTRATION,
);
} catch (\Throwable $e) {
$this->logger->warning('PreRegistration SMS failed', ['uuid' => $uuid, 'error' => $e->getMessage()]);
+7 -4
View File
@@ -4,8 +4,9 @@ namespace App\Auth\Service;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Shared\Message\SendSmsMessage;
use Symfony\Component\Messenger\MessageBusInterface;
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;
@@ -13,7 +14,8 @@ class OtpService
{
public function __construct(
private readonly CacheInterface $cache,
private readonly MessageBusInterface $bus,
private readonly SmsService $sms,
private readonly SmsTextResolver $smsText,
private readonly int $otpTtl = 1200,
private readonly string $appEnv = 'dev',
) {}
@@ -37,7 +39,8 @@ class OtpService
$this->cache->save($item);
if ($this->appEnv !== 'dev') {
$this->bus->dispatch(new SendSmsMessage($mobile, "کد تأیید شما: {$code}"));
$message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code]);
$this->sms->dispatchAsync($mobile, $message, tag: SmsLog::TAG_OTP);
}
return $uuid;
@@ -17,6 +17,7 @@ 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,
) {}
@@ -112,10 +113,11 @@ class ClinicInvitationService
$clinicName = $clinic->getName() ?? 'کلینیک';
$link = rtrim($this->appUrl, '/') . '/clinic-invitation/' . $inv->getToken();
$message = "دکتر گرامی، کلینیک {$clinicName} شما را برای همکاری دعوت کرده است.\n"
. "برای بررسی: {$link}\n"
. "این لینک تا ۷۲ ساعت معتبر است.";
$message = $this->smsText->resolve(\App\Sms\Entity\SmsLog::TAG_CLINIC_INVITATION, [
'clinic' => $clinicName,
'link' => $link,
]);
$this->smsService->dispatchAsync($inv->getMobile(), $message);
$this->smsService->dispatchAsync($inv->getMobile(), $message, tag: \App\Sms\Entity\SmsLog::TAG_CLINIC_INVITATION);
}
}
+7 -1
View File
@@ -46,6 +46,7 @@ class PaymentController extends BaseController
private readonly SubscriptionService $subscriptionService,
private readonly SmsWalletService $smsWalletService,
private readonly SmsService $smsService,
private readonly \App\Sms\Service\SmsTextResolver $smsText,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly SiteConfigRepository $configRepo,
@@ -635,9 +636,14 @@ class PaymentController extends BaseController
$mobile = $appointment->getPatientMobile();
if ($mobile) {
$when = date('Y-m-d H:i', $appointment->getSlotStart());
$message = $this->smsText->resolve(\App\Sms\Entity\SmsLog::TAG_PAYMENT, [
'doctor' => $appointment->getDoctor()->getName(),
'date' => $when,
]);
$this->smsService->dispatchAsync(
$mobile,
sprintf('نوبت شما با %s در تاریخ %s ثبت و تأیید شد.', $appointment->getDoctor()->getName(), $when)
$message,
tag: \App\Sms\Entity\SmsLog::TAG_PAYMENT,
);
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Sms\Command;
use App\Sms\Entity\SmsMessageTemplate;
use App\Sms\Repository\SmsMessageTemplateRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:seed-sms-message-templates',
description: 'متن پیش‌فرض پیامک‌های سیستمی را برای تگ‌هایی که هنوز رکورد ندارند می‌سازد',
)]
class SeedSmsMessageTemplatesCommand extends Command
{
public function __construct(private readonly SmsMessageTemplateRepository $repo)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$created = 0;
foreach (SmsMessageTemplate::DEFAULTS as $tag => $def) {
if ($this->repo->findByTag($tag) !== null) {
continue;
}
$this->repo->save(
new SmsMessageTemplate($tag, $def['title'], $def['body'], $def['variables']),
false,
);
$created++;
}
$this->repo->getEntityManager()->flush();
$output->writeln(sprintf('Seeded %d sms message templates.', $created));
return Command::SUCCESS;
}
}
+2 -2
View File
@@ -72,7 +72,7 @@ class SmsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'mobile و message الزامی است', 422);
}
$this->smsService->dispatchAsync($mobile, $message, $provider);
$this->smsService->dispatchAsync($mobile, $message, $provider, tag: \App\Sms\Entity\SmsLog::TAG_USER_TEMPLATE);
return $this->success(['message' => 'پیامک در صف ارسال قرار گرفت']);
}
@@ -483,7 +483,7 @@ class SmsController extends BaseController
$message = $template->renderBody($vars);
$this->smsService->dispatchAsync(
$mobile, $message, $provider, $template->getUuid(),
$vars, $template->getProviderCode()
$vars, $template->getProviderCode(), \App\Sms\Entity\SmsLog::TAG_USER_TEMPLATE
);
return $this->success(['message' => 'پیامک در صف ارسال قرار گرفت']);
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace App\Sms\Controller;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Sms\Entity\SmsMessageTemplate;
use App\Sms\Repository\SmsMessageTemplateRepository;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* مدیریت متن ویرایش‌پذیر پیامک‌های سیستمی (بر اساس تگ).
*/
#[OA\Tag(name: 'SMS')]
#[IsGranted('ROLE_ADMIN')]
class SmsMessageController extends BaseController
{
public function __construct(
private readonly SmsMessageTemplateRepository $repo,
) {}
#[OA\Get(
path: '/api/v1/admin/sms/messages',
summary: 'لیست متن‌های سیستمی پیامک (بر اساس تگ)',
security: [['bearerAuth' => []]],
responses: [new OA\Response(response: 200, description: 'لیست متن‌ها')]
)]
#[Route('/api/v1/admin/sms/messages', methods: ['GET'])]
public function list(): JsonResponse
{
$existing = [];
foreach ($this->repo->findAll() as $tpl) {
$existing[$tpl->getTag()] = $tpl->toArray();
}
// تگ‌های پیش‌فرضی که هنوز در DB رکورد ندارند را هم با مقدار پیش‌فرض نشان بده.
$items = [];
foreach (SmsMessageTemplate::DEFAULTS as $tag => $def) {
$items[] = $existing[$tag] ?? [
'tag' => $tag,
'title' => $def['title'],
'body' => $def['body'],
'variables' => $def['variables'],
'updated_at' => null,
];
}
return $this->success(['data' => $items]);
}
#[OA\Patch(
path: '/api/v1/admin/sms/messages/{tag}',
summary: 'ویرایش متن یک پیامک سیستمی',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['body'],
properties: [new OA\Property(property: 'body', type: 'string')]
)
),
responses: [
new OA\Response(response: 200, description: 'متن به‌روزرسانی شد'),
new OA\Response(response: 404, description: 'تگ ناشناخته'),
new OA\Response(response: 422, description: 'placeholder نامعتبر'),
]
)]
#[Route('/api/v1/admin/sms/messages/{tag}', methods: ['PATCH'])]
public function update(string $tag, Request $request): JsonResponse
{
if (!isset(SmsMessageTemplate::DEFAULTS[$tag])) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تگ پیامک ناشناخته است', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$body = trim((string) ($data['body'] ?? ''));
if ($body === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'متن پیامک الزامی است', 422);
}
$allowed = SmsMessageTemplate::DEFAULTS[$tag]['variables'];
preg_match_all('/\{([a-zA-Z0-9_]+)\}/', $body, $m);
$used = array_unique($m[1]);
$unknown = array_diff($used, $allowed);
if (!empty($unknown)) {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
'placeholder نامعتبر: ' . implode(', ', $unknown) . ' — مجاز: ' . implode(', ', $allowed),
422,
'body',
);
}
$tpl = $this->repo->findByTag($tag);
if ($tpl === null) {
$def = SmsMessageTemplate::DEFAULTS[$tag];
$tpl = new SmsMessageTemplate($tag, $def['title'], $body, $def['variables']);
} else {
$tpl->setBody($body);
}
$this->repo->save($tpl);
return $this->success(['data' => $tpl->toArray()]);
}
}
+26 -1
View File
@@ -10,6 +10,24 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Index(columns: ['mobile', 'created_at'], name: 'idx_sms_logs_mobile')]
class SmsLog
{
public const TAG_GLOBAL = 'global';
public const TAG_OTP = 'otp';
public const TAG_PAYMENT = 'payment';
public const TAG_CLINIC_INVITATION = 'clinic_invitation';
public const TAG_PRE_REGISTRATION = 'pre_registration';
public const TAG_NOTIFICATION_MOBILE = 'notification_mobile';
public const TAG_USER_TEMPLATE = 'user_template';
public const TAGS = [
self::TAG_GLOBAL,
self::TAG_OTP,
self::TAG_PAYMENT,
self::TAG_CLINIC_INVITATION,
self::TAG_PRE_REGISTRATION,
self::TAG_NOTIFICATION_MOBILE,
self::TAG_USER_TEMPLATE,
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -33,20 +51,26 @@ class SmsLog
#[ORM\Column(name: 'template_uuid', type: 'string', length: 36, nullable: true)]
private ?string $templateUuid = null;
#[ORM\Column(type: 'string', length: 30, options: ['default' => self::TAG_GLOBAL])]
private string $tag = self::TAG_GLOBAL;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(string $mobile, string $message, string $provider, bool $success)
public function __construct(string $mobile, string $message, string $provider, bool $success, string $tag = self::TAG_GLOBAL)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->mobile = $mobile;
$this->message = $message;
$this->provider = $provider;
$this->success = $success;
$this->tag = $tag;
$this->createdAt = time();
}
public function setTemplateUuid(?string $v): self { $this->templateUuid = $v; return $this; }
public function getTag(): string { return $this->tag; }
public function setTag(string $v): self { $this->tag = $v; return $this; }
public function toArray(): array
{
@@ -57,6 +81,7 @@ class SmsLog
'provider' => $this->provider,
'success' => $this->success,
'template_uuid' => $this->templateUuid,
'tag' => $this->tag,
'created_at' => $this->createdAt,
];
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Sms\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* متن ویرایش‌پذیرِ پیامک‌های سیستمی، کلیددار با تگ (SmsLog::TAG_*).
* body شامل placeholderهای {key} است که هنگام ارسال جایگزین می‌شوند.
*/
#[ORM\Entity]
#[ORM\Table(name: 'sms_message_templates')]
class SmsMessageTemplate
{
/**
* متن و متادیتای پیش‌فرض هر تگ — مرجع برای seed و fallback.
* @var array<string, array{title: string, body: string, variables: string[]}>
*/
public const DEFAULTS = [
SmsLog::TAG_OTP => [
'title' => 'کد تأیید ورود',
'body' => 'کد تأیید شما: {code}',
'variables' => ['code'],
],
SmsLog::TAG_PAYMENT => [
'title' => 'تأیید پرداخت و نوبت',
'body' => 'نوبت شما با {doctor} در تاریخ {date} ثبت و تأیید شد.',
'variables' => ['doctor', 'date'],
],
SmsLog::TAG_CLINIC_INVITATION => [
'title' => 'دعوت پزشک به کلینیک',
'body' => "دکتر گرامی، کلینیک {clinic} شما را برای همکاری دعوت کرده است.\nبرای بررسی: {link}\nاین لینک تا ۷۲ ساعت معتبر است.",
'variables' => ['clinic', 'link'],
],
SmsLog::TAG_PRE_REGISTRATION => [
'title' => 'پیش‌ثبت‌نام',
'body' => 'به کلینیک پرو خوش آمدید! شماره‌کاربری: {username} | رمز عبور: {password} | لینک ورود: {link}',
'variables' => ['username', 'password', 'link'],
],
SmsLog::TAG_NOTIFICATION_MOBILE => [
'title' => 'تأیید شماره اعلان',
'body' => "کد تأیید شماره اعلان شما: {code}\nاعتبار: ۵ دقیقه",
'variables' => ['code'],
],
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 30, unique: true)]
private string $tag;
#[ORM\Column(type: 'string', length: 100)]
private string $title;
#[ORM\Column(type: 'text')]
private string $body;
#[ORM\Column(type: 'json')]
private array $variables = [];
#[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 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 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 toArray(): array
{
return [
'tag' => $this->tag,
'title' => $this->title,
'body' => $this->body,
'variables' => $this->variables,
'updated_at' => $this->updatedAt,
];
}
}
+1
View File
@@ -11,5 +11,6 @@ final class SendSmsMessage
public readonly ?string $templateUuid = null,
public readonly array $templateVars = [],
public readonly ?string $templateCode = null,
public readonly string $tag = \App\Sms\Entity\SmsLog::TAG_GLOBAL,
) {}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Sms\Repository;
use App\Sms\Entity\SmsMessageTemplate;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SmsMessageTemplateRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SmsMessageTemplate::class);
}
public function findByTag(string $tag): ?SmsMessageTemplate
{
return $this->findOneBy(['tag' => $tag]);
}
public function save(SmsMessageTemplate $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}
+3 -2
View File
@@ -33,9 +33,10 @@ class SmsService
?string $templateUuid = null,
array $templateVars = [],
?string $templateCode = null,
string $tag = SmsLog::TAG_GLOBAL,
): void {
$this->bus->dispatch(new SendSmsMessage(
$mobile, $message, $provider, $templateUuid, $templateVars, $templateCode
$mobile, $message, $provider, $templateUuid, $templateVars, $templateCode, $tag
));
}
@@ -47,7 +48,7 @@ class SmsService
? $provider->sendTemplate($msg->mobile, $msg->templateCode, $msg->templateVars)
: $provider->send($msg->mobile, $msg->message);
$log = new SmsLog($msg->mobile, $msg->message, $provider->getName(), $success);
$log = new SmsLog($msg->mobile, $msg->message, $provider->getName(), $success, $msg->tag);
if ($msg->templateUuid) $log->setTemplateUuid($msg->templateUuid);
$this->logRepo->save($log);
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Sms\Service;
use App\Sms\Entity\SmsMessageTemplate;
use App\Sms\Repository\SmsMessageTemplateRepository;
/**
* متن پیامک سیستمی را بر اساس تگ resolve می‌کند: body ویرایش‌شده از DB، با جایگزینی
* placeholderهای {key}؛ اگر رکوردی نبود به متن پیش‌فرضِ SmsMessageTemplate::DEFAULTS برمی‌گردد.
*/
class SmsTextResolver
{
public function __construct(
private readonly SmsMessageTemplateRepository $repo,
) {}
/** @param array<string,string|int> $vars */
public function resolve(string $tag, array $vars = []): string
{
$template = $this->repo->findByTag($tag);
$body = $template?->getBody()
?? SmsMessageTemplate::DEFAULTS[$tag]['body']
?? '';
foreach ($vars as $key => $value) {
$body = str_replace('{' . $key . '}', (string) $value, $body);
}
return $body;
}
}