From d2ac10d8494b0196685f45ebba1435552cb3f01d Mon Sep 17 00:00:00 2001 From: root Date: Tue, 23 Jun 2026 10:35:52 +0000 Subject: [PATCH] feat: SMS campaign sub-tabs + recheck button + dual send protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SmsCampaignPanel: 4 sub-tabs (first_sms/second_sms/manual/paid_storage) - 'Проверить снова' button sets needs_check=true in sms_campaign_log - Auto-refresh every 30s with toggle - Elapsed time display per row - Script: after send, notification_status='sms_sending' (not 'link_ready') → double protection against re-sending to same group - Script: get_sms_to_check includes needs_check=true records regardless of age - Script: checked_at timestamp on each status check - DB: needs_check boolean + checked_at timestamptz columns added --- scripts/sms_first_campaign.py | 45 +++- src/components/admin/SmsCampaignPanel.jsx | 292 ++++++++++++++++------ 2 files changed, 247 insertions(+), 90 deletions(-) diff --git a/scripts/sms_first_campaign.py b/scripts/sms_first_campaign.py index c4cd983..1afe396 100644 --- a/scripts/sms_first_campaign.py +++ b/scripts/sms_first_campaign.py @@ -9,9 +9,14 @@ SuperSam — SMS First Campaign (State Machine) 2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration) 3. Обновляет статусы в order_groups + sms_campaign_log -Защита от повторной отправки: - - Группа с sms_campaign_log status='sent'/'checking' в последние max_check_duration_minutes → skip - - Код 231/132 = лимит одинаковых → не повторять +Защита от повторной отправки (ДВОЙНАЯ): + 1. После отправки SMS → notification_status = 'sms_sending' (не 'link_ready') + → get_groups_to_send НЕ находит эту группу (фильтр по notification_status = 'link_ready') + 2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h) + 3. Код 231/132 = лимит одинаковых → не повторять + +needs_check=true — admin нажал "Проверить снова" в UI + → скрипт проверяет даже если запись старше max_check_duration Коды sms.ru: 100 = в очереди, 101 = оператору, 102 = в пути → ждём @@ -192,19 +197,26 @@ def get_groups_to_send(conn): return [dict(r) for r in cur.fetchall()] def get_sms_to_check(conn, max_duration_min): - """SMS в логе со status='sent'/'checking', которые ещё не доставлены""" + """SMS в логе со status='sent'/'checking', которые ещё не доставлены. + Включает: + - Записи младше max_duration_min (обычная автопроверка) + - Записи с needs_check=true (admin нажал 'Проверить снова') — независимо от возраста + """ with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone, - scl.attempts, scl.created_at, scl.sms_code, + scl.attempts, scl.created_at, scl.sms_code, scl.needs_check, og.customer_name, og.group_key FROM sms_campaign_log scl JOIN order_groups og ON og.id = scl.order_group_id WHERE scl.campaign_type = 'first_sms' AND scl.status IN ('sent', 'checking') AND scl.sms_id IS NOT NULL - AND scl.created_at > NOW() - INTERVAL '%s minutes' - ORDER BY scl.created_at ASC + AND ( + scl.created_at > NOW() - INTERVAL '%s minutes' + OR scl.needs_check = true + ) + ORDER BY scl.needs_check DESC, scl.created_at ASC """ % max_duration_min) return [dict(r) for r in cur.fetchall()] @@ -239,8 +251,11 @@ def update_sms_log(conn, log_id, **kwargs): set_parts = [] values = [] for k, v in kwargs.items(): - set_parts.append(f"{k} = %s") - values.append(v) + if v == "NOW()": + set_parts.append(f"{k} = NOW()") + else: + set_parts.append(f"{k} = %s") + values.append(v) values.append(log_id) cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values) conn.commit() @@ -313,7 +328,13 @@ def step_send_new(conn, settings): sms_code=code, attempts=1, ) - log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}") + # ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status, + # чтобы get_groups_to_send не нашёл эту группу при следующем запуске + update_order_group(conn, group_id, { + "notification_status": "sms_sending", + "sms_sent_at": "NOW()", + }) + log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending") sent_count += 1 else: # Ошибка отправки @@ -357,9 +378,13 @@ def step_check_status(conn, settings): name = item.get("customer_name") or item.get("group_key", "—") phone = item.get("customer_phone", "") attempts = item.get("attempts", 1) + was_needs_check = item.get("needs_check", False) code, raw, api_code = check_sms_status(sms_id, api_id) + # Сбрасываем needs_check + ставим checked_at + update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()") + if code == DELIVERED_CODE: # Доставлено! log.info(f"Group {group_id}: SMS delivered (103)!") diff --git a/src/components/admin/SmsCampaignPanel.jsx b/src/components/admin/SmsCampaignPanel.jsx index 10e06dd..343c22b 100644 --- a/src/components/admin/SmsCampaignPanel.jsx +++ b/src/components/admin/SmsCampaignPanel.jsx @@ -1,22 +1,32 @@ /** * @file SmsCampaignPanel.jsx - * @description SMS Campaign log + settings for mega_admin. - * Shows sms_campaign_log entries with filtering and sms_campaign_settings editable. + * @description SMS Campaign management for mega_admin. + * Sub-tabs: Первая отправка | Второе сообщение | Ручное управление | Платное хранение + * Each tab: settings + log table + "Проверить снова" button + auto-refresh */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { Panel } from "../UI/Panel"; import { Badge } from "../UI/Badge"; import { supabase } from "../../supabaseClient"; -// ── Status labels ────────────────────────────────────────────────────────── +// ── Campaign tabs ─────────────────────────────────────────────────────────── +const CAMPAIGN_TABS = [ + { key: "first_sms", label: "Первая отправка", icon: "📤" }, + { key: "second_sms", label: "Второе сообщение", icon: "📨" }, + { key: "manual", label: "Ручное управление", icon: "🔧" }, + { key: "paid_storage", label: "Платное хранение", icon: "📦" }, +]; + +// ── Status labels ──────────────────────────────────────────────────────────── const STATUS_LABELS = { sent: "Отправлено", checking: "Проверяется", delivered: "Доставлено", send_failed: "Ошибка отправки", error: "Ошибка доставки", - expired: "Истекло", + expired: "Истёк срок", limit_exceeded: "Лимит превышен", + manual_override: "Ручной режим", }; const STATUS_TONES = { @@ -27,6 +37,7 @@ const STATUS_TONES = { error: "danger", expired: "warning", limit_exceeded: "danger", + manual_override: "warning", }; // ── SMS code labels (from sms.ru docs) ──────────────────────────────────────── @@ -35,7 +46,7 @@ const SMS_CODE_LABELS = { "101": "Оператору", "102": "В пути", "103": "Доставлено", - "104": "Истекло время", + "104": "Истёкло время", "105": "Удалено оператором", "106": "Сбой телефона", "107": "Неизвестная причина", @@ -70,7 +81,27 @@ const fmtPhone = (phone) => { return phone; }; +/** Минуты с момента created_at */ +const minutesAgo = (ts) => { + if (!ts) return null; + const diff = Date.now() - new Date(ts).getTime(); + return Math.floor(diff / 60000); +}; + +/** Человекочитаемый "прошло X мин" */ +const fmtElapsed = (ts) => { + const m = minutesAgo(ts); + if (m === null) return "—"; + if (m < 1) return "только что"; + if (m < 60) return `${m} мин назад`; + const h = Math.floor(m / 60); + const rest = m % 60; + return `${h}ч ${rest}м назад`; +}; + +// ── Component ──────────────────────────────────────────────────────────────── export const SmsCampaignPanel = () => { + const [activeTab, setActiveTab] = useState("first_sms"); const [logs, setLogs] = useState([]); const [settings, setSettings] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -78,16 +109,19 @@ export const SmsCampaignPanel = () => { const [filter, setFilter] = useState("all"); const [savingSettings, setSavingSettings] = useState(false); const [settingsSaved, setSettingsSaved] = useState(false); + const [checkingIds, setCheckingIds] = useState(new Set()); + const [autoRefresh, setAutoRefresh] = useState(true); + const refreshTimer = useRef(null); // ── Load data ────────────────────────────────────────────────────────────── const loadData = useCallback(async () => { - setIsLoading(true); setError(null); try { - // Load logs + // Load logs for active campaign tab let query = supabase .from("sms_campaign_log") .select("*") + .eq("campaign_type", activeTab) .order("created_at", { ascending: false }) .limit(200); @@ -99,11 +133,11 @@ export const SmsCampaignPanel = () => { if (logError) throw logError; setLogs(logData || []); - // Load settings + // Load settings for active campaign const { data: settingsData, error: settingsError } = await supabase .from("sms_campaign_settings") .select("*") - .eq("campaign_type", "first_sms") + .eq("campaign_type", activeTab) .single(); if (settingsError && settingsError.code !== "PGRST116") throw settingsError; setSettings(settingsData || null); @@ -112,9 +146,20 @@ export const SmsCampaignPanel = () => { } finally { setIsLoading(false); } - }, [filter]); + }, [activeTab, filter]); - useEffect(() => { loadData(); }, [loadData]); + // Initial load + auto-refresh + useEffect(() => { + setIsLoading(true); + loadData(); + }, [loadData]); + + useEffect(() => { + if (autoRefresh) { + refreshTimer.current = setInterval(() => loadData(), 30000); + return () => clearInterval(refreshTimer.current); + } + }, [autoRefresh, loadData]); // ── Save settings ────────────────────────────────────────────────────────── const handleSaveSettings = async () => { @@ -141,14 +186,42 @@ export const SmsCampaignPanel = () => { setSettings(prev => prev ? { ...prev, [key]: value } : prev); }; + // ── "Проверить снова" — sets needs_check=true ─────────────────────────────── + const handleRecheck = async (logId) => { + setCheckingIds(prev => new Set([...prev, logId])); + try { + const { error: updateError } = await supabase + .from("sms_campaign_log") + .update({ needs_check: true, updated_at: new Date().toISOString() }) + .eq("id", logId); + if (updateError) throw updateError; + // Update local state + setLogs(prev => prev.map(l => + l.id === logId ? { ...l, needs_check: true } : l + )); + } catch (e) { + setError(`Ошибка: ${e.message}`); + } finally { + setTimeout(() => { + setCheckingIds(prev => { + const next = new Set(prev); + next.delete(logId); + return next; + }); + }, 1000); + } + }; + // ── Stats summary ───────────────────────────────────────────────────────── const stats = logs.reduce((acc, log) => { acc[log.status] = (acc[log.status] || 0) + 1; return acc; }, {}); + const hasSettings = activeTab === "first_sms"; // Only first_sms has settings for now + // ── Render ────────────────────────────────────────────────────────────────── - if (isLoading) { + if (isLoading && logs.length === 0) { return (
Загрузка SMS-логов…
@@ -156,29 +229,44 @@ export const SmsCampaignPanel = () => { ); } - if (error) { - return ( - -
Ошибка: {error}
- -
- ); - } - return (
- {/* Settings panel */} - {settings && ( + {/* ── Campaign sub-tabs ─────────────────────────────────────────────── */} +
+ {CAMPAIGN_TABS.map(tab => ( + + ))} +
+ + {error && ( + +
{error}
+ +
+ )} + + {/* ── Settings panel (only for campaigns with settings) ─────────────── */} + {hasSettings && settings && (
-

Настройки кампании

+

Настройки: {CAMPAIGN_TABS.find(t => t.key === activeTab)?.label}

{settingsSaved && ( ✓ Сохранено )}
- + {/* Test / Production mode toggle */}
)}
+
{ )} - {/* Stats summary */} + {/* ── Not-yet-implemented tabs ──────────────────────────────────────── */} + {!hasSettings && ( + +
+ {CAMPAIGN_TABS.find(t => t.key === activeTab)?.label} — в разработке +
+
+ )} + + {/* ── Stats summary ──────────────────────────────────────────────────── */}
Всего: {logs.length} @@ -279,34 +377,46 @@ export const SmsCampaignPanel = () => {
- {/* Filter buttons */} -
- setFilter("all")}> - Все ({logs.length}) - - {Object.entries(STATUS_LABELS).map(([status, label]) => { - const count = stats[status] || 0; - if (count === 0) return null; - return ( - setFilter(status)}> - {label} ({count}) - - ); - })} + {/* ── Filter + auto-refresh ──────────────────────────────────────────── */} +
+
+ setFilter("all")}> + Все ({logs.length}) + + {Object.entries(STATUS_LABELS).map(([status, label]) => { + const count = stats[status] || 0; + if (count === 0) return null; + return ( + setFilter(status)}> + {label} ({count}) + + ); + })} +
+
- {/* Log table */} + {/* ── Log table ──────────────────────────────────────────────────────── */}
-
+
{/* Header */} -
+
Телефон
SMS ID
Статус
Код
-
Попытка
+
Поп.
Создано
+
Действие
{/* Rows */} {logs.length === 0 ? ( @@ -314,45 +424,67 @@ export const SmsCampaignPanel = () => { Нет записей в логе
) : ( - logs.map((entry) => ( -
-
- {fmtPhone(entry.customer_phone)} + logs.map((entry) => { + const canRecheck = entry.status === "sent" || entry.status === "checking"; + const isChecking = checkingIds.has(entry.id); + const elapsed = fmtElapsed(entry.created_at); + return ( +
+
+ {fmtPhone(entry.customer_phone)} +
+
+ {entry.sms_id || "—"} +
+
+ + {STATUS_LABELS[entry.status] || entry.status} + + {entry.needs_check && ( +
⟳ в очереди
+ )} +
+
+ {entry.sms_code || "—"} + {entry.sms_code && SMS_CODE_LABELS[entry.sms_code] && ( +
{SMS_CODE_LABELS[entry.sms_code]}
+ )} +
+
+ {entry.attempts || 0} +
+
+ {fmtTime(entry.created_at)} +
{elapsed}
+ {entry.error_message && ( +
{entry.error_message.slice(0, 80)}
+ )} +
+
+ {canRecheck ? ( + + ) : ( + + )} +
-
- {entry.sms_id || "—"} -
-
- - {STATUS_LABELS[entry.status] || entry.status} - -
-
- {entry.sms_code || "—"} - {entry.sms_code && SMS_CODE_LABELS[entry.sms_code] && ( -
{SMS_CODE_LABELS[entry.sms_code]}
- )} -
-
- {entry.attempts || 0} -
-
- {fmtTime(entry.created_at)} - {entry.error_message && ( -
{entry.error_message.slice(0, 80)}
- )} -
-
- )) + ); + }) )}
- {/* Refresh button */} + {/* ── Refresh button ────────────────────────────────────────────────── */}