# 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 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/ {"externalId": 3, "event": "order.created"} -> 201, vznikl TK-4822 POST /webhook/ticket/ {"externalId": 3, "event": "email.sent"} -> 200, doplněn TK-4822 POST /webhook/ticket/ {"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. ```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. ## 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](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.