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:
2026-10-03 00:45:41 +03:00
parent b5c6b1973e
commit f7f0b039dd
28 changed files with 1381 additions and 17 deletions

View 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>
);
}

View File

@@ -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) {

View File

@@ -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 } }),