Add Telegram cancellation import functionality with tests and documentation
- Implemented Telegram cancellation parsing logic in `telegramCancellationParser.js`. - Created unit tests for cancellation parsing in `telegramCancellationParser.test.js`. - Developed integration tests for Telegram import service in `telegramImport.integration.test.js`. - Added API tests for Telegram import endpoints in `telegramImportApi.test.js`. - Introduced service tests for Telegram import logic in `telegramImportService.test.js`. - Documented the Telegram cancellation import process in `telegram-cancellation-import.md`. - Built a React component for managing Telegram imports in `TelegramImportPanel.jsx`.
This commit is contained in:
135
frontend/src/components/TelegramImportPanel.jsx
Normal file
135
frontend/src/components/TelegramImportPanel.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ExternalLink, RefreshCw, Send, X } from 'lucide-react';
|
||||
import { telegramImportApi } from '../services/api';
|
||||
import { useCity } from '../contexts/CityContext';
|
||||
|
||||
const connectionLabels = {
|
||||
disabled: 'Импорт выключен', configuration_required: 'Нужна настройка аккаунта',
|
||||
waiting: 'Ожидание подключения', connecting: 'Подключение к Telegram', ready: 'Канал подключён', error: 'Ошибка подключения'
|
||||
};
|
||||
const errorLabels = {
|
||||
SESSION_MISSING: 'Не найден файл авторизации. Выполните авторизацию аккаунта на сервере.',
|
||||
AUTH_REQUIRED: 'Требуется повторная авторизация Telegram-аккаунта.',
|
||||
AUTH_KEY_UNREGISTERED: 'Авторизация отозвана. Подключите аккаунт повторно.',
|
||||
SESSION_REVOKED: 'Авторизация отозвана. Подключите аккаунт повторно.',
|
||||
SESSION_FILE_PERMISSIONS: 'Файл авторизации должен быть доступен только владельцу.',
|
||||
SESSION_FILE_IN_CHECKOUT: 'Перенесите файл авторизации за пределы проекта.',
|
||||
USER_ACCOUNT_REQUIRED: 'Нужен отдельный пользовательский аккаунт Telegram.',
|
||||
CHANNEL_PRIVATE: 'Канал недоступен этому аккаунту.', CHANNEL_INVALID: 'Проверьте имя канала в настройках сервера.',
|
||||
USERNAME_NOT_OCCUPIED: 'Канал с таким именем не найден.',
|
||||
FLOOD_WAIT: 'Telegram ограничил запросы. Импорт продолжится после паузы.',
|
||||
HISTORY_LIMIT: 'В канале слишком много сообщений за последние три дня. Нужна проверка импорта.',
|
||||
IMPORT_FAILED: 'Проверьте соединение с Telegram и миграцию базы данных.'
|
||||
};
|
||||
const formatTime = value => value ? new Date(value).toLocaleString('ru-RU', { timeZone: 'Europe/Minsk' }) : 'ещё не выполнялось';
|
||||
|
||||
export default function TelegramImportPanel({ routes, onChanged }) {
|
||||
const { activeCity } = useCity();
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
try { setData((await telegramImportApi.getAll()).data); setError(''); }
|
||||
catch (err) { setError(err.response?.data?.error || 'Не удалось загрузить импорт Telegram.'); }
|
||||
};
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = setInterval(load, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const sync = async () => {
|
||||
setBusy(true);
|
||||
try { await telegramImportApi.sync(); await load(); onChanged(); }
|
||||
catch (err) { setError(err.response?.data?.error || 'Не удалось прочитать канал.'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const review = async (row, values) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await telegramImportApi.review(row.id, { ...values, content_hash: row.content_hash });
|
||||
setEditing(null); await load(); onChanged();
|
||||
} catch (err) { setError(err.response?.data?.error || 'Не удалось сохранить решение.'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const status = data?.status;
|
||||
const configuredCity = status?.citySlug === activeCity?.slug;
|
||||
const pending = data?.messages.filter(m => m.status === 'review') || [];
|
||||
const rows = showHistory ? data?.messages || [] : pending;
|
||||
return (
|
||||
<section className="card mb-6" aria-label="Отмены из Telegram">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-gray-900 flex items-center gap-2"><Send className="h-4 w-4 text-primary-600" />Отмены из Telegram</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{status ? configuredCity ? connectionLabels[status.connection] : 'Для выбранного города источник не настроен' : 'Загрузка источника…'}
|
||||
{status && <> · @{status.channel}</>}
|
||||
</p>
|
||||
{status?.connection === 'disabled' && <p className="text-sm text-gray-500 mt-1">Подключите отдельный аккаунт на сервере. Отмены будут поступать автоматически.</p>}
|
||||
{status?.enabled && <p className="text-xs text-gray-500 mt-1">Последняя проверка: {formatTime(status.lastSyncAt)} · {status.autoPublish ? 'Однозначные отмены публикуются автоматически' : 'Публикация после проверки'}</p>}
|
||||
{status?.lastError && <p className="text-sm text-amber-700 mt-2">{errorLabels[status.lastError] || 'Не удалось подключиться к Telegram.'}</p>}
|
||||
{status?.retryAt && <p className="text-xs text-gray-500 mt-1">Следующая попытка не раньше: {formatTime(status.retryAt)}</p>}
|
||||
<p className="text-xs text-gray-500 mt-2">Срок действия — до полуночи дня отмены по Минску. АВ/ВОКЗАЛ распознаётся как Автовокзал.</p>
|
||||
</div>
|
||||
<button type="button" className="btn-secondary flex items-center justify-center shrink-0" onClick={sync} disabled={busy || !configuredCity || !status?.enabled || status?.missing.length > 0 || status?.syncing}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${busy || status?.syncing ? 'animate-spin' : ''}`} />Проверить канал
|
||||
</button>
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-red-700 mt-3">{error}</p>}
|
||||
{data && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-gray-600">На проверке: <strong>{pending.length}</strong></p>
|
||||
<button className="text-sm text-primary-600 hover:underline" onClick={() => setShowHistory(!showHistory)}>{showHistory ? 'Только на проверке' : 'Показать историю'}</button>
|
||||
</div>
|
||||
{rows.map(row => (
|
||||
<div key={row.id} className="mt-3 rounded-lg border border-gray-200 p-3">
|
||||
<div className="flex flex-wrap justify-between gap-2 text-xs text-gray-500">
|
||||
<span>{formatTime(row.published_at)} · {{ review: 'На проверке', published: 'Опубликовано', dismissed: 'Отклонено', expired: 'День отмены завершён' }[row.status]}</span>
|
||||
<a href={row.source_url} target="_blank" rel="noreferrer" className="text-primary-600 inline-flex items-center gap-1">Оригинал<ExternalLink className="h-3 w-3" /></a>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-gray-700 mt-2">{row.raw_text || 'Вложение без текста — откройте оригинал.'}</p>
|
||||
{row.status === 'review' && <>
|
||||
{row.reasons.map(reason => <p key={reason} className="text-xs text-amber-700 mt-2">{reason}</p>)}
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<button className="btn-primary text-sm" disabled={busy} onClick={() => setEditing(row)}>Проверить и опубликовать</button>
|
||||
<button className="btn-secondary text-sm" disabled={busy} onClick={() => review(row, { action: 'dismiss' })}>Отклонить</button>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
))}
|
||||
{!rows.length && <p className="text-sm text-gray-500 mt-3">{showHistory ? 'Сообщений об отменах пока нет.' : 'Нет сообщений, требующих проверки.'}</p>}
|
||||
</div>
|
||||
)}
|
||||
{editing && <TelegramReviewForm key={editing.id} row={editing} routes={routes} busy={busy} onCancel={() => setEditing(null)} onSubmit={values => review(editing, values)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TelegramReviewForm({ row, routes, busy, onSubmit, onCancel }) {
|
||||
const publicationDate = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
}).format(new Date(row.published_at));
|
||||
const [form, setForm] = useState({
|
||||
action: 'publish', service_date: row.parsed?.serviceDate || publicationDate, route_id: row.parsed?.plans?.length === 1 ? row.parsed.plans[0].routeId : '',
|
||||
title: 'Отмена отдельных рейсов', message: row.raw_text.slice(0, 5000)
|
||||
});
|
||||
const change = e => setForm({ ...form, [e.target.name]: e.target.value });
|
||||
return (
|
||||
<form className="mt-4 rounded-lg bg-gray-50 p-4 space-y-3" onSubmit={e => { e.preventDefault(); onSubmit(form); }}>
|
||||
<div className="flex items-center justify-between"><h3 className="font-medium">Проверка отмены</h3><button type="button" onClick={onCancel} aria-label="Закрыть проверку" disabled={busy}><X className="h-5 w-5" /></button></div>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<label className="text-sm">Маршрут<select className="input mt-1 w-full" name="route_id" value={form.route_id} onChange={change} required><option value="">Выберите направление</option>{routes.filter(r => r.is_active).map(r => <option key={r.id} value={r.id}>№{r.route_number} · {r.name}</option>)}</select></label>
|
||||
<label className="text-sm">День отмены<input className="input mt-1 w-full" type="date" name="service_date" value={form.service_date} onChange={change} required /></label>
|
||||
</div>
|
||||
<label className="text-sm block">Заголовок<input className="input mt-1 w-full" name="title" value={form.title} maxLength={200} onChange={change} required /></label>
|
||||
<label className="text-sm block">Текст уведомления<textarea className="input mt-1 w-full" rows={5} name="message" value={form.message} maxLength={5000} onChange={change} required /></label>
|
||||
<p className="text-xs text-gray-500">Уточните остановки и времена. Уведомление будет действовать до 00:00 следующего дня по Минску. Ссылка на оригинал добавится автоматически.</p>
|
||||
<div className="flex flex-wrap gap-2"><button className="btn-primary" disabled={busy}>Опубликовать отмену</button><button type="button" className="btn-secondary" disabled={busy} onClick={onCancel}>Отмена</button></div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import { Plus, Edit, Trash2, AlertCircle, AlertTriangle, Info, Clock, Filter, Re
|
||||
import { alertsApi, routesApi } from '../services/api';
|
||||
import { AlertTypeBadge } from '../components/AlertComponents';
|
||||
import { PageLoader } from '../components/AnimatedComponents';
|
||||
import TelegramImportPanel from '../components/TelegramImportPanel';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
export default function AlertsPage() {
|
||||
const { user } = useAuth();
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [routes, setRoutes] = useState([]);
|
||||
const [templates, setTemplates] = useState([]);
|
||||
@@ -14,6 +17,22 @@ export default function AlertsPage() {
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
const updateClock = () => {
|
||||
clearTimeout(timer);
|
||||
const current = new Date();
|
||||
setNow(current);
|
||||
const boundaries = alerts.flatMap(item => [item.start_time, item.end_time])
|
||||
.filter(Boolean).map(value => new Date(value).getTime()).filter(value => value > current.getTime());
|
||||
if (boundaries.length) timer = setTimeout(updateClock, Math.min(Math.min(...boundaries) - current.getTime() + 5, 2_147_483_647));
|
||||
};
|
||||
updateClock();
|
||||
document.addEventListener('visibilitychange', updateClock);
|
||||
return () => { clearTimeout(timer); document.removeEventListener('visibilitychange', updateClock); };
|
||||
}, [alerts]);
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
@@ -96,12 +115,12 @@ export default function AlertsPage() {
|
||||
}
|
||||
|
||||
const statusCounts = alerts.reduce((counts, alert) => {
|
||||
counts[getAlertStatus(alert)] += 1;
|
||||
counts[getAlertStatus(alert, now)] += 1;
|
||||
return counts;
|
||||
}, { active: 0, scheduled: 0, draft: 0, expired: 0 });
|
||||
const filteredAlerts = alerts.filter(alert =>
|
||||
(filterType === 'all' || alert.alert_type === filterType)
|
||||
&& (filterStatus === 'all' || getAlertStatus(alert) === filterStatus)
|
||||
&& (filterStatus === 'all' || getAlertStatus(alert, now) === filterStatus)
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -154,6 +173,8 @@ export default function AlertsPage() {
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{user?.role === 'admin' && <TelegramImportPanel routes={routes} onChanged={loadData} />}
|
||||
|
||||
<div className="card mb-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Тип уведомления</h3>
|
||||
@@ -258,6 +279,7 @@ export default function AlertsPage() {
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
index={index}
|
||||
now={now}
|
||||
onEdit={() => handleEdit(alert)}
|
||||
onDelete={() => handleDelete(alert.id)}
|
||||
/>
|
||||
@@ -294,7 +316,7 @@ export default function AlertsPage() {
|
||||
|
||||
function getAlertStatus(alert, now = new Date()) {
|
||||
if (!alert.is_active) return 'draft';
|
||||
if (alert.end_time && new Date(alert.end_time) < now) return 'expired';
|
||||
if (alert.end_time && new Date(alert.end_time) <= now) return 'expired';
|
||||
if (alert.start_time && new Date(alert.start_time) > now) return 'scheduled';
|
||||
return 'active';
|
||||
}
|
||||
@@ -320,7 +342,7 @@ function FilterButton({ active, onClick, icon: Icon, color = 'primary', children
|
||||
);
|
||||
}
|
||||
|
||||
function AlertCard({ alert, index, onEdit, onDelete }) {
|
||||
function AlertCard({ alert, index, now, onEdit, onDelete }) {
|
||||
const alertIcons = {
|
||||
delay: { icon: Clock, color: 'text-amber-600' },
|
||||
cancellation: { icon: AlertTriangle, color: 'text-red-600' },
|
||||
@@ -331,7 +353,7 @@ function AlertCard({ alert, index, onEdit, onDelete }) {
|
||||
const config = alertIcons[alert.alert_type] || alertIcons.info;
|
||||
const Icon = config.icon;
|
||||
|
||||
const status = getAlertStatus(alert);
|
||||
const status = getAlertStatus(alert, now);
|
||||
const statusLabels = {
|
||||
active: { label: 'В эфире', className: 'bg-green-100 text-green-700' },
|
||||
scheduled: { label: 'Запланировано', className: 'bg-blue-100 text-blue-600' },
|
||||
@@ -430,12 +452,11 @@ function toDateTimeInput(value = new Date()) {
|
||||
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`;
|
||||
}
|
||||
|
||||
// PostgreSQL currently stores alert timestamps without a timezone. Keep UTC
|
||||
// values there, while the operator always sees and enters Polotsk time.
|
||||
// Store an explicit UTC instant; the operator enters and sees Minsk time.
|
||||
function toApiDateTime(value) {
|
||||
if (!value) return null;
|
||||
const date = new Date(`${value}:00${MINSK_OFFSET}`);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toISOString().slice(0, 19);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toISOString();
|
||||
}
|
||||
|
||||
function toMinskDate(value) {
|
||||
|
||||
@@ -188,6 +188,12 @@ export const holidaysApi = {
|
||||
};
|
||||
|
||||
// Alerts API (уведомления)
|
||||
export const telegramImportApi = {
|
||||
getAll: () => api.get('/telegram-import'),
|
||||
sync: () => api.post('/telegram-import/sync'),
|
||||
review: (id, data) => api.post(`/telegram-import/${id}/review`, data)
|
||||
};
|
||||
|
||||
export const alertsApi = {
|
||||
getAll: (params) => api.get('/alerts', { params }),
|
||||
getActive: (routeId) => api.get('/alerts/active', { params: { route_id: routeId } }),
|
||||
|
||||
Reference in New Issue
Block a user