Projekt srovnan se zasadami v D:\GitHubRepository\CLAUDE.md bez zmeny chovani.
Struktura: scripts/ (skripty konektoru) -> connectors/, src/scripts ->
src/runtime/scripts; src/index.ts jen startuje, novy src/app.ts s createApp();
routes/dashboard.ts a routes/settings.ts rozdeleny do slozek; openapi.ts
rozdelen na openapi/{index,helpers,components} a paths/* (98 cest overeno
shodnych); ticketStore, automationStore a services jsou fasady nad slozkami
data/tickets, data/automations a data/services/catalog. process.env se cte
jen v config.ts. Web: hooky v hooks/, sdilena ui/Table a ui/ServiceIcon,
surove inputy nahrazeny komponentami, sedm velkych souboru rozdeleno.
Nastroje: eslint (typescript-eslint, react-hooks v7), prettier, editorconfig,
nvmrc, .env.example, vitest; skripty lint, format, test. Lint je cisty bez
jedineho eslint-disable (nove hooky useLatest a useSyncFromSource, odvozeny
stav misto setState v effectu). noUncheckedIndexedAccess v obou tsconfig,
84 mist zuzeno bez non-null operatoru; odhalilo zalohu backoffu fronty pri
nule pokusu a Retry-After NaN pri max 0. Cely kod naformatovan prettierem.
Testy: 8 souboru, 105 testu (prava, viditelnost, podminky a opakovani
v executoru, redaktor tajemstvi, sitove guardy, migrace resitelu, tickety,
health a prihlaseni pres supertest). Testy odhalily dve chyby ve vyhodnoceni
podminek, obe opravene: chybejici castka se porovnavala jako nula a podminka
nad vystupem druheho kroku cetla hodnotu prvniho se stejnym nazvem.
Pojmenovane konstanty misto magickych hodnot, ctx.util.base64 pro skripty
konektoru, README a dokumentace aktualizovany vcetne znamych odchylek.
11 KiB
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.
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:
{
"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/tickets/stats.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.
{
"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.
Helpdesk: ticket, ktery vidi dve firmy
Ticket patri jedne firme. U helpdesku ale figuruji dve: ta, ktera pozadavek poslala, a ta, ktera ho resi. Reseni je jedno pole navic, ne druha hranice viditelnosti.
| Pole | Kdo to je |
|---|---|
Ticket.tenantId |
firma, ktera pozadavek resi, tedy vlastnik |
Ticket.helpdeskSourceId |
firma, ktera pozadavek poslala |
Vlastnikem je zamerne dodavatel, ne zadavatel. Kdyby byl vlastnikem zadavatel, mel by resitel pozadavek jen jako cizi ticket a nemel by ho ve sve fronte, ve statistikach ani v prirazovani. Takhle je to na jeho strane obycejny ticket a nemuselo se kvuli tomu sahnout na nic z toho, co uz funguje.
Komu pozadavek pripadne, urcuje helpdeskProviderId na firme zadavatele.
Nastavuje ho spravce platformy v Nastaveni, Firmy. Kdo koho obsluhuje je
obchodni vztah, ne volba klienta - kdyby si dodavatele vybiral uzivatel, poslal
by pozadavek nekomu, s kym nema smlouvu. Bez vyplneneho dodavatele se pozadavek
nezalozi a rekne se to nahlas.
Co smi zadavatel
| Akce | Smi |
|---|---|
| Videt svoje pozadavky | ano |
| Otevrit detail a prubeh | ano |
| Pripsat komentar | ano |
| Menit stav, resitele, typ | ne |
| Videt ostatni tickety resitele | ne |
Komentar je jedina zmena, kterou nad cizim ticketem smi. Doplnit, co zapomnel napsat, je presne to, kvuli cemu se pozadavek otevira; stav urcuje ten, kdo to resi.
Filtruje se podle helpdeskSourceIds, ktere nahrazuje filtr podle
vlastnika - zadavatel vlastnikem neni, takze by mu jinak nezbylo nic. Bezny
seznam ticketu tim zustava nedotceny: listTickets({ tenantIds }) se nezmenil.
Kdo helpdesk vidi
Pravo helpdesk.view (videt sekci) a helpdesk.create (poslat pozadavek).
Obe prideluje admin te firmy pres role, stejne jako u ostatnich prav.
Zalozka helpdesk je v katalogu modulu jako povinna, aby ji mely i firmy
zalozene driv - o tom, kdo ji uvidi, stejne rozhoduje pravo.
API
| Metoda | Cesta | Popis |
|---|---|---|
| GET | /api/dashboard/helpdesk |
pozadavky teto firmy |
| POST | /api/dashboard/helpdesk |
poslat pozadavek |
| GET | /api/dashboard/helpdesk/:id |
detail vlastniho pozadavku |
| POST | /api/dashboard/helpdesk/:id/comment |
pripsat komentar |
Stranka portalu je /dashboard/helpdesk.
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.
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.