fix(sms): parse melipayamak xml pattern tags and add search filter in admin panel
Some checks failed
Deploy Canina / deploy (push) Successful in 2m13s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 7s

This commit is contained in:
parsa aghaei 2026-09-05 09:21:01 +03:30
parent 99e726b08b
commit 2f41d4afe7
2 changed files with 201 additions and 60 deletions

View File

@ -494,26 +494,30 @@ export class SmsService {
*/ */
private parsePatternsXml(xml: string): MeliPayamakPattern[] { private parsePatternsXml(xml: string): MeliPayamakPattern[] {
const patterns: MeliPayamakPattern[] = []; const patterns: MeliPayamakPattern[] = [];
const itemRegex = /<SharedServiceBody>([\s\S]*?)<\/SharedServiceBody>/gi; // MeliPayamak uses <ShareServiceBody> and child tags <BodyID>, <Title>, <Body>, <BodyStatus>
// We match both <ShareServiceBody> and <SharedServiceBody> for backwards/cross compatibility
const itemRegex = /<(?:Share|Shared)ServiceBody>([\s\S]*?)<\/(?:Share|Shared)ServiceBody>/gi;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = itemRegex.exec(xml)) !== null) { while ((match = itemRegex.exec(xml)) !== null) {
const block = match[1]; const block = match[1];
const idMatch = block.match(/<Id>(\d+)<\/Id>/i); const idMatch = block.match(/<(?:BodyID|Id)>(\d+)<\/(?:BodyID|Id)>/i);
const titleMatch = block.match(/<Title>([\s\S]*?)<\/Title>/i); const titleMatch = block.match(/<Title>([\s\S]*?)<\/Title>/i);
const bodyMatch = block.match(/<Body>([\s\S]*?)<\/Body>/i); const bodyMatch = block.match(/<Body>([\s\S]*?)<\/Body>/i);
const statusMatch = block.match(/<Status>(-?\d+)<\/Status>/i); const statusMatch = block.match(/<(?:BodyStatus|Status)>(-?\d+)<\/(?:BodyStatus|Status)>/i);
if (idMatch) { if (idMatch) {
const id = parseInt(idMatch[1], 10); const id = parseInt(idMatch[1], 10);
const title = (titleMatch ? titleMatch[1] : '') const title = (titleMatch ? titleMatch[1] : '')
.replace(/&lt;/g, '<') .replace(/&lt;/g, '<')
.replace(/&gt;/g, '>') .replace(/&gt;/g, '>')
.replace(/&amp;/g, '&'); .replace(/&amp;/g, '&')
.trim();
const body = (bodyMatch ? bodyMatch[1] : '') const body = (bodyMatch ? bodyMatch[1] : '')
.replace(/&lt;/g, '<') .replace(/&lt;/g, '<')
.replace(/&gt;/g, '>') .replace(/&gt;/g, '>')
.replace(/&amp;/g, '&'); .replace(/&amp;/g, '&')
.trim();
const status = statusMatch ? parseInt(statusMatch[1], 10) : 0; const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
let statusText = 'در انتظار تایید'; let statusText = 'در انتظار تایید';
@ -611,6 +615,22 @@ export class SmsService {
password: config.password, password: config.password,
}); });
remotePatterns = this.parsePatternsXml(rawXml); remotePatterns = this.parsePatternsXml(rawXml);
if (remotePatterns.length > 0) {
// Cache patterns asynchronously in database
this.prisma.setting
.upsert({
where: { key: 'sms_patterns_cache' },
update: { value: remotePatterns as any },
create: {
key: 'sms_patterns_cache',
category: 'sms_patterns',
value: remotePatterns as any,
},
})
.catch((err) => {
this.logger.warn(`Failed to cache SMS patterns: ${err.message}`);
});
}
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
this.logger.warn( this.logger.warn(
@ -619,7 +639,7 @@ export class SmsService {
} }
} }
// Load local stored patterns from settings table // Load local stored patterns from settings table (fallback if offline or newly added locally)
const storedSetting = await this.prisma.setting.findFirst({ const storedSetting = await this.prisma.setting.findFirst({
where: { category: 'sms_patterns' }, where: { category: 'sms_patterns' },
}); });

View File

@ -170,6 +170,8 @@ export default function SmsSettingsPage() {
const [isSubmittingPattern, setIsSubmittingPattern] = useState(false); const [isSubmittingPattern, setIsSubmittingPattern] = useState(false);
const [editingPattern, setEditingPattern] = useState<PatternItem | null>(null); const [editingPattern, setEditingPattern] = useState<PatternItem | null>(null);
const [editPatternBody, setEditPatternBody] = useState(''); const [editPatternBody, setEditPatternBody] = useState('');
const [patternFilter, setPatternFilter] = useState('');
const [patternStatusFilter, setPatternStatusFilter] = useState<'ALL' | 'APPROVED' | 'PENDING' | 'REJECTED'>('ALL');
// Logs State // Logs State
const [logs, setLogs] = useState<SmsLogItem[]>([]); const [logs, setLogs] = useState<SmsLogItem[]>([]);
@ -1419,33 +1421,147 @@ export default function SmsSettingsPage() {
</button> </button>
</div> </div>
{/* Action Bar */} {/* Action & Filter Bar */}
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row items-center justify-between gap-4"> {(() => {
<div className="flex items-center gap-3"> const trimmedFilter = patternFilter.trim().toLowerCase();
<span className="text-sm font-bold text-gray-700"> const filteredPatterns = patterns.filter((p) => {
مجموع الگوهای ثبت شده: <span className="text-purple-600 font-mono">{patterns.length}</span> if (patternStatusFilter === 'APPROVED' && p.status !== 1) return false;
</span> if (patternStatusFilter === 'PENDING' && p.status !== 0) return false;
if (patternStatusFilter === 'REJECTED' && p.status !== 2 && p.status !== -1) return false;
if (!trimmedFilter) return true;
const matchesId = String(p.id).includes(trimmedFilter);
const matchesTitle = (p.title || '').toLowerCase().includes(trimmedFilter);
const matchesBody = (p.body || '').toLowerCase().includes(trimmedFilter);
const matchesAssigned = (p.assignedTo || []).some((a) => a.toLowerCase().includes(trimmedFilter));
return matchesId || matchesTitle || matchesBody || matchesAssigned;
});
return (
<>
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4">
{/* Search and Filters */}
<div className="flex flex-1 flex-col sm:flex-row items-stretch sm:items-center gap-3">
{/* Search Input */}
<div className="relative flex-1 max-w-md">
<div className="absolute inset-y-0 right-0 pr-3.5 flex items-center pointer-events-none text-gray-400">
<Search className="w-4 h-4" />
</div>
<input
type="text"
value={patternFilter}
onChange={(e) => setPatternFilter(e.target.value)}
placeholder="جستجو در متن، عنوان یا کد الگو..."
className="w-full pr-10 pl-9 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs font-medium text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-all"
/>
{patternFilter && (
<button
type="button"
onClick={() => setPatternFilter('')}
className="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400 hover:text-gray-600"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Status Filter */}
<div className="flex items-center gap-1.5 bg-gray-100/80 p-1 rounded-xl border border-gray-200/60 text-xs font-bold shrink-0">
<button
type="button"
onClick={() => setPatternStatusFilter('ALL')}
className={`px-3 py-1.5 rounded-lg transition-all ${
patternStatusFilter === 'ALL'
? 'bg-white text-purple-700 shadow-xs'
: 'text-gray-600 hover:text-gray-900'
}`}
>
همه ({patterns.length})
</button>
<button
type="button"
onClick={() => setPatternStatusFilter('APPROVED')}
className={`px-3 py-1.5 rounded-lg transition-all ${
patternStatusFilter === 'APPROVED'
? 'bg-white text-emerald-700 shadow-xs'
: 'text-gray-600 hover:text-gray-900'
}`}
>
تایید شده
</button>
<button
type="button"
onClick={() => setPatternStatusFilter('PENDING')}
className={`px-3 py-1.5 rounded-lg transition-all ${
patternStatusFilter === 'PENDING'
? 'bg-white text-amber-700 shadow-xs'
: 'text-gray-600 hover:text-gray-900'
}`}
>
در انتظار
</button>
<button
type="button"
onClick={() => setPatternStatusFilter('REJECTED')}
className={`px-3 py-1.5 rounded-lg transition-all ${
patternStatusFilter === 'REJECTED'
? 'bg-white text-rose-700 shadow-xs'
: 'text-gray-600 hover:text-gray-900'
}`}
>
نیازمند ویرایش
</button>
</div>
</div>
{/* Actions and Counter */}
<div className="flex items-center justify-between sm:justify-end gap-3 shrink-0">
<button <button
type="button" type="button"
onClick={refreshPatterns} onClick={refreshPatterns}
disabled={isLoadingPatterns} disabled={isLoadingPatterns}
className="p-2 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-xl transition-all border border-gray-200 flex items-center gap-1.5 text-xs font-bold" className="p-2.5 text-gray-600 hover:text-purple-600 hover:bg-purple-50 rounded-xl transition-all border border-gray-200 flex items-center gap-1.5 text-xs font-bold"
title="بازخوانی از وب‌سرویس ملی پیامک" title="بازخوانی از وب‌سرویس ملی پیامک"
> >
<RefreshCw className={`w-3.5 h-3.5 ${isLoadingPatterns ? 'animate-spin text-purple-600' : ''}`} /> <RefreshCw className={`w-3.5 h-3.5 ${isLoadingPatterns ? 'animate-spin text-purple-600' : ''}`} />
<span>بروزرسانی از ملی پیامک</span> <span className="hidden sm:inline">بروزرسانی از ملی پیامک</span>
</button> </button>
</div>
<button <button
type="button" type="button"
onClick={() => setIsAddModalOpen(true)} onClick={() => setIsAddModalOpen(true)}
className="bg-purple-600 hover:bg-purple-700 text-white text-xs font-bold py-2.5 px-5 rounded-xl transition-all flex items-center gap-2 shadow-md shadow-purple-200" className="bg-purple-600 hover:bg-purple-700 text-white text-xs font-bold py-2.5 px-4 rounded-xl transition-all flex items-center gap-2 shadow-md shadow-purple-200"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
درج الگوی جدید (Add Pattern) <span>درج الگوی جدید</span>
</button> </button>
</div> </div>
</div>
{/* Filter info banner if filtering */}
{(patternFilter || patternStatusFilter !== 'ALL') && (
<div className="flex items-center justify-between px-3 py-2 bg-purple-50/60 rounded-xl border border-purple-100 text-xs text-purple-900 font-medium">
<div className="flex items-center gap-2">
<Filter className="w-3.5 h-3.5 text-purple-600" />
<span>
نمایش <strong>{filteredPatterns.length}</strong> از <strong>{patterns.length}</strong> الگو
{patternFilter && (
<> برای عبارت «<span className="font-bold text-purple-700">{patternFilter}</span>»</>
)}
</span>
</div>
<button
type="button"
onClick={() => {
setPatternFilter('');
setPatternStatusFilter('ALL');
}}
className="text-purple-600 hover:text-purple-800 font-bold hover:underline text-[11px]"
>
پاک کردن فیلترها
</button>
</div>
)}
{/* Patterns Table */} {/* Patterns Table */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden"> <div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
@ -1462,14 +1578,16 @@ export default function SmsSettingsPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100 font-medium"> <tbody className="divide-y divide-gray-100 font-medium">
{patterns.length === 0 ? ( {filteredPatterns.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="p-8 text-center text-gray-400 text-xs"> <td colSpan={6} className="p-8 text-center text-gray-400 text-xs">
هیچ الگویی یافت نشد. میتوانید با دکمه «درج الگوی جدید» اولین پترن خود را ثبت کنید. {patterns.length === 0
? 'هیچ الگویی یافت نشد. می‌توانید با دکمه «درج الگوی جدید» اولین پترن خود را ثبت کنید.'
: 'هیچ الگویی با شرایط فیلتر جستجو شده مطابقت ندارد.'}
</td> </td>
</tr> </tr>
) : ( ) : (
patterns.map((p) => ( filteredPatterns.map((p) => (
<tr key={p.id} className="hover:bg-purple-50/40 transition-colors"> <tr key={p.id} className="hover:bg-purple-50/40 transition-colors">
<td className="p-4 font-mono font-bold text-gray-900"> <td className="p-4 font-mono font-bold text-gray-900">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -1591,6 +1709,9 @@ export default function SmsSettingsPage() {
</table> </table>
</div> </div>
</div> </div>
</>
);
})()}
</div> </div>
) : ( ) : (
/* Logs & Tracking Tab */ /* Logs & Tracking Tab */