Ticketovaci system: udalosti, externi ID, statistiky a widgety nad konektory

Jakakoliv udalost se muze stat ticketem. Prijem je verejny endpoint na firmu
(`POST /webhook/ticket/:token`), takze zalozit ticket jde i bez stavby stromu.

Externi ID je unikatni V RAMCI FIRMY: dalsi zprava se stejnym ID se navesi na
existujici ticket misto zalozeni druheho, a stejne ID u jine firmy je jiny
ticket. Cislo a retezec jsou tentyz klic. Udalosti se drzi cele vcetne
prijatych dat a jdou rozbalit v detailu - je to neco jineho nez log.

Ticket nove nese firstResponseAt, resolvedAt, resolvedById a reopenCount.
Bez nich neslo rict, kdo kolik odbavil ani jak dlouho zakaznik cekal.
`getAgentStats` z toho pocita vykon resitelu vcetne medianovych casu
a vracenych ticketu. Pocet vyresenych sam o sobe odmenuje toho, kdo tickety
zaviral predcasne, proto je vraceni videt vedle nej.

Widgety: klient konecne vola /widget-data. Endpoint existoval, ale nikdo ho
nepouzival, takze vlastni widget hlasil "nepodarilo se zobrazit". Pribyl zdroj
`connector` - co umi zjistit napojena sluzba, jde vytahnout do dlazdice pres
tentyz skript, ktery pouziva krok automatizace. Vysledek se cachuje.

Akce a widgety uz nejsou v nastaveni, maji vlastni zalozku vedle automatizaci.
Telo akce se sklada stromem, ne JSONem v textarei - je to tentyz editor,
jen misto karty spoustece je "spousti clovek tlacitkem na ticketu".

Nova zalozka Lide se seznamem a detailem osoby. Seznam ticketu i lidi ma dva
pohledy, tabulku a dlazdice.

Opraveno: createTicket bral typeId, fields, tags i assigneeGroupId, ale nikdy
je neukladal. Ticket zalozeny s typem zustaval bez typu a bez vlastnich poli.

Dlouhe pomlcky, sipky, vypustky a bullety pryc z celeho projektu.

