Fronta a worker: webhook odpovi hned, praci udelaji workeri

Webhook uz nic nevykonava v requestu. Zapise udalost do fronty a odpovi 202
do jednotek milisekund; strom vykona worker na pozadi. Za konektory nerucime,
takze cekat na cizi sluzbu v requestu znamena ztracet udalosti pri timeoutu.

Fronta ma opakovani s rostouci prodlevou (30 s, 2 min, 10 min, hodina),
spravedlive poradi po firmach (jedna firma s tisicem udalosti nezablokuje
ostatni), navrat zaseknutych behu po restartu a uklid hotovych. Marna chyba
se neopakuje - chybejici skript za minutu existovat nezacne.

Tri druhy spoustecu: push (webhook), vnitrni udalost (vznik a zmena ticketu)
a pull, tedy pravidelne dotazovani u sluzeb bez webhooku (posta, zpravy).
Planovac jen rekne "je cas", samotny dotaz je prvni krok stromu, takze ma
zaznam v logu a opakuje se pri chybe jako cokoliv jineho.

Kontrakt tela webhooku: kazdy parametr ma cestu (data.order.id,
errors.0.message), takze jde napojit i odesilatel s vnorenym modelem.
U adresy je metoda, ukazka tela a kopiruje se cela adresa vcetne domeny.

Vnitrni kroky, ktere sahaji do naseho uloziste: ticket/upsert (zaloz nebo
dopln podle externiho ID), assign-least-busy, assign-by-external, set-type,
set-stage, add-tags, set-status, incident/create, flow/pause a flow/log.

Faze ticketu jako treti osa vedle stavu a stitku. Stav je zivotni cyklus
a pocitaji se z nej statistiky, faze je workflow daneho typu a muze byt jen
jedna, takze se na ni da spolehnout v podmince.

ID z cizich aplikaci u resitele: voicebot posle voicebotId a ticket skonci
u toho, komu patri. Vazba je na jednom miste, ne v kazde automatizaci.

Kazda chyba zaklada incident se dvema urovnemi: impact cte klient a je
srozumitelny, detail cte admin a je v nem cely beh, ktery krok selhal, cele
hlaseni a data na vstupu. Detail vidi jen spravce platformy.

Ochrana proti smycce: automatizace navazana na zmenu ticketu ticket meni,
cimz se spousti znovu - pri vyvoji to server polozilo. Resi to oznaceni behu
pres AsyncLocalStorage a strop peti behu na jeden ticket za minutu.

Upozorneni pri prideleni prace vcetne cisla u zalozky Tickety. Zivy dashboard:
dlazdice nad nasimi daty na udalost, data z konektoru podle ttlSec s moznosti
vynutit nacteni znovu.

Opraveno: path a intervalSec u spoustece se pri ulozeni zahazovaly; nad
seznamem neslo pouzit contains, takze na stitky neslo postavit podminku;
novejsi vystup kroku ted prekryje starsi misto hlaseni konfliktu.

