diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index 56259b8..2571663 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -16,7 +16,6 @@ React aplikaci ze slozky `dist/public`. | Prihlaseni | hotovo | JWT, demo ucty | | Dashboard | hotovo | prehled, tickety, incidenty, automatizace, nastaveni | | Zivy dashboard pres SSE | hotovo | zmeny se projevi bez obnoveni stranky | -| Simulace provozu | hotovo | tlacitko v postrannim menu portalu | | Katalog sluzeb | hotovo | 29 sluzeb, 7 kategorii vcetne Obecne | | Builder automatizaci | hotovo | strom akci, vetveni podminkou | | Webhook s registrovanou adresou | hotovo | token generuje server, verejny endpoint validuje data | @@ -48,7 +47,7 @@ React aplikaci ze slozky `dist/public`. | 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 | +| Beh automatizaci | castecne | strom se vykona, ale synchronne a bez fronty | | Uloziste konektoru | hotovo | Postgres, nebo JSON soubor. Udaje vzdy sifrovane | | Uloziste pro zbytek | hotovo | tickety, automatizace, incidenty, rozlozeni, entity | | Monetizace a cena za krok | navrh | popis v 16-monetizace.md, neni naprogramovane | @@ -66,9 +65,11 @@ v portalu i v `/health/ready` a rozhoduje o nem jedno misto, viz | `file` | neni databaze, je `DATA_DIR` | prezije restart, ne redeploy | | `memory` | neni ani `DATA_DIR` | ztrati se pri restartu | -Beh automatizaci zatim neexistuje: ulozeny strom se nevykonava, provoz se dela -simulaci. Popis, jak to ma vypadat, je v -[10-runtime-a-kapacita.md](10-runtime-a-kapacita.md). +Beh automatizaci uz existuje, ale je **synchronni v requestu**: webhook ceka, +nez cely strom dobehne, a pri padu procesu se rozdelany beh ztrati. Neni fronta +ani opakovani. Co to znamena pro vetsi provoz a co s tim, je zmerene +v [19-kapacita-200-firem.md](19-kapacita-200-firem.md), navrh fronty +v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md). Obsah verejneho webu je ukazkovy. Nazev firmy, reference, tym i cisla jsou vymyslene a pred ostrym pouzitim se musi nahradit. Firemni udaje jsou na jednom @@ -77,8 +78,9 @@ miste v `web/src/config/brand.ts`. Zivy stream drzi seznam posluchacu v pameti jedne instance. Pri vice instancich by ho musel nahradit sdileny kanal, napriklad Redis pub/sub. -Log ticketu zatim plni simulace, ne skutecny beh. Zaznamy jsou realisticke, -ale nevznikly vykonanim ulozeneho stromu - runtime neexistuje. +Log ticketu uz plni skutecny beh: kazdy krok stromu se do nej zapise vcetne +toho, co sluzba vratila. Ukazkova sada ticketu ma log psany rucne, aby bylo +co ukazat i na prazdne instanci. ## Dalsi krok @@ -124,4 +126,5 @@ pro frontu, beh kroku a rozpocet na 150 klientu, a | [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 | +| [19-kapacita-200-firem.md](19-kapacita-200-firem.md) | zmereno, co zvladne soucasny stav | | [99-zmeny.md](99-zmeny.md) | zaznam zmen, nejnovejsi nahore | diff --git a/documentation/04-api.md b/documentation/04-api.md index 90e1b81..2600d76 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -80,7 +80,6 @@ Vyzaduji `Authorization: Bearer `: | POST | `/api/admin/impersonate/stop` | | GET | `/api/admin/impersonate/candidates` | | GET | `/api/admin/audit` | -| POST | `/api/simulate` | Sprava zaznamu ma u kazde entity stejnou petici (seznam, detail, vytvoreni, uprava, mazani) na `/api/dashboard/settings/`, protoze ji dela jedna @@ -246,21 +245,6 @@ s 403. Kazde prepnuti i ukonceni je v auditu vcetne toho, kdo to byl doopravdy. Svuj puvodni token si klient odklada do `sessionStorage`, server o nem nic nevi. -## Simulace - -`POST /api/simulate` vyvola provozni udalost pro nahled ziveho dashboardu. -Zamerne meni skutecna data, ne jen posila falesnou notifikaci. - -Akce: `ticket.created`, `ticket.resolved`, `incident.started`, -`incident.resolved`, `automation.run`. - -U `ticket.created` urcuje `channel` (whatsapp, facebook, instagram, email, voice, -form, portal), odkud pozadavek prisel, a podle toho se poskladá i log ticketu. `knownCustomer: false` znamena, ze CRM firmu nedohleda - -ticket zustane bez zakaznika i bez resitele a v logu je videt proc. - -Nevyplnena pole server doplni ukazkovou hodnotou. U akci s "resolved" se bez -zadaneho id pouzije prvni nevyrizeny zaznam. - ## Sluzby a konektory Popis modelu je v [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md), tady jen API. diff --git a/documentation/15-rejstrik-funkci.md b/documentation/15-rejstrik-funkci.md index 46a92bc..895d52b 100644 --- a/documentation/15-rejstrik-funkci.md +++ b/documentation/15-rejstrik-funkci.md @@ -37,6 +37,8 @@ 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. | +| `runFlow(steps, context, options)` | `src/runtime/executor.ts` | Vykoná strom kroků. Nikdy nevyhodí výjimku, chyba je výsledek. Používá to akce na ticketu i webhook, aby se strom choval všude stejně. | +| `widgetCatalog(tenantIds, userId)` | `src/data/widgets.ts` | Jediná definice toho, co jde položit na dashboard. Používá ji nabídka i kontrola ukládaného rozložení. | | `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. | @@ -69,6 +71,7 @@ Viz [11-skripty-konektoru.md](11-skripty-konektoru.md). | `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. | +| `TicketTable` | `components/dashboard/TicketTable.tsx` | Tabulka ticketů pro všechna místa. Na mobilu se místo posouvání do strany kreslí karty. | | `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. | diff --git a/documentation/19-kapacita-200-firem.md b/documentation/19-kapacita-200-firem.md new file mode 100644 index 0000000..c1b035e --- /dev/null +++ b/documentation/19-kapacita-200-firem.md @@ -0,0 +1,160 @@ +# Kapacita: 200 firem, 10 automatizací, 15 lidí na firmu + +Otázka zněla: zvládne to současný stav s Postgresem, a co to bude chtít +za server. Odpověď je změřená, ne odhadnutá. + +**Krátce: v současném stavu ne.** Runtime běží, ale datová vrstva je psaná +na stovky až tisíce ticketů, ne na miliony. Co konkrétně to zastaví a v jakém +pořadí to opravit, je níž. + +## Co se měřilo + +Jeden proces, režim souboru, tickety se posílaly přes příjem událostí. +Měřeno na vývojovém stroji, tedy horní hranice latence, ne serveru. + +| Ticketů | Výpis seznamu | Statistiky řešitelů | Soubor | +| --- | --- | --- | --- | +| 103 | 1,9 ms | 1,6 ms | 153 kB | +| 503 | 6,1 ms | 12,1 ms | 729 kB | +| 1 003 | 14,2 ms | 2,7 ms | 1,4 MB | +| 2 003 | 18,8 ms | 21,1 ms | 2,9 MB | +| 5 003 | 52,9 ms | 1,7 ms | 7,2 MB | + +Příjem událostí: **130 až 190 událostí za sekundu** včetně celého kola +HTTP, uložení a zápisu do logu. + +Z toho plyne: + +- **jeden ticket = 1,4 kB** v uložišti, +- **výpis roste lineárně**, zhruba 10 mikrosekund na ticket, protože se + filtruje pole v paměti, +- v režimu souboru se **při každé změně přepisuje celý soubor**. Při pěti + tisících ticketech je to 7 MB zápisu kvůli jednomu komentáři. S Postgresem + to odpadá, tam je to jeden řádek. + +## Kolik toho bude + +200 firem, 10 automatizací každá, 15 lidí. Odhad provozu: + +| Veličina | Výpočet | Za den | Za měsíc | +| --- | --- | --- | --- | +| Běhy automatizací | 2 000 automatizací, 100 běhů denně | 200 000 | 6 mil. | +| Kroky | 3 kroky na běh | 600 000 | 18 mil. | +| Tickety | 200 firem, 200 denně | 40 000 | 1,2 mil. | +| Řádky logu | 5 na ticket plus kroky | ~800 000 | 24 mil. | + +Špička není průměr. 7 kroků za sekundu v průměru znamená ve špičce klidně +100 za sekundu, protože e-shopy neposílají objednávky rovnoměrně. + +## Co to v současném stavu zastaví + +### 1. Všechna data jsou v paměti procesu + +`withMirror` drží tickety, automatizace a incidenty v poli a při startu je +**všechny načte**. Při 1,2 milionu ticketů měsíčně je to 1,7 GB jen na tickety, +a to bez logu. Aplikace se nenastartuje. + +Opravit se musí tak, že se tickety čtou dotazem s indexem a stránkováním, +ne z pole. `withMirror` má zůstat na tom, co je malé a čte se pořád (rozložení +dashboardu), ne na provozních datech. + +### 2. Výpis prochází všechno + +`listTickets` filtruje pole přes všechny firmy. Změřeno 10 mikrosekund na +ticket, takže při milionu ticketů je jeden výpis 10 sekund. Musí to být +`WHERE tenant_id = ... AND status = ... ORDER BY updated_at LIMIT 50` nad +indexem, tedy jednotky milisekund bez ohledu na objem. + +### 3. Log je uvnitř ticketu + +Ticket si nese `trace` i `events` jako součást jednoho záznamu. Každý řádek +logu tím přepisuje celý ticket i s celým dosavadním logem. Po padesáti krocích +je to padesát zápisů, každý delší než ten předchozí. + +Log patří do vlastní tabulky s cizím klíčem na ticket a s retencí. Při 24 +milionech řádků měsíčně a plné odpovědi služby u každého je to řádově +**stovky GB za měsíc**, takže retence není detail, ale podmínka provozu. + +### 4. Runtime běží v požadavku + +`runFlow` vykoná strom rovnou v HTTP požadavku. U ruční akce je to správně, +u webhooku ne: + +- odesílatel čeká, než doběhnou všechny kroky, +- při pádu procesu se rozdělaný běh ztratí, protože není kde by byl zapsaný, +- není retry, není omezení souběhu na jednu službu, nic to nebrzdí. + +### 5. Statistiky se počítají celé + +`getAgentStats` projde všechny tickety firmy při každém zobrazení widgetu. +Při desítkách tisíc na firmu to jde, při milionu ne. Patří to do agregace +počítané dávkově, nebo aspoň do dotazu s `GROUP BY` nad indexem. + +### 6. Jedna instance + +Živý stream (SSE), mezipaměť widgetů nad konektory i kopie konfigurace +v paměti jsou vázané na proces. Při dvou instancích za load balancerem se +rozejdou. Řeší to `LISTEN/NOTIFY` na obnovu kopií a sdílený kanál na stream. + +## Konektory nejsou v naší moci + +Za rychlost a výsledek cizí služby neručíme, a proto se s tím musí počítat +v návrhu, ne v provozu: + +| Riziko | Co s tím | +| --- | --- | +| Služba odpovídá pomalu | Timeout na krok, ne na celý běh. Běh se uspí a pokračuje. | +| Služba je chvíli mimo | Opakování s rostoucí prodlevou, ne hned a ne donekonečna. | +| Služba je mimo dlouho | Vypnout ji po sérii chyb a nezkoušet každý běh znovu, ať netrpí ostatní. | +| Služba má limit volání | Strop souběžných volání **na dvojici firma a služba**, ne globálně. | +| Služba odpoví dvakrát jinak | Klíč proti dvojímu provedení u kroku, aby se nevystavila druhá faktura. | +| Služba je pomalá jen pro jednu firmu | Fronta po firmách, aby jedna firma nezablokovala ostatní. | + +Timeout a rozlišení "zkusit znovu" a "marné" už v `scripts/http.ts` je, +klíč proti dvojímu provedení taky. Chybí to, co je nad tím: fronta, opakování +a vypínání služby po sérii chyb. + +## Co je potřeba udělat, v pořadí + +1. **Tickety, běhy a log do Postgresu jako tabulky**, ne jako JSON v paměti. + Indexy na `(tenant_id, updated_at)`, `(tenant_id, external_id)` unikátní, + `(tenant_id, assignee_id, status)`. Stránkování na všech výpisech. +2. **Log a události zvlášť** od ticketu, s retencí. Bez toho to zaroste. +3. **Fronta běhů.** Tabulka `run_queue`, výběr přes `SELECT ... FOR UPDATE + SKIP LOCKED`, worker jako druhý proces téhož obrazu. Webhook jen zapíše + událost a odpoví, běh se stane na pozadí. +4. **Opakování a vypínání služby** kolem kroku. +5. **Agregace statistik** dávkově, ne při každém zobrazení. +6. **`LISTEN/NOTIFY`** na obnovu kopií v paměti a sdílený kanál na živý stream. + +Body 1 až 3 jsou podmínka, aby to vůbec šlo pustit. Zbytek je o tom, aby to +bylo použitelné. + +## Kolik serveru to bude chtít + +Po těch úpravách, pro zadaných 200 firem: + +| Část | Kolik | Proč | +| --- | --- | --- | +| Web a API | 2 instance, 1 vCPU a 1 GB každá | Požadavky jsou krátké, jde hlavně o dostupnost při restartu. | +| Worker běhů | 2 instance, 1 vCPU a 512 MB každá | Kroky čekají na cizí službu, procesor se skoro nepoužije. Jeden proces zvládne stovky souběžných kroků. | +| Postgres | 4 vCPU, 8 GB RAM, 200 GB disku | 10 až 20 zápisů za sekundu v průměru je málo, disk sežere log. | +| Celkem | ~8 vCPU, ~11 GB RAM | | + +Kritické číslo není procesor, ale **disk pod databází** a retence logu. +S plnou odpovědí služby u každého kroku je to zhruba 20 GB měsíčně na +každých 20 milionů řádků, takže 200 GB je půl roku provozu. Buď se odpovědi +po měsíci zahazují a nechá se jen shrnutí, nebo se počítá s tím, že disk +poroste. + +Škálování je vodorovné: víc událostí znamená víc workerů, ne větší stroj. +Jediné, co se škáluje svisle, je databáze, a ta má u tohoto objemu ještě +velkou rezervu. + +## Co funguje už teď + +Aby to nevypadalo hůř, než to je: runtime **běží**. Webhook vykoná strom, +akce na ticketu vykoná strom, kroky si předávají výstupy, podmínka větví +a celý průběh se zapíše do logu ticketu i s tím, co která služba vrátila. +Na jednu firmu s desítkami ticketů denně je současný stav použitelný. +Na 200 firem ne. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index ea6e68b..a3f7630 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,50 @@ Nejnovejsi nahore. +## 2026-08-13 - runtime, prokliky z widgetu a kapacitni rozbor + +### Pridano + +- `src/runtime/executor.ts`: **strom se konecne vykonava**. Jde krok po kroku, + u podminky se vetvi, do poli dosadi `{{parametry}}`, akci pusti pres + `runScript` a vystupy pripise do kontextu, aby na ne mohl dalsi krok + odkazat. Cely prubeh jde do logu ticketu vcetne toho, co sluzba vratila. + Pouzivaji ho **obe** cesty: akce na ticketu i webhook automatizace. +- Z widgetu se da prokliknout na to, co je za cislem: z lidi na cloveka, + ze seskupeni na uz vyfiltrovany seznam ticketu. Odkazy sklada **server**, + protoze on jediny zna filtr widgetu. +- Seznam ticketu cte filtr z adresy (`?typeId=`, `?tag=`, `?assignee=`, ...), + takze proklik vede na spravny vyber a odkaz jde poslat kolegovi. +- Seznam ticketu umi filtrovat na typ, tag a skupinu. +- Spolecna `TicketTable`: jedna tabulka pro seznam i pro detail osoby. + **Na mobilu se neposouva do strany**, uzka obrazovka dostane karty. +- [19-kapacita-200-firem.md](19-kapacita-200-firem.md): zmereny rozbor toho, + co se stane pri 200 firmach, a co to bude chtit za server. + +### Opraveno + +- **Rozlozeni dashboardu s vlastnim widgetem se nedalo ulozit.** `validateLayout` + znala jen vestaveny katalog, takze kazdy pokus skoncil hlaskou, ze widget + v katalogu neexistuje. Katalog je ted jedna funkce (`widgetCatalog`) a pouziva + ji nabidka i kontrola. +- Katalog widgetu je za konkretni firmu, ne za vsechny firmy uzivatele. Driv + slo polozit dlazdici jedne firmy na dashboard druhe, kde k ni data nikdy + neprisla. + +### Odebrano + +- **Simulace** vcetne tlacitka, dialogu i endpointu `/api/simulate`. Provoz se + ted dela prijmem udalosti, ktery je skutecny. +- Trojice pohledu nad tickety. Vyber firmy je select, "moje" je prepinac - + driv to delalo totez dvakrat. + +### Overeno + +7 kontrol proti bezicimu serveru: ulozeni rozlozeni s vlastnim widgetem, +oddeleni katalogu po firmach, vykonani stromu akce i webhooku, zapis behu do +logu, odkazy z widgetu a filtrovani podle nich. K tomu mereni na 5 000 +ticketech, ze ktereho vychazi rozbor kapacity. + ## 2026-08-13 - ticketovaci system: udalosti, externi ID, statistiky, widgety Popis v [18-ticketovaci-system.md](18-ticketovaci-system.md). diff --git a/src/data/connectorStore.ts b/src/data/connectorStore.ts index e717815..77e62ec 100644 --- a/src/data/connectorStore.ts +++ b/src/data/connectorStore.ts @@ -127,12 +127,24 @@ export async function initConnectorStore(options: InitOptions = {}): Promise local.flush(); if (useFile) { - reasons.push('Data se ukládají do JSON souboru, takže přežijí restart, ale ne redeploy.'); + /* + * Soubory lezi uvnitr containeru. Restart je nechá byt, redeploy postavi + * novy container a slozka je zas prazdna. Aby data zustala, musi byt + * `DATA_DIR` na pripojenem svazku, nebo se musi nastavit `DATABASE_URL` - + * obojí je vec nasazeni, ne aplikace, proto se to rekne nahlas. + */ + reasons.push( + `Data se ukládají do souborů v ${config.dataDir}. Přežijí restart, ale ne redeploy, ` + + 'protože složka je uvnitř kontejneru. Aby zůstala: nastavte DATABASE_URL, ' + + 'nebo namiřte DATA_DIR na připojený svazek.', + ); if (!canSealSecrets()) { reasons.push('Přístupové údaje se neukládají, protože není čím je zašifrovat.'); } } else { - reasons.push('Data jsou jen v paměti procesu, restart je smaže.'); + reasons.push( + 'Data jsou jen v paměti procesu, restart je smaže. Nastavte DATA_DIR, nebo DATABASE_URL.', + ); } status = { diff --git a/src/data/dashboardLayouts.ts b/src/data/dashboardLayouts.ts index 01a8d78..47a5337 100644 --- a/src/data/dashboardLayouts.ts +++ b/src/data/dashboardLayouts.ts @@ -7,7 +7,8 @@ * prezije restart. Vychozi sada se neuklada - kdo si nic neupravil, nema zaznam. */ -import { findWidget, type WidgetSize } from './widgets.js'; +import type { CatalogWidget } from './widgets.js'; +import type { WidgetSize } from './widgets.js'; import { defineStore, nowIso, type TenantEntity } from './store/index.js'; import { withMirror } from './store/mirror.js'; @@ -88,8 +89,12 @@ export function resetLayout(userId: string, tenantId: string): LayoutItem[] { * Overi rozlozeni proti katalogu. Vraci seznam problemu, prazdny znamena v poradku. * Neznamy widget ani nepovolena sirka se neulozi - klient by pak dostal zpatky * neco, co neumi vykreslit. + * + * Katalog je **parametr**, ne pevny seznam: patri do nej i vlastni widgety + * firmy a ty zavisi na tom, kdo se pta. Viz `widgetCatalog`. */ -export function validateLayout(items: LayoutItem[]): string[] { +export function validateLayout(items: LayoutItem[], catalog: CatalogWidget[]): string[] { + const byId = new Map(catalog.map((widget) => [widget.id, widget])); const problems: string[] = []; if (items.length > MAX_WIDGETS) { @@ -101,7 +106,7 @@ export function validateLayout(items: LayoutItem[]): string[] { if (seen.has(item.id)) problems.push(`Widget s ID „${item.id}" je uvedený dvakrát.`); seen.add(item.id); - const definition = findWidget(item.widgetId); + const definition = byId.get(item.widgetId); if (!definition) { problems.push(`Widget „${item.widgetId}" v katalogu neexistuje.`); continue; diff --git a/src/data/incidentStore.ts b/src/data/incidentStore.ts index a8ddf25..51b702d 100644 --- a/src/data/incidentStore.ts +++ b/src/data/incidentStore.ts @@ -155,7 +155,7 @@ export function updateIncidentStatus(id: string, status: IncidentStatus): Incide return incident; } -/** Prvni bezici incident - pouziva simulace, kdyz uzivatel neurci ktery. */ +/** Prvni bezici incident. */ export function firstActiveIncident(): Incident | undefined { return incidents.find((i) => i.status !== 'resolved'); } diff --git a/src/data/mock.ts b/src/data/mock.ts index dbf58e6..8509e50 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -30,7 +30,7 @@ export function getSummary(tenantIds: string[]) { const automations = listAutomations(tenantIds); const series = getRunsSeries(); - // Dnesni sloupec grafu doplnujeme o skutecne behy, aby se simulace projevila. + // Dnesni sloupec grafu doplnujeme o skutecne behy automatizaci. const runsToday = automations.reduce((sum, a) => sum + a.runsToday, 0); if (series.length > 0) { series[series.length - 1] = { ...series[series.length - 1], runs: runsToday }; diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 00b8fd0..824e65b 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -814,6 +814,12 @@ export interface TicketFilter { assignee?: string; status?: TicketStatus; channel?: TicketChannel; + /** Typ ticketu. `none` = tickety bez typu. */ + typeId?: string; + /** Jeden tag. `none` = tickety bez tagu. */ + tag?: string; + /** Skupina resitelu. `none` = bez skupiny. */ + groupId?: string; } export function listTickets(filter: TicketFilter): Ticket[] { @@ -821,6 +827,20 @@ export function listTickets(filter: TicketFilter): Ticket[] { if (!filter.tenantIds.includes(ticket.tenantId)) return false; if (filter.status && ticket.status !== filter.status) return false; if (filter.channel && ticket.channel !== filter.channel) return false; + if (filter.typeId === 'none' ? ticket.typeId : filter.typeId && ticket.typeId !== filter.typeId) + return false; + if (filter.tag === 'none') { + if ((ticket.tags ?? []).length > 0) return false; + } else if (filter.tag && !(ticket.tags ?? []).includes(filter.tag)) { + return false; + } + if ( + filter.groupId === 'none' + ? ticket.assigneeGroupId + : filter.groupId && ticket.assigneeGroupId !== filter.groupId + ) { + return false; + } if (filter.assignee === 'unassigned') return ticket.assigneeId === null; if (filter.assignee && ticket.assigneeId !== filter.assignee) return false; return true; @@ -851,7 +871,7 @@ export function getTicket(id: string, tenantIds: string[]): TicketDetail | undef }; } -/** Prvni nevyrizeny ticket - pouziva simulace, kdyz uzivatel neurci ktery. */ +/** Prvni nevyrizeny ticket. */ export function firstOpenTicket(tenantIds: string[]): Ticket | undefined { const stored = tickets.find( (t) => t.status !== 'resolved' && tenantIds.includes(t.tenantId), diff --git a/src/data/widgets.ts b/src/data/widgets.ts index 3e2939e..5f3f259 100644 --- a/src/data/widgets.ts +++ b/src/data/widgets.ts @@ -6,6 +6,8 @@ * i novou komponentu na webu, proto jich je zamerne malo a jsou obecne. */ +import { listCustomWidgets, sizesFor, type WidgetRender, type WidgetSource } from './customWidgets.js'; + export type WidgetSize = 'third' | 'half' | 'full'; export type WidgetKind = @@ -15,7 +17,9 @@ export type WidgetKind = | 'incidentList' | 'workload' /** Vykon resitelu. Data chodi z `/widget-data`, stejne jako u vlastnich. */ - | 'agents'; + | 'agents' + /** Vlastni widget: kresleni si urcuje jeho `render`. */ + | WidgetRender; export interface WidgetDefinition { id: string; @@ -144,3 +148,40 @@ export const builtinSources: Record widget.id === id); } + +/** Polozka katalogu tak, jak ji vidi klient. Vlastni widget nese i zdroj dat. */ +export interface CatalogWidget extends WidgetDefinition { + /** true = widget si firma nadefinovala sama. */ + custom: boolean; + render?: WidgetRender; + source?: WidgetSource; + target?: number; +} + +/** + * Cely katalog: pevne widgety plus vlastni te firmy. + * + * **Jedina definice toho, co jde polozit na dashboard.** Pouziva ji nabidka + * widgetu i kontrola ukladaneho rozlozeni. Kdyz to byly dva seznamy, kontrola + * znala jen pevne widgety a ulozit rozlozeni s vlastnim widgetem vzdycky + * selhalo hlaskou, ze v katalogu neni. + */ +export function widgetCatalog(tenantIds: string[], userId: string): CatalogWidget[] { + const custom = listCustomWidgets(tenantIds, userId).map( + (widget): CatalogWidget => ({ + id: widget.id, + name: widget.name, + description: widget.description, + // Klient kresli podle `kind`, u vlastniho widgetu je to jeho `render`. + kind: widget.render, + sizes: sizesFor(widget.render), + defaultSize: widget.size, + custom: true, + render: widget.render, + source: widget.source, + ...(widget.target !== undefined ? { target: widget.target } : {}), + }), + ); + + return [...widgets.map((widget): CatalogWidget => ({ ...widget, custom: false })), ...custom]; +} diff --git a/src/index.ts b/src/index.ts index 6d010e0..36bd4a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,6 @@ import { buildOpenApiDocument } from './openapi.js'; import { authRouter } from './routes/auth.js'; import { contactRouter } from './routes/contact.js'; import { dashboardRouter } from './routes/dashboard.js'; -import { simulateRouter } from './routes/simulate.js'; import { webhookRouter } from './routes/webhook.js'; import { bootstrapData } from './data/bootstrap.js'; import { flushConnectorStore, initConnectorStore, storageStatus } from './data/connectorStore.js'; @@ -114,7 +113,6 @@ api.use( api.use('/api/auth', authRouter); api.use('/api/dashboard', dashboardRouter); api.use('/api/admin', adminRouter); -api.use('/api/simulate', simulateRouter); api.use('/api/contact', contactRouter); api.use('/webhook', webhookRouter); diff --git a/src/openapi.ts b/src/openapi.ts index 16b683e..11c0ae3 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -136,7 +136,6 @@ export function buildOpenApiDocument() { { name: 'Skripty', description: 'Vykonna cast sluzby: manifest, kod a zkusebni beh' }, { name: 'Nastaveni', description: 'Firmy, lide, role a prava, typy ticketu, akce, widgety' }, { name: 'Sprava platformy', description: 'Audit a prepnuti na jiny ucet' }, - { name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' }, { name: 'Webhook', description: 'Verejny prijem dat do automatizace' }, { name: 'Kontakt', description: 'Poptavkovy formular z webu' }, ], @@ -1969,98 +1968,6 @@ export function buildOpenApiDocument() { }, }, }, - '/api/simulate': { - post: { - tags: ['Simulace'], - summary: 'Vyvolat provozni udalost', - description: - 'Meni skutecna data, aby bylo videt, jak dashboard reaguje zive. ' + - 'Nevyplnena pole server doplni ukazkovou hodnotou.', - security: [{ bearerAuth: [] }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['action'], - properties: { - action: { - type: 'string', - enum: [ - 'ticket.created', - 'ticket.resolved', - 'incident.started', - 'incident.resolved', - 'automation.run', - ], - }, - subject: { type: 'string' }, - body: { type: 'string', description: 'Cely text pozadavku.' }, - channel: { - type: 'string', - enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'], - description: 'Odkud pozadavek prisel. Podle toho se poskladá i log ticketu.', - }, - contact: { type: 'string' }, - knownCustomer: { - type: 'boolean', - description: - 'false = CRM firmu nedohleda, ticket zustane bez zakaznika i bez resitele.', - }, - priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] }, - ticketId: { type: 'string' }, - title: { type: 'string' }, - service: { type: 'string' }, - severity: { type: 'string', enum: ['sev1', 'sev2', 'sev3'] }, - incidentId: { type: 'string' }, - automationId: { type: 'string' }, - ok: { type: 'boolean' }, - }, - }, - }, - }, - }, - responses: { - '200': { description: 'Udalost provedena' }, - '201': { description: 'Zaznam vytvoren' }, - '409': { description: 'Neni co provest' }, - }, - }, - }, - '/webhook/{token}': { - post: { - tags: ['Webhook'], - summary: 'Prijem dat do automatizace', - description: - 'Verejny endpoint bez prihlaseni. Autorizuje neuhodnutelny token v adrese. ' + - 'Telo se overuje proti parametrum deklarovanym u spoustece.', - parameters: [ - { - name: 'token', - in: 'path', - required: true, - schema: { type: 'string' }, - description: '32znakovy token vygenerovany serverem.', - }, - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { type: 'object', additionalProperties: true }, - example: { customer: 'Nordis', score: 18, comment: 'vse ok' }, - }, - }, - }, - responses: { - '202': { description: 'Prijato' }, - '400': { description: 'Chybi parametr nebo nesedi typ' }, - '404': { description: 'Neznamy token' }, - '409': { description: 'Automatizace je pozastavena' }, - }, - }, - }, '/api/contact': { post: { tags: ['Kontakt'], diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index a00fe9f..c3e397e 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -39,8 +39,7 @@ import { validateLayout, } from '../data/dashboardLayouts.js'; import { collectScopes } from '../data/flowScope.js'; -import { widgets } from '../data/widgets.js'; -import { listCustomWidgets, sizesFor } from '../data/customWidgets.js'; +import { widgetCatalog } from '../data/widgets.js'; import { listIncidents } from '../data/incidentStore.js'; import { getSummary } from '../data/mock.js'; import { findPersonByEmail, listGroups, listPeople } from '../data/people.js'; @@ -125,23 +124,15 @@ dashboardRouter.get('/incidents', (_req, res) => { * Diky tomu se rozlozeni dashboardu nemuselo menit. */ dashboardRouter.get('/widgets', (req, res) => { - const access = accessFor(req.user!); - const tenantIds = access.tenants.map((tenant) => tenant.id); + /* + * Katalog je za konkretni firmu, stejne jako rozlozeni. Kdyby vracel widgety + * vsech firem uzivatele, sla by polozit dlazdice Automie na dashboard + * Nordisu - v nabidce by byla, ale data by k ni nikdy neprisla. + */ + const tenantId = layoutTenant(req, res); + if (!tenantId) return; - const custom = listCustomWidgets(tenantIds, req.user!.id).map((widget) => ({ - id: widget.id, - name: widget.name, - description: widget.description, - kind: widget.render, - sizes: sizesFor(widget.render), - defaultSize: widget.size, - custom: true, - render: widget.render, - source: widget.source, - ...(widget.target !== undefined ? { target: widget.target } : {}), - })); - - res.json({ items: [...widgets.map((widget) => ({ ...widget, custom: false })), ...custom] }); + return res.json({ items: widgetCatalog([tenantId], req.user!.id) }); }); /** @@ -198,7 +189,10 @@ dashboardRouter.put('/layout', (req, res) => { }); } - const problems = validateLayout(parsed.data.items); + const problems = validateLayout( + parsed.data.items, + widgetCatalog([tenantId], req.user!.id), + ); if (problems.length > 0) { console.warn(`[layout] ${req.user!.email}: neplatne rozlozeni - ${problems.join(' ')}`); return res.status(400).json({ error: 'validation_error', message: problems[0] }); @@ -384,6 +378,14 @@ const ticketChannels: TicketChannel[] = [ function ticketFilterFrom(query: Record, scope: ResolvedScope): TicketFilter { const filter: TicketFilter = { tenantIds: scope.tenantIds }; + // Filtry, na ktere se odkazuje z widgetu. `none` znamena "bez toho". + const typeId = query.typeId; + if (typeof typeId === 'string' && typeId !== '') filter.typeId = typeId; + const tag = query.tag; + if (typeof tag === 'string' && tag !== '') filter.tag = tag; + const groupId = query.groupId; + if (typeof groupId === 'string' && groupId !== '') filter.groupId = groupId; + // Pohled "moje" je silnejsi nez rucni filtr na resitele. if (scope.scope === 'mine') { filter.assignee = scope.personId ?? '__nikdo__'; diff --git a/src/routes/simulate.ts b/src/routes/simulate.ts deleted file mode 100644 index e701051..0000000 --- a/src/routes/simulate.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { Router } from 'express'; -import { z } from 'zod'; -import { listAutomations, recordRun } from '../data/automationStore.js'; -import { - createIncident, - firstActiveIncident, - updateIncidentStatus, -} from '../data/incidentStore.js'; -import { - createTicket, - firstOpenTicket, - updateTicketStatus, - type TicketChannel, - type TicketPriority, - type TraceInput, -} from '../data/ticketStore.js'; -import { accessFor } from '../data/access.js'; -import { requireAuth } from '../middleware/auth.js'; - -export const simulateRouter = Router(); - -simulateRouter.use(requireAuth); - -/** - * Simulace provoznich udalosti pro nahled zivého dashboardu. - * - * Zamerne MENI skutecna data v ulozisti, ne jen posila falesnou notifikaci. - * Diky tomu se zmena projevi i v seznamech a v souhrnu, ne jen v bublinach. - */ -const schema = z.discriminatedUnion('action', [ - z.object({ - action: z.literal('ticket.created'), - /** Odkud pozadavek prisel. Podle toho se poskladá i log. */ - channel: z - .enum(['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal']) - .optional(), - subject: z.string().trim().min(3).optional(), - /** Cely text pozadavku. Bez nej se doplni z predmetu a kanalu. */ - body: z.string().trim().min(3).optional(), - contact: z.string().trim().min(2).optional(), - priority: z.enum(['low', 'normal', 'high', 'critical']).optional(), - /** false = CRM firmu nedohleda, ticket zustane bez zakaznika a bez resitele. */ - knownCustomer: z.boolean().optional(), - }), - z.object({ - action: z.literal('ticket.resolved'), - ticketId: z.string().optional(), - }), - z.object({ - action: z.literal('incident.started'), - title: z.string().trim().min(3).optional(), - service: z.string().trim().min(2).optional(), - severity: z.enum(['sev1', 'sev2', 'sev3']).optional(), - }), - z.object({ - action: z.literal('incident.resolved'), - incidentId: z.string().optional(), - }), - z.object({ - action: z.literal('automation.run'), - automationId: z.string().optional(), - ok: z.boolean().optional(), - }), -]); - -/** - * Ukazkovy prichod pozadavku podle kanalu. Kanal urcuje nejen text, - * ale i to, jak vypada zaznam spoustece v logu ticketu. - */ -const channelPresets: Record< - TicketChannel, - { - serviceId: string; - operationId: string; - triggerLabel: string; - subjects: string[]; - contact: string; - reply: string; - /** Odkaz na zdrojovou zpravu u poskytovatele. null tam, kde zadny neni. */ - sourceRef: string | null; - /** Co spoustec vratil. `%s` se nahradi predmetem. */ - payload: (contact: string, reply: string, subject: string) => string; - } -> = { - whatsapp: { - serviceId: 'whatsapp', - operationId: 'message-received', - triggerLabel: 'Přijata zpráva z WhatsApp', - subjects: [ - 'Nechodí ranní report objednávek', - 'Potřebujeme přidat další číslo na hlášení', - 'Zákazník si stěžuje na dvojí SMS', - ], - contact: 'Bistro Kolektiv', - reply: '+420 774 902 331', - sourceRef: 'wamid.HBgLNDIwNzc0OTAyMzMx', - payload: (contact, reply, subject) => - `{ "from": "${reply.replace(/\s/g, '')}", "profileName": "${contact}", "text": "${subject}" }`, - }, - facebook: { - serviceId: 'facebook', - operationId: 'message-received', - triggerLabel: 'Přijata zpráva z Facebook Messengeru', - subjects: [ - 'Dotaz na otevírací dobu poboček', - 'Nefunguje objednávkový formulář', - 'Reklamace poškozené zásilky', - ], - contact: 'Martin Dvořák', - reply: 'fb:1029384756', - sourceRef: 'mid.simulace.fb', - payload: (contact, reply, subject) => - `{ "senderId": "${reply.replace('fb:', '')}", "senderName": "${contact}", "text": "${subject}" }`, - }, - instagram: { - serviceId: 'instagram', - operationId: 'message-received', - triggerLabel: 'Přijata přímá zpráva na Instagramu', - subjects: [ - 'Máte to ještě skladem?', - 'Kdy dorazí objednávka', - 'Jde změnit adresa doručení', - ], - contact: 'kavarna.uzastavky', - reply: 'ig:kavarna.uzastavky', - sourceRef: 'ig.simulace', - payload: (contact, _reply, subject) => - `{ "username": "${contact}", "text": "${subject}" }`, - }, - email: { - serviceId: 'email', - operationId: 'received', - triggerLabel: 'Přijat e-mail do schránky podpora@', - subjects: [ - 'Faktura se nespárovala s platbou', - 'Žádost o změnu fakturačních údajů', - 'Export do skladu skončil chybou', - ], - contact: 'Tomáš Beran', - reply: 'tomas.beran@nordis.cz', - sourceRef: '', - payload: (_contact, reply, subject) => - `{ "from": "${reply}", "subject": "${subject}", "attachments": 1 }`, - }, - voice: { - serviceId: 'voicebot', - operationId: 'call-received', - triggerLabel: 'Příchozí hovor na linku 800 100 200', - subjects: [ - 'Voicebot nerozumí názvu ulice', - 'Hovory se po 18:00 nepřepojují', - 'Zákazník žádá zpětné volání', - ], - contact: 'Petra Klientová', - reply: '+420 601 118 224', - sourceRef: 'cl_simulace', - payload: (_contact, reply, _subject) => - `{ "callId": "cl_${Math.floor(Math.random() * 90_000) + 10_000}", "from": "${reply.replace(/\s/g, '')}", "durationSec": 74 }`, - }, - form: { - serviceId: 'form', - operationId: 'submitted', - triggerLabel: 'Odeslán formulář Požadavek na úpravu', - subjects: [ - 'Rozšíření hlasového scénáře', - 'Napojení dalšího e-shopu', - 'Nový report pro management', - ], - contact: 'Jana Sedláčková', - reply: 'jana.sedlackova@logitrans.cz', - sourceRef: null, - payload: (contact, _reply, subject) => - `{ "company": "LogiTrans", "contact": "${contact}", "topic": "${subject}" }`, - }, - portal: { - serviceId: 'ticket', - operationId: 'created', - triggerLabel: 'Založeno ručně z portálu', - subjects: [ - 'Interní kontrola nastavení zálohování', - 'Revize oprávnění v CRM', - 'Aktualizace hlasového scénáře', - ], - contact: 'Interní požadavek', - reply: 'portal@automia.cz', - sourceRef: null, - payload: (contact, _reply, subject) => `{ "createdBy": "${contact}", "subject": "${subject}" }`, - }, -}; - -/** Firmy, ktere CRM zna. Simulace z nich bere zakaznika, kdyz ma byt dohledany. */ -const knownCompanies = [ - { id: 'crm_1042', company: 'Firma s.r.o.' }, - { id: 'crm_2210', company: 'Nordis a.s.' }, - { id: 'crm_3390', company: 'LogiTrans' }, -]; - -/** - * Resitel podle kanalu. Zamerne primitivni pravidlo - jde o to ukazat, - * ze ticket nekonci na hromade, ale u konkretniho cloveka. - */ -const routingRule: Record = { - voice: { id: 'ppl_novakova', name: 'Eva Nováková', why: 'voice -> voiceboti' }, - email: { id: 'ppl_kriz', name: 'Martin Kříž', why: 'email -> integrace' }, - whatsapp: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'whatsapp -> servicedesk' }, - facebook: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'facebook -> servicedesk' }, - instagram: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'instagram -> servicedesk' }, - form: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'form -> servicedesk' }, - portal: { id: 'ppl_uhlir', name: 'Jiří Uhlíř', why: 'portal -> vedoucí týmu' }, -}; - -/** - * Log prichodu pozadavku. Presne tohle chce clovek videt, kdyz se pta - * "proc ten ticket vypada takhle a co na to rekly jednotlive sluzby". - */ -function buildIntakeTrace(input: { - channel: TicketChannel; - subject: string; - contact: string; - reply: string; - priority: TicketPriority; - known: (typeof knownCompanies)[number] | null; -}): TraceInput[] { - const preset = channelPresets[input.channel]; - const routing = routingRule[input.channel]; - - const trace: TraceInput[] = [ - { - kind: 'trigger', - serviceId: preset.serviceId, - operationId: preset.operationId, - label: preset.triggerLabel, - status: 'ok', - response: preset.payload(input.contact, input.reply, input.subject), - durationMs: 60, - }, - { - kind: 'action', - serviceId: 'ai-text', - operationId: 'classify', - label: 'Zařazení do kategorie', - status: 'ok', - response: `{ "category": "pozadavek", "priority": "${input.priority}", "confidence": 0.83 }`, - durationMs: 880, - }, - { - kind: 'action', - serviceId: 'raynet', - operationId: 'upsert-contact', - label: 'Dohledání firmy v CRM', - status: input.known ? 'ok' : 'error', - response: input.known - ? `{ "companyId": "${input.known.id}", "name": "${input.known.company}" }` - : `{ "matches": 0, "searchedBy": "reply", "value": "${input.reply}" }`, - durationMs: 590, - }, - ]; - - if (input.known) { - trace.push({ - kind: 'condition', - label: 'knownCustomer je splněno', - status: 'ok', - response: 'true, pokračuje větev ANO', - children: [ - { - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - label: 'Založení ticketu', - status: 'ok', - response: `{ "customerId": "${input.known.id}", "priority": "${input.priority}" }`, - durationMs: 70, - }, - { - kind: 'action', - serviceId: 'ticket', - operationId: 'assign', - label: 'Přiřazení řešitele podle kanálu', - status: 'ok', - response: `{ "assignee": "${routing.name}", "rule": "${routing.why}" }`, - durationMs: 35, - }, - ], - }); - return trace; - } - - trace.push({ - kind: 'condition', - label: 'knownCustomer není splněno', - status: 'skipped', - response: 'false, pokračuje větev NE', - children: [ - { - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - label: 'Založení ticketu bez napojení na firmu', - status: 'ok', - response: `{ "customerId": null, "priority": "${input.priority}" }`, - durationMs: 68, - }, - { - kind: 'action', - serviceId: 'raynet', - operationId: 'create-lead', - label: 'Založení obchodního případu k dohledání', - status: 'ok', - response: `{ "leadId": "lead_${Math.floor(Math.random() * 9_000) + 1_000}", "stage": "k overeni" }`, - durationMs: 910, - }, - { - kind: 'action', - serviceId: 'ticket', - operationId: 'assign', - label: 'Přiřazení řešitele', - status: 'skipped', - response: 'Přeskočeno, bez známé firmy nelze určit garanta a ticket zůstal ve frontě', - }, - ], - }); - return trace; -} - -const defaultIncidents = [ - { title: 'Výpadek spojení s fakturačním API', service: 'Integrace / iDoklad' }, - { title: 'Zpoždění doručování webhooků', service: 'Webhook Router' }, - { title: 'Hlasová brána odmítá hovory', service: 'Voicebot Gateway' }, -]; - -/** Nahodny prvek - jen pro rozmanitost ukazkovych dat. */ -function pick(items: T[]): T { - return items[Math.floor(Math.random() * items.length)]; -} - -simulateRouter.post('/', (req, res) => { - const parsed = schema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný vstup simulace.', - }); - } - - // Simulace pisi do dat, takze musi vedet, ci. Bez firmy nema kam. - const access = accessFor(req.user!); - const tenantId = access.defaultTenantId; - if (!tenantId) { - return res.status(403).json({ - error: 'no_tenant', - message: 'Účet nepatří do žádné firmy, simulace nemá kam zapsat.', - }); - } - const tenantIds = access.tenants.map((tenant) => tenant.id); - - const input = parsed.data; - console.info(`[simulace] ${input.action} spustil ${req.user?.email} pro ${tenantId}`); - - switch (input.action) { - case 'ticket.created': { - const channel = input.channel ?? 'whatsapp'; - const preset = channelPresets[channel]; - const priority = input.priority ?? 'normal'; - const subject = input.subject ?? pick(preset.subjects); - const contact = input.contact ?? preset.contact; - // Vychozi chovani je "zakaznika zname" - nezname je zajimavejsi, ale vzacnejsi. - const known = (input.knownCustomer ?? true) ? pick(knownCompanies) : null; - - const ticket = createTicket({ - tenantId, - subject, - // Kanal posila cely text, predmet je jen jeho zkraceni. - body: input.body ?? `${subject}. Poslal ${contact} přes ${preset.triggerLabel}.`, - sourceRef: preset.sourceRef, - channel, - priority, - customer: { - id: known?.id ?? null, - company: known?.company ?? 'Neznámá firma', - contact, - reply: preset.reply, - }, - // Bez znameho zakaznika neni podle ceho resitele urcit, ticket zustane ve fronte. - assigneeId: known ? routingRule[channel].id : null, - trace: buildIntakeTrace({ channel, subject, contact, reply: preset.reply, priority, known }), - }); - return res.status(201).json({ ok: true, ticket }); - } - - case 'ticket.resolved': { - const target = input.ticketId ? { id: input.ticketId } : firstOpenTicket(tenantIds); - if (!target) { - return res.status(409).json({ - error: 'nothing_to_resolve', - message: 'Není co vyřešit, všechny tickety jsou hotové.', - }); - } - const ticket = updateTicketStatus(target.id, 'resolved', tenantIds); - if (!ticket) { - return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); - } - return res.json({ ok: true, ticket }); - } - - case 'incident.started': { - const preset = pick(defaultIncidents); - const incident = createIncident({ - title: input.title ?? preset.title, - service: input.service ?? preset.service, - severity: input.severity ?? 'sev2', - }); - return res.status(201).json({ ok: true, incident }); - } - - case 'incident.resolved': { - const target = input.incidentId ? { id: input.incidentId } : firstActiveIncident(); - if (!target) { - return res.status(409).json({ - error: 'nothing_to_resolve', - message: 'Není co vyřešit, žádný incident neběží.', - }); - } - const incident = updateIncidentStatus(target.id, 'resolved'); - if (!incident) { - return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' }); - } - return res.json({ ok: true, incident }); - } - - case 'automation.run': { - const automations = listAutomations(tenantIds).filter((a) => a.enabled); - const targetId = input.automationId ?? automations[0]?.id; - if (!targetId) { - return res.status(409).json({ - error: 'no_automation', - message: 'Není co spustit, žádná automatizace není aktivní.', - }); - } - const automation = recordRun(targetId, input.ok ?? true, tenantIds); - if (!automation) { - return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); - } - return res.json({ ok: true, automation }); - } - - default: { - // Vetve jsou vycerpane, tohle je jen pojistka pri rozsireni schematu. - console.error('[simulace] neosetrena akce:', input); - return res.status(400).json({ error: 'unknown_action', message: 'Neznámá akce.' }); - } - } -}); diff --git a/src/routes/ticketActions.ts b/src/routes/ticketActions.ts index 948dd80..cb6ef43 100644 --- a/src/routes/ticketActions.ts +++ b/src/routes/ticketActions.ts @@ -37,6 +37,7 @@ import { import { findTicketType, validateTicketFields } from '../data/ticketTypes.js'; import { runScript } from '../scripts/runner.js'; import { scriptIdFor } from '../scripts/lookup.js'; +import { runFlow } from '../runtime/executor.js'; export const ticketActionsRouter = Router(); @@ -235,9 +236,9 @@ interface ActionResult { /** * Vykonani tela akce. * - * Zatim synchronne v requestu. Zamerne: rucni akce nemusi cekat na frontu - * a `runScript` je uz hotovy vstupni bod na kroku. Az bude runtime, zavola - * se odsud totez z workeru. + * Synchronne v requestu. Zamerne: rucni akci clovek odklikl a ceka na vysledek, + * fronta by mu ho jen oddalila. Az bude runtime na vic strojich, zavola se + * odsud totez z workeru - `runFlow` je stejny vstupni bod. */ async function runAction( action: TicketAction, @@ -250,14 +251,41 @@ async function runAction( const body = action.body; if (body.kind === 'tree') { - // Strom akce potrebuje runtime, ktery zatim neexistuje. Rekne se to nahlas, - // misto aby se tvarilo, ze se neco stalo. + if (body.steps.length === 0) { + return { + ok: false, + summary: 'akce nemá žádné kroky', + detail: 'Strom je prázdný. Doplňte kroky v definici akce.', + durationMs: Date.now() - startedAt, + outputs: {}, + }; + } + + /* + * Kontext stromu: udaje ticketu a hodnoty z doptavaciho dialogu. Na obojí + * jde v krocích odkazovat pres `{{jmeno}}`, stejne jako u jedne operace. + */ + const context: Record = { ...facts, ...form }; + const run = await runFlow(body.steps, context, { + tenantId: ticket.tenantId, + ticketId: ticket.id, + idempotencyKey: `ticket:${ticket.id}:${action.id}`, + }); + + if (!run.ok) { + console.warn(`[actions] ${action.id} na ${ticket.id} od ${actorEmail}: ${run.error}`); + } + + const failed = run.steps.find((step) => !step.ok); return { - ok: false, - summary: 'strom akce zatím nejde vykonat, chybí runtime', - detail: 'Akce s vlastním stromem půjde spustit až s runtime automatizací.', - durationMs: Date.now() - startedAt, - outputs: {}, + ok: run.ok, + summary: run.ok + ? `proběhlo, ${run.steps.length} ${run.steps.length === 1 ? 'krok' : 'kroků'}` + : (run.error ?? 'selhalo'), + // Cele hlaseni toho kroku, ktery to zastavil. Zbytek je v logu ticketu. + detail: failed?.detail ?? null, + durationMs: run.durationMs, + outputs: run.context as Record, }; } diff --git a/src/routes/webhook.ts b/src/routes/webhook.ts index 0f38c05..4d5d1fe 100644 --- a/src/routes/webhook.ts +++ b/src/routes/webhook.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { findByWebhookToken, recordRun, type TriggerField } from '../data/automationStore.js'; import type { FieldType } from '../data/conditions.js'; import { findByIntakeToken } from '../data/tenants.js'; +import { runFlow } from '../runtime/executor.js'; import { intakeEvent } from '../data/ticketStore.js'; import { findTicketType, listTicketTypes } from '../data/ticketTypes.js'; import { publish } from '../events/bus.js'; @@ -191,18 +192,59 @@ webhookRouter.post('/:token', (req, res) => { automationId: automation.id, fields: Object.keys(payload), }); - recordRun(automation.id); console.info( `[webhook] ${automation.id}: prijato (${Object.keys(payload).join(', ') || 'bez dat'})`, ); - // PROTOTYP: strom se nevykonava, jen potvrdime prijem. - // Runtime je popsany v docs/08-automatizace-builder.md. - return res.status(202).json({ - accepted: true, - automationId: automation.id, - message: 'Požadavek přijat. Prototyp strom akcí nevykonává.', - }); + if (automation.flow.steps.length === 0) { + recordRun(automation.id); + return res.status(202).json({ + accepted: true, + automationId: automation.id, + message: 'Přijato. Automatizace nemá žádné kroky.', + }); + } + + /* + * Strom se vykona hned v requestu. Odesilatel tim dostane skutecny vysledek + * misto "prijato" - u desitek kroku je to v poradku, u tisicu udalosti za + * minutu to patri do fronty, viz documentation/10-runtime-a-kapacita.md. + */ + return void runFlow(automation.flow.steps, payload, { + tenantId: automation.tenantId, + ticketId: null, + idempotencyKey: `webhook:${automation.id}:${token.slice(0, 8)}`, + }) + .then((run) => { + recordRun(automation.id, run.ok); + const failed = run.steps.find((step) => !step.ok); + + if (!run.ok) { + console.warn(`[webhook] ${automation.id}: beh selhal - ${run.error}`); + } + + // Chyba stromu neni chyba prijmu. Data jsme prevzali, jen se nepovedlo + // je zpracovat - proto 200 s `ok: false`, ne 500. + return res.status(200).json({ + accepted: true, + automationId: automation.id, + ok: run.ok, + steps: run.steps.length, + durationMs: run.durationMs, + error: run.error, + // Cele hlaseni toho kroku, ktery to zastavil. + detail: failed?.detail ?? null, + }); + }) + .catch((err: unknown) => { + // `runFlow` chyby nevyhazuje, tohle je posledni pojistka. + console.error(`[webhook] ${automation.id}: neocekavana chyba behu:`, err); + recordRun(automation.id, false); + return res.status(500).json({ + error: 'internal_error', + message: err instanceof Error ? err.message : 'Běh selhal.', + }); + }); }); /** Overi prichozi data proti deklarovanym parametrum spoustece. */ diff --git a/src/routes/widgetData.ts b/src/routes/widgetData.ts index 78ed602..f7be23b 100644 --- a/src/routes/widgetData.ts +++ b/src/routes/widgetData.ts @@ -55,13 +55,30 @@ const requestSchema = z.object({ widgetIds: z.array(z.string().min(1)).max(24), }); -/** Jedna hodnota, nebo seskupeny vysledek. Klient podle `render` vi, co s tim. */ +/** + * Jedna hodnota, nebo seskupeny vysledek. Klient podle `render` vi, co s tim. + * + * U cisel a radku chodi `href`: kam se proklikne ten, kdo chce videt, co je + * za tim. Sklada ho **server**, protoze on jediny zna filtr widgetu - klient + * by si musel odvozovat, co ktery zdroj znamena, a jednou by se to rozeslo. + */ type WidgetValue = - | { kind: 'number'; value: number } - | { kind: 'groups'; rows: Array<{ key: string; label: string; value: number }> } - | { kind: 'series'; points: Array<{ date: string; value: number }> } - | { kind: 'tickets'; items: Array<{ id: string; subject: string; status: string; assignee: string | null }> } - | { kind: 'workload'; rows: Array<{ name: string; open: number; overCapacity: boolean }> } + | { kind: 'number'; value: number; href?: string } + | { + kind: 'groups'; + rows: Array<{ key: string; label: string; value: number; href?: string }>; + } + | { kind: 'series'; points: Array<{ date: string; value: number }>; href?: string } + | { + kind: 'tickets'; + items: Array<{ id: string; subject: string; status: string; assignee: string | null }>; + href?: string; + } + | { + kind: 'workload'; + rows: Array<{ personId: string; name: string; open: number; overCapacity: boolean; href: string }>; + href?: string; + } | { kind: 'agents'; rows: AgentStatsRow[] } /** Vysledek z konektoru. `stale` rika, ze je to z cache, ne cerstve zavolane. */ | { @@ -303,6 +320,48 @@ function toExternal( }; } +/** + * Adresa seznamu ticketu s dosazenym filtrem. + * + * Klic seskupeni se prevadi na parametr, ktery seznam umi. Prazdny klic + * znamena "bez toho", proto `none` - prazdna hodnota v adrese by se cetla + * jako "nefiltrovat". + */ +function ticketsHref( + filter: WidgetTicketFilter, + scope: ResolvedScope, + extra?: { groupBy?: WidgetGroupBy; key?: string }, +): string { + const params = new URLSearchParams(); + const tenantId = scope.tenantIds.length === 1 ? scope.tenantIds[0] : null; + if (tenantId) params.set('tenantId', tenantId); + + if (filter.status?.length === 1) params.set('status', filter.status[0]); + if (filter.channel?.length === 1) params.set('channel', filter.channel[0]); + if (filter.typeIds?.length === 1) params.set('typeId', filter.typeIds[0]); + if (filter.tags?.length === 1) params.set('tag', filter.tags[0]); + if (filter.assignee?.length === 1) params.set('assignee', filter.assignee[0]); + if (filter.groupIds?.length === 1) params.set('groupId', filter.groupIds[0]); + + if (extra?.groupBy) { + const key = extra.key === '' || extra.key === undefined ? 'none' : extra.key; + const byGroup: Record = { + assignee: 'assignee', + group: 'groupId', + status: 'status', + type: 'typeId', + tag: 'tag', + channel: 'channel', + }; + // Prazdna skupina u resitele je fronta, na to ma seznam vlastni hodnotu. + if (extra.groupBy === 'assignee' && key === 'none') params.set('assignee', 'unassigned'); + else params.set(byGroup[extra.groupBy], key); + } + + const query = params.toString(); + return query ? `/dashboard/tickety?${query}` : '/dashboard/tickety'; +} + function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue { if (source.kind === 'agentStats') { return { @@ -316,10 +375,14 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue return { kind: 'workload', rows: workload.rows.map((row) => ({ + personId: row.person.id, name: row.person.name, open: row.open, overCapacity: row.overloaded, + // Z vytizeni se chce clovek dostat na toho cloveka, ne na cisla. + href: `/dashboard/lide/${row.person.id}`, })), + href: '/dashboard/lide', }; } @@ -334,7 +397,13 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue ); if (source.kind === 'ticketCount') { - if (!source.groupBy) return { kind: 'number', value: tickets.length }; + if (!source.groupBy) { + return { + kind: 'number', + value: tickets.length, + href: ticketsHref(source.filter, scope), + }; + } const counts = new Map(); for (const ticket of tickets) { @@ -345,7 +414,12 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue return { kind: 'groups', rows: [...counts.entries()] - .map(([key, value]) => ({ key, label: groupLabel(key, source.groupBy!), value })) + .map(([key, value]) => ({ + key, + label: groupLabel(key, source.groupBy!), + value, + href: ticketsHref(source.filter, scope, { groupBy: source.groupBy, key }), + })) .sort((a, b) => b.value - a.value), }; } @@ -353,6 +427,7 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue if (source.kind === 'ticketList') { return { kind: 'tickets', + href: ticketsHref(source.filter, scope), items: tickets .slice(0, source.limit) .map((ticket) => ({ @@ -374,7 +449,7 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue value: tickets.filter((ticket) => ticket.createdAt.slice(0, 10) === day).length, }); } - return { kind: 'series', points }; + return { kind: 'series', points, href: ticketsHref(source.filter, scope) }; } /** Zdroj vlastniho widgetu. Vestavene maji svuj v `builtinSources`. */ diff --git a/src/runtime/executor.ts b/src/runtime/executor.ts new file mode 100644 index 0000000..96fe355 --- /dev/null +++ b/src/runtime/executor.ts @@ -0,0 +1,335 @@ +/** + * Vykonavani stromu kroku. + * + * Tohle je ta cast, ktera do ted chybela: strom se dal poskladat a ulozit, ale + * nic ho nespustilo. Pouziva se na dvou mistech a je zamerne jedno: + * - telo akce na ticketu, kterou spusti clovek tlacitkem, + * - automatizace spustena webhookem. + * + * Co dela: + * - jde krok po kroku, u podminky se vetvi, + * - do poli kroku dosadi `{{parametry}}` z kontextu, + * - akci vykona pres `runScript`, tedy tim samym skriptem jako vsude jinde, + * - vystupy kroku pripise do kontextu, aby na ne mohl dalsi krok odkazat, + * - kazdy krok zapise do logu vcetne toho, co sluzba vratila. + * + * Co nedela, a je to zamer: + * - **nebezi ve fronte**. Beh je synchronni v ramci jednoho requestu, takze + * dlouhy strom drzi spojeni. Na desitky kroku to staci, na statisice udalosti + * ne - popis fronty a rozdeleni na worker je v documentation/10-runtime-a-kapacita.md. + * - **neopakuje se sam**. Chyba krok zastavi a zapise se cela, misto aby se + * tise zkousela znovu. + */ + +import { defaultConnectorFor, getConnector, type Connector } from '../data/connectorStore.js'; +import type { FlowStep } from '../data/automationStore.js'; +import { isUnary, type ConditionOperator } from '../data/conditions.js'; +import { renderTemplate } from '../data/templates.js'; +import { appendTrace, type TraceInput } from '../data/ticketStore.js'; +import { scriptIdFor } from '../scripts/lookup.js'; +import { runScript } from '../scripts/runner.js'; + +/** + * Hodnoty, na ktere jde ve strome odkazovat. + * + * Klic je jmeno, ktere se pise do sablony jako `{{jmeno}}`. Vystupy kroku se + * pridavaji pod `krok.vystup`, takze si dva stejne kroky neprepisou hodnoty. + */ +export type RunContext = Record; + +export interface RunOptions { + /** Firma, jejimz jmenem se to vola. Urcuje, ktery konektor se pouzije. */ + tenantId: string; + /** Ticket, do jehoz logu se zapisuje. null = beh mimo ticket. */ + ticketId: string | null; + /** Stabilni klic proti dvojimu provedeni. */ + idempotencyKey: string | null; + /** Kolik kroku se nejvys vykona. Pojistka proti nechtene dlouhemu stromu. */ + maxSteps?: number; +} + +export interface StepResult { + stepId: string; + kind: 'action' | 'condition'; + label: string; + ok: boolean; + /** U podminky, kterou vetvi se slo. */ + branch?: 'yes' | 'no'; + summary: string; + /** Cele hlaseni nebo cela odpoved sluzby. Nikdy se nezkracuje. */ + detail: string | null; + durationMs: number; +} + +export interface RunResult { + ok: boolean; + /** Kolik kroku se opravdu vykonalo, vcetne podminek. */ + steps: StepResult[]; + /** Kontext po behu, tedy i vystupy kroku. */ + context: RunContext; + /** Prvni chyba, ktera beh zastavila. */ + error: string | null; + durationMs: number; +} + +/** Vychozi strop kroku. Vic uz neni strom, ale program. */ +const MAX_STEPS = 50; + +/** + * Vykona strom. + * + * **Nikdy nevyhodi vyjimku.** Chyba je vysledek, ne pad - stejna dohoda jako + * u `runScript`. Volajici tak nemusi resit try/catch a chyba se vzdycky dostane + * az k uzivateli i do logu ticketu. + */ +export async function runFlow( + steps: FlowStep[], + context: RunContext, + options: RunOptions, +): Promise { + const startedAt = Date.now(); + const results: StepResult[] = []; + const working: RunContext = { ...context }; + const limit = options.maxSteps ?? MAX_STEPS; + + let error: string | null = null; + + async function walk(list: FlowStep[]): Promise { + for (const step of list) { + if (results.length >= limit) { + error = `Strom má víc než ${limit} kroků, běh se zastavil.`; + return false; + } + + if (step.kind === 'condition') { + const branch = evaluate(step, working) ? 'yes' : 'no'; + results.push({ + stepId: step.id, + kind: 'condition', + label: describeCondition(step, working), + ok: true, + branch, + summary: branch === 'yes' ? 'splněno' : 'nesplněno', + detail: null, + durationMs: 0, + }); + + // Prochazi se jen jedna vetev, ta druha se nevykona. + if (!(await walk(branch === 'yes' ? step.yes : step.no))) return false; + continue; + } + + const result = await runAction(step, working, options); + results.push(result); + if (!result.ok) { + error = result.summary; + return false; + } + } + return true; + } + + await walk(steps); + + // Log ticketu dostane cely prubeh naraz, ne po jednom zapisu. + if (options.ticketId && results.length > 0) { + appendTrace(options.ticketId, results.map(toTrace)); + } + + return { + ok: error === null, + steps: results, + context: working, + error, + durationMs: Date.now() - startedAt, + }; +} + +/** Vykona jeden krok pres skript sluzby. */ +async function runAction( + step: Extract, + context: RunContext, + options: RunOptions, +): Promise { + const startedAt = Date.now(); + const label = `${step.serviceId}/${step.operationId}`; + + const scriptId = scriptIdFor(step.serviceId, step.operationId); + if (!scriptId) { + return { + stepId: step.id, + kind: 'action', + label, + ok: false, + summary: 'operace nemá výkonnou část', + detail: + `Služba ${step.serviceId} nemá pro operaci ${step.operationId} skript, ` + + 'takže ji nelze vykonat. Zbytek stromu se nespustil.', + durationMs: Date.now() - startedAt, + }; + } + + let connector: Connector | undefined; + try { + connector = step.connectorId + ? await getConnector(step.connectorId, [options.tenantId]) + : await defaultConnectorFor(options.tenantId, step.serviceId); + } catch (err) { + // Nedostupne uloziste konektoru nesmi vypadat jako chyba cizi sluzby. + return { + stepId: step.id, + kind: 'action', + label, + ok: false, + summary: 'napojení se nepodařilo načíst', + detail: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - startedAt, + }; + } + + const inputs = fillTemplates(step.inputs ?? {}, context); + const result = await runScript(scriptId, inputs, { + connector: connector ?? null, + // Klic je stabilni na dvojici beh a krok, takze opakovane odeslani + // nevystavi druhou fakturu. + idempotencyKey: options.idempotencyKey ? `${options.idempotencyKey}:${step.id}` : undefined, + }); + + if (!result.ok) { + return { + stepId: step.id, + kind: 'action', + label, + ok: false, + summary: result.error?.message ?? 'krok selhal', + detail: result.error?.detail ?? null, + durationMs: Date.now() - startedAt, + }; + } + + // Vystupy pod jmenem kroku, aby si dva stejne kroky neprepsaly hodnoty. + context[step.id] = result.outputs; + for (const [key, value] of Object.entries(result.outputs)) { + context[`${step.id}.${key}`] = value; + } + + return { + stepId: step.id, + kind: 'action', + label, + ok: true, + summary: 'hotovo', + detail: describe(result.outputs), + durationMs: Date.now() - startedAt, + }; +} + +/** Dosadi `{{jmeno}}` z kontextu do vsech poli kroku. */ +function fillTemplates( + inputs: Record, + context: RunContext, +): Record { + const values: Record = {}; + for (const [key, value] of Object.entries(context)) { + values[key] = value === null || value === undefined ? '' : stringify(value); + } + + const filled: Record = {}; + for (const [key, template] of Object.entries(inputs)) { + filled[key] = renderTemplate(template, values); + } + return filled; +} + +function stringify(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return JSON.stringify(value); +} + +/** Vyhodnoti podminku nad kontextem. */ +function evaluate( + step: Extract, + context: RunContext, +): boolean { + const raw = context[step.fieldId] ?? context[nameOf(step.fieldId)]; + const value = raw === undefined || raw === null ? '' : stringify(raw); + const expected = step.value ?? ''; + + return compare(step.operator, value, expected); +} + +/** + * Parametr se v kontextu drzi pod jmenem, ale podminka se odkazuje ID. + * `ticket.status` je pod `status`, `krok1.faktura` pod `krok1.faktura`. + */ +function nameOf(fieldId: string): string { + const dot = fieldId.indexOf('.'); + return dot === -1 ? fieldId : fieldId.slice(dot + 1); +} + +function compare(operator: ConditionOperator, value: string, expected: string): boolean { + const number = Number(value); + const expectedNumber = Number(expected); + + switch (operator) { + case 'eq': + return value === expected; + case 'neq': + return value !== expected; + case 'contains': + return value.toLowerCase().includes(expected.toLowerCase()); + case 'startsWith': + return value.toLowerCase().startsWith(expected.toLowerCase()); + case 'gt': + return Number.isFinite(number) && Number.isFinite(expectedNumber) && number > expectedNumber; + case 'gte': + return Number.isFinite(number) && Number.isFinite(expectedNumber) && number >= expectedNumber; + case 'lt': + return Number.isFinite(number) && Number.isFinite(expectedNumber) && number < expectedNumber; + case 'lte': + return Number.isFinite(number) && Number.isFinite(expectedNumber) && number <= expectedNumber; + case 'isEmpty': + return value.trim() === ''; + case 'isNotEmpty': + return value.trim() !== ''; + case 'isTrue': + return value === 'true' || value === '1'; + case 'isFalse': + return value === 'false' || value === '0' || value === ''; + default: + // Neznamy operator radeji neprojde, nez aby tise vetvil vzdycky stejne. + console.warn(`[runtime] neznamy operator ${operator}, podminka nesplnena`); + return false; + } +} + +function describeCondition( + step: Extract, + context: RunContext, +): string { + const raw = context[step.fieldId] ?? context[nameOf(step.fieldId)]; + const value = raw === undefined || raw === null ? '(prázdné)' : stringify(raw); + const name = nameOf(step.fieldId); + + if (isUnary(step.operator)) return `Podmínka: ${name} ${step.operator} (${value})`; + return `Podmínka: ${name} ${step.operator} ${step.value ?? ''} (${value})`; +} + +/** Co krok vratil, do logu. Delsi vystup se orizne, cely je ve vysledku. */ +function describe(outputs: Record): string | null { + const keys = Object.keys(outputs); + if (keys.length === 0) return null; + const text = JSON.stringify(outputs); + return text.length > 4_000 ? `${text.slice(0, 4_000)}...` : text; +} + +function toTrace(step: StepResult): TraceInput { + return { + kind: step.kind === 'condition' ? 'condition' : 'action', + status: step.ok ? 'ok' : 'error', + label: `${step.label}: ${step.summary}`, + response: step.detail, + durationMs: step.durationMs, + }; +} diff --git a/web/src/components/dashboard/DashboardLayout.tsx b/web/src/components/dashboard/DashboardLayout.tsx index 63edc08..d2579c3 100644 --- a/web/src/components/dashboard/DashboardLayout.tsx +++ b/web/src/components/dashboard/DashboardLayout.tsx @@ -2,7 +2,6 @@ import { AlarmClock, Blocks, ExternalLink, - FlaskConical, LayoutDashboard, LayoutGrid, LifeBuoy, @@ -23,7 +22,6 @@ import { useAuth, type AuthUser } from '@/auth/AuthContext'; import { EventStreamProvider } from '@/components/dashboard/EventStreamProvider'; import { EventToasts } from '@/components/dashboard/EventToasts'; import { LiveIndicator } from '@/components/dashboard/LiveIndicator'; -import { SimulationModal } from '@/components/dashboard/SimulationModal'; import { Logo } from '@/components/layout/Logo'; import { cn } from '@/lib/cn'; import { useApiQuery } from '@/lib/useApiQuery'; @@ -79,7 +77,6 @@ function DashboardShell() { const navigate = useNavigate(); const location = useLocation(); const [sidebarOpen, setSidebarOpen] = useState(false); - const [simulationOpen, setSimulationOpen] = useState(false); useEffect(() => { setSidebarOpen(false); @@ -135,16 +132,6 @@ function DashboardShell() { ); })} - - {/* Nastroj pro nahled zivého dashboardu, proto az za beznou navigaci. */} -
@@ -216,7 +203,6 @@ function DashboardShell() {
- setSimulationOpen(false)} /> ); } diff --git a/web/src/components/dashboard/SimulationModal.tsx b/web/src/components/dashboard/SimulationModal.tsx deleted file mode 100644 index 2c2f036..0000000 --- a/web/src/components/dashboard/SimulationModal.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { AlarmClock, AlertCircle, CheckCircle2, LifeBuoy, Play, Workflow } from 'lucide-react'; -import { useState } from 'react'; -import type { FormEvent, ReactNode } from 'react'; -import { Button } from '@/components/ui/Button'; -import { Modal } from '@/components/ui/Modal'; -import { apiFetch } from '@/lib/api'; -import { cn } from '@/lib/cn'; - -type Action = - | 'ticket.created' - | 'ticket.resolved' - | 'incident.started' - | 'incident.resolved' - | 'automation.run'; - -interface Result { - ok: boolean; - message: string; -} - -const priorities = [ - { value: 'low', label: 'Nízká' }, - { value: 'normal', label: 'Běžná' }, - { value: 'high', label: 'Vysoká' }, - { value: 'critical', label: 'Kritická' }, -] as const; - -/** Kanal urcuje nejen text, ale i to, jak bude vypadat log ticketu. */ -const channels = [ - { value: 'whatsapp', label: 'WhatsApp' }, - { value: 'facebook', label: 'Facebook Messenger' }, - { value: 'instagram', label: 'Instagram' }, - { value: 'email', label: 'E-mail' }, - { value: 'voice', label: 'Hlasová linka' }, - { value: 'form', label: 'Webový formulář' }, - { value: 'portal', label: 'Portál (ručně)' }, -] as const; - -const severities = [ - { value: 'sev3', label: 'SEV3 - menší' }, - { value: 'sev2', label: 'SEV2 - vážný' }, - { value: 'sev1', label: 'SEV1 - kritický' }, -] as const; - -const inputClass = - 'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none'; - -/** - * Simulace provoznich udalosti. Slouzi k nahledu zivého dashboardu bez toho, - * aby se muselo cekat na skutecny provoz. - * - * Nejde o falesne notifikace - kazda akce opravdu zmeni data na serveru, - * takze se projevi i v seznamech a v souhrnu. - */ -export function SimulationModal({ open, onClose }: { open: boolean; onClose: () => void }) { - const [running, setRunning] = useState(null); - const [result, setResult] = useState(null); - - const [subject, setSubject] = useState(''); - const [contact, setContact] = useState(''); - const [priority, setPriority] = useState<(typeof priorities)[number]['value']>('normal'); - const [channel, setChannel] = useState<(typeof channels)[number]['value']>('whatsapp'); - const [knownCustomer, setKnownCustomer] = useState(true); - - const [title, setTitle] = useState(''); - const [service, setService] = useState(''); - const [severity, setSeverity] = useState<(typeof severities)[number]['value']>('sev2'); - - async function run(action: Action, body: Record = {}) { - setRunning(action); - setResult(null); - try { - await apiFetch('/api/simulate', { - method: 'POST', - body: { action, ...body }, - }); - setResult({ ok: true, message: 'Hotovo. Změna se objevila v dashboardu.' }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Simulaci se nepodařilo spustit.'; - console.error('[simulace] selhalo:', err); - setResult({ ok: false, message }); - } finally { - setRunning(null); - } - } - - function submitTicket(event: FormEvent) { - event.preventDefault(); - void run('ticket.created', { - // Prazdna pole neposilame, server pak doplni ukazkovou hodnotu. - ...(subject.trim() ? { subject: subject.trim() } : {}), - ...(contact.trim() ? { contact: contact.trim() } : {}), - channel, - priority, - knownCustomer, - }); - setSubject(''); - } - - function submitIncident(event: FormEvent) { - event.preventDefault(); - void run('incident.started', { - ...(title.trim() ? { title: title.trim() } : {}), - ...(service.trim() ? { service: service.trim() } : {}), - severity, - }); - setTitle(''); - } - - return ( - -
- -
-
- - -
- setSubject(event.target.value)} - placeholder="Předmět (nepovinné, jinak se doplní ukázkový)" - className={inputClass} - /> - setContact(event.target.value)} - placeholder="Kdo píše (nepovinné)" - className={inputClass} - /> - - -
-
- - -
- setTitle(event.target.value)} - placeholder="Popis (nepovinné, jinak se doplní ukázkový)" - className={inputClass} - /> -
- setService(event.target.value)} - placeholder="Služba" - className={cn(inputClass, 'min-w-0 flex-1')} - /> - -
- -
-
- - -

