fix(alerts): подтвердить риск ручного повтора сообщения

This commit is contained in:
Denozordec committed 2026-10-03 19:21:19 +07:00
1 parent 7d0608bbce
commit b109532f33
11 files changed
+103 -16

No files matched your search

+30 -8
View File
@@ -1,11 +1,19 @@
"use client"
import {useCallback,useEffect,useRef,useState} from "react"
import {useCallback,useEffect,useRef,useState,useSyncExternalStore} from "react"
import type {AlertsOutboxOverview} from "@mmapp/contracts/alerts"
import {can} from "@/lib/auth"
import {getAlertsOutbox} from "@/shared/api/alerts-outbox"
import {getAlertsOutbox,retryAlertsOutbox} from "@/shared/api/alerts-outbox"
import {OpsPanel} from "@/components/ops-panel"
import {Button} from "@/components/ui/button"
export function AlertsOutboxPanel({base}:{base:string}){
import {ApiClientError} from "@/shared/api/http-client"
import {outboxPending,subscribeOutboxPending,setOutboxPending} from "@/lib/alerts-outbox-checkpoint"
import {AlertDialog,AlertDialogContent,AlertDialogHeader,AlertDialogTitle,AlertDialogDescription,AlertDialogFooter} from "@/components/ui/alert-dialog"
export function AlertsOutboxPanel({base,operationsBusy,available,beginOperation,endOperation}:{base:string;operationsBusy:boolean;available:boolean;beginOperation:()=>boolean;endOperation:()=>void}){
const source=JSON.stringify(["live",base]),unknown=useSyncExternalStore(subscribeOutboxPending,()=>outboxPending(source),()=>false)
const [selected,setSelected]=useState<AlertsOutboxOverview["items"][number]|null>(null),[ack,setAck]=useState(false),[reviewed,setReviewed]=useState(false),[mutationBusy,setMutationBusy]=useState(false),[mutationError,setMutationError]=useState<string|null>(null),[message,setMessage]=useState<string|null>(null)
const mutationLock=useRef(false),writeController=useRef<AbortController|null>(null),writeRevision=useRef(0)
const writable=can("mm:alerts:write")&&available&&!operationsBusy&&!unknown&&!mutationBusy
const [data,setData]=useState<AlertsOutboxOverview|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null),[receivedAt,setReceivedAt]=useState<string|null>(null)
const active=useRef(true),revision=useRef(0),controller=useRef<AbortController|null>(null)
const refresh=useCallback(async()=>{
@@ -15,15 +23,29 @@ export function AlertsOutboxPanel({base}:{base:string}){
try{const result=await getAlertsOutbox(base,{signal:controller.current.signal});if(current()){setData(result);setReceivedAt(new Date().toISOString())}}catch(e){if(current())setError(e instanceof Error?e.message:"Не удалось получить очередь")}
finally{if(current())setBusy(false)}
},[base])
useEffect(()=>{const generation=revision;active.current=true;let pending=true;queueMicrotask(()=>{if(pending)void refresh()});return()=>{pending=false;active.current=false;generation.current++;controller.current?.abort()}},[refresh])
return <section aria-label="Очередь Telegram" className="min-w-0"><OpsPanel title="Очередь Telegram" description="Приём сообщения API и доставка в чат — разные состояния." headerRight={<Button type="button" variant="outline" size="sm" aria-label="Обновить очередь Telegram" disabled={busy} onClick={()=>void refresh()}>Обновить</Button>} contentClassName="space-y-3 px-5 py-4">
useEffect(()=>{const generation=revision,writeGeneration=writeRevision;active.current=true;let pending=true;queueMicrotask(()=>{if(pending)void refresh()});return()=>{pending=false;active.current=false;generation.current++;writeGeneration.current++;controller.current?.abort();writeController.current?.abort()}},[refresh])
async function retry(){
if(!selected||!ack||!writable||mutationLock.current||!beginOperation())return
mutationLock.current=true;setMutationBusy(true);setMutationError(null);setMessage(null);controller.current?.abort();revision.current++;setBusy(false)
const requestRevision=++writeRevision.current,abort=new AbortController();writeController.current=abort;setOutboxPending(source,true)
const current=()=>active.current&&requestRevision===writeRevision.current
try{await retryAlertsOutbox(base,selected.id,{expectedVersion:selected.resolutionVersion,acknowledgedDuplicateRisk:true},{signal:abort.signal});if(current()){setOutboxPending(source,false);setSelected(null);setMessage("Сообщение поставлено в очередь. Отправка и доставка ещё не подтверждены.");void refresh()}}
catch(e){if(current()){if(e instanceof ApiClientError&&[400,401,403,409,422].includes(e.status))setOutboxPending(source,false);setMutationError(e instanceof Error?e.message:"Результат повтора неизвестен")}}
finally{mutationLock.current=false;endOperation();if(current())setMutationBusy(false)}
}
return <section aria-label="Очередь Telegram" className="min-w-0"><OpsPanel title="Очередь Telegram" description="Приём сообщения API и доставка в чат — разные состояния." headerRight={<Button type="button" variant="outline" size="sm" aria-label="Обновить очередь Telegram" disabled={busy||mutationBusy} onClick={()=>void refresh()}>Обновить</Button>} contentClassName="space-y-3 px-5 py-4">
{error?<p role="alert" className="text-xs text-destructive break-words">{error}{data?". Показан последний успешный снимок очереди.":""}</p>:null}
<p role="status" className="text-xs text-muted-foreground">{busy?"Обновление очереди…":data?"Последний успешный ответ: "+new Date(receivedAt!).toLocaleString("ru-RU"):"Данные очереди ещё не получены."}</p>
{message?<p role="status" className="text-xs break-words">{message}</p>:null}
{mutationError?<p role="alert" className="text-xs text-destructive break-words">{mutationError}</p>:null}
{unknown?<div className="space-y-2 text-xs"><p role="status">{mutationBusy?"Постановка в очередь выполняется…":"Результат повтора неизвестен. Автоматический повтор заблокирован. Проверьте очередь и чат: сообщение могло быть отправлено."}</p><label className="flex items-start gap-2"><input type="checkbox" checked={reviewed} disabled={mutationBusy} onChange={e=>setReviewed(e.target.checked)}/>Проверил очередь и чат</label><Button type="button" size="sm" variant="outline" disabled={!reviewed||mutationBusy} onClick={()=>{setOutboxPending(source,false);setReviewed(false);void refresh()}}>Результат повтора проверен</Button></div>:null}
{data?<>
<p className="text-sm">Ожидают: {data.counts.pending} · API принял: {data.counts.sent} · Завершены с ошибкой: {data.counts.failed}</p>
{data.counts.unknown||data.counts.processing||data.counts.unrecognized?<p role="status" className="text-xs break-words">Нужна проверка: неизвестный результат — {data.counts.unknown}, в обработке — {data.counts.processing}, нераспознанный статус — {data.counts.unrecognized}. Эти записи автоматически не переотправляются. Обработка может означать текущий запрос или прерванный процесс отправки; проверьте чат и журнал перед повтором.</p>:null}
<p className="text-xs text-muted-foreground">Показано {data.items.length} из {data.counts.processing+data.counts.unknown} удержанных записей. Повторная отправка из этого экрана пока недоступна.</p>
{data.items.length?<ul className="space-y-2">{data.items.map(item=><li key={item.id} className="break-words text-xs"><p className="font-medium">{item.ruleName||item.id}</p><p className="text-muted-foreground">{item.status==="unknown"?"Результат неизвестен":"Запрос начат, завершение не подтверждено"} · {new Date(item.attemptedAt).toLocaleString("ru-RU")}</p></li>)}</ul>:null}
<p className="text-xs text-muted-foreground">Показано {data.items.length} из {data.counts.processing+data.counts.unknown} удержанных записей. Повтор разрешён только для неизвестного результата после проверки чата.</p>
{data.items.length?<ul className="space-y-2">{data.items.map(item=><li key={item.id} className="break-words text-xs"><p className="font-medium">{item.ruleName||item.id}</p><p className="text-muted-foreground">{item.status==="unknown"?"Результат неизвестен":"Запрос начат, завершение не подтверждено"} · {new Date(item.attemptedAt).toLocaleString("ru-RU")}</p>{item.status==="unknown"?<Button type="button" size="sm" variant="outline" aria-label={"Повторить сообщение "+(item.ruleName||item.id)} disabled={!writable||item.resolutionVersion===2147483647} onClick={()=>{setSelected(item);setAck(false);setMutationError(null)}}>Повторить после проверки</Button>:null}</li>)}</ul>:null}
</>:null}
</OpsPanel></section>
</OpsPanel>
<AlertDialog open={selected!==null} onOpenChange={open=>{if(!open&&!mutationLock.current)setSelected(null)}}><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Повторить сообщение?</AlertDialogTitle><AlertDialogDescription>{selected?.ruleName||selected?.id}. Предыдущая попытка могла доставить сообщение. Повтор может создать дубликат; повтор использует сохранённое сообщение и настройки подключения Telegram на момент новой попытки.</AlertDialogDescription></AlertDialogHeader><label className="flex items-start gap-2 text-sm"><input type="checkbox" checked={ack} disabled={mutationBusy} onChange={e=>setAck(e.target.checked)}/>Проверил чат и принимаю риск дубликата</label>{mutationError?<p role="alert" className="text-xs text-destructive break-words">{mutationError}</p>:null}<AlertDialogFooter><Button type="button" variant="outline" disabled={mutationBusy} onClick={()=>setSelected(null)}>Отмена</Button><Button type="button" disabled={!ack||!writable} onClick={()=>void retry()}>{mutationBusy?"Постановка в очередь…":"Поставить в очередь повторно"}</Button></AlertDialogFooter></AlertDialogContent></AlertDialog>
</section>
}