Overeno 21 kontrolami proti bezicimu serveru v rezimu souboru.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 15:13:09 +02:00
co-authored by Claude Opus 5
parent 6c90372991
commit 29de584df8
54 changed files with 3479 additions and 323 deletions
+8 -1
View File
@@ -21,6 +21,11 @@ React aplikaci ze slozky `dist/public`.
| Builder automatizaci | hotovo | strom akci, vetveni podminkou |
| Webhook s registrovanou adresou | hotovo | token generuje server, verejny endpoint validuje data |
| Tickety na konkretni lidi | hotovo | resitel, filtr moje, prehled vytizeni tymu |
| Prijem udalosti do ticketu | hotovo | webhook na firmu, externi ID unikatni za firmu |
| Udalosti na ticketu | hotovo | dalsi zprava se navesi na tentyz ticket |
| Statistiky resitelu | hotovo | odbaveno, mediany casu, vracene, fronta |
| Pohledy tabulka a dlazdice | hotovo | tickety i lide |
| Stranka Lide a detail osoby | hotovo | vykon a co ma u sebe |
| Log ticketu ve strome | hotovo | vcetne toho, co ktera sluzba vratila |
| Kanaly do ticketu | hotovo | WhatsApp, e-mail, hlas a formular jako spoustece |
| Parametry od sluzby | hotovo | katalog je deklaruje, server je dosazuje pri ulozeni |
@@ -39,7 +44,8 @@ React aplikaci ze slozky `dist/public`.
| Osoby a skupiny resitelu | hotovo | ticket lze prehodit na skupinu, ne jen na cloveka |
| Typy ticketu a vlastni pole | hotovo | typ rozhoduje, ktere akce se na ticketu ukazou |
| Vydefinovane akce na ticketu | hotovo | vazba na typ nebo tag, telo je operace, strom, skript |
| Vlastni widgety | hotovo | zdroj dat z ticketu nebo automatizaci, seskupeni |
| Vlastni widgety | hotovo | vcetne zdroje z konektoru a vykonu resitelu |
| Telo akce jako strom | hotovo | tentyz editor jako automatizace |
| Audit a prepnuti na jiny ucet | hotovo | prepnuti je vychozi jen pro cteni, vse v auditu |
| Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu |
| Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime |
@@ -117,4 +123,5 @@ pro frontu, beh kroku a rozpocet na 150 klientu, a
| [15-rejstrik-funkci.md](15-rejstrik-funkci.md) | k cemu je jaka funkce a komponenta |
| [16-monetizace.md](16-monetizace.md) | **navrh**: cena za krok a balicky |
| [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md) | prava, typy, akce, widgety, prepnuti uctu |
| [18-ticketovaci-system.md](18-ticketovaci-system.md) | udalosti, externi ID, statistiky, pohledy |
| [99-zmeny.md](99-zmeny.md) | zaznam zmen, nejnovejsi nahore |
+24
View File
@@ -16,6 +16,8 @@ Verejne:
| POST | `/api/auth/login` | prihlaseni, vraci JWT |
| POST | `/api/contact` | poptavka z webu |
| POST | `/webhook/:token` | prijem dat do automatizace |
| POST | `/webhook/ticket/:token` | prijem udalosti do ticketu |
| GET | `/webhook/ticket/:token` | napoveda k prijmu |
Vyzaduji `Authorization: Bearer <token>`:
@@ -30,6 +32,10 @@ Vyzaduji `Authorization: Bearer <token>`:
| DELETE | `/api/dashboard/layout` |
| GET | `/api/dashboard/summary` |
| GET | `/api/dashboard/people` |
| GET | `/api/dashboard/people/:id` |
| GET | `/api/dashboard/intake` |
| POST | `/api/dashboard/intake/regenerate` |
| GET | `/api/dashboard/settings/actions/:id/scope` |
| GET | `/api/dashboard/tickets` |
| GET | `/api/dashboard/tickets/workload` |
| GET | `/api/dashboard/tickets/:id` |
@@ -204,6 +210,24 @@ s celym chybovym hlasenim a `durationMs`. Cely prubeh se zapise do logu ticketu.
Vestavene akce (`type`, `tags`, `group`, `assign`, `status`, `comment`) jsou
zvlast: meni ticket sam, ne cizi sluzbu, a kazda ma vlastni pravo.
## Prijem udalosti do ticketu
Popis modelu je v [18-ticketovaci-system.md](18-ticketovaci-system.md).
`POST /webhook/ticket/:token` je **verejny**, autorizuje token firmy v adrese.
Vraci 201 kdyz ticket vznikl, 200 kdyz se udalost navesila na existujici:
```json
{ "ok": true, "created": false, "ticketId": "TK-4822", "externalId": "3", "eventId": "tev_2" }
```
`externalId` je unikatni **v ramci firmy**. Token urcuje firmu, takze dve firmy
mohou obe poslat objednavku cislo 3 a nedojde ke smichani. Cislo i retezec jsou
tentyz klic.
Neznamy `typeId` se zahodi a zaloguje, ticket vznikne bez typu. Odmitnout celou
udalost kvuli jednomu poli by znamenalo ztratu dat.
## Prava a navigace
`GET /api/dashboard/access` vraci `permissions` (efektivni prava po slouceni
+9 -1
View File
@@ -37,6 +37,10 @@ Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět.
| `hasPermission(...)` | `src/data/permissions.ts` | Jedna kontrola. Používá ji `crudRouter` i ruční handlery. |
| `navFor(...)` | `src/data/tenantFeatures.ts` | Průnik toho, co firma má, a toho, na co má člověk právo. Navigace chodí ze serveru. |
| `recordAudit(input)` | `src/data/audit.ts` | Zápis do auditu. Nevrací chybu a nečeká se - rozbitý audit nesmí rozbít aplikaci. |
| `intakeEvent(input)` | `src/data/ticketStore.ts` | Přijme událost zvenku: podle externího ID buď založí ticket, nebo ji navěsí na existující. Jediná cesta, kterou se událost stává ticketem. |
| `getAgentStats(...)` | `src/data/ticketStore.ts` | Výkon řešitelů: odbavené, mediány časů, vrácené, fronta. Používá to widget i detail osoby, aby čísla seděla. |
| `findByExternalId(...)` | `src/data/ticketStore.ts` | Ticket firmy podle externího ID. Klíč je dvojice firma a ID. |
| `findByIntakeToken(token)` | `src/data/tenants.ts` | Firma podle tokenu příjmu. Určuje i to, v jakém rozsahu je externí ID unikátní. |
| `refreshCaches()` | `src/data/bootstrap.ts` | Obnoví všechny kopie v paměti. Volá se po zápisu, který je může změnit. |
| `bootstrapData({databaseReady})` | `src/data/bootstrap.ts` | Seznam všech entit a provozních dat. **Nová entita se přidává tady**, ne rozesetě po modulech. |
@@ -64,10 +68,14 @@ Viz [11-skripty-konektoru.md](11-skripty-konektoru.md).
| `parseJsonField` | `components/dashboard/EntityAdmin.tsx` | Textové pole s JSONem na hodnotu, s hlášením, kde je chyba. |
| `TicketActions` | `components/dashboard/TicketActions.tsx` | CTA akcí na ticketu plus typ, tagy a vlastní pole. Seznam akcí chodí ze serveru už vyfiltrovaný. |
| `ErrorDetail` | `components/dashboard/ErrorDetail.tsx` | Rozbalovací celé chybové hlášení s kopírováním. Chyba se nikdy nezkracuje. |
| `CustomWidgetCard` | `components/dashboard/widgets/CustomWidget.tsx` | Vykreslí widget, jehož data počítá server: číslo, pruhy, tabulka výkonu, časová řada, seznam, data z konektoru. |
| `TicketEvents` | `components/dashboard/TicketEvents.tsx` | Příchozí události ticketu včetně celého přijatého JSONu. |
| `ViewSwitch` | `components/dashboard/ViewSwitch.tsx` | Přepínač tabulka nebo dlaždice. Používají ho všechny seznamy. |
| `FlowCanvas` s `start` | `components/dashboard/flow/FlowCanvas.tsx` | Tentýž strom kroků i bez spouštěče - pro tělo akce, které spouští člověk. |
| `MappingEditor` | `components/dashboard/flow/MappingEditor.tsx` | Editor transformací v obou režimech (pole na pole, JSON). |
| `DataState` | `components/dashboard/DataState.tsx` | Načítání, chyba, prázdno. Ať to každá stránka nekreslí po svém. |
| `apiFetch<T>` | `lib/api.ts` | Jediná cesta na API: base path, token, `ApiError` s celým hlášením ze serveru. |
| `useApiQuery<T>` | `lib/useApiQuery.ts` | Načtení dat do stránky včetně `reload`. |
| `useApiQuery<T>` | `lib/useApiQuery.ts` | Načtení dat do stránky včetně `reload`. S `body` pošle POST (dávkové načtení), s `enabled: false` se neptá vůbec. |
| `cn(...)` | `lib/cn.ts` | Skládání tříd. Podmíněné třídy nikdy ručně přes šablonu. |
| `format*` | `lib/format.ts` | Čísla, procenta, datum, relativní čas, trvání. Formátování se nepíše v komponentě. |
| `serviceIcon(key)` | `lib/serviceIcons.ts` | Klíč ikony ze serveru na komponentu. Server neposílá komponenty. |
+188
View File
@@ -0,0 +1,188 @@
# Ticketovací systém
Jak se z události stane ticket, jak se k němu navěsí další, a co se z toho dá
vyčíst. Model práv a typů je v [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md).
## Ticketem se může stát jakákoliv událost
Vstup je jeden veřejný endpoint na firmu:
```
POST /webhook/ticket/<token>
```
Token patří **firmě**, ne automatizaci. Založit ticket má jít i bez toho, aby
se kvůli tomu stavěl strom. Adresu najde ten, kdo spravuje napojení, na
`GET /api/dashboard/intake`; přegeneruje se `POST /api/dashboard/intake/regenerate`
a stará okamžitě přestane platit.
Tělo:
```json
{
"externalId": 3,
"source": "eshop",
"event": "order.created",
"subject": "Objednávka 3",
"typeId": "tt_order",
"fields": { "orderNumber": "3", "orderTotal": 2450 },
"tags": ["vip"],
"payload": { "cokoliv": "co se má uložit celé" }
}
```
Povinné není nic kromě těla samotného. Chybí předmět? Použije se popisek nebo
typ události. Neznámý `typeId`? Zahodí se a zaloguje, ticket vznikne bez typu -
odmítnout celou událost kvůli překlepu v jednom poli by znamenalo ztrátu dat.
## Externí ID a navěšování
`externalId` je ID u odesílatele, typicky číslo objednávky. Je **unikátní
v rámci firmy**, ne globálně: dvě firmy můžou mít objednávku číslo 3 a nesmí si
o sebe zavadit. Firmu určuje token v adrese.
Když už ticket se stejným externím ID v té firmě je, událost se na něj
**navěsí** místo založení druhého:
```
POST /webhook/ticket/<token> {"externalId": 3, "event": "order.created"} -> 201, vznikl TK-4822
POST /webhook/ticket/<token> {"externalId": 3, "event": "email.sent"} -> 200, doplněn TK-4822
POST /webhook/ticket/<jiná firma> {"externalId": 3} -> 201, vznikl TK-4823
```
Číslo a řetězec jsou tentýž klíč: odesílatel pošle `3`, my držíme `"3"`. Jinak
by `3` a `"3"` byly dva tickety a nikdo by nepoznal proč.
Bez `externalId` se vždycky zakládá nový ticket. Hádat podle předmětu by
slučovalo věci, které spolu nesouvisí.
### Událost není řádek logu
Dvě různé věci, které se snadno pletou:
| | Co to je | Kde se bere |
| --- | --- | --- |
| **Událost** | fakt zvenku, celá přijatá data | poslal odesílatel |
| **Řádek logu** | naše stopa toho, co se dělo uvnitř | zapsala aplikace |
Události se ukazují na detailu ticketu nad logem a dají se rozbalit na celý
přijatý JSON. Když se někdo ptá, proč ticket vypadá takhle, je to jediná
odpověď. Drží se jich nejvýš 200 na ticket, starší se odmazávají - nekonečně
rostoucí ticket by při každém zápisu přepisoval víc a víc dat.
## Co se u ticketu měří
Aby šlo říct, kdo kolik odbavil a komu to nejde, nestačí počítat vyřešené.
Ticket proto nese:
| Pole | Kdy se zapíše | K čemu |
| --- | --- | --- |
| `firstResponseAt` | při prvním přiřazení, komentáři nebo změně stavu | jak dlouho zákazník čekal na reakci |
| `resolvedAt` | při přechodu na vyřešeno | doba řešení |
| `resolvedById` | tamtéž, je to ten, kdo ho měl u sebe | komu se vyřešení připíše |
| `reopenCount` | při návratu z vyřešeno | kolikrát to hotové nebylo |
`firstResponseAt` se zapisuje **jednou a nepřepisuje**. Je to okamžik, kdy
zákazník přestal čekat. Kdyby se přepisoval při každé změně, měřil by poslední
dotek, což je úplně jiná veličina.
`reopenCount` je záměrně vedle počtu vyřešených. Samotný počet vyřešených
odměňuje toho, kdo tickety zavíral předčasně.
## Výkon řešitelů
Widget **Výkon řešitelů** (`panel.agents`) a detail osoby ukazují za 30 dní:
- **odbaveno** - kolik vyřešil,
- **ve frontě** - kolik má právě teď,
- **doba řešení** a **reakce** - mediány,
- **vráceno** - kolik se mu jich vrátilo,
- **nejstarší** - co mu leží nejdéle.
Medián, ne průměr: jeden ticket zapomenutý přes dovolenou by průměr úplně
rozhodil. Fronta se počítá vždycky celá, bez ohledu na období - leží tam bez
ohledu na to, na co se zrovna díváme.
Čísla počítá `getAgentStats` v `src/data/ticketStore.ts` a používá je widget
i detail osoby. Kdyby si je stránka počítala sama, na dvou místech by vyšlo
něco jiného.
## Pohledy
| Stránka | Co ukazuje |
| --- | --- |
| Tickety | seznam s filtry, **tabulka nebo dlaždice** |
| Detail ticketu | obsah, události, log, akce, typ, tagy, řešitel, skupina |
| Lidé | řešitelé firmy a jejich vytížení, **tabulka nebo dlaždice** |
| Detail osoby | její výkon, co má u sebe, co naposledy vyřešila |
Přepínač pohledu je jedna komponenta (`components/dashboard/ViewSwitch.tsx`)
a používají ji obě stránky se seznamem.
## Widgety nad daty i nad konektory
Widget je dvojice: `render` (jak se to kreslí) a `source` (odkud jsou data).
Zdroje:
| Zdroj | Co dělá |
| --- | --- |
| `ticketCount` | počet ticketů podle filtru, volitelně seskupený |
| `ticketList` | seznam ticketů |
| `ticketSeries` | časová řada |
| `workload` | kdo co má u sebe |
| `agentStats` | výkon řešitelů |
| `connector` | **data z napojené služby** |
Zdroj `connector` zavolá **tentýž skript**, který používá krok automatizace
i akce na ticketu, a z výsledku vezme, co je v `path`. Widget nemá vlastní
cestu k cizí službě - jinak by se chovala jinak než zbytek aplikace.
```json
{
"kind": "connector",
"serviceId": "idoklad",
"operationId": "find-issued-invoice",
"connectorId": null,
"inputs": {},
"path": "total",
"ttlSec": 300
}
```
Výsledek se **drží v mezipaměti** (`ttlSec`, nejméně 30 s). Bez toho by každé
otevření přehledu znamenalo volání cizího API za každou dlaždici, a to má
limity a někdy se za něj platí. U dat z konektoru se vedle čísla ukazuje jejich
stáří a jestli jsou z mezipaměti.
Data všech dlaždic chodí **jedním requestem** (`POST /api/dashboard/widget-data`).
Widget, který selže, hlásí chybu **na své pozici** a celou, nezkrácenou - jeden
rozbitý zdroj nesmí zhasnout celý přehled.
## Kde se co definuje
Akce a widgety **nejsou v nastavení**. Je to definice toho, co aplikace umí,
stejná úroveň jako automatizace, a mají vlastní záložku:
| Záložka | Co tam patří |
| --- | --- |
| Automatizace | stromy, které běží samy |
| Akce | tlačítka na ticketu, tělo je **tentýž strom** |
| Widgety | dlaždice na přehled |
| Nastavení | firmy, lidé, role, typy ticketů, audit |
Tělo akce se skládá stejným editorem jako automatizace. Místo karty spouštěče
je karta "spouští člověk tlačítkem na ticketu" a parametry, na které jde
v krocích odkazovat, jsou údaje ticketu plus vlastní pole jeho typu. Ty počítá
server (`GET /api/dashboard/settings/actions/:id/scope`), aby si klient nedělal
druhý seznam, který se časem rozejde.
## Co zatím nejde
Uložený strom se **nevykoná** - chybí runtime, stejně jako u automatizací.
Akce s jednou operací běží, protože ta se dá poslat rovnou do skriptu. Popis
toho, jak má runtime vypadat, je v
[10-runtime-a-kapacita.md](10-runtime-a-kapacita.md).
Externí ID hlídá jedinečnost v paměti procesu. Nad databází k tomu patří
částečný unikátní index na dvojici `(tenant_id, external_id)`, aby to platilo
i při běhu na víc strojích.
+56
View File
@@ -2,6 +2,62 @@
Nejnovejsi nahore.
## 2026-08-13 - ticketovaci system: udalosti, externi ID, statistiky, widgety
Popis v [18-ticketovaci-system.md](18-ticketovaci-system.md).
### Pridano - prijem udalosti
- `POST /webhook/ticket/:token`: **jakakoliv udalost se muze stat ticketem**.
Token patri firme, ne automatizaci, takze zalozit ticket jde i bez stromu.
- `externalId` na ticketu, **unikatni v ramci firmy**. Dalsi udalost se stejnym
ID se navesi na existujici ticket misto zalozeni druheho. Cislo a retezec
jsou tentyz klic, jinak by `3` a `"3"` byly dva tickety.
- Udalosti se u ticketu drzi cele vcetne prijatych dat a jdou rozbalit
v detailu. Je to neco jineho nez log: log je nase stopa, udalost fakt zvenku.
### Pridano - co se meri
- `firstResponseAt`, `resolvedAt`, `resolvedById` a `reopenCount` na ticketu.
Bez nich neslo rict, kdo kolik odbavil ani jak dlouho zakaznik cekal.
- `getAgentStats`: odbavene za obdobi, fronta, medianove casy do vyreseni
a do prvni reakce, vracene tickety. Median zamerne, ne prumer.
- Widget **Vykon resitelu** a detail osoby pouzivaji tutéž funkci, takze
cisla sedi na obou mistech.
### Pridano - widgety
- Klient konecne vola `/api/dashboard/widget-data`. Predtim endpoint existoval,
ale nikdo ho nepouzival, takze vlastni widget hlasil "nepodarilo se zobrazit".
- Novy zdroj `connector`: co umi zjistit napojena sluzba, jde vytahnout do
dlazdice. Vola se tentyz skript jako v kroku automatizace, vysledek se
cachuje (`ttlSec`, nejmene 30 s).
- Mrtvy odkaz v rozlozeni jde v rezimu uprav odstranit, ne jen precist.
### Zmeneno
- **Akce a widgety maji vlastni zalozku**, uz nejsou v nastaveni. Je to
definice toho, co aplikace umi, stejna uroven jako automatizace.
- **Telo akce se sklada stromem**, ne JSONem v textarei. Je to tentyz editor
jako u automatizaci, jen misto karty spoustece je "spousti clovek".
- Nova zalozka **Lide** se seznamem resitelu a detailem osoby.
- Seznam ticketu i lidi ma **dva pohledy**, tabulku a dlazdice.
- Dlouhe pomlcky pryc z celeho projektu (48 znaku ve 23 souborech).
### Opraveno
- `createTicket` bral `typeId`, `fields`, `tags` i `assigneeGroupId`, ale
**nikdy je neukladal**. Ticket zalozeny s typem tak zustaval bez typu
a bez vlastnich poli.
### Overeno
21 kontrol proti bezicimu serveru v rezimu souboru: navazani druhe udalosti na
tentyz ticket, oddeleni firem pri stejnem externim ID, ulozeni typu a poli
z prijmu, vyreseni s casem i clovekem, zapocteni navratu z vyreseno, cisla ve
widgetu vykonu, cele chybove hlaseni u widgetu nad nenapojenym konektorem,
detail osoby, rozsah parametru akce, ulozeni stromu akce a navigace.
## 2026-08-13 - firmy, prava, typy ticketu, akce, widgety a uloziste pro vsechno
Dodelany cely [navrh rozsireni](09-navrh-rozsireni.md) a vsechna data se