- Působí na první vhodný záznam, nemusíte nic vybírat. -

-
- void run('ticket.resolved')} - disabled={running !== null} - busy={running === 'ticket.resolved'} - > - Vyřešit ticket - - void run('incident.resolved')} - disabled={running !== null} - busy={running === 'incident.resolved'} - > - Vyřešit incident - - void run('automation.run', { ok: true })} - disabled={running !== null} - busy={running === 'automation.run'} - > - Spustit automatizaci - - void run('automation.run', { ok: false })} - disabled={running !== null} - busy={running === 'automation.run'} - > - Neúspěšný běh - -
-
- - {result && ( -

- {result.ok ? ( - - ) : ( - - )} - {result.message} -

- )} - -

- Simulace zapisuje do stejných dat jako běžný provoz. Data jsou v paměti API, - takže se restartem serveru vrátí do výchozího stavu. -

-
-
- ); -} - -function Panel({ - icon: Icon, - title, - children, -}: { - icon: typeof LifeBuoy; - title: string; - children: ReactNode; -}) { - return ( -
-

- - {title} -

- {children} -
- ); -} - -function QuickButton({ - onClick, - disabled, - busy, - children, -}: { - onClick: () => void; - disabled: boolean; - busy: boolean; - children: ReactNode; -}) { - return ( - - ); -} diff --git a/web/src/components/dashboard/TicketTable.tsx b/web/src/components/dashboard/TicketTable.tsx new file mode 100644 index 0000000..e5ad57d --- /dev/null +++ b/web/src/components/dashboard/TicketTable.tsx @@ -0,0 +1,184 @@ +import { Clock, RotateCcw } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { + TicketChannelBadge, + TicketPriorityBadge, + TicketStatusBadge, +} from '@/components/dashboard/StatusBadge'; +import { Badge } from '@/components/ui/Badge'; +import { cn } from '@/lib/cn'; +import { formatDateTime, formatRelative } from '@/lib/format'; +import type { Ticket } from '@/types/dashboard'; + +/** + * Tabulka ticketu. + * + * Jedna komponenta pro vsechna mista, kde se tickety vypisuji - seznam, + * detail osoby, cokoliv dalsiho. Kdyby si to kazda stranka kreslila po svem, + * jedna by casem ukazovala jine sloupce nez druha. + * + * **Na mobilu se nescroluje do strany.** Uzka obrazovka dostane misto tabulky + * karty se stejnymi udaji: vodorovne posouvani v tabulce o osmi sloupcich je + * na telefonu nepouzitelne a schovavat sloupce znamena, ze udaj proste zmizi. + */ +export function TicketTable({ + tickets, + meId, + /** Sloupce, ktere nemaji smysl v danem kontextu. */ + hide, + empty = 'Žádné tickety.', +}: { + tickets: Ticket[]; + meId?: string | null; + hide?: Array<'assignee' | 'customer' | 'channel'>; + empty?: string; +}) { + if (tickets.length === 0) { + return ( +

+ {empty} +

+ ); + } + + const skip = new Set(hide ?? []); + + return ( + <> + {/* Tabulka od tabletu vys. */} +
+ + + + + {!skip.has('customer') && } + {!skip.has('channel') && } + + + {!skip.has('assignee') && } + + + + + {tickets.map((ticket) => ( + + + + {!skip.has('customer') && ( + + )} + + {!skip.has('channel') && ( + + )} + + + + + {!skip.has('assignee') && ( + + )} + + + + ))} + +
TicketZákazníkKanálStavPrioritaŘešíAktualizace
+ + {ticket.subject} + + + {ticket.id} + {ticket.externalId && externí {ticket.externalId}} + {ticket.reopenCount > 0 && ( + + + {ticket.reopenCount}x vráceno + + )} + + + {ticket.customer.id ? ( + {ticket.customer.company} + ) : ( + nedohledáno + )} + {ticket.customer.contact && ( + {ticket.customer.contact} + )} + + + + + + + + {ticket.assignee ? ( + + {ticket.assignee.name} + + ) : ( + ve frontě + )} + + + {formatRelative(ticket.updatedAt)} + +
+
+ + {/* Na uzke obrazovce karty misto posouvani do strany. */} +
    + {tickets.map((ticket) => ( +
  • + +
    + {ticket.id} + +
    + +

    {ticket.subject}

    + +
    + {!skip.has('channel') && } + + {ticket.externalId && externí {ticket.externalId}} + {ticket.reopenCount > 0 && {ticket.reopenCount}x vráceno} +
    + +
    + {!skip.has('customer') && ( + + {ticket.customer.id ? ( + ticket.customer.company + ) : ( + zákazník nedohledán + )} + + )} + {!skip.has('assignee') && ( + + {ticket.assignee?.name ?? 've frontě'} + + )} + + + {formatRelative(ticket.updatedAt)} + +
    + +
  • + ))} +
+ + ); +} diff --git a/web/src/components/dashboard/TicketWorkload.tsx b/web/src/components/dashboard/TicketWorkload.tsx index 769ee93..e78c02b 100644 --- a/web/src/components/dashboard/TicketWorkload.tsx +++ b/web/src/components/dashboard/TicketWorkload.tsx @@ -1,4 +1,5 @@ -import { AlertTriangle, Inbox, Users } from 'lucide-react'; +import { AlertTriangle, ChevronRight, Inbox, Users } from 'lucide-react'; +import { Link } from 'react-router-dom'; import { cn } from '@/lib/cn'; import { formatRelative } from '@/lib/format'; import type { Workload } from '@/types/dashboard'; @@ -39,19 +40,35 @@ export function TicketWorkload({ const width = busiest === 0 ? 0 : Math.round((row.open / busiest) * 100); return ( -
  • +
  • + {/* + Radek filtruje seznam, sipka vpravo vede na cloveka. Odkaz nesmi + byt uvnitr tlacitka - dva ovladace v sobe jsou neplatne HTML + a klavesnici se v tom neda pohybovat. + */} + + + + + ); +} + +/** Dlazdicovy pohled na tytéz tickety. */ +function TicketCards({ + tickets, + showAge, + empty, +}: { + tickets: Ticket[]; + showAge: boolean; + empty: string; +}) { + if (tickets.length === 0) { + return ( +

    + {empty} +

    + ); + } + + return ( +
    + {tickets.map((ticket) => ( + +
    + {ticket.id} + +
    +

    + {ticket.subject} +

    +

    + {ticket.externalId && externí {ticket.externalId}} + + {showAge + ? `leží ${formatRelative(ticket.createdAt)}` + : ticket.resolvedAt + ? `vyřešeno ${formatRelative(ticket.resolvedAt)}` + : ''} + + {ticket.reopenCount > 0 && ( + vráceno {ticket.reopenCount}x + )} +

    + + ))} +
    ); } diff --git a/web/src/pages/dashboard/Tickets.tsx b/web/src/pages/dashboard/Tickets.tsx index 68eaf5b..52b0de5 100644 --- a/web/src/pages/dashboard/Tickets.tsx +++ b/web/src/pages/dashboard/Tickets.tsx @@ -1,7 +1,7 @@ -import { ChevronRight, Search } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { Search } from 'lucide-react'; +import { useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import { DataState } from '@/components/dashboard/DataState'; import { TicketChannelBadge, @@ -9,6 +9,7 @@ import { TicketStatusBadge, } from '@/components/dashboard/StatusBadge'; import { TicketWorkload } from '@/components/dashboard/TicketWorkload'; +import { TicketTable } from '@/components/dashboard/TicketTable'; import { ViewSwitch } from '@/components/dashboard/ViewSwitch'; import { Badge } from '@/components/ui/Badge'; import { cn } from '@/lib/cn'; @@ -34,12 +35,6 @@ const statusFilters: Array<{ value: TicketStatus; label: string }> = [ { value: 'resolved', label: 'Vyřešené' }, ]; -const scopeOptions: Array<{ value: TicketScope; label: string; hint: string }> = [ - { value: 'all', label: 'Vše', hint: 'Napříč všemi firmami. Jen pro správce platformy.' }, - { value: 'tenant', label: 'Firma', hint: 'Všechny tickety vybrané firmy.' }, - { value: 'mine', label: 'Moje', hint: 'Jen tickety, které mám u sebe.' }, -]; - const channelFilters: Array<{ value: TicketChannel; label: string }> = [ { value: 'whatsapp', label: 'WhatsApp' }, { value: 'facebook', label: 'Facebook' }, @@ -56,15 +51,36 @@ export default function Tickets() { // Co uzivatel smi, rozhoduje server. Klient jen kresli, co prislo. const access = useApiQuery('/api/dashboard/access'); - const [scope, setScope] = useState(null); - const [tenantId, setTenantId] = useState(null); + /* + * Pocatecni filtr z adresy. Diky tomu vede proklik z widgetu na uz + * vyfiltrovany seznam a odkaz jde poslat kolegovi. + */ + const [params] = useSearchParams(); + const initial = useRef({ + tenantId: params.get('tenantId'), + assignee: params.get('assignee'), + status: params.get('status') as TicketStatus | null, + channel: params.get('channel') as TicketChannel | null, + typeId: params.get('typeId'), + tag: params.get('tag'), + groupId: params.get('groupId'), + }).current; + + const [scope, setScope] = useState(initial.tenantId ? 'tenant' : null); + const [tenantId, setTenantId] = useState(initial.tenantId); /** null = vsichni, 'unassigned' = fronta, jinak ID resitele. */ - const [assignee, setAssignee] = useState(null); - const [status, setStatus] = useState(null); - const [channel, setChannel] = useState(null); + const [assignee, setAssignee] = useState(initial.assignee); + const [status, setStatus] = useState(initial.status); + const [channel, setChannel] = useState(initial.channel); + /** Filtry, ktere se daji nastavit jen odkazem. V UI k nim tlacitko neni. */ + const [typeId, setTypeId] = useState(initial.typeId); + const [tag, setTag] = useState(initial.tag); + const [groupId, setGroupId] = useState(initial.groupId); const [query, setQuery] = useState(''); /** Tabulka na porovnavani, dlazdice na rychly prehled. */ const [view, setView] = useState<'table' | 'grid'>('table'); + /** Jen moje. Server to resi pohledem `mine`, klient jen prepina. */ + const [mine, setMine] = useState(false); // Filtrovani resi server, aby seznam a prehled nikdy neukazovaly jina cisla. const path = useMemo(() => { @@ -72,6 +88,9 @@ export default function Tickets() { if (scope) params.set('scope', scope); if (tenantId) params.set('tenantId', tenantId); if (assignee) params.set('assignee', assignee); + if (typeId) params.set('typeId', typeId); + if (tag) params.set('tag', tag); + if (groupId) params.set('groupId', groupId); if (status) params.set('status', status); if (channel) params.set('channel', channel); const search = params.toString(); @@ -140,29 +159,34 @@ export default function Tickets() { /> - {/* Pohled je jina osa nez filtry - urcuje, ktera data vubec dorazi. */} + {/* + Dva ovladace, ne tri. Firma se vybira selectem, "moje" prepinacem. + Puvodni trojice pohledu delala totez podruhe: pohled Firma znamenal + tentyz vyber jako select vedle nej a pohled Moje totez co prepinac. + */}
    - {scopeOptions - .filter((option) => allowedScopes.includes(option.value)) - .map((option) => ( - setScope(option.value)} - title={option.hint} - > - {option.label} - - ))} - - {/* Prepinac firmy davá smysl jen tomu, kdo jich ma vic. */} - {tenants.length > 1 && activeScope !== 'all' && ( + {tenants.length > 1 && ( )} -
    -
    - setAssignee(null)}> - Kdokoliv - + {allowedScopes.includes('mine') && ( + { + const next = !mine; + setMine(next); + setScope(next ? 'mine' : activeScope === 'all' ? 'all' : 'tenant'); + if (next) setAssignee(null); + }} + title="Jen tickety, které mám u sebe." + > + Moje tickety + + )} + setAssignee(assignee === 'unassigned' ? null : 'unassigned')} + onClick={() => { + setAssignee(assignee === 'unassigned' ? null : 'unassigned'); + // Fronta a moje se vylucuji, ticket ve fronte nema resitele. + if (assignee !== 'unassigned' && mine) { + setMine(false); + setScope('tenant'); + } + }} + title="Tickety, které nemá nikdo u sebe." > Ve frontě + + {/* + Filtry z odkazu. V UI k nim tlacitko neni, ale musi jit zrusit - + jinak clovek prijde z widgetu a uz se z uzkeho vyberu nedostane. + */} + {typeId && ( + setTypeId(null)} title="Zrušit filtr na typ"> + typ: {typeId === 'none' ? 'bez typu' : typeId} x + + )} + {tag && ( + setTag(null)} title="Zrušit filtr na tag"> + tag: {tag === 'none' ? 'bez tagu' : tag} x + + )} + {groupId && ( + setGroupId(null)} title="Zrušit filtr na skupinu"> + skupina: {groupId === 'none' ? 'bez skupiny' : groupId} x + + )}
    @@ -283,87 +345,10 @@ export default function Tickets() { ))}
    ) : ( -
    - - - - - - - - - - - - - - - - {items.map((ticket) => ( - - - - - - - - - - - - ))} - -
    IDPředmětZákazníkKanálStavPrioritaŘešíAktualizace - Detail -
    {ticket.id} - - {ticket.subject} - - - {ticket.customer.id ? ( - {ticket.customer.company} - ) : ( - nedohledáno - )} - - {ticket.customer.contact} - - - - - - - - - {ticket.assignee ? ( - - {ticket.assignee.name} - - ) : ( - ve frontě - )} - - - {formatRelative(ticket.updatedAt)} - - - - - -
    -
    + /* Tatáz tabulka jako na detailu osoby, vcetne chovani na mobilu. */ +
    + +
    )} diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index 27be978..dcf4454 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -494,14 +494,29 @@ export type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge'; /** Hodnota vlastniho widgetu ze serveru. Podle `kind` se pozna, co s tim. */ export type WidgetValue = - | { kind: 'number'; value: number } - | { kind: 'groups'; rows: Array<{ key: string; label: string; value: number }> } - | { kind: 'series'; points: Array<{ date: string; value: number }> } + /** `href` je odkaz na to, co je za cislem. Sklada ho server, ne klient. */ + | { kind: 'number'; value: number; href?: string } + | { + kind: 'groups'; + rows: Array<{ key: string; label: string; value: number; href?: string }>; + } + | { kind: 'series'; points: Array<{ date: string; value: number }>; href?: string } | { kind: 'tickets'; items: Array<{ id: string; subject: string; status: TicketStatus; assignee: string | null }>; + href?: string; + } + | { + kind: 'workload'; + rows: Array<{ + personId: string; + name: string; + open: number; + overCapacity: boolean; + href: string; + }>; + href?: string; } - | { kind: 'workload'; rows: Array<{ name: string; open: number; overCapacity: boolean }> } | { kind: 'agents'; rows: AgentStatsRow[] } /** * Vysledek z konektoru. `stale` znamena, ze je to z mezipameti serveru -