Overeno dvema scenari proti bezicimu serveru, 34 kontrol: firma se skladem,
expedici a IT, a hovory z voicebota (callSid do externiho ID, status do faze,
prirazeni podle voicebotId, tri zpravy = jeden ticket se tremi udalostmi).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 16:41:02 +02:00
co-authored by Claude Opus 5
parent 5d186dcd2e
commit a57eca123e
38 changed files with 2942 additions and 112 deletions
@@ -74,6 +74,15 @@ function accessLabel(user: AuthUser | null, access: Access | null): string {
function DashboardShell() {
const { user, logout } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
/*
* Cislo u zalozky Tickety a upozorneni. Obnovuje se na udalost, ne casovacem -
* kdyz nekomu prijde ticket, ma to videt hned, ne za minutu.
*/
const notifications = useApiQuery<{ unread: number; mine: number }>(
'/api/dashboard/notifications',
{ refetchOn: ['notification.created', 'ticket.assigned', 'ticket.resolved'] },
);
const navigate = useNavigate();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -128,7 +137,19 @@ function DashboardShell() {
}
>
<Icon className="size-4 shrink-0" />
{entry.label}
<span className="flex-1">{entry.label}</span>
{/*
Kolik ticketu ma prihlaseny u sebe. Nula se nekresli - prazdny
odznak je jen sum.
*/}
{entry.key === 'tickets' && (notifications.data?.mine ?? 0) > 0 && (
<span
className="grid min-w-5 place-items-center rounded-full bg-brand-500/20 px-1.5 text-xs font-semibold text-brand-200"
title="Kolik ticketů máte u sebe"
>
{notifications.data?.mine}
</span>
)}
</NavLink>
);
})}
+2 -1
View File
@@ -1,4 +1,4 @@
import { AlarmClock, CheckCircle2, LifeBuoy, UserCheck, Webhook, Workflow, X } from 'lucide-react';
import { AlarmClock, Bell, CheckCircle2, LifeBuoy, UserCheck, Webhook, Workflow, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
@@ -22,6 +22,7 @@ const style: Record<DashboardEventType, { icon: LucideIcon; tone: string }> = {
'automation.deleted': { icon: Workflow, tone: 'text-white/50 bg-white/5' },
'automation.run': { icon: Workflow, tone: 'text-ok-400 bg-ok-500/12' },
'webhook.received': { icon: Webhook, tone: 'text-accent-300 bg-accent-500/12' },
'notification.created': { icon: Bell, tone: 'text-brand-300 bg-brand-500/12' },
};
/** Bubliny o tom, co se prave stalo. Bez nich by zive zmeny nebyly poznat. */
@@ -42,6 +42,7 @@ export function TriggerConfig({
<WebhookAddress
token={trigger.webhookToken}
baseUrl={webhookBaseUrl}
fields={trigger.fields}
onRegenerate={onRegenerateToken}
regenerating={regenerating}
/>
@@ -151,6 +152,21 @@ function CustomFields({
))}
</select>
{/*
Cesta v tele. Prazdna = hodnota lezi primo pod nazvem.
Bez toho by slo napojit jen ploche telo a odesilatel
s vnorenym modelem by se musel prizpusobovat nam.
*/}
<input
value={field.path ?? ''}
onChange={(event) =>
updateField(field.id, { path: event.target.value.trim() || undefined })
}
placeholder="cesta v těle, např. data.order.id"
title="Kde hodnota v přijatém JSONu je. Prázdné = přímo pod názvem."
className="min-w-40 flex-1 rounded-lg border border-ink-600/70 bg-ink-900/70 px-2.5 py-1.5 font-mono text-xs text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none"
/>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-white/55">
<input
type="checkbox"
@@ -226,14 +242,63 @@ function ProvidedFields({
}
/** Registrovana adresa webhooku - token generuje server, tady se jen ukazuje. */
/**
* Ukazkove telo podle deklarovanych parametru.
*
* Respektuje cestu: kdyz je parametr `orderId` na `data.order.id`, ukazka ho
* ukaze zanoreny presne tam. Jinak by odesilatel poslal plocho a nic by nesedelo.
*/
function exampleBody(fields: TriggerField[]): string {
if (fields.length === 0) return '{\n "cokoliv": "co pošle odesílatel"\n}';
const body: Record<string, unknown> = {};
for (const field of fields) {
const path = (field.path ?? field.name).split('.');
let target = body;
for (let i = 0; i < path.length - 1; i += 1) {
const key = path[i];
const next = path[i + 1];
// Cislo v ceste znamena pole, ne objekt s klicem "0".
if (target[key] === undefined) target[key] = /^\d+$/.test(next) ? [] : {};
target = target[key] as Record<string, unknown>;
}
target[path[path.length - 1]] = exampleValue(field.type);
}
return JSON.stringify(body, null, 2);
}
function exampleValue(type: TriggerField['type']): unknown {
switch (type) {
case 'number':
return 42;
case 'boolean':
return true;
case 'date':
return '2026-01-31';
case 'object':
return { klic: 'hodnota' };
case 'list':
return ['první', 'druhá'];
default:
return 'text';
}
}
function WebhookAddress({
token,
baseUrl,
fields,
onRegenerate,
regenerating,
}: {
token: string | undefined;
baseUrl: string;
/** Deklarovane parametry. Podle nich se sklada ukazka tela. */
fields: TriggerField[];
onRegenerate: () => void;
regenerating: boolean;
}) {
@@ -251,7 +316,14 @@ function WebhookAddress({
);
}
const url = `${baseUrl}/${token}`;
/*
* Cela adresa vcetne domeny. Server zna `PUBLIC_ORIGIN` jen kdyz je nastavena,
* jinak posle cestu bez domeny - a takovou adresu nejde vlozit do cizi
* aplikace. Domenu proto doplni prohlizec, ktery ji zna vzdycky.
*/
const url = baseUrl.startsWith('http')
? `${baseUrl}/${token}`
: `${window.location.origin}${baseUrl}/${token}`;
async function copy() {
try {
@@ -293,6 +365,28 @@ function WebhookAddress({
</button>
</div>
{/*
Ukazka tela podle deklarovanych parametru. Bez ni musi ten, kdo webhook
zapojuje, hadat, jak ma JSON vypadat - a hadat se bude spatne.
*/}
<div className="mt-3">
<p className="mb-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
Ukázka požadavku
</p>
<pre className="overflow-x-auto rounded-lg border border-ink-600/70 bg-ink-900 p-3 font-mono text-xs leading-relaxed text-white/70">
{`POST ${url}
Content-Type: application/json
${exampleBody(fields)}`}
</pre>
{fields.length === 0 && (
<p className="mt-1.5 text-xs text-white/35">
Zatím nejsou nadeklarované parametry, takže se přijme jakékoliv tělo.
Doplňte je výš, ať je jasné, co má odesílatel poslat.
</p>
)}
</div>
<p className="mt-2.5 text-xs leading-relaxed text-white/45">
Token v adrese je jediná ochrana - kdo ji zná, může automatizaci spustit.
Nesdílejte ji veřejně a neposílejte ji v odkazech.
@@ -1,4 +1,4 @@
import { AlertTriangle, ArrowDownRight, ArrowUpRight, Clock, RotateCcw } from 'lucide-react';
import { AlertTriangle, ArrowDownRight, ArrowUpRight, Clock, RefreshCw, RotateCcw } from 'lucide-react';
import { Link } from 'react-router-dom';
import { TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import { Badge } from '@/components/ui/Badge';
@@ -21,11 +21,14 @@ export function CustomWidgetCard({
value,
error,
loading,
onRefresh,
}: {
definition: WidgetDefinition;
value: WidgetValue | undefined;
error: string | undefined;
loading: boolean;
/** Vyzada cerstva data z konektoru. U dlazdic nad nasimi daty nema smysl. */
onRefresh?: () => void;
}) {
if (loading && !value && !error) {
return (
@@ -60,18 +63,20 @@ export function CustomWidgetCard({
}
if (value.kind === 'external') {
const live = { at: value.at, stale: value.stale, ttlSec: value.ttlSec, onRefresh };
if (value.value !== null && value.rows.length === 0) {
return <Number definition={definition} value={value.value} at={value.at} stale={value.stale} />;
return <Number definition={definition} value={value.value} {...live} />;
}
if (value.rows.length > 0) {
return (
<Shell definition={definition} at={value.at} stale={value.stale}>
<Shell definition={definition} {...live}>
<Bars rows={value.rows} />
</Shell>
);
}
return (
<Shell definition={definition} at={value.at} stale={value.stale}>
<Shell definition={definition} {...live}>
<p className="text-sm break-words text-white/70">{value.text ?? 'Bez dat.'}</p>
</Shell>
);
@@ -294,12 +299,16 @@ function Number({
value,
at,
stale,
ttlSec,
onRefresh,
href,
}: {
definition: WidgetDefinition;
value: number | null;
at?: string;
stale?: boolean;
ttlSec?: number;
onRefresh?: () => void;
href?: string;
}) {
const target = definition.target;
@@ -317,7 +326,14 @@ function Number({
);
return (
<Shell definition={definition} at={at} stale={stale} href={href}>
<Shell
definition={definition}
at={at}
stale={stale}
ttlSec={ttlSec}
onRefresh={onRefresh}
href={href}
>
<div className="flex items-end gap-2">
{href ? (
<Link to={href} className="group">
@@ -388,12 +404,16 @@ function Shell({
definition,
at,
stale,
ttlSec,
onRefresh,
href,
children,
}: {
definition: WidgetDefinition;
at?: string;
stale?: boolean;
ttlSec?: number;
onRefresh?: () => void;
/** Kam vede nadpis. Bez nej je to jen text. */
href?: string;
children: React.ReactNode;
@@ -418,12 +438,29 @@ function Shell({
</div>
{/* U dat z cizi sluzby je stari podstatne - jinak se ctou cisla z vcerejska. */}
{at && (
<span
className="shrink-0 text-xs text-white/30"
title={stale ? 'Z mezipaměti, aby se cizí služba nevolala při každém otevření.' : undefined}
>
{stale ? 'z mezipaměti ' : ''}
{formatRelative(at)}
<span className="flex shrink-0 items-center gap-1.5">
<span
className="text-xs text-white/30"
title={
ttlSec
? `Data z konektoru se drží ${ttlSec} s, aby se cizí služba nevolala při každém otevření.`
: undefined
}
>
{stale ? 'z mezipaměti ' : ''}
{formatRelative(at)}
</span>
{onRefresh && (
<button
type="button"
onClick={onRefresh}
aria-label="Načíst znovu z konektoru"
title="Načíst znovu z konektoru"
className="grid size-6 place-items-center rounded-md text-white/30 transition-colors hover:bg-white/8 hover:text-brand-200"
>
<RefreshCw className="size-3.5" />
</button>
)}
</span>
)}
</div>
+7 -1
View File
@@ -173,7 +173,13 @@ export function collectScopes(
}
const outputs = outputsOf(step);
if (outputs.length > 0) available = [...available, ...outputs];
if (outputs.length > 0) {
// Novejsi vystup prekryje starsi se stejnym jmenem, stejne jako to
// dela runtime pri dosazovani do sablon. Musi to sedet se serverem,
// viz src/data/flowScope.ts.
const names = new Set(outputs.map((output) => output.name));
available = [...available.filter((field) => !names.has(field.name)), ...outputs];
}
}
};
+54 -1
View File
@@ -120,15 +120,67 @@ export default function Overview() {
{
body: { widgetIds: computedIds },
enabled: computedIds.length > 0,
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
/*
* Data z nasich dat se prekresli hned po udalosti. Data z konektoru
* ne - ta drzi server v mezipameti podle `ttlSec` u widgetu, aby se
* cizi sluzba nevolala pri kazdem hnuti.
*/
refetchOn: [
'ticket.created',
'ticket.updated',
'ticket.assigned',
'ticket.resolved',
'automation.run',
],
},
);
/*
* Tikot pro dlazdice nad konektory.
*
* Server drzi vysledek `ttlSec` a rekne, kdy bude novy (`nextAt`). Prehled
* se po te dobe zeptá znovu - dokud je mezipamet cerstva, dostane ji zpatky
* bez volani cizi sluzby, takze to nic nestoji.
*/
useEffect(() => {
const times = (widgetData.data?.items ?? [])
.map((item) => (item.ok && item.value?.kind === 'external' ? item.value.nextAt : null))
.filter((value): value is string => value !== null)
.map((value) => new Date(value).getTime() - Date.now());
if (times.length === 0) return;
// Nejblizsi obnova rozhoduje, ostatni dostanou svou pri dalsim kole.
const wait = Math.max(Math.min(...times), 5_000);
const timer = setTimeout(() => widgetData.reload(), wait);
return () => clearTimeout(timer);
}, [widgetData.data, widgetData.reload]);
const computed = useMemo(
() => new Map((widgetData.data?.items ?? []).map((item) => [item.id, item])),
[widgetData.data],
);
/**
* Rucni obnova dat z konektoru.
*
* `force` obejde mezipamet serveru. Zamerne to neni vychozi chovani -
* kazde otevreni prehledu by pak znamenalo volani cizi sluzby za kazdou
* dlazdici a to ma limity.
*/
async function refreshExternal() {
try {
await apiFetch(`/api/dashboard/widget-data${scopeQuery}`, {
method: 'POST',
body: { widgetIds: computedIds, force: true },
});
} catch (err) {
console.warn('[prehled] obnova z konektoru selhala:', err);
} finally {
widgetData.reload();
}
}
// ----------------------------------------------------------- rezim uprav
const [editing, setEditing] = useState(false);
@@ -384,6 +436,7 @@ export default function Overview() {
value={result?.ok ? result.value : undefined}
error={result && !result.ok ? result.error : (widgetData.error ?? undefined)}
loading={widgetData.loading}
onRefresh={() => void refreshExternal()}
/>
) : (
<WidgetCard definition={definition} data={data} />
+6
View File
@@ -145,6 +145,12 @@ export default function Settings() {
initial: 8,
hint: 'Kolik nevyřízených ticketů je pro něj zdravá zátěž. Není to limit.',
},
{
name: 'externalIds',
label: 'ID z cizích aplikací',
kind: 'tags',
hint: 'Oddělená čárkou. Podle nich umí automatizace předat ticket právě jemu, například voicebotId.',
},
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
]}
/>
+17
View File
@@ -104,6 +104,14 @@ export interface Ticket {
typeId: string | null;
/** Hodnoty vlastnich poli daneho typu. */
fields: Record<string, string | number | boolean | null>;
/**
* Faze ve workflow typu, napr. "ceka na zabaleni".
*
* Tri osy: `status` je zivotni cyklus (podle nej se pocitaji statistiky),
* `stage` je postup uvnitr typu a definuje si ho firma, `tags` jsou volne
* stitky. Faze muze byt jen jedna, proto se na ni da spolehnout v podmince.
*/
stage: string | null;
/** Volne tagy. Akce se muze vazat na typ **nebo** na tag. */
tags: string[];
/** Kdy se ticketu prvne nekdo venoval. */
@@ -409,6 +417,11 @@ export interface TriggerField {
name: string;
type: FieldType;
required: boolean;
/**
* Kde ta hodnota v tele je, kdyz to neni primo `name`.
* Napriklad `data.order.id` nebo `errors.0.message`.
*/
path?: string;
}
// ----------------------------------------------------------- strom automatizace
@@ -529,6 +542,10 @@ export type WidgetValue =
rows: Array<{ key: string; label: string; value: number }>;
at: string;
stale: boolean;
/** Kdy se data z konektoru natáhnou znovu. */
nextAt: string;
/** Jak dlouho se drzi, v sekundach. Nastavuje se u widgetu. */
ttlSec: number;
};
/**
+3 -1
View File
@@ -15,7 +15,9 @@ export type DashboardEventType =
| 'automation.updated'
| 'automation.deleted'
| 'automation.run'
| 'webhook.received';
| 'webhook.received'
/** Nekomu prislo upozorneni. Portal podle toho prekresli cislo u zalozky. */
| 'notification.created';
export interface DashboardEvent {
id: string;