diff --git a/.dockerignore b/.dockerignore index c5a06a1..f925df8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,6 @@ documentation/ .env .env.* # Lokalni data a klic. Do image nepatri, v containeru vznikne cista slozka. -data/ +# Lomitko na zacatku je nutne: bez nej by se vzorec shodl s KAZDOU +# slozkou 'data', tedy i se src/data - a zdrojove soubory by tise chybely. +/data/ diff --git a/.gitignore b/.gitignore index 33528f6..4417650 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ Thumbs.db .idea/ # Data mimo databazi (JSON snapshot a klic). Nikdy do gitu. -data/ +# Lomitko na zacatku je nutne: bez nej by se vzorec shodl s KAZDOU +# slozkou 'data', tedy i se src/data - a zdrojove soubory by tise chybely. +/data/ diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index e00ea7d..65ea3b5 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -33,17 +33,36 @@ React aplikaci ze slozky `dist/public`. | Skripty konektoru | hotovo | manifest, kontrola parametru, hot reload, iDoklad | | Konektory za firmu | hotovo | pristupove udaje v konektoru, overeni napojeni | | Transformace dat | hotovo | pravidla i sablona JSON, kroky si predavaji struktury | -| Sprava clenstvi z portalu | chybi | memberships jdou zmenit jen v kodu | +| Sprava clenstvi z portalu | hotovo | uzivatele, firmy a role v Nastaveni | +| Role a prava jako data | hotovo | 26 prav v katalogu, vlastni role za firmu | +| Zalozky a limity za firmu | hotovo | navigace chodi ze serveru, ne z kodu klienta | +| Osoby a skupiny resitelu | hotovo | ticket lze prehodit na skupinu, ne jen na cloveka | +| Typy ticketu a vlastni pole | hotovo | typ rozhoduje, ktere akce se na ticketu ukazou | +| Vydefinovane akce na ticketu | hotovo | vazba na typ nebo tag, telo je operace, strom, skript | +| Vlastni widgety | hotovo | zdroj dat z ticketu nebo automatizaci, seskupeni | +| 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 | | Uloziste konektoru | hotovo | Postgres, nebo JSON soubor. Udaje vzdy sifrovane | -| Uloziste pro zbytek | chybi | automatizace, rozlozeni a tickety jsou v pameti | +| Uloziste pro zbytek | hotovo | tickety, automatizace, incidenty, rozlozeni, entity | +| Monetizace a cena za krok | navrh | popis v 16-monetizace.md, neni naprogramovane | | Odesilani e-mailu z formulare | chybi | poptavka se zatim jen loguje | ## Znama omezeni -Data jsou v pameti procesu. Restart containeru vrati tickety, incidenty -i automatizace do vychoziho stavu. Nove vytvorene zaznamy se ztrati. +Data prezijou restart, ale ne redeploy, kdyz neni databaze. Rezim se pozna +v portalu i v `/health/ready` a rozhoduje o nem jedno misto, viz +[14-databaze.md](14-databaze.md): + +| Rezim | Kdy | Nasledek | +| --- | --- | --- | +| `postgres` | je `DATABASE_URL` a migrace prosly | data se neztraci | +| `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). 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 @@ -72,7 +91,30 @@ Prvni cast navrhu uz je hotova: vykonna cast konektoru, tedy skripty s manifestem a kontrolou parametru, viz [11-skripty-konektoru.md](11-skripty-konektoru.md). Runner je pripraveny, chybi nad nim fronta. -Zbytek navrhu je ve dvou souborech, oba jsou navrh k rozhodnuti, ne popis stavu: -[09-navrh-rozsireni.md](09-navrh-rozsireni.md) pro datove modely a prava, -[10-runtime-a-kapacita.md](10-runtime-a-kapacita.md) pro frontu, beh kroku -a rozpocet na 150 klientu. +Datove modely a prava z [09-navrh-rozsireni.md](09-navrh-rozsireni.md) jsou +hotove, popis stavu je v [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md). +Navrh k rozhodnuti zustava [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md) +pro frontu, beh kroku a rozpocet na 150 klientu, a +[16-monetizace.md](16-monetizace.md) pro cenu za krok. + +## Dokumentace + +| Soubor | O cem | +| --- | --- | +| [02-appfactory-proxy.md](02-appfactory-proxy.md) | beh za reverse proxy, ROOT_PATH, health | +| [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md) | kde co je | +| [04-api.md](04-api.md) | endpointy a to, co ze Swaggeru neni videt | +| [05-dashboard-a-builder.md](05-dashboard-a-builder.md) | editor automatizaci | +| [06-tickety.md](06-tickety.md) | model ticketu a log prubehu | +| [07-firmy-a-prava.md](07-firmy-a-prava.md) | firmy, pohledy, kdo co vidi | +| [08-dashboard-widgety.md](08-dashboard-widgety.md) | nastavitelny prehled | +| [09-navrh-rozsireni.md](09-navrh-rozsireni.md) | puvodni navrh rozsireni | +| [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md) | **navrh**: fronta, beh kroku, kapacita | +| [11-skripty-konektoru.md](11-skripty-konektoru.md) | vykonna cast sluzeb | +| [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md) | sluzba, konektor, viditelnost | +| [13-transformace-dat.md](13-transformace-dat.md) | pole na pole a JSON na JSON | +| [14-databaze.md](14-databaze.md) | tri rezimy uloziste, migrace, sifrovani | +| [15-rejstrik-funkci.md](15-rejstrik-funkci.md) | k cemu je jaka funkce a komponenta | +| [16-monetizace.md](16-monetizace.md) | **navrh**: cena za krok a balicky | +| [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md) | prava, typy, akce, widgety, prepnuti uctu | +| [99-zmeny.md](99-zmeny.md) | zaznam zmen, nejnovejsi nahore | diff --git a/documentation/04-api.md b/documentation/04-api.md index f92c3bf..673c5a7 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -36,6 +36,11 @@ Vyzaduji `Authorization: Bearer `: | POST | `/api/dashboard/tickets/:id/assign` | | POST | `/api/dashboard/tickets/:id/status` | | POST | `/api/dashboard/tickets/:id/comment` | +| POST | `/api/dashboard/tickets/:id/type` | +| POST | `/api/dashboard/tickets/:id/tags` | +| POST | `/api/dashboard/tickets/:id/group` | +| GET | `/api/dashboard/tickets/:id/actions` | +| POST | `/api/dashboard/tickets/:id/actions/:actionId` | | GET | `/api/dashboard/incidents` | | GET | `/api/dashboard/storage` | | GET | `/api/dashboard/services` | @@ -58,8 +63,26 @@ Vyzaduji `Authorization: Bearer `: | PUT | `/api/dashboard/automations/:id` | | DELETE | `/api/dashboard/automations/:id` | | POST | `/api/dashboard/automations/:id/webhook/regenerate` | +| POST | `/api/dashboard/widget-data` | +| GET | `/api/dashboard/widget-data/options` | +| GET | `/api/dashboard/settings/catalog` | +| GET | `/api/dashboard/settings/features` | +| GET | `/api/dashboard/settings/people-overview` | +| GET | `/api/dashboard/settings/users-overview` | +| PATCH | `/api/dashboard/settings/users/:id/password` | +| POST | `/api/admin/impersonate` | +| 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 +fabrika (`src/routes/crud.ts`): + +`tenants`, `users`, `roles`, `people`, `groups`, `ticket-types`, `actions`, +`widgets`, `features`. + ## Format chyb Jednotny pro cele API: @@ -165,6 +188,40 @@ toho, co ktera volana sluzba vratila. `POST /api/dashboard/tickets/:id/assign` s telem `{"assigneeId": null}` vrati ticket do fronty. Neznamy resitel vraci 404, ne tiche odpojeni. +## Akce na ticketu + +Popis modelu je v [09-navrh-rozsireni.md](09-navrh-rozsireni.md). + +`GET /api/dashboard/tickets/:id/actions` vraci **jen akce, ktere v teto situaci +opravdu jdou spustit**: sedi typ nebo tag, projdou podminky a volajici na ne ma +pravo. Klient si nefiltruje nic - jinak by se to pocitalo na dvou mistech +a jednou se to rozejde. + +`POST /api/dashboard/tickets/:id/actions/:actionId` vraci **200 i kdyz akce +selhala**. Selhani akce neni chyba API. V odpovedi je `ok`, `summary`, `detail` +s celym chybovym hlasenim a `durationMs`. Cely prubeh se zapise do logu ticketu. + +Vestavene akce (`type`, `tags`, `group`, `assign`, `status`, `comment`) jsou +zvlast: meni ticket sam, ne cizi sluzbu, a kazda ma vlastni pravo. + +## Prava a navigace + +`GET /api/dashboard/access` vraci `permissions` (efektivni prava po slouceni +roli), `nav` (zalozky, ktere ma volajici videt) a `platformAdmin`. Klient podle +toho kresli, ale **nic si nedovozuje** - kdo co smi, rozhoduje server u kazdeho +requestu znovu. + +`GET /api/dashboard/settings/catalog` vraci katalog prav a modulu, aby formular +role nemel seznam prav napsany v kodu klienta. + +## Prepnuti na jiny ucet + +`POST /api/admin/impersonate` vraci novy token s narokem `act` (kdo se za koho +vydava) a `writes`. Bez `writes` middleware **odmitne cokoliv jineho nez GET** +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. diff --git a/documentation/15-rejstrik-funkci.md b/documentation/15-rejstrik-funkci.md new file mode 100644 index 0000000..a4c1eeb --- /dev/null +++ b/documentation/15-rejstrik-funkci.md @@ -0,0 +1,86 @@ +# Rejstřík znovupoužitelných funkcí a komponent + +K čemu je co, aby se za rok nepsalo znovu něco, co už existuje. Když píšeš +druhou funkci, která dělá skoro totéž jako něco odsud, je to skoro vždycky +chyba - buď se má použít ta původní, nebo se má rozšířit. + +Podrobný popis je vždycky v komentáři u samotné funkce. Tady je jen jedna věta +a kdy to použít. + +## Ukládání dat (server) + +Rozhoduje se na **jednom místě**, viz [14-databaze.md](14-databaze.md). +Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět. + +| Co | Kde | K čemu | +| --- | --- | --- | +| `defineStore(kind)` | `src/data/store/index.ts` | Založí úložiště pro nový druh záznamu. Jeden řádek na entitu. | +| `initStores({databaseReady})` | `src/data/store/index.ts` | Vybere režim. Volá se jednou při startu, nikde jinde. | +| `flushStores()` | `src/data/store/index.ts` | Dopíše rozepsané zápisy. Jen při ukončení procesu. | +| `withCache(store)` | `src/data/store/cached.ts` | Kopie v paměti pro **konfigurační** entity, které se čtou při každém requestu (uživatelé, role, firmy). Čte se synchronně, obnovuje se po zápisu. | +| `withMirror(store)` | `src/data/store/mirror.ts` | Opačný směr než `withCache`: data se mění v paměti a po každé změně se celý záznam zapíše. Pro **provozní** data (tickety, automatizace, incidenty, rozložení). | +| `isVisible(entity, options)` | `src/data/store/types.ts` | Vidí volající tenhle záznam? Prázdný seznam firem znamená "nic", ne "vše". | +| `nowIso()` | `src/data/store/types.ts` | Časová značka. Ať se nepíše `new Date().toISOString()` na třiceti místech. | +| `memorySnapshot` / `fileSnapshot` | `src/data/snapshot.ts` | Nižší vrstva pod `createLocalStore`: atomický zápis JSONu s debounce. Přímo se nepoužívá. | +| `db()`, `query`, `queryOne`, `transaction` | `src/db/pool.ts` | Postgres. `dbFor(tenantId)` je připravený šev pro rozdělení na víc databází. | +| `seal`, `open`, `sealAll`, `openAll` | `src/db/secretBox.ts` | Šifrování přístupových údajů konektorů (AES-256-GCM). Nic tajného se neukládá jinak. | +| `runMigrations()` | `src/db/migrate.ts` | Migrace pod zámkem, jeden soubor = jedna transakce. | + +## Entity a práva (server) + +| Co | Kde | K čemu | +| --- | --- | --- | +| `crudRouter(options)` | `src/routes/crud.ts` | Celý CRUD nad jednou entitou: seznam, detail, vytvoření, úprava, mazání, právo, audit. Nová entita v nastavení = jeden `crudRouter`, ne pět handlerů. | +| `readScope(req)` | `src/routes/crud.ts` | Ze které firmy smí request číst. Povinný argument všech `list` volání. | +| `accessFor(user, tenantId?)` | `src/data/access.ts` | Co uživatel smí: práva, záložky, výchozí firma. Klient si nic nedovozuje sám. | +| `permissionsOf(user, tenantId)` | `src/data/permissions.ts` | Efektivní práva z rolí. Pětisekundová cache, `invalidatePermissions()` po zápisu. | +| `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. | +| `refreshCaches()` | `src/data/bootstrap.ts` | Obnoví všechny kopie v paměti. Volá se po zápisu, který je může změnit. | +| `bootstrapData({databaseReady})` | `src/data/bootstrap.ts` | Seznam všech entit a provozních dat. **Nová entita se přidává tady**, ne rozesetě po modulech. | + +## Skripty a konektory (server) + +Viz [11-skripty-konektoru.md](11-skripty-konektoru.md). + +| Co | Kde | K čemu | +| --- | --- | --- | +| `runScript(id, inputs, ctx)` | `src/scripts/runner.ts` | Spustí skript. **Nikdy nevyhodí výjimku**, chybu vrací jako výsledek s celým hlášením. | +| `validateValues(...)` | `src/scripts/values.ts` | Jedna kontrola pro vstupy i výstupy skriptu podle manifestu. | +| `scriptUtil` | `src/scripts/util.ts` | Nádobíčko pro skripty: `pick`, `first`, `num`, `date`, `need`, `get`, `applyRules`, `fillJson`. Skript nemá sahat na nic jiného. | +| `createRedactor(...)` | `src/scripts/util.ts` | Vyškrtá tajemství z textu **před** logováním. Používá se u všeho, co jde do logu. | +| `applyRules`, `fillJson` | `src/scripts/mapping.ts` | Transformace dat: pole na pole s převody, nebo objekt na objekt. Viz [13-transformace-dat.md](13-transformace-dat.md). | +| `getPath(obj, path)` | `src/scripts/mapping.ts` | Čtení `zakaznik.adresa.mesto` z neznámého objektu. | +| `resolveTarget(...)` | `src/scripts/connections.ts` | Z konektoru poskládá adresu a hlavičky. Přístupové údaje nikam jinam nevedou. | +| `createHttp(...)` | `src/scripts/http.ts` | HTTP se timeoutem, limitem odpovědi a rozlišením "zkusit znovu" a "marné". | +| `scriptIdFor(serviceId, operationId)` | `src/scripts/lookup.ts` | Který skript obsluhuje operaci z katalogu. | + +## Klient + +| Co | Kde | K čemu | +| --- | --- | --- | +| `EntityAdmin` | `components/dashboard/EntityAdmin.tsx` | Celá správa jedné entity: tabulka, modál, validace, mazání. Nová záložka nastavení = popis sloupců a polí, ne nová stránka. | +| `parseJsonField` | `components/dashboard/EntityAdmin.tsx` | Textové pole s JSONem na hodnotu, s hlášením, kde je chyba. | +| `TicketActions` | `components/dashboard/TicketActions.tsx` | CTA akcí na ticketu plus typ, tagy a vlastní pole. Seznam akcí chodí ze serveru už vyfiltrovaný. | +| `ErrorDetail` | `components/dashboard/ErrorDetail.tsx` | Rozbalovací celé chybové hlášení s kopírováním. Chyba se nikdy nezkracuje. | +| `MappingEditor` | `components/dashboard/flow/MappingEditor.tsx` | Editor transformací v obou režimech (pole na pole, JSON). | +| `DataState` | `components/dashboard/DataState.tsx` | Načítání, chyba, prázdno. Ať to každá stránka nekreslí po svém. | +| `apiFetch` | `lib/api.ts` | Jediná cesta na API: base path, token, `ApiError` s celým hlášením ze serveru. | +| `useApiQuery` | `lib/useApiQuery.ts` | Načtení dat do stránky včetně `reload`. | +| `cn(...)` | `lib/cn.ts` | Skládání tříd. Podmíněné třídy nikdy ručně přes šablonu. | +| `format*` | `lib/format.ts` | Čísla, procenta, datum, relativní čas, trvání. Formátování se nepíše v komponentě. | +| `serviceIcon(key)` | `lib/serviceIcons.ts` | Klíč ikony ze serveru na komponentu. Server neposílá komponenty. | +| `usePageMeta` | `lib/usePageMeta.ts` | Titulek stránky. | +| `Badge`, `Button`, `Modal`, `Card`, ... | `components/ui/` | Základní prvky. Nový vzhled tlačítka patří sem, ne do stránky. | + +## Pravidla, která z toho plynou + +1. **Nová entita v nastavení**: `defineStore` v modulu entity, řádek v + `bootstrap.ts`, `crudRouter` v `settings.ts`, popis v `Settings.tsx`. + Nic jiného se psát nemusí. +2. **Data, která se mění za provozu**, jdou přes `withMirror`. Data, která se + čtou při každém requestu a mění zřídka, přes `withCache`. Obojí nikdy. +3. **Chybu se nesmí zkracovat.** Server vrací celé hlášení, klient ho umí + zobrazit (`ErrorDetail`). +4. **Klient nepočítá práva.** Co smí, říká `accessFor`. diff --git a/documentation/16-monetizace.md b/documentation/16-monetizace.md new file mode 100644 index 0000000..f248fa2 --- /dev/null +++ b/documentation/16-monetizace.md @@ -0,0 +1,95 @@ +# Návrh monetizace a cena za krok + +Stav: **návrh**, není naprogramované. Zbytek dokumentace popisuje hotové věci, +tenhle soubor ne - viz [99-zmeny.md](99-zmeny.md). + +Zadání bylo: chci si napsat vlastní automatizaci a hned vidět, kolik by mě +měsíčně stála, když si k jednotlivým krokům zadám ceny (například iDoklad, +odeslání objednávky, 0,10 Kč). + +## Co se dá účtovat + +Čtyři věci, každá měří něco jiného: + +| Základ | Co to znamená | Proč / proč ne | +| --- | --- | --- | +| Za uživatele | Kolik lidí má přístup do portálu | Předvídatelné, ale nesouvisí s tím, co aplikace dělá. U automatizací platí zákazník za lidi, kteří tam nemusí chodit. | +| Za automatizaci | Kolik má zapnutých stromů | Trestá to rozdělení jednoho velkého stromu na tři přehledné. Špatná motivace. | +| **Za krok** | Kolik kroků se skutečně vykonalo | Odpovídá naší práci: každý krok je jedno volání služby, jeden zápis, jeden běh skriptu. Zákazník vidí, za co platí. | +| Za objem dat | Kolik toho proteče | Nesouvisí s náklady, u nás jsou to kilobajty. | + +Doporučení: **paušál plus kroky**. Paušál kryje portál, tickety, úložiště +a podporu, kroky kryjí provoz automatizací. Bez paušálu je zákazník, který má +automatizace vypnuté, zdarma, a přesto mu běží ticketovací systém. + +## Ceník kroků + +Cena patří **k operaci v katalogu**, ne ke službě. Zápis faktury do iDokladu +a jeho dotaz na kontakt stojí jinak, protože nás jinak stojí. + +Tři pásma: + +| Pásmo | Příklady | Návrh ceny | +| --- | --- | --- | +| Obecné | pauza, zápis do logu, podmínka, transformace dat | 0 Kč. Účtovat podmínku je jako účtovat mezeru v textu. | +| Naše práce | webhook, plánovač, založení ticketu, přiřazení řešitele, HTTP požadavek | 0,02 Kč | +| Cizí služba | iDoklad, Shoptet, SAP, CRM, hlasová brána, AI | 0,05 až 0,50 Kč podle toho, co za to platíme sami | + +Číslo je jedno pole u operace, takže se dá měnit bez zásahu do kódu. Ceník +je verzovaný: změna ceny nepřepíše historii, jinak by se zpětně změnila +i vyúčtovaná částka. + +## Odhad ceny v editoru + +Co k tomu je potřeba: + +1. **Cena u operace.** Do katalogu (`src/data/services.ts`) přidat + `priceCzk` k `ServiceOperation`. Chybějící cena znamená 0, ne chybu - + nová operace nesmí rozbít odhad. +2. **Očekávaný počet běhů.** Jedno číslo u automatizace, které zadá uživatel + ("čekám 400 objednávek denně"). Bez něj nejde nic spočítat a hádat to za + uživatele by dalo číslo, kterému nebude věřit. +3. **Součet přes strom.** Podmínka má dvě větve a projde se vždycky jen jedna. + Pro odhad se bere **dražší** větev, aby výsledek byl horní hranice, ne + příjemné číslo, které se pak překročí. Vedle se ukáže i levnější varianta. +4. **Zobrazení.** V editoru u každého kroku jeho cena, nahoře součet za běh + a za měsíc. Jedna funkce, obě čísla, žádný druhý výpočet na klientovi. + +Odhad je záměrně jen odhad: skutečná fakturace se počítá ze **skutečně +vykonaných kroků** zapsaných při běhu, ne z toho, co editor předpověděl. + +## Skutečné vyúčtování + +Každý vykonaný krok už teď zapisuje řádek do logu ticketu nebo běhu +automatizace. Na fakturaci z toho chybí: + +- u řádku evidovat firmu, operaci a cenu **platnou v tu chvíli**, +- denní součet za firmu (aby se faktura nepočítala přes miliony řádků), +- měsíční uzávěrka, která součty zamkne. + +Model je stejný jako u auditu: připisovací tabulka, nic se nepřepisuje. +Uzavřený měsíc se nedá změnit, jen opravit dobropisem. + +## Balíčky + +Cena za krok samotná zákazníka děsí, protože nezná svoje čísla. Proto balíčky +s předplacenými kroky a stejnou cenou nad limit: + +| Balíček | Paušál | Kroků v ceně | Nad limit | +| --- | --- | --- | --- | +| Start | 490 Kč | 5 000 | 0,05 Kč | +| Provoz | 1 900 Kč | 40 000 | 0,04 Kč | +| Firma | 6 900 Kč | 200 000 | 0,03 Kč | + +Nad limit se **nevypíná**. Zastavit zákazníkovi fakturaci objednávek kvůli +překročení limitu je horší než mu to dofakturovat. Limit hlásí varování +v portálu a e-mailem. + +## Co je potřeba rozhodnout + +1. Účtují se kroky, které skončily chybou? Návrh: **ne u naší chyby, ano + u chyby cizí služby**, protože volání jsme opravdu zaplatili. Musí to být + v ceníku vidět, jinak to vypadá jako počítání chyb ve svůj prospěch. +2. Účtuje se opakování po chybě? Návrh: první opakování zdarma, další ano. +3. Účtují se kroky v testovacím režimu? Návrh: ne, ale s denním limitem, aby + se testem ceník neobcházel. diff --git a/documentation/17-nastaveni-a-prava.md b/documentation/17-nastaveni-a-prava.md new file mode 100644 index 0000000..509a525 --- /dev/null +++ b/documentation/17-nastaveni-a-prava.md @@ -0,0 +1,128 @@ +# Nastavení, práva, typy ticketů, akce a widgety + +Co všechno se dá nastavit z portálu a proč je to postavené takhle. Model je +z [09-navrh-rozsireni.md](09-navrh-rozsireni.md), tady je hotový stav. +Seznam funkcí a komponent, které se u toho mají použít, je +v [15-rejstrik-funkci.md](15-rejstrik-funkci.md). + +## Jedna vrstva pro všechny záznamy + +Firmy, uživatelé, role, řešitelé, skupiny, typy ticketů, akce a widgety mají +společné to, že se u nich dělá totéž: seznam za firmu, detail, zápis, mazání, +kontrola práva, zápis do auditu. Napsat to osmkrát znamená osm skoro stejných +souborů, ze kterých se jeden opraví a ostatní ne. + +Proto tři vrstvy, každá napsaná jednou: + +| Vrstva | Kde | Co dělá | +| --- | --- | --- | +| Úložiště | `src/data/store/` | `EntityStore` a dvě implementace. Volající nepozná, jestli běží Postgres nebo JSON soubor. | +| API | `src/routes/crud.ts` | `crudRouter` vyrobí pětici endpointů včetně práva a auditu. | +| Klient | `components/dashboard/EntityAdmin.tsx` | Tabulka, modál, validace, mazání. | + +Nová entita v nastavení pak znamená: `defineStore` v modulu entity, jeden řádek +v `bootstrap.ts`, jeden `crudRouter` v `settings.ts`, jeden popis v +`Settings.tsx`. Nic víc. + +## Práva jsou data + +Práv je 26 a jsou v katalogu (`src/data/permissions.ts`). Role je **záznam**, +ne konstanta v kódu: firma si může udělat vlastní roli s vlastní kombinací +práv. Systémové role (`admin`, `agent`, `viewer`) se měnit nedají, aby si nikdo +neodebral právo, kterým je odebírá. + +Členství uživatele ve firmě nese `roleIds`, protože jeden člověk může být +v Automii řešitel a u Nordisu správce jejich servicedesku. + +Neznámé právo se **odmítne** už při ukládání role. Kdyby se jen ignorovalo, +překlep by znamenal roli, která tiše nic nesmí. + +## Navigace chodí ze serveru + +Co uživatel vidí za záložky, je průnik dvou věcí: + +1. co má firma zaplacené a zapnuté (`tenantFeatures`), +2. na co má člověk právo. + +Počítá to server (`navFor`) a klient jen kreslí. Kdyby si klient záložky +dovozoval sám, počítalo by se to na dvou místech a jednou by se to rozešlo - +a ta chyba by znamenala odkaz do 403. + +## Typy ticketů a tagy + +Typ ticketu nese **vlastní pole** (klíč, popisek, typ, povinnost, nápověda) +a může mít vlastní workflow stavů. Tag je volné označení bez polí za ním. + +Rozdíl není kosmetický: + +- **Typ** znamená "za tímhle stojí data". Objednávka má číslo objednávky + a částku, takže se na ni dá navázat akce, která ta data potřebuje. +- **Tag** znamená "takhle si to označuju". Hodí se na filtry a widgety. + +Akce se proto váže na **typ nebo tag**, ale akci s daty má smysl vázat na typ. + +Pole `object` a `list` se v detailu ticketu ručně nevyplňují - plní je +automatizace. Textarea s JSONem by tam byla past, ne pohodlí. + +## Vydefinované akce + +Akce a automatizační stromy jsou **dvě různé věci** a nemají mezi sebou žádnou +společnou mezivrstvu. Automatizace běží sama, akce je tlačítko, které zmáčkne +člověk. + +Tělo akce je jedno z trojice: + +| Tělo | Kdy | Příklad | +| --- | --- | --- | +| Operace konektoru | běžný případ | odeslat objednávku do iDokladu | +| Vlastní strom | akce má víc kroků a rozhodování | dohledat kontakt, vystavit fakturu, odeslat e-mailem | +| Skript | nic z toho nestačí | vlastní výpočet nebo cizí API, které v katalogu není | + +Server vrací k ticketu **jen akce, které v té situaci opravdu jdou spustit**: +sedí typ nebo tag, projdou podmínky a volající na ně má právo. Klient +nefiltruje nic. + +Spuštění akce vrací 200 i když akce selhala - selhání akce není chyba API. +V odpovědi je celé chybové hlášení a celý průběh se zapíše do logu ticketu. + +Vestavěné akce (typ, tagy, skupina, přiřazení, stav, komentář) jsou zvlášť: +mění ticket sám, ne cizí službu, a každá má vlastní právo. + +## Vlastní widgety + +Widget je definice zdroje dat plus způsob zobrazení. Zdroj je ticket nebo +automatizace, k tomu filtr a případné seskupení (podle stavu, kanálu, řešitele, +typu, tagu). + +Data pro celý přehled chodí **jedním requestem**. Deset dlaždic nesmí znamenat +deset dotazů. + +Viditelnost je stejná jako u služeb: všichni, konkrétní firmy, konkrétní lidé, +nebo jen správce. + +## Přepnutí na jiný účet + +Správce platformy se může podívat na portál očima konkrétního uživatele. +Dvě věci, na kterých to stojí: + +1. **Výchozí je jen pro čtení.** Podívat se, co klient vidí, je mnohem + častější než za něj něco měnit, a nechtěný zápis pod cizím jménem je to + nejhorší, co se tady může stát. Zápis se musí zapnout vědomě a middleware + ho jinak odmítne u všeho kromě GET. +2. **Všechno je v auditu** včetně toho, kdo to doopravdy byl. Bez auditu nemá + přepínání účtů co dělat v produktu. + +Svůj původní token si klient odkládá do `sessionStorage`. Server o něm nic neví, +takže ho nemůže ani omylem prodloužit. + +## Co se ukládá a co se drží v paměti + +| Data | Jak | Proč | +| --- | --- | --- | +| Firmy, uživatelé, role, řešitelé, skupiny, typy, akce, widgety, záložky | `withCache` | Čtou se při každém requestu, mění se zřídka. Kopie v paměti, obnova po zápisu. | +| Tickety včetně logu, automatizace, incidenty, rozložení dashboardu | `withMirror` | Mění se v paměti za provozu, po každé změně se celý záznam zapíše. | +| Audit | přímo do úložiště | Jen se připisuje, nikdy nečte při každém requestu. | +| Konektory | vlastní úložiště | Nesou šifrovaná tajemství a potřebují částečný unikátní index. Viz [14-databaze.md](14-databaze.md). | + +Čítače ID se při startu dopočítají z uložených záznamů, takže nový ticket +nikdy nepřepíše starý. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index b7e916a..336d9c7 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,60 @@ Nejnovejsi nahore. +## 2026-08-13 - firmy, prava, typy ticketu, akce, widgety a uloziste pro vsechno + +Dodelany cely [navrh rozsireni](09-navrh-rozsireni.md) a vsechna data se +ukladaji. Rejstrik novych funkci a komponent je v +[15-rejstrik-funkci.md](15-rejstrik-funkci.md). + +### Pridano - obecne vrstvy + +- `src/data/store/`: jedno rozhrani `EntityStore` a dve implementace, soubor + nebo pamet (`local.ts`) a Postgres nad tabulkou `records` (`postgres.ts`). + Volajici nepozna, ktera bezi. Nova entita znamena jeden `defineStore` a jeden + radek v `bootstrap.ts`. +- `store/cached.ts` (`withCache`) pro konfiguracni entity ctene pri kazdem + requestu a `store/mirror.ts` (`withMirror`) pro provozni data, ktera se meni + v pameti a po zmene se cela zapisuji. Dva pomocniky, ne osm skoro stejnych + souboru. +- `put` v rozhrani uloziste: zapis celeho zaznamu bez skladani patche. +- `src/routes/crud.ts`: fabrika CRUD rout. Seznam, detail, zapis, mazani, pravo + a audit na jednom miste. +- `components/dashboard/EntityAdmin.tsx`: obecna sprava zaznamu na klientovi. + Nova zalozka nastaveni je popis sloupcu a poli, ne nova stranka. + +### Pridano - funkce + +- Firmy, uzivatele, clenstvi, osoby a skupiny resitelu se spravuji z portalu. +- Role a prava jsou **data**, ne pevny seznam v kodu: katalog 26 prav, vlastni + role za firmu, systemove role nejde menit. Navigace chodi ze serveru jako + prunik toho, co firma ma, a toho, na co ma clovek pravo. +- Typy ticketu s vlastnimi poli, tagy a vlastni workflow stavu. +- Vydefinovane akce na ticketu: vazba na **typ nebo tag**, telo je jedna + operace, vlastni strom, nebo skript. Server posila jen akce, ktere v dane + situaci projdou podminkami a pravy - klient si nepocita, co ukazat. +- Vlastni widgety dashboardu vcetne dat na jeden request a seskupeni. +- Audit a prepnuti spravce na jiny ucet. Prepnuti je **vychozi jen pro cteni**, + zapis se musi zapnout vedome a je videt v auditu. +- Detail ticketu: CTA akci, typ, tagy, vlastni pole a prehozeni na skupinu. + +### Pridano - uloziste pro provozni data + +Tickety vcetne logu, automatizace, incidenty a rozlozeni dashboardu se po kazde +zmene zapisuji. Citace ID se pri startu dopocitaji z ulozenych zaznamu, takze +novy ticket nikdy neprepise stary. + +### Overeno + +V rezimu `file`: zmena typu, tagu, vlastnich poli, stavu, komentare, nova +automatizace, novy incident a upravene rozlozeni dashboardu prezily **tvrde +ukonceni procesu** a po restartu byly zpatky vcetne logu ticketu. Novy ticket +dostal dalsi cislo, ne cislo existujiciho. Agent bez prav dostane 403 na spravu +roli a uzsi navigaci. `passwordHash` se nikdy nevraci. + +Databaze se v tomhle kole neoverovala, nebylo na cem - kod pro ni je stejny +a psany soucasne, ale nebezel. + ## 2026-08-12 - soubor jako uloziste bez databaze Mockup se k databazi nedostane, takze pribyl treti rezim: JSON soubor. diff --git a/src/data/audit.ts b/src/data/audit.ts new file mode 100644 index 0000000..fd7bf6e --- /dev/null +++ b/src/data/audit.ts @@ -0,0 +1,114 @@ +/** + * Audit: kdo co udelal. + * + * Zapisuji se i **odepreni**. Bez nich by opakovane pokusy o cizi firmu nikde + * nezustaly a poznalo by se to jen tak, ze si toho nekdo vsimne v konzoli. + * + * Bez auditu nema smysl impersonace: kdyz se spravce platformy podiva za + * uzivatele a neco zmeni, musi zustat zaznam, ze to byl on. + * + * Zapis je **pripisovaci**. Nic se neaktualizuje ani nemaze, jen se po case + * odmazavaji nejstarsi zaznamy, aby to nerostlo bez konce. + */ + +import { randomUUID } from 'node:crypto'; +import { defineStore, nowIso, type ListOptions, type TenantEntity } from './store/index.js'; + +export interface AuditEntry extends TenantEntity { + /** Kdo to udelal. */ + userId: string; + userEmail: string; + /** Vyplnene pri impersonaci: kdo se za nej vydaval. */ + actedBy: string | null; + /** Co se stalo, napr. `connector.update`, `impersonate.start`. */ + action: string; + /** Nad cim, napr. ID konektoru. */ + target: string | null; + detail: Record; + result: 'ok' | 'denied'; +} + +export const auditStore = defineStore('audit'); + +/** Kolik zaznamu se drzi. Starsi se pri zapisu odmazou. */ +const MAX_ENTRIES = 5_000; + +export interface AuditInput { + userId: string; + userEmail: string; + actedBy?: string | null; + tenantId: string | null; + action: string; + target?: string | null; + detail?: Record; + result?: 'ok' | 'denied'; +} + +/** + * Zapise zaznam. + * + * Zamerne **nevraci chybu a necekana se**: kdyby audit shodil request, znamenalo + * by to, ze rozbity audit rozbije celou aplikaci. Chyba se loguje. + */ +export function recordAudit(input: AuditInput): void { + const timestamp = nowIso(); + const entry: AuditEntry = { + id: `aud_${randomUUID().slice(0, 12)}`, + tenantId: input.tenantId, + userId: input.userId, + userEmail: input.userEmail, + actedBy: input.actedBy ?? null, + action: input.action, + target: input.target ?? null, + detail: input.detail ?? {}, + result: input.result ?? 'ok', + createdAt: timestamp, + updatedAt: timestamp, + }; + + void auditStore + .create(entry) + .then(() => trim()) + .catch((err: unknown) => { + console.error('[audit] zaznam se nepodarilo ulozit:', err); + }); +} + +let trimming = false; + +/** Odmaze nejstarsi zaznamy nad limit. Bezi nejvyse jednou naraz. */ +async function trim(): Promise { + if (trimming) return; + trimming = true; + try { + const all = await auditStore.listAll(); + if (all.length <= MAX_ENTRIES) return; + + const sorted = all.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + const excess = sorted.slice(0, all.length - MAX_ENTRIES); + for (const entry of excess) { + await auditStore.remove(entry.id, { tenantIds: [], includeGlobal: true }); + } + console.info(`[audit] odmazano ${excess.length} nejstarsich zaznamu`); + } catch (err) { + console.error('[audit] uklid selhal:', err); + } finally { + trimming = false; + } +} + +export interface AuditFilter extends ListOptions { + action?: string; + result?: 'ok' | 'denied'; + limit?: number; +} + +/** Nejnovejsi nahore. Audit se cte odzadu. */ +export async function listAudit(filter: AuditFilter): Promise { + const all = await auditStore.list(filter); + return all + .filter((entry) => !filter.action || entry.action.startsWith(filter.action)) + .filter((entry) => !filter.result || entry.result === filter.result) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, filter.limit ?? 200); +} diff --git a/src/data/automationStore.ts b/src/data/automationStore.ts index 78b688f..698e686 100644 --- a/src/data/automationStore.ts +++ b/src/data/automationStore.ts @@ -8,6 +8,8 @@ import { randomBytes } from 'node:crypto'; import { publish } from '../events/bus.js'; +import { defineStore } from './store/index.js'; +import { withMirror } from './store/mirror.js'; import { isUnary, type ConditionOperator, type FieldType } from './conditions.js'; import { actionInputsFor, findService } from './services.js'; import { collectScopes, duplicateNames, scopeFor } from './flowScope.js'; @@ -292,6 +294,39 @@ function deriveKind(flow: AutomationFlow): AutomationKind { const store = new Map(); let idCounter = 0; +/** + * Uloziste. Automatizace se drzi v pameti kvuli synchronnimu cteni (webhook + * hleda podle tokenu pri kazdem requestu) a po kazde zmene se cela zapise. + * Kam - databaze, soubor, nebo nikam - rozhoduje `data/store/index.ts`. + */ +const mirror = withMirror(defineStore('automation')); + +/** Zapise do pameti i do uloziste. Kazda zmena jde skrz tohle. */ +function save(automation: StoredAutomation): void { + store.set(automation.id, automation); + mirror.save(automation); +} + +/** + * Nacte automatizace z uloziste. Vola se pri startu, viz data/bootstrap.ts. + * + * Kdyz uloziste nic nema, ulozi se ukazkova sada, ktera je v tuhle chvili + * v pameti. + */ +export async function initAutomations(): Promise { + const rows = await mirror.load(() => [...store.values()]); + + store.clear(); + for (const row of rows) store.set(row.id, row); + + // Citac musi pokracovat za nejvyssim ulozenym cislem, jinak by nova + // automatizace prepsala starou. + for (const id of store.keys()) { + const number = Number(/^AUT-(\d+)$/.exec(id)?.[1] ?? 0); + if (number > idCounter) idCounter = number; + } +} + function nextId(): string { idCounter += 1; return `AUT-${String(idCounter).padStart(2, '0')}`; @@ -813,7 +848,7 @@ export function createAutomation(name: string, tenantId: string): AutomationDeta createdAt: now, updatedAt: now, }; - store.set(id, stored); + save(stored); console.info(`[automations] vytvorena automatizace ${id} "${name}"`); publish('automation.created', `Vytvořena automatizace ${id}: ${name}`, { automationId: id }); return toDetail(stored); @@ -859,7 +894,7 @@ export function updateAutomation( updated.enabled = false; } - store.set(id, updated); + save(updated); console.info( `[automations] ulozena automatizace ${id} (kroku: ${countSteps(updated.flow.steps)}, aktivni: ${updated.enabled}, nedodelku: ${issues.length})`, ); @@ -926,7 +961,7 @@ export function regenerateWebhookToken( }, updatedAt: new Date().toISOString(), }; - store.set(id, updated); + save(updated); console.info(`[automations] ${id}: token webhooku pregenerovan, stara adresa neplati`); return toDetail(updated); } @@ -959,7 +994,7 @@ export function recordRun(id: string, ok = true, tenantIds?: string[]): Automati successRate: Math.round(successRate * 10) / 10, lastRunAt: new Date().toISOString(), }; - store.set(id, updated); + save(updated); publish( 'automation.run', @@ -975,6 +1010,7 @@ export function deleteAutomation(id: string, tenantIds: string[]): boolean { const stored = findWritable(id, tenantIds); const name = stored?.name; const existed = stored !== undefined && store.delete(id); + if (existed) mirror.drop(id); if (!existed) { console.warn(`[automations] pokus o smazani nedostupne automatizace: ${id}`); } else { diff --git a/src/data/bootstrap.ts b/src/data/bootstrap.ts new file mode 100644 index 0000000..e051fce --- /dev/null +++ b/src/data/bootstrap.ts @@ -0,0 +1,132 @@ +/** + * Nacteni vsech ulozist pri startu. + * + * Jedno misto, kde se rekne "tohle jsou entity a tohle jejich vychozi sada". + * Kdyby to bylo rozeseto po modulech, jeden by se pri pridani zapomnel a chybel + * by az v provozu. + * + * Poradi zalezi: role a firmy musi byt driv nez prava, prava driv nez cokoliv, + * co se na ne pta. + */ + +import { refreshActions, actionPermissions, actionStore, seedActions } from './ticketActions.js'; +import { refreshCustomWidgets, customWidgetStore, seedCustomWidgets } from './customWidgets.js'; +import { auditStore } from './audit.js'; +import { + groupStore, + personStore, + refreshGroups, + refreshPeople, + seedGroups, + seedPeople, +} from './people.js'; +import { + refreshRoles, + roleStore, + setDynamicPermissions, + systemRoles, + invalidatePermissions, +} from './permissions.js'; +import { initStores, type EntityStore, type TenantEntity } from './store/index.js'; +import { featuresStore, refreshFeatures, seedFeatures } from './tenantFeatures.js'; +import { refreshTenants, seedTenants, tenantStore } from './tenants.js'; +import { refreshTicketTypes, seedTicketTypes, ticketTypeStore } from './ticketTypes.js'; +import { refreshUsers, seedUsers, userStore } from './users.js'; +import { initTickets } from './ticketStore.js'; +import { initAutomations } from './automationStore.js'; +import { initLayouts } from './dashboardLayouts.js'; +import { initIncidents } from './incidentStore.js'; + +/** + * Seznam entit. Pridani nove znamena jeden radek tady, ne hledani po kodu. + * `seed` se pouzije jen kdyz uloziste pro dany druh nic nema. + */ +const entities: Array<{ + store: EntityStore; + seed?: () => TenantEntity[]; +}> = [ + { store: tenantStore as EntityStore, seed: seedTenants }, + { store: userStore as EntityStore, seed: seedUsers }, + { store: roleStore as EntityStore, seed: systemRoles }, + { store: personStore as EntityStore, seed: seedPeople }, + { store: groupStore as EntityStore, seed: seedGroups }, + { store: featuresStore as EntityStore, seed: seedFeatures }, + { store: ticketTypeStore as EntityStore, seed: seedTicketTypes }, + { store: actionStore as EntityStore, seed: seedActions }, + { store: customWidgetStore as EntityStore, seed: seedCustomWidgets }, + // Audit vychozi sadu nema, zaznamy vznikaji az provozem. + { store: auditStore as EntityStore }, +]; + +/** + * Provozni data, ktera se v pameti meni na miste a ukladaji cela (`withMirror`). + * + * Jsou zvlast od seznamu vys, protoze si init resi sami: nactou uloziste, + * srovnaji podle nej kopii v pameti a dopocitaji citace ID. + */ +const runtimeData: Array<{ name: string; init: () => Promise }> = [ + { name: 'tickety', init: initTickets }, + { name: 'automatizace', init: initAutomations }, + { name: 'incidenty', init: initIncidents }, + { name: 'rozlozeni dashboardu', init: initLayouts }, +]; + +/** + * Obnovi kopie v pameti u entit, ktere se ctou pri kazdem requestu. + * Vola se pri startu a po kazdem zapisu, ktery je muze zmenit. + */ +export async function refreshCaches(): Promise { + await Promise.all([ + refreshTenants(), + refreshUsers(), + refreshRoles(), + refreshPeople(), + refreshGroups(), + refreshFeatures(), + refreshTicketTypes(), + refreshActions(), + refreshCustomWidgets(), + ]); + + // Prava k akcim vznikaji z definic akci, takze se registruji az po jejich nacteni. + setDynamicPermissions(actionPermissions()); + invalidatePermissions(); +} + +export async function bootstrapData(options: { databaseReady: boolean }): Promise { + const mode = initStores(options); + + for (const entity of entities) { + try { + await entity.store.init(entity.seed); + } catch (err) { + // Jedna rozbita entita nesmi shodit start. Portal pak ukaze prazdny seznam, + // coz je lepsi nez nefunkcni sluzba pro AppFactory. + console.error( + `[data] uloziste ${entity.store.kind} se nepodarilo nacist:`, + err instanceof Error ? err.message : err, + ); + } + } + + await refreshCaches(); + console.info(`[data] nactena uloziste (${mode}): ${entities.map((e) => e.store.kind).join(', ')}`); + + /* + * Tickety az po resitelich a typech: `toTicket` dohledava resitele podle ID + * a bez nich by u kazdeho ticketu hlasil, ze resitel neexistuje. + */ + for (const item of runtimeData) { + try { + await item.init(); + } catch (err) { + console.error( + `[data] ${item.name} se nepodarilo nacist:`, + err instanceof Error ? err.message : err, + ); + } + } + console.info(`[data] nactena provozni data: ${runtimeData.map((i) => i.name).join(', ')}`); + + return mode; +} diff --git a/src/data/connectors/local.ts b/src/data/connectors/local.ts new file mode 100644 index 0000000..5021ec3 --- /dev/null +++ b/src/data/connectors/local.ts @@ -0,0 +1,244 @@ +/** + * Konektory mimo databazi. + * + * Jedna implementace pro dva rezimy. Lisi se **jen tim, kam se to uklada**: + * + * - `memorySnapshot` nikam, tedy cista pamet procesu, + * - `fileSnapshot` do JSON souboru, coz prezije restart procesu i containeru. + * + * Kdyby to byly dve implementace, jedna by se casem opravila a druha ne. + * Persistence je proto parametr, ne kopie kodu. + * + * Tajne hodnoty se do souboru **zapisuji zasifrovane**. Plaintext v souboru je + * stejny problem jako plaintext v tabulce: kdo se dostane k disku nebo k zaloze, + * ma klientske klice k iDokladu. + */ + +import { randomUUID } from 'node:crypto'; +import { openAll, sealAll } from '../../db/secretBox.js'; +import { findService } from '../services.js'; +import type { SnapshotStore } from '../snapshot.js'; +import { + nowIso, + type Connector, + type ConnectorRepository, + type CreateConnectorInput, + type UpdateConnectorInput, +} from './types.js'; + +/** + * Tvar zaznamu v souboru. Proti `Connector` se lisi jen tim, ze `secrets` + * je zasifrovana obalka misto ctecich hodnot. + */ +interface StoredConnector extends Omit { + secrets: Record; +} + +export interface LocalConnectorsOptions { + store: SnapshotStore; + /** false = tajne hodnoty se do souboru nezapisuji, protoze neni cim sifrovat. */ + canEncrypt: boolean; +} + +export function createLocalConnectors(options: LocalConnectorsOptions): ConnectorRepository & { + init(): Promise; + flush(): Promise; +} { + const { store, canEncrypt } = options; + const rows: Connector[] = []; + + /** Ulozi aktualni stav. V pametovem rezimu to nic nedela. */ + function persist(): void { + if (store.kind === 'memory') return; + + store.save( + rows.map((row) => { + const { values, ...rest } = row; + // Bez klice se hodnoty nezapisuji vubec. Radsi je zadat znovu nez je + // mit v souboru citelne. + return { ...rest, secrets: canEncrypt ? sealAll(values) : {} }; + }), + ); + } + + function byTenant(tenantIds: string[]): Connector[] { + return rows.filter((row) => tenantIds.includes(row.tenantId)); + } + + function clearDefaults(tenantId: string, serviceId: string): void { + for (const row of rows) { + if (row.tenantId === tenantId && row.serviceId === serviceId) row.isDefault = false; + } + } + + /** Kopie, aby volajici nemohl zmenit stav uloziste zapisem do vysledku. */ + function copy(row: Connector): Connector { + return { ...row, values: { ...row.values } }; + } + + return { + kind: store.kind === 'file' ? 'memory' : 'memory', + + async init() { + const stored = await store.load(); + rows.length = 0; + for (const item of stored) { + const { secrets, ...rest } = item; + rows.push({ ...rest, values: openAll(secrets) }); + } + await seed(); + }, + + async flush() { + await store.flush(); + }, + + async list(tenantIds, listOptions = {}) { + return byTenant(tenantIds) + .filter((row) => !listOptions.serviceId || row.serviceId === listOptions.serviceId) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')) + .map(copy); + }, + + async get(id, tenantIds) { + const row = rows.find((item) => item.id === id); + // Cizi konektor se chova jako neexistujici, ne jako chyba prava. + if (!row || !tenantIds.includes(row.tenantId)) return undefined; + return copy(row); + }, + + async defaultFor(tenantId, serviceId) { + const forService = rows.filter( + (row) => row.tenantId === tenantId && row.serviceId === serviceId && row.enabled, + ); + const chosen = forService.find((row) => row.isDefault) ?? forService[0]; + return chosen ? copy(chosen) : undefined; + }, + + async countsByService(tenantIds) { + const counts = new Map(); + for (const row of byTenant(tenantIds)) { + counts.set(row.serviceId, (counts.get(row.serviceId) ?? 0) + 1); + } + return counts; + }, + + async create(input: CreateConnectorInput) { + const timestamp = nowIso(); + const existing = rows.filter( + (row) => row.tenantId === input.tenantId && row.serviceId === input.serviceId, + ); + + const connector: Connector = { + id: `con_${randomUUID().slice(0, 8)}`, + tenantId: input.tenantId, + serviceId: input.serviceId, + name: input.name, + baseUrl: input.baseUrl ?? null, + values: { ...(input.values ?? {}) }, + enabled: true, + status: 'untested', + lastCheckAt: null, + lastError: null, + // Prvni konektor na sluzbu je vychozi, jinak by krok bez vyberu nemel co vzit. + isDefault: input.isDefault ?? existing.length === 0, + createdAt: timestamp, + updatedAt: timestamp, + }; + + if (connector.isDefault) clearDefaults(input.tenantId, input.serviceId); + rows.push(connector); + persist(); + return copy(connector); + }, + + async update(id, patch: UpdateConnectorInput, tenantIds) { + const row = rows.find((item) => item.id === id); + if (!row || !tenantIds.includes(row.tenantId)) return undefined; + + if (patch.name !== undefined) row.name = patch.name; + if (patch.baseUrl !== undefined) row.baseUrl = patch.baseUrl; + if (patch.enabled !== undefined) row.enabled = patch.enabled; + + if (patch.values) { + for (const [key, value] of Object.entries(patch.values)) { + if (value === '') delete row.values[key]; + else row.values[key] = value; + } + // Zmena udaju znamena, ze predchozi overeni uz nic nerika. + row.status = 'untested'; + row.lastError = null; + } + + if (patch.isDefault === true) { + clearDefaults(row.tenantId, row.serviceId); + row.isDefault = true; + } + + row.updatedAt = nowIso(); + persist(); + return copy(row); + }, + + async remove(id, tenantIds) { + const row = rows.find((item) => item.id === id); + if (!row || !tenantIds.includes(row.tenantId)) return false; + + rows.splice(rows.indexOf(row), 1); + + // Kdyz zmizel vychozi, prevezme to prvni zbyly - jinak by kroky bez vyberu + // prestaly fungovat, aniz by se cokoliv jineho zmenilo. + if (row.isDefault) { + const next = rows.find( + (item) => item.tenantId === row.tenantId && item.serviceId === row.serviceId, + ); + if (next) next.isDefault = true; + } + + persist(); + return true; + }, + + async setStatus(id, status, error, tenantIds) { + const row = rows.find((item) => item.id === id); + if (!row || !tenantIds.includes(row.tenantId)) return undefined; + + row.status = status; + row.lastError = error; + row.lastCheckAt = nowIso(); + row.updatedAt = row.lastCheckAt; + persist(); + return copy(row); + }, + }; + + /** + * Ukazkovy konektor bez vyplnenych udaju. + * + * Zamerne bez nich: ukazuje presne ten stav, ve kterem konektor vznikne, tedy + * "sluzba je napojena, ale chybi pristupove udaje". Zaklada se jen kdyz nic + * jineho neni, aby po restartu neprepsal to, co uzivatel nastavil. + */ + async function seed(): Promise { + if (rows.length > 0) return; + if (!findService('idoklad')) return; + + const timestamp = nowIso(); + rows.push({ + id: `con_${randomUUID().slice(0, 8)}`, + tenantId: 'tnt_automia', + serviceId: 'idoklad', + name: 'iDoklad Automia', + baseUrl: null, + values: {}, + enabled: true, + status: 'untested', + lastCheckAt: null, + lastError: null, + isDefault: true, + createdAt: timestamp, + updatedAt: timestamp, + }); + persist(); + } +} diff --git a/src/data/customWidgets.ts b/src/data/customWidgets.ts new file mode 100644 index 0000000..abca3fe --- /dev/null +++ b/src/data/customWidgets.ts @@ -0,0 +1,152 @@ +/** + * Vlastni widgety na prehledu. + * + * Puvodni katalog (`widgets.ts`) byl pevny: `kind` rikal, kterou komponentu + * vykreslit, a nic dalsiho se nastavit nedalo. Vlastni widget to rozdeluje + * na dve veci: + * + * - `render` jak se to kresli (cislo, graf, seznam, tabulka, ukazatel), + * - `source` odkud jsou data. + * + * **Uzivatel nepise dotazy.** Vybira z filtru, ktery uz umi seznam ticketu. + * Tim je "widget nad stavy ticketu" hotovy bez jedineho noveho dotazu do dat + * a bez moznosti napsat neco, co server polozi. + * + * Popis je v documentation/09-navrh-rozsireni.md, bod 4. + */ + +import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { withCache } from './store/cached.js'; +import type { TicketChannel, TicketStatus } from './ticketStore.js'; + +export type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge'; + +/** Podle ceho se da seskupovat. Bez toho by byl widget na kazdeho cloveka. */ +export type WidgetGroupBy = 'assignee' | 'group' | 'status' | 'type' | 'tag' | 'channel'; + +export type WidgetPeriod = 'today' | '7d' | '30d' | 'month' | 'all'; + +/** Filtr nad tickety. Tentyz, ktery umi `GET /api/dashboard/tickets`. */ +export interface WidgetTicketFilter { + status?: TicketStatus[]; + channel?: TicketChannel[]; + typeIds?: string[]; + tags?: string[]; + /** `me` = prihlaseny, `unassigned` = fronta, jinak ID resitelu. */ + assignee?: string[]; + groupIds?: string[]; + period?: WidgetPeriod; +} + +export type WidgetSource = + | { kind: 'ticketCount'; filter: WidgetTicketFilter; groupBy?: WidgetGroupBy } + | { kind: 'ticketList'; filter: WidgetTicketFilter; limit: number } + | { kind: 'ticketSeries'; filter: WidgetTicketFilter; bucket: 'day' | 'week' } + | { kind: 'workload'; groupIds?: string[] }; + +export type WidgetSize = 'third' | 'half' | 'full'; + +export interface CustomWidget extends TenantEntity { + tenantId: string; + /** null = firemni widget, jinak osobni. */ + ownerId: string | null; + name: string; + description: string; + render: WidgetRender; + source: WidgetSource; + /** Porovnavaci hodnota u ukazatele. */ + target?: number; + size: WidgetSize; +} + +export const customWidgetStore = defineStore('customWidget'); +const cache = withCache(customWidgetStore); + +export function seedCustomWidgets(): CustomWidget[] { + const timestamp = nowIso(); + const base = { tenantId: 'tnt_automia', ownerId: null, createdAt: timestamp, updatedAt: timestamp }; + + return [ + { + ...base, + id: 'cw_orders_open', + name: 'Otevřené objednávky', + description: 'Tickety typu Objednávka, které nejsou vyřešené.', + render: 'stat', + source: { + kind: 'ticketCount', + filter: { typeIds: ['tt_order'], status: ['new', 'open', 'waiting'] }, + }, + size: 'third', + }, + { + ...base, + id: 'cw_by_assignee', + name: 'Nevyřešené podle lidí', + description: 'Kdo co má u sebe. Ukázka seskupení.', + render: 'table', + source: { + kind: 'ticketCount', + filter: { status: ['new', 'open', 'waiting'] }, + groupBy: 'assignee', + }, + size: 'half', + }, + ]; +} + +export async function refreshCustomWidgets(): Promise { + await cache.refresh(); +} + +/** Widgety firmy: firemni plus osobni prihlaseneho. */ +export function listCustomWidgets(tenantIds: string[], userId: string): CustomWidget[] { + return cache + .all() + .filter((widget) => tenantIds.includes(widget.tenantId)) + .filter((widget) => widget.ownerId === null || widget.ownerId === userId) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')); +} + +export function findCustomWidget(id: string): CustomWidget | undefined { + return cache.byId(id); +} + +/** Ktere sirky ma smysl nabizet. Graf v tretine sloupce se necte. */ +export function sizesFor(render: WidgetRender): WidgetSize[] { + if (render === 'stat' || render === 'gauge') return ['third', 'half']; + return ['half', 'full']; +} + +/** + * Overi definici widgetu. + * Vraci popisy problemu, prazdne pole znamena v poradku. + */ +export function validateWidget(widget: CustomWidget): string[] { + const problems: string[] = []; + + if (!sizesFor(widget.render).includes(widget.size)) { + problems.push( + `Šířka ${widget.size} nemá u ${widget.render} smysl, vyberte ${sizesFor(widget.render).join(' nebo ')}.`, + ); + } + + if (widget.source.kind === 'ticketList' && widget.source.limit > 50) { + problems.push('Seznam smí mít nejvýš 50 řádků, jinak se dlaždice nedá přečíst.'); + } + + // Ukazatel bez cile nema co ukazat. + if (widget.render === 'gauge' && (widget.target === undefined || widget.target <= 0)) { + problems.push('Ukazatel potřebuje cílovou hodnotu větší než nula.'); + } + + // Cislo ze seznamu nebo casove rady nejde vykreslit jako jedna hodnota. + if (widget.render === 'stat' && widget.source.kind !== 'ticketCount') { + problems.push('Jedno číslo umí jen zdroj Počet ticketů.'); + } + if (widget.render === 'chart' && widget.source.kind !== 'ticketSeries') { + problems.push('Graf umí jen zdroj Časová řada.'); + } + + return problems; +} diff --git a/src/data/dashboardLayouts.ts b/src/data/dashboardLayouts.ts index 53c0ba1..01a8d78 100644 --- a/src/data/dashboardLayouts.ts +++ b/src/data/dashboardLayouts.ts @@ -3,10 +3,13 @@ * clovek ve dvou firmach chce v kazde videt neco jineho a smichat mu to * dohromady by bylo horsi nez zadne nastaveni. * - * POZOR: data jsou v pameti procesu, restart je vrati na vychozi rozlozeni. + * Uklada se do uloziste (databaze nebo JSON soubor), takze upraveny dashboard + * prezije restart. Vychozi sada se neuklada - kdo si nic neupravil, nema zaznam. */ import { findWidget, type WidgetSize } from './widgets.js'; +import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { withMirror } from './store/mirror.js'; export interface LayoutItem { /** Instance widgetu. Tentyz widget muze byt na dashboardu vickrat. */ @@ -30,10 +33,28 @@ const defaultLayout: LayoutItem[] = [ const layouts = new Map(); +/** + * Zaznam v ulozisti. ID je dvojice uzivatel a firma, takze zapis nepotrebuje + * hledat, co uz tam je - prepise se podle klice. + */ +interface StoredLayout extends TenantEntity { + userId: string; + items: LayoutItem[]; +} + +const mirror = withMirror(defineStore('dashboardLayout')); + function key(userId: string, tenantId: string): string { return `${userId}:${tenantId}`; } +/** Nacte ulozena rozlozeni. Vola se pri startu, viz data/bootstrap.ts. */ +export async function initLayouts(): Promise { + const rows = await mirror.load(); + layouts.clear(); + for (const row of rows) layouts.set(key(row.userId, row.tenantId ?? ''), row.items); +} + export function getLayout(userId: string, tenantId: string): LayoutItem[] { return layouts.get(key(userId, tenantId)) ?? defaultLayout.map((item) => ({ ...item })); } @@ -44,14 +65,21 @@ export function hasCustomLayout(userId: string, tenantId: string): boolean { } export function saveLayout(userId: string, tenantId: string, items: LayoutItem[]): LayoutItem[] { - layouts.set(key(userId, tenantId), items); + const id = key(userId, tenantId); + layouts.set(id, items); + + const timestamp = nowIso(); + mirror.save({ id, tenantId, userId, items, createdAt: timestamp, updatedAt: timestamp }); console.info(`[layout] ${userId}@${tenantId}: ulozeno ${items.length} widgetu`); return items; } /** Vrati dashboard do vychoziho stavu. */ export function resetLayout(userId: string, tenantId: string): LayoutItem[] { - layouts.delete(key(userId, tenantId)); + const id = key(userId, tenantId); + layouts.delete(id); + // Vychozi rozlozeni se neuklada, takze vraceni znamena smazat zaznam. + mirror.drop(id); console.info(`[layout] ${userId}@${tenantId}: vraceno na vychozi`); return getLayout(userId, tenantId); } diff --git a/src/data/incidentStore.ts b/src/data/incidentStore.ts index bcb52e7..a8ddf25 100644 --- a/src/data/incidentStore.ts +++ b/src/data/incidentStore.ts @@ -1,9 +1,15 @@ /** * Uloziste incidentu. Stejny princip jako ticketStore - kazda zmena - * posle udalost na sbernici a projevi se v dashboardu okamzite. + * posle udalost na sbernici a projevi se v dashboardu okamzite, drzi se + * v pameti a po zmene se cely zaznam zapise do uloziste. + * + * Incident je **platformni**, ne firemni: vypadek hlasove brany se tyka vsech, + * kdo ji pouzivaji. Proto `tenantId: null`. */ import { publish } from '../events/bus.js'; +import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { withMirror } from './store/mirror.js'; export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3'; export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved'; @@ -54,6 +60,44 @@ const incidents: Incident[] = [ let counter = 231; +/** Tvar v ulozisti. Cas vzniku je `startedAt`, `createdAt` je kvuli rozhrani. */ +interface StoredIncident extends Incident, TenantEntity { + tenantId: null; +} + +const mirror = withMirror(defineStore('incident')); + +function toStored(incident: Incident): StoredIncident { + return { + ...incident, + tenantId: null, + createdAt: incident.startedAt, + updatedAt: nowIso(), + }; +} + +/** Ulozi incident. Necekana se, chyba se loguje. */ +function persist(incident: Incident): void { + mirror.save(toStored(incident)); +} + +/** Nacte incidenty z uloziste. Vola se pri startu, viz data/bootstrap.ts. */ +export async function initIncidents(): Promise { + const rows = await mirror.load(() => incidents.map(toStored)); + + incidents.length = 0; + for (const row of rows) { + // Sloupce uloziste zpatky nepatri, incident je nezna. + const { tenantId: _tenantId, createdAt: _createdAt, updatedAt: _updatedAt, ...incident } = row; + incidents.push(incident); + } + + for (const incident of incidents) { + const number = Number(/^INC-(\d+)$/.exec(incident.id)?.[1] ?? 0); + if (number > counter) counter = number; + } +} + export function listIncidents(): Incident[] { return [...incidents].sort((a, b) => { if (a.status === 'resolved' && b.status !== 'resolved') return 1; @@ -78,6 +122,7 @@ export function createIncident(input: { resolvedAt: null, }; incidents.unshift(incident); + persist(incident); publish('incident.started', `Nový incident ${incident.id}: ${incident.title}`, { incidentId: incident.id, @@ -95,6 +140,7 @@ export function updateIncidentStatus(id: string, status: IncidentStatus): Incide incident.status = status; incident.resolvedAt = status === 'resolved' ? new Date().toISOString() : null; + persist(incident); if (status === 'resolved') { publish('incident.resolved', `Incident ${incident.id} vyřešen: ${incident.title}`, { diff --git a/src/data/permissions.ts b/src/data/permissions.ts new file mode 100644 index 0000000..674e50a --- /dev/null +++ b/src/data/permissions.ts @@ -0,0 +1,272 @@ +/** + * Prava a role jako data. + * + * `TenantRole = 'admin' | 'agent'` na ucetni, skladnika a vedouciho nestacilo + * a pridavat dalsi hodnoty do unionu je slepa ulicka - kazdy klient chce jine. + * Role jsou proto zaznamy a pravo je retezec. + * + * Katalog prav je zdroj pravdy o tom, co vubec existuje. Stejny princip jako + * u katalogu sluzeb a widgetu: nastaveni role je zaskrtavatkova tabulka nad + * timhle seznamem, ne volne psane retezce. + * + * Popis modelu je v documentation/16-prava-a-role.md. + */ + +import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import type { User } from '../types.js'; + +export type PermissionKey = string; + +export interface PermissionDefinition { + key: PermissionKey; + label: string; + /** Do ktere skupiny patri v nastaveni role. */ + group: string; + /** true = pravo je nase, klientsky admin ho neprideluje. */ + platform?: boolean; + hint?: string; +} + +/** + * Katalog prav. + * + * Pravo k vlastni akci se sem nepise - vznika spolu s akci jako `action:` + * a nabizi se dynamicky, viz `dynamicPermissions` nize. + */ +export const permissionCatalog: PermissionDefinition[] = [ + // ------------------------------------------------------------------ tickety + { key: 'ticket.view', label: 'Vidět tickety', group: 'Tickety' }, + { key: 'ticket.comment', label: 'Komentovat ticket', group: 'Tickety' }, + { key: 'ticket.assign.self', label: 'Vzít ticket na sebe', group: 'Tickety' }, + { + key: 'ticket.assign.others', + label: 'Přehodit ticket na kolegu', + group: 'Tickety', + hint: 'Bez tohoto práva si člověk může ticket jen vzít, ne ho poslat dál.', + }, + { key: 'ticket.assign.group', label: 'Přehodit ticket na skupinu', group: 'Tickety' }, + { key: 'ticket.status.change', label: 'Měnit stav ticketu', group: 'Tickety' }, + { key: 'ticket.priority.change', label: 'Měnit prioritu', group: 'Tickety' }, + { key: 'ticket.type.change', label: 'Měnit typ ticketu', group: 'Tickety' }, + { key: 'ticket.reopen', label: 'Otevřít vyřešený ticket', group: 'Tickety' }, + { key: 'ticket.tag', label: 'Přidávat a odebírat tagy', group: 'Tickety' }, + + // ------------------------------------------------------------- nastaveni firmy + { key: 'ticketType.manage', label: 'Spravovat typy ticketů', group: 'Nastavení firmy' }, + { key: 'action.manage', label: 'Spravovat definice akcí', group: 'Nastavení firmy' }, + { key: 'automation.edit', label: 'Upravovat automatizace', group: 'Nastavení firmy' }, + { key: 'connector.manage', label: 'Spravovat konektory', group: 'Nastavení firmy' }, + { key: 'widget.manage', label: 'Spravovat vlastní widgety', group: 'Nastavení firmy' }, + { key: 'group.manage', label: 'Spravovat skupiny řešitelů', group: 'Nastavení firmy' }, + + // -------------------------------------------------------------------- lide + { key: 'people.manage', label: 'Spravovat řešitele', group: 'Lidé' }, + { key: 'user.manage', label: 'Spravovat uživatele a členství', group: 'Lidé' }, + { + key: 'role.manage', + label: 'Spravovat role a práva', + group: 'Lidé', + hint: 'Kdo to má, může si přidat jakékoliv další právo.', + }, + + // ------------------------------------------------------------------ platforma + { + key: 'tenant.manage', + label: 'Spravovat firmy', + group: 'Platforma', + platform: true, + }, + { + key: 'tenant.features', + label: 'Nastavovat záložky a limity firem', + group: 'Platforma', + platform: true, + }, + { + key: 'script.edit', + label: 'Upravovat skripty služeb', + group: 'Platforma', + platform: true, + hint: 'Úprava skriptu mění chování všeho, co ho používá.', + }, + { + key: 'service.visibility', + label: 'Zpřístupňovat služby firmám', + group: 'Platforma', + platform: true, + }, + { + key: 'impersonate', + label: 'Přihlásit se za jiného uživatele', + group: 'Platforma', + platform: true, + }, + { key: 'audit.view', label: 'Vidět audit', group: 'Platforma', platform: true }, +]; + +const catalogKeys = new Set(permissionCatalog.map((item) => item.key)); + +// ------------------------------------------------------------------------ role + +export interface Role extends TenantEntity { + /** Stabilni klic, napr. `admin`. U vlastnich roli je stejny jako ID. */ + key: string; + name: string; + description: string; + permissions: PermissionKey[]; + /** true = systemova role, nejde smazat ani prejmenovat. */ + system: boolean; +} + +export const roleStore = defineStore('role'); + +/** Vsechna prava mimo platformni. Pro systemovou roli spravce firmy. */ +function tenantPermissions(): PermissionKey[] { + return permissionCatalog.filter((item) => !item.platform).map((item) => item.key); +} + +/** + * Systemove role. + * + * `admin` a `agent` zustavaji, aby se zadny existujici ucet nemusel predelavat. + * Jsou platformni (`tenantId: null`), takze je vidi kazda firma a nikdo krome + * nas je nezmeni. + */ +export function systemRoles(): Role[] { + const timestamp = nowIso(); + const base = { tenantId: null, system: true, createdAt: timestamp, updatedAt: timestamp }; + + return [ + { + ...base, + id: 'role_admin', + key: 'admin', + name: 'Správce firmy', + description: 'Vidí a nastavuje všechno ve své firmě.', + permissions: tenantPermissions(), + }, + { + ...base, + id: 'role_agent', + key: 'agent', + name: 'Řešitel', + description: 'Řeší tickety. Nemůže přehazovat práci ani měnit nastavení.', + permissions: [ + 'ticket.view', + 'ticket.comment', + 'ticket.assign.self', + 'ticket.status.change', + 'ticket.tag', + ], + }, + { + ...base, + id: 'role_accountant', + key: 'accountant', + name: 'Účetní', + description: 'Tickety kolem dokladů a akce nad nimi. Ukázka vlastní role.', + permissions: ['ticket.view', 'ticket.comment', 'ticket.assign.self', 'ticket.status.change'], + }, + ]; +} + +// -------------------------------------------------------------- vyhodnocovani + +/** Prava, ktera nejsou v katalogu, ale vznikaji za behu (akce). */ +let dynamicPermissions: PermissionDefinition[] = []; + +/** + * Zaregistruje prava vznikla z definic akci. + * Vola se pri zmene akci, aby je nastaveni role nabidlo. + */ +export function setDynamicPermissions(items: PermissionDefinition[]): void { + dynamicPermissions = items; +} + +export function allPermissions(): PermissionDefinition[] { + return [...permissionCatalog, ...dynamicPermissions]; +} + +/** Neznamy klic se ignoruje a loguje. Pravo, ktere zmizelo, nesmi shodit prihlaseni. */ +export function knownPermission(key: PermissionKey): boolean { + return catalogKeys.has(key) || dynamicPermissions.some((item) => item.key === key); +} + +/** + * Cache efektivnich prav za uzivatele. + * + * Prava se ctou pri kazdem requestu, ale meni se zridka. Bez cache by kazdy + * dotaz znamenal cteni roli z uloziste. + */ +const effective = new Map }>(); +const CACHE_MS = 5_000; + +/** Zahodi cache. Vola se po zmene roli nebo clenstvi. */ +export function invalidatePermissions(): void { + effective.clear(); +} + +let loadedRoles: Role[] = []; + +/** Nacte role do pameti. Vola se pri startu a po kazde zmene. */ +export async function refreshRoles(): Promise { + loadedRoles = await roleStore.listAll(); + invalidatePermissions(); +} + +export function rolesById(): Map { + return new Map(loadedRoles.map((role) => [role.id, role])); +} + +/** Role dostupne dane firme: jeji vlastni plus systemove. */ +export function rolesFor(tenantId: string | null): Role[] { + return loadedRoles.filter((role) => role.tenantId === null || role.tenantId === tenantId); +} + +/** + * Prava uzivatele. + * + * Spojeni pres vsechna clenstvi, protoze pravo je bud, nebo neni - rozlisovat + * ho za firmu by znamenalo predavat firmu do kazde kontroly a to je presne to + * misto, kde se na ni zapomene. + * + * Spravce platformy ma vsechno. Je to nase pravo, ne zakaznicke. + */ +export function permissionsOf(user: User): Set { + if (user.platformAdmin) return new Set(allPermissions().map((item) => item.key)); + + const cached = effective.get(user.id); + if (cached && Date.now() - cached.at < CACHE_MS) return cached.permissions; + + const byId = rolesById(); + const byKey = new Map(loadedRoles.map((role) => [role.key, role])); + const result = new Set(); + + for (const membership of user.memberships) { + for (const roleRef of membership.roleIds) { + // Odkaz muze byt ID nebo klic systemove role, aby slo zapsat oboji. + const role = byId.get(roleRef) ?? byKey.get(roleRef); + if (!role) { + console.warn(`[permissions] ${user.email}: clenstvi odkazuje na neznamou roli ${roleRef}`); + continue; + } + for (const permission of role.permissions) { + if (knownPermission(permission)) result.add(permission); + else console.warn(`[permissions] role ${role.key}: nezname pravo ${permission}`); + } + } + } + + effective.set(user.id, { at: Date.now(), permissions: result }); + return result; +} + +export function hasPermission(user: User, permission: PermissionKey): boolean { + return permissionsOf(user).has(permission); +} + +/** Ma uzivatel aspon jedno z prav? Pro zalozky, kde staci cokoliv z modulu. */ +export function hasAnyPermission(user: User, permissions: PermissionKey[]): boolean { + const owned = permissionsOf(user); + return permissions.some((permission) => owned.has(permission)); +} diff --git a/src/data/refresh.ts b/src/data/refresh.ts new file mode 100644 index 0000000..68d92fd --- /dev/null +++ b/src/data/refresh.ts @@ -0,0 +1,38 @@ +/** + * Obnova kopii v pameti po zapisu. + * + * Zvlastni soubor kvuli cyklickym importum: routy potrebuji obnovu, ale + * `bootstrap.ts` importuje vsechna uloziste vcetne tech, ktere routy pouzivaji. + * Tenhle modul je proto tenka prepazka. + * + * Volani se **slucuje**. Deset zapisu za sebou znamena jednu obnovu, jinak by + * hromadna uprava znamenala desetkrat precteni vsech roli a lidi. + */ + +import { refreshCaches } from './bootstrap.js'; + +let pending: Promise | null = null; +let again = false; + +export async function bootstrapDataRefresh(): Promise { + if (pending) { + // Uz se obnovuje. Poznacime, ze po dokonceni ma prijit jeste jedna - + // jinak by se zmena z posledniho zapisu neprojevila. + again = true; + return pending; + } + + pending = refreshCaches() + .catch((err: unknown) => { + console.error('[data] obnova kopii selhala:', err); + }) + .finally(() => { + pending = null; + if (again) { + again = false; + void bootstrapDataRefresh(); + } + }); + + return pending; +} diff --git a/src/data/snapshot.ts b/src/data/snapshot.ts new file mode 100644 index 0000000..706e13b --- /dev/null +++ b/src/data/snapshot.ts @@ -0,0 +1,146 @@ +/** + * Ukladani do JSON souboru. + * + * Prostredek pro mockup, kde databaze neni. Prezije **restart procesu** + * i containeru, ale ne redeploy - filesystem containeru je docasny. Je to tedy + * mezistupen mezi pameti a databazi, ne jeji nahrada. + * + * Zapis je atomicky: nejdriv do docasneho souboru, pak prejmenovani. Pri padu + * uprostred zapisu tak nezustane polovicni JSON, ktery by se pri startu + * nenacetl. Zapisy se navic slucuji, aby deset uprav za sebou neznamenalo + * deset zapisu na disk. + * + * Rozbity soubor **neshodi start**. Zaloguje se a jede se s prazdnymi daty: + * aplikace, ktera nenastartuje, je pro AppFactory nefunkcni sluzba. + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** Obalka souboru. `version` je tu kvuli budoucim zmenam tvaru dat. */ +interface SnapshotFile { + version: number; + savedAt: string; + items: T[]; +} + +const VERSION = 1; +const DEBOUNCE_MS = 200; + +export interface SnapshotStore { + readonly kind: 'memory' | 'file'; + /** Popis pro portal a log. */ + readonly location: string | null; + load(): Promise; + /** Naplanuje zapis. Vraci hned, zapis probehne po slouceni. */ + save(items: T[]): void; + /** Dokonci rozepsany zapis. Vola se pri ukonceni procesu. */ + flush(): Promise; +} + +/** Uloziste, ktere nikam nezapisuje. Rezim pameti. */ +export function memorySnapshot(): SnapshotStore { + return { + kind: 'memory', + location: null, + async load() { + return []; + }, + save() { + // Zamerne nic. Pametovy rezim data neuklada. + }, + async flush() { + // Zamerne nic. + }, + }; +} + +export function fileSnapshot(file: string): SnapshotStore { + let pending: T[] | null = null; + let timer: NodeJS.Timeout | null = null; + let writing: Promise = Promise.resolve(); + + async function writeNow(items: T[]): Promise { + const payload: SnapshotFile = { + version: VERSION, + savedAt: new Date().toISOString(), + items, + }; + + const temp = `${file}.tmp`; + try { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(temp, JSON.stringify(payload, null, 2), 'utf8'); + // Prejmenovani je atomicke. Bez nej by pad uprostred zapisu nechal + // polovicni JSON, ktery se pri startu nenacte. + await fs.rename(temp, file); + } catch (err) { + console.error( + `[snapshot] ${file} se nepodarilo zapsat:`, + err instanceof Error ? err.message : err, + ); + await fs.rm(temp, { force: true }).catch(() => undefined); + } + } + + return { + kind: 'file', + location: file, + + async load() { + let raw: string; + try { + raw = await fs.readFile(file, 'utf8'); + } catch { + // Chybejici soubor je bezny stav, ne chyba. Prvni spusteni. + return []; + } + + try { + const parsed = JSON.parse(raw) as SnapshotFile; + if (!Array.isArray(parsed.items)) throw new Error('chybi pole items'); + if (parsed.version !== VERSION) { + console.warn( + `[snapshot] ${file} ma verzi ${parsed.version}, ocekava se ${VERSION}. ` + + 'Data se nacitaji, ale zkontrolujte tvar.', + ); + } + console.info(`[snapshot] ${file}: nacteno ${parsed.items.length} zaznamu`); + return parsed.items; + } catch (err) { + // Rozbity soubor se zalohuje, at neni prepsan, a jede se s prazdnymi daty. + const broken = `${file}.broken`; + console.error( + `[snapshot] ${file} nelze precist (${err instanceof Error ? err.message : err}), ` + + `puvodni soubor je v ${broken}`, + ); + await fs.rename(file, broken).catch(() => undefined); + return []; + } + }, + + save(items) { + // Kopie, aby pozdejsi zmeny v pameti neovlivnily to, co se prave zapisuje. + pending = items.map((item) => ({ ...item })); + if (timer) return; + + timer = setTimeout(() => { + timer = null; + const snapshot = pending; + pending = null; + if (snapshot) writing = writing.then(() => writeNow(snapshot)); + }, DEBOUNCE_MS); + }, + + async flush() { + if (timer) { + clearTimeout(timer); + timer = null; + } + const snapshot = pending; + pending = null; + if (snapshot) writing = writing.then(() => writeNow(snapshot)); + await writing; + }, + }; +} diff --git a/src/data/store/cached.ts b/src/data/store/cached.ts new file mode 100644 index 0000000..f3903e8 --- /dev/null +++ b/src/data/store/cached.ts @@ -0,0 +1,53 @@ +/** + * Uloziste se synchronni kopii v pameti. + * + * Nektere entity se ctou pri **kazdem requestu**: uzivatel v autorizaci, firmy + * pri vypoctu prav, resitele u ticketu. Delat kvuli tomu asynchronni dotaz by + * znamenalo predelat autorizacni middleware na async a cist tabulku i pri kazdem + * pingu na health. + * + * Meni se naopak zridka. Drzi se proto kopie v pameti, cte se z ni synchronne + * a po kazdem zapisu se obnovi. + * + * Pouziva se **jen na konfiguracni entity**, tedy male seznamy. Na tickety nebo + * behy ne - tam by kopie v pameti byla to same jako zadna databaze. + * + * Pri vic instancich by kopie mohla byt na chvili zastarala. Resenim je + * `LISTEN/NOTIFY`, viz documentation/10-runtime-a-kapacita.md. + */ + +import type { EntityStore, TenantEntity } from './types.js'; + +export interface CachedStore { + readonly store: EntityStore; + /** Nacte kopii. Vola se pri startu a po kazdem zapisu. */ + refresh(): Promise; + /** Synchronni cteni vsech zaznamu. */ + all(): T[]; + find(predicate: (entity: T) => boolean): T | undefined; + byId(id: string): T | undefined; +} + +export function withCache(store: EntityStore): CachedStore { + let rows: T[] = []; + + return { + store, + + async refresh() { + rows = await store.listAll(); + }, + + all() { + return rows; + }, + + find(predicate) { + return rows.find(predicate); + }, + + byId(id) { + return rows.find((row) => row.id === id); + }, + }; +} diff --git a/src/data/store/index.ts b/src/data/store/index.ts new file mode 100644 index 0000000..fad3713 --- /dev/null +++ b/src/data/store/index.ts @@ -0,0 +1,97 @@ +/** + * Vyber uloziste pro obecne entity. + * + * Jedine misto, kde se rozhoduje mezi souborem, pameti a databazi. Nikde jinde + * se to nezjistuje - kdyby se to rozlezlo po kodu, jedno misto by se zapomnelo. + * + * Pouziti: + * ```ts + * const roles = defineStore('role'); + * await roles.init(() => systemRoles); // pri startu + * await roles.list({ tenantIds, includeGlobal: true }); + * ``` + * + * Vsechny vytvorene stores se registruji, aby se pri ukonceni procesu daly + * najednou dopsat na disk. + */ + +import path from 'node:path'; +import { config } from '../../config.js'; +import { isDatabaseEnabled } from '../../db/pool.js'; +import { createLocalStore } from './local.js'; +import { createPostgresStore } from './postgres.js'; +import type { EntityStore, TenantEntity } from './types.js'; + +export type { Entity, EntityStore, ListOptions, TenantEntity } from './types.js'; +export { isVisible, nowIso } from './types.js'; + +/** Rezim se urcuje jednou pri startu, aby se stores nechovaly kazdy jinak. */ +let useDatabase = false; +let ready = false; +const registry = new Map>(); + +/** Nastavi rezim. Vola se pri startu po migracich, pred `init` jednotlivych stores. */ +export function initStores(options: { databaseReady: boolean }): 'postgres' | 'file' | 'memory' { + useDatabase = isDatabaseEnabled() && options.databaseReady; + ready = true; + + if (useDatabase) return 'postgres'; + return config.dataDir === '' ? 'memory' : 'file'; +} + +export function storesMode(): 'postgres' | 'file' | 'memory' { + if (useDatabase) return 'postgres'; + return config.dataDir === '' ? 'memory' : 'file'; +} + +/** + * Vytvori uloziste pro dany druh entity. + * + * Vola se na urovni modulu, tedy jeste pred `initStores`. Backend se proto + * vybira az pri prvnim pouziti - `init` uz o rezimu vi. + */ +export function defineStore(kind: string): EntityStore { + let backend: EntityStore | null = null; + + function resolve(): EntityStore { + if (!backend) { + if (!ready) { + console.warn(`[stores] ${kind} se pouziva pred initStores, jede se v pameti`); + } + backend = useDatabase + ? createPostgresStore(kind) + : createLocalStore({ + kind, + file: config.dataDir === '' ? null : path.join(config.dataDir, `${kind}.json`), + }); + } + return backend; + } + + const store: EntityStore = { + kind, + init: (seed) => resolve().init(seed), + list: (options) => resolve().list(options), + listAll: () => resolve().listAll(), + get: (id, options) => resolve().get(id, options), + getRaw: (id) => resolve().getRaw(id), + create: (entity) => resolve().create(entity), + put: (entity) => resolve().put(entity), + update: (id, patch, options) => resolve().update(id, patch, options), + remove: (id, options) => resolve().remove(id, options), + flush: () => resolve().flush(), + }; + + registry.set(kind, store as EntityStore); + return store; +} + +/** Dopise vsechna uloziste na disk. Vola se pri ukonceni procesu. */ +export async function flushStores(): Promise { + await Promise.all([...registry.values()].map((store) => store.flush())); +} + +/** Kolik druhu entit je zaregistrovanych. Pro diagnostiku. */ +export function storeKinds(): string[] { + return [...registry.keys()].sort(); +} diff --git a/src/data/store/local.ts b/src/data/store/local.ts new file mode 100644 index 0000000..386fa22 --- /dev/null +++ b/src/data/store/local.ts @@ -0,0 +1,110 @@ +/** + * Obecne uloziste v souboru nebo v pameti. + * + * Jeden kod pro oba rezimy, lisi se jen tim, kam se zapisuje. Persistence je + * proto parametr (`SnapshotStore`), ne druha kopie kodu. + */ + +import { fileSnapshot, memorySnapshot, type SnapshotStore } from '../snapshot.js'; +import { + isVisible, + nowIso, + type EntityStore, + type ListOptions, + type TenantEntity, +} from './types.js'; + +export interface LocalStoreOptions { + kind: string; + /** Cesta k souboru, nebo null pro cistou pamet. */ + file: string | null; +} + +export function createLocalStore( + options: LocalStoreOptions, +): EntityStore { + const snapshot: SnapshotStore = options.file + ? fileSnapshot(options.file) + : memorySnapshot(); + + let rows: T[] = []; + + function persist(): void { + snapshot.save(rows); + } + + /** Kopie, aby volajici nemohl zmenit stav uloziste zapisem do vysledku. */ + function copy(row: T): T { + return structuredClone(row); + } + + return { + kind: options.kind, + + async init(seed) { + rows = await snapshot.load(); + // Vychozi sada jen kdyz nic neni. Po restartu nesmi prepsat to, + // co uzivatel nastavil. + if (rows.length === 0 && seed) { + rows = seed(); + if (rows.length > 0) persist(); + } + }, + + async list(listOptions: ListOptions) { + return rows.filter((row) => isVisible(row, listOptions)).map(copy); + }, + + async listAll() { + return rows.map(copy); + }, + + async get(id, listOptions) { + const row = rows.find((item) => item.id === id); + // Cizi zaznam se chova jako neexistujici, ne jako chyba prava. + if (!row || !isVisible(row, listOptions)) return undefined; + return copy(row); + }, + + async getRaw(id) { + const row = rows.find((item) => item.id === id); + return row ? copy(row) : undefined; + }, + + async create(entity) { + rows.push(entity); + persist(); + return copy(entity); + }, + + async put(entity) { + const index = rows.findIndex((item) => item.id === entity.id); + if (index === -1) rows.push(entity); + else rows[index] = entity; + persist(); + }, + + async update(id, patch, listOptions) { + const index = rows.findIndex((item) => item.id === id); + if (index === -1 || !isVisible(rows[index], listOptions)) return undefined; + + // ID ani cas vzniku se prepsat nesmi, i kdyby prisly v patchi. + const { id: _id, createdAt: _createdAt, ...rest } = patch as Partial; + rows[index] = { ...rows[index], ...(rest as Partial), updatedAt: nowIso() }; + persist(); + return copy(rows[index]); + }, + + async remove(id, listOptions) { + const index = rows.findIndex((item) => item.id === id); + if (index === -1 || !isVisible(rows[index], listOptions)) return false; + rows.splice(index, 1); + persist(); + return true; + }, + + async flush() { + await snapshot.flush(); + }, + }; +} diff --git a/src/data/store/mirror.ts b/src/data/store/mirror.ts new file mode 100644 index 0000000..acee856 --- /dev/null +++ b/src/data/store/mirror.ts @@ -0,0 +1,75 @@ +/** + * Uloziste pro data, ktera se v pameti meni na miste. + * + * `withCache` resi konfiguracni entity: male seznamy, ktere se ctou pri kazdem + * requestu a meni zridka. Tickety, automatizace a rozlozeni dashboardu jsou + * jiny pripad - drzi se v pameti kvuli rychlosti a synchronnimu cteni, ale + * **meni se za provozu** a kazda zmena musi prezit restart. + * + * Rozdil proti `withCache` je smer: + * - `withCache` cte z uloziste do pameti po zapisu, + * - `withMirror` zapisuje z pameti do uloziste po zmene. + * + * Zapis je zamerne **fire-and-forget**. Kdyby selhani zapisu shodilo request, + * rozbite uloziste by rozbilo celou aplikaci. Data zustanou spravne v pameti + * a chyba se zaloguje - tohle je stejna dohoda jako u auditu (viz data/audit.ts). + * + * Pouziti: + * ```ts + * const mirror = withMirror(defineStore('ticket')); + * const rows = await mirror.load(() => seededTickets); // pri startu + * mirror.save(ticket); // po kazde zmene + * ``` + */ + +import type { EntityStore, TenantEntity } from './types.js'; + +export interface MirroredStore { + readonly store: EntityStore; + /** + * Nacte data z uloziste. Kdyz je prazdne, ulozi vychozi sadu z `seed` + * a vrati ji - takze prvni start naplni uloziste a druhy uz cte jeho obsah. + */ + load(seed?: () => T[]): Promise; + /** Zapise cely zaznam, zalozi nebo prepise. Necekana se. */ + save(entity: T): void; + /** Smaze zaznam. Necekana se. */ + drop(id: string): void; +} + +export function withMirror(store: EntityStore): MirroredStore { + function report(action: string, err: unknown): void { + console.error(`[${store.kind}] ${action} se nepodarilo ulozit:`, err); + } + + return { + store, + + async load(seed) { + await store.init(seed); + return store.listAll(); + }, + + save(entity) { + void store.put(entity).catch((err: unknown) => report(entity.id, err)); + }, + + drop(id) { + /* + * Firma se dohleda ze zaznamu. `remove` ji potrebuje a prazdny seznam + * znamena "nic" (viz isVisible), takze bez dohledani by se nesmazalo nic. + * Pravo si volajici overil driv, tady uz jde jen o zapis. + */ + void store + .getRaw(id) + .then((entity) => { + if (!entity) return false; + return store.remove(id, { + tenantIds: entity.tenantId === null ? [] : [entity.tenantId], + includeGlobal: true, + }); + }) + .catch((err: unknown) => report(`smazani ${id}`, err)); + }, + }; +} diff --git a/src/data/store/postgres.ts b/src/data/store/postgres.ts new file mode 100644 index 0000000..a0a388b --- /dev/null +++ b/src/data/store/postgres.ts @@ -0,0 +1,156 @@ +/** + * Obecne uloziste v Postgresu, tabulka `records`. + * + * Rozdil proti souborovemu ulozisti je jen v tomhle souboru. Volajici nepozna, + * ktere je zapnute. + */ + +import { query, queryOne } from '../../db/pool.js'; +import { + nowIso, + type EntityStore, + type ListOptions, + type TenantEntity, +} from './types.js'; + +interface RecordRow { + id: string; + tenant_id: string | null; + data: Record; + created_at: Date; + updated_at: Date; +} + +function toEntity(row: RecordRow): T { + // `data` nese cely zaznam vcetne id a casu, sloupce jsou tam kvuli dotazum. + return { + ...(row.data as unknown as T), + id: row.id, + tenantId: row.tenant_id, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + }; +} + +export function createPostgresStore(kind: string): EntityStore { + /** + * Podminka na viditelnost. + * + * Prazdny seznam firem znamena "nic", ne "vse". Bez teto vetve by + * `= ANY('{}')` sice nic nevratilo, ale spolehat se na to je past. + */ + function scope(options: ListOptions): { sql: string; params: unknown[] } { + const params: unknown[] = [kind, options.tenantIds]; + const global = options.includeGlobal === true ? ' OR tenant_id IS NULL' : ''; + return { + sql: `kind = $1 AND (tenant_id = ANY($2)${global})`, + params, + }; + } + + return { + kind, + + async init(seed) { + if (!seed) return; + // Vychozi sada jen kdyz tabulka pro tenhle druh jeste nic nema. + const existing = await queryOne<{ count: string }>( + 'SELECT count(*) AS count FROM records WHERE kind = $1', + [kind], + ); + if (Number(existing?.count ?? 0) > 0) return; + + for (const entity of seed()) { + await this.create(entity); + } + }, + + async list(options) { + const { sql, params } = scope(options); + const rows = await query( + `SELECT id, tenant_id, data, created_at, updated_at FROM records + WHERE ${sql} + ORDER BY created_at`, + params, + ); + return rows.map((row) => toEntity(row)); + }, + + async listAll() { + const rows = await query( + `SELECT id, tenant_id, data, created_at, updated_at FROM records + WHERE kind = $1 ORDER BY created_at`, + [kind], + ); + return rows.map((row) => toEntity(row)); + }, + + async get(id, options) { + const { sql, params } = scope(options); + const row = await queryOne( + `SELECT id, tenant_id, data, created_at, updated_at FROM records + WHERE ${sql} AND id = $${params.length + 1}`, + [...params, id], + ); + return row ? toEntity(row) : undefined; + }, + + async getRaw(id) { + const row = await queryOne( + `SELECT id, tenant_id, data, created_at, updated_at FROM records + WHERE kind = $1 AND id = $2`, + [kind, id], + ); + return row ? toEntity(row) : undefined; + }, + + async create(entity) { + const row = await queryOne( + `INSERT INTO records (kind, id, tenant_id, data, created_at, updated_at) + VALUES ($1, $2, $3, $4::jsonb, $5, $5) + RETURNING id, tenant_id, data, created_at, updated_at`, + [kind, entity.id, entity.tenantId, JSON.stringify(entity), entity.createdAt], + ); + return toEntity(row!); + }, + + async put(entity) { + // Konflikt na primarnim klici (kind, id), viz migraci 002_records.sql. + await query( + `INSERT INTO records (kind, id, tenant_id, data, created_at, updated_at) + VALUES ($1, $2, $3, $4::jsonb, $5, now()) + ON CONFLICT (kind, id) DO UPDATE + SET data = excluded.data, tenant_id = excluded.tenant_id, updated_at = now()`, + [kind, entity.id, entity.tenantId, JSON.stringify(entity), entity.createdAt], + ); + }, + + async update(id, patch, options) { + const current = await this.get(id, options); + if (!current) return undefined; + + // ID ani cas vzniku se prepsat nesmi, i kdyby prisly v patchi. + const { id: _id, createdAt: _createdAt, ...rest } = patch as Partial; + const merged = { ...current, ...(rest as Partial), updatedAt: nowIso() }; + + const row = await queryOne( + `UPDATE records SET data = $3::jsonb, tenant_id = $4, updated_at = now() + WHERE kind = $1 AND id = $2 + RETURNING id, tenant_id, data, created_at, updated_at`, + [kind, id, JSON.stringify(merged), merged.tenantId], + ); + return row ? toEntity(row) : undefined; + }, + + async remove(id, options) { + const current = await this.get(id, options); + if (!current) return false; + await query('DELETE FROM records WHERE kind = $1 AND id = $2', [kind, id]); + return true; + }, + + async flush() { + // Databaze nic neodklada. + }, + }; +} diff --git a/src/data/store/types.ts b/src/data/store/types.ts new file mode 100644 index 0000000..9379f60 --- /dev/null +++ b/src/data/store/types.ts @@ -0,0 +1,80 @@ +/** + * Obecne uloziste zaznamu. + * + * Kazda nova entita (typy ticketu, akce, role, widgety, ...) potrebuje totez: + * seznam za firmu, cteni podle ID, zapis, mazani. Napsat to pro kazdou zvlast + * ve dvou backendech znamena osm skoro stejnych souboru, ze kterych se jeden + * casem opravi a ostatni ne. + * + * Proto jedno rozhrani a dve implementace: + * - `store/local.ts` JSON soubor nebo cista pamet, + * - `store/postgres.ts` tabulka `records` s JSONB. + * + * Vyber je na jednom miste v `store/index.ts`. + * + * Konektory maji vlastni uloziste zamerne: nesou sifrovana tajemstvi a potrebuji + * castecny unikatni index na vychozi napojeni. To se v obecnem ulozisti resit + * neda, viz documentation/14-databaze.md. + */ + +/** Kazdy zaznam ma ID a vi, kdy vznikl. */ +export interface Entity { + id: string; + createdAt: string; + updatedAt: string; +} + +/** + * Zaznam patrici firme. + * + * `tenantId: null` znamena platformni zaznam, tedy nas, ne zakaznikuv. + * Typicky systemova role nebo sluzba dostupna vsem. + */ +export interface TenantEntity extends Entity { + tenantId: string | null; +} + +export interface ListOptions { + /** + * Ze kterych firem se smi vracet. **Povinny argument**, ne volitelny. + * Zapomenuty filtr tak neznamena "vse", ale nezkompiluje se. + */ + tenantIds: string[]; + /** true = vrati i platformni zaznamy (`tenantId: null`). */ + includeGlobal?: boolean; +} + +export interface EntityStore { + readonly kind: string; + /** Nacte data z uloziste. Vola se jednou pri startu. */ + init(seed?: () => T[]): Promise; + list(options: ListOptions): Promise; + /** Vsechny zaznamy bez ohledu na firmu. Jen pro spravce platformy. */ + listAll(): Promise; + get(id: string, options: ListOptions): Promise; + /** Zaznam bez kontroly firmy. Volajici si ji musi overit sam. */ + getRaw(id: string): Promise; + create(entity: T): Promise; + /** + * Zapise zaznam tak, jak je - zalozi nebo prepise. + * + * Je to tady pro entity, ktere se v pameti meni na miste a pak se ukladaji + * cele (tickety, automatizace, rozlozeni dashboardu). `update` s patchem by + * u nich znamenal skladat rozdil, ktery volajici uz zna cely. + */ + put(entity: T): Promise; + update(id: string, patch: Partial, options: ListOptions): Promise; + remove(id: string, options: ListOptions): Promise; + /** Dokonci rozepsany zapis. Vola se pri ukonceni procesu. */ + flush(): Promise; +} + +export function nowIso(): string { + return new Date().toISOString(); +} + +/** Vidi volajici tenhle zaznam? */ +export function isVisible(entity: TenantEntity, options: ListOptions): boolean { + if (entity.tenantId === null) return options.includeGlobal === true; + return options.tenantIds.includes(entity.tenantId); +} diff --git a/src/data/tenantFeatures.ts b/src/data/tenantFeatures.ts new file mode 100644 index 0000000..23dbd56 --- /dev/null +++ b/src/data/tenantFeatures.ts @@ -0,0 +1,169 @@ +/** + * Co firma vubec ma: zalozky, limity, zpristupnene sluzby. + * + * **Dve vrstvy s jinym vlastnikem, ktere se nesmi michat:** + * + * | Vrstva | Kdo nastavuje | Znamena | + * | --------------- | ---------------- | ------------------------------ | + * | `TenantFeatures`| my | co ma firma zaplacene a zapnute | + * | `Role` | admin te firmy | kdo z jejich lidi to smi | + * + * Efektivni viditelnost je prunik. Vypnuty modul neexistuje ani pro admina te + * firmy - nema si ho jak zapnout, protoze ho nema. Kdyby to byla jedna vrstva, + * klientsky admin by si mohl zapnout, co si nekoupil. + * + * Popis je v documentation/17-zalozky-a-limity.md. + */ + +import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { withCache } from './store/cached.js'; +import { hasAnyPermission } from './permissions.js'; +import type { User } from '../types.js'; + +/** Klic zalozky. Frontend si ho mapuje na cestu a komponentu. */ +export type ModuleKey = + | 'overview' + | 'automations' + | 'services' + | 'connectors' + | 'scripts' + | 'tickets' + | 'incidents' + | 'settings'; + +export interface ModuleDefinition { + key: ModuleKey; + label: string; + /** Aspon jedno z tehle prav staci, aby zalozka mela smysl. */ + permissions: string[]; + /** true = zalozku nejde vypnout, bez ni by portal nemel kde zacit. */ + required?: boolean; + /** true = jen pro spravce platformy, firma ji nikdy nevidi. */ + platform?: boolean; +} + +/** + * Katalog modulu. Zdroj pravdy o tom, co lze firme zapnout. + * Nova zalozka = zaznam tady plus cesta na klientovi. + */ +export const moduleCatalog: ModuleDefinition[] = [ + { key: 'overview', label: 'Přehled', permissions: [], required: true }, + { key: 'tickets', label: 'Tickety', permissions: ['ticket.view'] }, + { key: 'automations', label: 'Automatizace', permissions: ['automation.edit'] }, + { key: 'services', label: 'Služby', permissions: ['connector.manage'] }, + { key: 'connectors', label: 'Konektory', permissions: ['connector.manage'] }, + { key: 'incidents', label: 'Incidenty', permissions: ['ticket.view'] }, + { key: 'settings', label: 'Nastavení', permissions: [], required: true }, + // Skripty jsou nase vykonna cast, klient je needituje. + { key: 'scripts', label: 'Skripty', permissions: ['script.edit'], platform: true }, +]; + +export interface TenantLimits { + automations: number; + widgets: number; + actions: number; + connectors: number; +} + +export interface TenantFeatures extends TenantEntity { + /** Ktere moduly firma ma. Povinne se doplnuji vzdy. */ + modules: ModuleKey[]; + limits: TenantLimits; + /** + * Ktere sluzby firma vidi navic nad `visibility: everyone`. + * Prazdne pole = jen to, co je verejne. + */ + serviceIds: string[]; +} + +export const featuresStore = defineStore('tenantFeatures'); +const cache = withCache(featuresStore); + +export const defaultLimits: TenantLimits = { + automations: 50, + widgets: 12, + actions: 30, + connectors: 20, +}; + +/** Vychozi sada: vsechno krome platformnich modulu. */ +export function defaultModules(): ModuleKey[] { + return moduleCatalog.filter((module) => !module.platform).map((module) => module.key); +} + +export function seedFeatures(): TenantFeatures[] { + const timestamp = nowIso(); + // Vychozi zaznam je platformni (tenantId null) a plati pro kazdou firmu, + // ktera svuj vlastni nema. Diky tomu nova firma neni bez zalozek. + return [ + { + id: 'feat_default', + tenantId: null, + modules: defaultModules(), + limits: defaultLimits, + serviceIds: [], + createdAt: timestamp, + updatedAt: timestamp, + }, + ]; +} + +export async function refreshFeatures(): Promise { + await cache.refresh(); +} + +/** Nastaveni firmy, nebo vychozi, kdyz vlastni nema. */ +export function featuresOf(tenantId: string | null): TenantFeatures { + const own = tenantId ? cache.find((item) => item.tenantId === tenantId) : undefined; + if (own) return own; + + const fallback = cache.find((item) => item.tenantId === null); + if (fallback) return fallback; + + // Bez ulozeneho nastaveni radsi vychozi v pameti nez prazdny portal. + const timestamp = nowIso(); + return { + id: 'feat_fallback', + tenantId: null, + modules: defaultModules(), + limits: defaultLimits, + serviceIds: [], + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +export function limitsOf(tenantId: string | null): TenantLimits { + return featuresOf(tenantId).limits; +} + +export interface NavItem { + key: ModuleKey; + label: string; +} + +/** + * Zalozky, ktere ma uzivatel videt. + * + * Prunik dvou vrstev: co firma ma a na co ma uzivatel pravo. Modul bez + * pozadovanych prav se nevykresli, i kdyz firma modul ma - jinak by clovek + * klikl a dostal 403. + */ +export function navFor(user: User, tenantId: string | null): NavItem[] { + const features = featuresOf(tenantId); + const enabled = new Set(features.modules); + + return moduleCatalog + .filter((module) => { + if (module.platform) return user.platformAdmin; + if (!module.required && !enabled.has(module.key)) return false; + if (module.permissions.length === 0) return true; + return hasAnyPermission(user, module.permissions); + }) + .map((module) => ({ key: module.key, label: module.label })); +} + +/** Vidi firma tuhle sluzbu diky zpristupneni? */ +export function tenantHasService(tenantId: string | null, serviceId: string): boolean { + return featuresOf(tenantId).serviceIds.includes(serviceId); +} diff --git a/src/data/ticketActions.ts b/src/data/ticketActions.ts new file mode 100644 index 0000000..8220633 --- /dev/null +++ b/src/data/ticketActions.ts @@ -0,0 +1,275 @@ +/** + * Definice akci na ticketu. + * + * Akce a automatizacni strom jsou **dve samostatne veci**. Akce se vaze na typ + * nebo tag ticketu a spousti ji clovek kliknutim, strom se spousti udalosti + * a dela si to sam. Spolecny je pod obojim jen katalog sluzeb a model kroku. + * + * Telo akce ma tri druhy, kazdy je jina uroven slozitosti pro tehoz cloveka: + * - `operation` jeden krok sluzby, nastavi se formularem, + * - `tree` vlastni strom, tentyz builder jako u automatizaci, + * - `script` vlastni skript, kdyz jde o prevod dat. + * + * Popis je v documentation/09-navrh-rozsireni.md, sekce 1 a 2. + */ + +import type { FlowStep } from './automationStore.js'; +import { operatorAllowedForType, type ConditionOperator } from './conditions.js'; +import type { PermissionDefinition } from './permissions.js'; +import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { withCache } from './store/cached.js'; +import { findService } from './services.js'; +import { ticketScopeFields, type TicketType } from './ticketTypes.js'; + +/** Podminka nad ticketem. Vsechny musi platit, aby se tlacitko ukazalo. */ +export interface ActionCondition { + /** ID pole ze scope ticketu, napr. `ticket.status`. */ + fieldId: string; + operator: ConditionOperator; + value?: string; +} + +export type ActionBody = + /** Jeden krok sluzby. Nejcastejsi pripad, nastavi se bez builderu. */ + | { + kind: 'operation'; + serviceId: string; + operationId: string; + /** null = vychozi konektor firmy. */ + connectorId: string | null; + inputs: Record; + } + /** Vlastni strom. Tentyz model i builder jako u automatizaci. */ + | { kind: 'tree'; steps: FlowStep[] } + /** Vlastni skript. Podminky a limity jsou u skriptu. */ + | { kind: 'script'; scriptId: string; inputs: Record }; + +/** Na co se uzivatel doptá pred spustenim. */ +export interface ActionFormField { + id: string; + label: string; + kind: 'text' | 'longtext' | 'choice'; + required: boolean; + options?: Array<{ value: string; label: string }>; + hint?: string; +} + +export interface TicketAction extends TenantEntity { + tenantId: string; + /** Na co se vaze. Aspon jedno z obojiho, jinak by se nabizela vsude. */ + ticketTypeIds: string[]; + tags: string[]; + label: string; + icon: string; + style: 'primary' | 'default' | 'danger'; + order: number; + /** Kdy se tlacitko vubec ukaze. */ + visibleWhen: ActionCondition[]; + /** Text potvrzeni, null = spusti se hned. */ + confirm: string | null; + form: ActionFormField[]; + body: ActionBody; + enabled: boolean; +} + +export const actionStore = defineStore('ticketAction'); +const cache = withCache(actionStore); + +/** Pravo k akci vznika spolu s ni. Admin pak zaskrtava akce, ne prava. */ +export function actionPermission(actionId: string): string { + return `action:${actionId}`; +} + +export function seedActions(): TicketAction[] { + const timestamp = nowIso(); + return [ + { + id: 'tka_send_idoklad', + tenantId: 'tnt_automia', + ticketTypeIds: ['tt_order'], + tags: [], + label: 'Odeslat do iDokladu', + icon: 'Receipt', + style: 'primary', + order: 1, + // Dvakrat odeslana objednavka by znamenala dva doklady. + visibleWhen: [{ fieldId: 'ticket.status', operator: 'neq', value: 'resolved' }], + confirm: 'Objednávka se odešle do iDokladu. Pokračovat?', + form: [], + body: { + kind: 'operation', + serviceId: 'idoklad', + operationId: 'create-issued-invoice', + connectorId: null, + inputs: { + partnerId: '{{partnerId}}', + description: 'Objednávka {{orderNumber}}', + itemName: 'Zboží dle objednávky {{orderNumber}}', + unitPrice: '{{orderTotal}}', + }, + }, + enabled: true, + createdAt: timestamp, + updatedAt: timestamp, + }, + ]; +} + +export async function refreshActions(): Promise { + await cache.refresh(); +} + +export function listActions(tenantIds: string[]): TicketAction[] { + return cache + .all() + .filter((action) => tenantIds.includes(action.tenantId)) + .sort((a, b) => a.order - b.order || a.label.localeCompare(b.label, 'cs')); +} + +export function findAction(id: string): TicketAction | undefined { + return cache.byId(id); +} + +/** Prava vznikla z akci. Registruji se do katalogu prav, aby je role nabidly. */ +export function actionPermissions(): PermissionDefinition[] { + return cache.all().map((action) => ({ + key: actionPermission(action.id), + label: `Spustit: ${action.label}`, + group: 'Akce na ticketu', + })); +} + +// ------------------------------------------------------------- vyhodnocovani + +/** Hodnoty, proti kterym se vyhodnocuje `visibleWhen`. */ +export type TicketFacts = Record; + +function compare(operator: ConditionOperator, actual: unknown, expected: string | undefined): boolean { + const text = actual === null || actual === undefined ? '' : String(actual); + + switch (operator) { + case 'eq': + return text === (expected ?? ''); + case 'neq': + return text !== (expected ?? ''); + case 'contains': + return text.toLowerCase().includes((expected ?? '').toLowerCase()); + case 'startsWith': + return text.toLowerCase().startsWith((expected ?? '').toLowerCase()); + case 'isEmpty': + return text.trim() === ''; + case 'isNotEmpty': + return text.trim() !== ''; + case 'isTrue': + return actual === true || text === 'true'; + case 'isFalse': + return actual === false || text === 'false' || text === ''; + case 'gt': + case 'gte': + case 'lt': + case 'lte': { + const left = Number(actual); + const right = Number(expected); + if (!Number.isFinite(left) || !Number.isFinite(right)) return false; + if (operator === 'gt') return left > right; + if (operator === 'gte') return left >= right; + if (operator === 'lt') return left < right; + return left <= right; + } + default: + return false; + } +} + +/** Plati vsechny podminky? Prazdny seznam znamena "vzdy". */ +export function conditionsPass(conditions: ActionCondition[], facts: TicketFacts): boolean { + return conditions.every((condition) => compare(condition.operator, facts[condition.fieldId], condition.value)); +} + +/** + * Akce, ktere na ticket sedi. + * + * Filtruje **server**, ne klient. Je to totez pravidlo jako u `access`: + * kdyby si klient pocital, ktera tlacitka ukazat, pocitalo by se to na dvou + * mistech a jednou se to rozejde. + */ +export function actionsForTicket( + tenantIds: string[], + ticket: { typeId: string | null; tags: string[] }, + facts: TicketFacts, + can: (permission: string) => boolean, +): TicketAction[] { + return listActions(tenantIds).filter((action) => { + if (!action.enabled) return false; + + const typeMatches = + action.ticketTypeIds.length === 0 || + (ticket.typeId !== null && action.ticketTypeIds.includes(ticket.typeId)); + const tagMatches = + action.tags.length === 0 || action.tags.some((tag) => ticket.tags.includes(tag)); + + // Prazdna vazba na obou stranach by znamenala akci vsude, to nechceme. + if (action.ticketTypeIds.length === 0 && action.tags.length === 0) return false; + if (!typeMatches || !tagMatches) return false; + + if (!can(actionPermission(action.id))) return false; + return conditionsPass(action.visibleWhen, facts); + }); +} + +// ------------------------------------------------------------------ validace + +/** + * Overi definici akce. + * + * Kontroluje se pri ulozeni, aby se preklep nedozvedel uzivatel az z padleho + * behu. Vraci popisy problemu, prazdne pole znamena v poradku. + */ +export function validateAction(action: TicketAction, types: TicketType[]): string[] { + const problems: string[] = []; + + if (action.ticketTypeIds.length === 0 && action.tags.length === 0) { + problems.push('Vyberte typ ticketu nebo tag, jinak by se akce nabízela všude.'); + } + + for (const typeId of action.ticketTypeIds) { + if (!types.some((type) => type.id === typeId)) { + problems.push(`Typ ticketu ${typeId} v této firmě neexistuje.`); + } + } + + // Podminky se smi ptat jen na to, co ticket opravdu ma. + const type = types.find((item) => action.ticketTypeIds.includes(item.id)); + const available = new Map(ticketScopeFields(type).map((field) => [field.id, field])); + + for (const condition of action.visibleWhen) { + const field = available.get(condition.fieldId); + if (!field) { + problems.push(`Podmínka odkazuje na pole ${condition.fieldId}, které ticket nemá.`); + continue; + } + if (!operatorAllowedForType(condition.operator, field.type)) { + problems.push( + `Operátor ${condition.operator} nelze použít na pole ${field.name} typu ${field.type}.`, + ); + } + } + + // Kopie do lokalni promenne: v callbacku by TypeScript zuzeni typu ztratil. + const body = action.body; + + if (body.kind === 'operation') { + const service = findService(body.serviceId); + if (!service) { + problems.push(`Služba ${body.serviceId} neexistuje.`); + } else if (!service.actions.some((operation) => operation.id === body.operationId)) { + problems.push(`Služba ${service.name} nemá akci ${body.operationId}.`); + } + } + + if (body.kind === 'tree' && body.steps.length === 0) { + problems.push('Strom akce nemá žádný krok.'); + } + + return problems; +} diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 417fadb..73622ca 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -10,11 +10,16 @@ * - ticket ma vzdy jednoho resitele (nebo zadneho), aby slo rict "mas to u sebe", * - ticket si nese strom zaznamu o tom, co se s nim delo a co ktera sluzba vratila. * - * POZOR: data jsou v pameti procesu, restart API je vrati na vychozi sadu. + * Data se drzi v pameti a po kazde zmene se cely ticket zapise do uloziste + * (`withMirror`). Pri startu se cte uloziste, ukazkova sada nize se pouzije jen + * kdyz je prazdne. Kam se zapisuje - databaze, soubor, nebo nikam - rozhoduje + * `data/store/index.ts`, tady se to neresi. */ import { publish } from '../events/bus.js'; import { findPerson, type Person } from './people.js'; +import { defineStore } from './store/index.js'; +import { withMirror } from './store/mirror.js'; export type TicketStatus = 'new' | 'open' | 'waiting' | 'resolved'; export type TicketPriority = 'low' | 'normal' | 'high' | 'critical'; @@ -153,6 +158,63 @@ const tickets: StoredTicket[] = []; /** Log ticketu drzime zvlast - je to jina zivotnost i jiny objem dat. */ const traces = new Map(); +/** + * Tvar v ulozisti. Log je soucasti zaznamu zamerne: v pameti se drzi zvlast + * kvuli objemu, ale ukladat ho jako druhou entitu by znamenalo dva zapisy + * pri kazdem kroku automatizace a moznost, ze jeden z nich selze. + */ +interface PersistedTicket extends StoredTicket { + trace: TicketTraceEntry[]; +} + +const mirror = withMirror(defineStore('ticket')); + +/** Ulozi ticket vcetne logu. Necekana se, chyba se loguje. */ +function persist(ticket: StoredTicket): void { + mirror.save({ ...ticket, trace: traces.get(ticket.id) ?? [] }); +} + +/** + * Oznaci ticket jako zmeneny a ulozi ho. + * + * Kazda zmena jde skrz tohle, aby neslo upravit ticket a zapomenout na + * `updatedAt` nebo na zapis. Pary "prirad radek, uloz" se jinak rozejdou. + */ +function touch(ticket: StoredTicket): void { + ticket.updatedAt = new Date().toISOString(); + persist(ticket); +} + +/** + * Nacte tickety z uloziste. Vola se pri startu, viz data/bootstrap.ts. + * + * Kdyz uloziste nic nema, ulozi se ukazkova sada, ktera je v tuhle chvili + * v pameti. Po prvnim startu je tedy uloziste jediny zdroj pravdy. + */ +export async function initTickets(): Promise { + const rows = await mirror.load(() => + tickets.map((ticket) => ({ ...ticket, trace: traces.get(ticket.id) ?? [] })), + ); + + tickets.length = 0; + traces.clear(); + for (const row of rows) { + const { trace, ...stored } = row; + tickets.push(stored); + traces.set(row.id, trace ?? []); + } + + // Citac musi pokracovat za nejvyssim ulozenym cislem, jinak by nove tickety + // prepisovaly stare. + for (const ticket of tickets) { + const number = Number(/^TK-(\d+)$/.exec(ticket.id)?.[1] ?? 0); + if (number > ticketCounter) ticketCounter = number; + } + + // Log muze byt dlouhy, do citace radku se to nepocita. + traceCounter = [...traces.values()].reduce((sum, list) => sum + list.length, traceCounter); +} + let ticketCounter = 4_821; let traceCounter = 0; @@ -214,6 +276,11 @@ export function appendTrace(ticketId: string, inputs: TraceInput[]): number { } const before = existing.length; flattenTrace(inputs, null, existing); + + // Log je soucast ulozeneho ticketu, takze zapis do logu je zmena ticketu. + const ticket = tickets.find((item) => item.id === ticketId); + if (ticket) persist(ticket); + return existing.length - before; } @@ -806,6 +873,7 @@ export function createTicket(input: CreateTicketInput): Ticket { }; tickets.unshift(stored); traces.set(stored.id, flattenTrace(input.trace ?? [], null, [])); + persist(stored); publish('ticket.created', `Nový ticket ${stored.id}: ${stored.subject}`, { ticketId: stored.id, @@ -840,7 +908,7 @@ export function updateTicketStatus( const previous = ticket.status; ticket.status = status; - ticket.updatedAt = new Date().toISOString(); + touch(ticket); appendTrace(id, [ { @@ -888,7 +956,7 @@ export function assignTicket( } ticket.assigneeId = person?.id ?? null; - ticket.updatedAt = new Date().toISOString(); + touch(ticket); appendTrace(id, [ { @@ -926,7 +994,7 @@ export function setTicketType( ticket.typeId = typeId; if (fields) ticket.fields = { ...(ticket.fields ?? {}), ...fields }; - ticket.updatedAt = new Date().toISOString(); + touch(ticket); appendTrace(id, [ { kind: 'note', status: 'info', label: `Typ ticketu nastaven na ${typeId ?? 'bez typu'}` }, @@ -941,7 +1009,7 @@ export function setTicketTags(id: string, tags: string[], tenantIds: string[]): if (!ticket) return undefined; ticket.tags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))]; - ticket.updatedAt = new Date().toISOString(); + touch(ticket); publish('ticket.updated', `Ticket ${ticket.id} má upravené tagy`, { ticketId: ticket.id }); return toTicket(ticket); } @@ -962,7 +1030,7 @@ export function assignTicketGroup( ticket.assigneeGroupId = groupId; if (groupId) ticket.assigneeId = null; - ticket.updatedAt = new Date().toISOString(); + touch(ticket); appendTrace(id, [ { @@ -987,7 +1055,7 @@ export function addComment( return undefined; } - ticket.updatedAt = new Date().toISOString(); + touch(ticket); appendTrace(id, [{ kind: 'note', label: `${author}: ${text}`, status: 'info' }]); publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id }); diff --git a/src/data/ticketTypes.ts b/src/data/ticketTypes.ts new file mode 100644 index 0000000..394d391 --- /dev/null +++ b/src/data/ticketTypes.ts @@ -0,0 +1,167 @@ +/** + * Typy ticketu a vlastni pole. + * + * Bez typu neni na cem tlacitka rozlisovat a bez vlastnich poli neni s cim + * pracovat: akce "Potvrdit objednavku" potrebuje cislo objednavky, ne jen + * predmet a text. + * + * Dve `id` u pole nejsou zbytecna, je to totez rozdeleni, ktere uz v projektu je: + * - **podminky odkazuji na `id`**, aby prejmenovani nic nerozbilo, + * - **sablony odkazuji na `key`**, aby si to clovek precetl. + * + * Typ versus tag: typ je prave jeden a stoji za nim pole, tagu je libovolne + * mnozstvi a nestoji za nimi nic. Podrobnosti v documentation/09, sekce + * "Typ nebo tag". + */ + +import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { withCache } from './store/cached.js'; +import type { FieldType } from './conditions.js'; + +export interface TicketTypeField { + /** Stabilni, odkazuji se na nej podminky. Prejmenovani je nerozbije. */ + id: string; + /** Pouziva se v sablonach jako `{{orderNumber}}`. */ + key: string; + label: string; + type: FieldType; + required: boolean; + options?: Array<{ value: string; label: string }>; + hint?: string; +} + +export interface TicketType extends TenantEntity { + tenantId: string; + /** Kratky klic, napr. `order`. Pouziva se ve filtrech a podminkach. */ + key: string; + name: string; + /** Klic ikony, mapuje se na klientovi. */ + icon: string; + /** Vlastni workflow stavu. Prazdne = vychozi ctverice ticketu. */ + statuses: string[]; + fields: TicketTypeField[]; +} + +export const ticketTypeStore = defineStore('ticketType'); +const cache = withCache(ticketTypeStore); + +export function seedTicketTypes(): TicketType[] { + const timestamp = nowIso(); + return [ + { + id: 'tt_order', + tenantId: 'tnt_automia', + key: 'order', + name: 'Objednávka', + icon: 'ShoppingCart', + statuses: [], + fields: [ + { + id: 'fld_order_no', + key: 'orderNumber', + label: 'Číslo objednávky', + type: 'string', + required: true, + }, + { + id: 'fld_order_total', + key: 'orderTotal', + label: 'Částka', + type: 'number', + required: false, + }, + { + id: 'fld_order_partner', + key: 'partnerId', + label: 'ID odběratele v iDokladu', + type: 'number', + required: false, + hint: 'Umí ho dohledat akce Najít kontakt.', + }, + ], + createdAt: timestamp, + updatedAt: timestamp, + }, + ]; +} + +export async function refreshTicketTypes(): Promise { + await cache.refresh(); +} + +export function listTicketTypes(tenantIds: string[]): TicketType[] { + return cache + .all() + .filter((type) => tenantIds.includes(type.tenantId)) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')); +} + +export function findTicketType(id: string): TicketType | undefined { + return cache.byId(id); +} + +export function findTicketTypeByKey(tenantId: string, key: string): TicketType | undefined { + return cache.find((type) => type.tenantId === tenantId && type.key === key); +} + +/** + * Parametry, ktere typ ticketu prinasi do stromu. + * + * Vestavena pole ticketu plus vlastni pole typu. Tvarem odpovida + * `ProvidedField`, aby s tim builder umel pracovat bez zmeny. + */ +export function ticketScopeFields(type: TicketType | undefined): Array<{ + id: string; + name: string; + type: FieldType; + required: boolean; +}> { + const builtin = [ + { id: 'ticket.id', name: 'ticketId', type: 'string' as FieldType, required: true }, + { id: 'ticket.subject', name: 'subject', type: 'string' as FieldType, required: true }, + { id: 'ticket.body', name: 'body', type: 'string' as FieldType, required: false }, + { id: 'ticket.status', name: 'status', type: 'string' as FieldType, required: true }, + { id: 'ticket.priority', name: 'priority', type: 'string' as FieldType, required: true }, + { id: 'ticket.company', name: 'company', type: 'string' as FieldType, required: false }, + { id: 'ticket.contact', name: 'contact', type: 'string' as FieldType, required: false }, + { id: 'ticket.reply', name: 'reply', type: 'string' as FieldType, required: false }, + ]; + + if (!type) return builtin; + + return [ + ...builtin, + ...type.fields.map((field) => ({ + id: `ticket.${field.id}`, + name: field.key, + type: field.type, + required: field.required, + })), + ]; +} + +/** + * Overi hodnoty vlastnich poli proti typu. + * Vraci popisy problemu, prazdne pole znamena v poradku. + */ +export function validateTicketFields( + type: TicketType | undefined, + values: Record, +): string[] { + if (!type) return []; + + const problems: string[] = []; + const known = new Set(type.fields.map((field) => field.key)); + + for (const field of type.fields) { + const value = values[field.key]; + const missing = value === undefined || value === null || value === ''; + if (field.required && missing) problems.push(`${field.label} je povinné.`); + } + + for (const key of Object.keys(values)) { + if (!known.has(key)) problems.push(`Typ ${type.name} pole „${key}" nemá.`); + } + + return problems; +} diff --git a/src/openapi.ts b/src/openapi.ts index 6b6893a..16b683e 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1,5 +1,110 @@ import { config } from './config.js'; +/** + * Sprava zaznamu ma u kazde entity stejnou petici endpointu, protoze ji na + * serveru dela jedna fabrika (`routes/crud.ts`). Popisovat ji devetkrat rucne + * by znamenalo devet mist, ktere se casem rozejdou. + */ +function crudPaths(entity: { + /** Cast cesty, napr. `roles`. */ + path: string; + /** Jak se o tom mluvi v popisu, napr. `roli`. */ + label: string; + /** Pravo, ktere je na zapis potreba. */ + permission: string; +}) { + const id = { name: 'id', in: 'path', required: true, schema: { type: 'string' } }; + const body = { + required: true, + content: { 'application/json': { schema: { type: 'object' } } }, + }; + const record = { + description: 'Zaznam', + content: { 'application/json': { schema: { type: 'object' } } }, + }; + const denied = { '403': { description: `Chybi pravo ${entity.permission}` } }; + const base = `/api/dashboard/settings/${entity.path}`; + + return { + [base]: { + get: { + tags: ['Nastaveni'], + summary: `Seznam - ${entity.label}`, + description: 'Vraci jen zaznamy firem, do kterych volajici patri.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Seznam', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { items: { type: 'array', items: { type: 'object' } } }, + }, + }, + }, + }, + ...denied, + }, + }, + post: { + tags: ['Nastaveni'], + summary: `Vytvorit - ${entity.label}`, + security: [{ bearerAuth: [] }], + requestBody: body, + responses: { '201': record, '400': { description: 'Neplatny vstup' }, ...denied }, + }, + }, + [`${base}/{id}`]: { + get: { + tags: ['Nastaveni'], + summary: `Detail - ${entity.label}`, + security: [{ bearerAuth: [] }], + parameters: [id], + responses: { '200': record, '404': { description: 'Neexistuje' }, ...denied }, + }, + patch: { + tags: ['Nastaveni'], + summary: `Upravit - ${entity.label}`, + description: 'Posilaji se jen menena pole. ID a cas vzniku se prepsat nedaji.', + security: [{ bearerAuth: [] }], + parameters: [id], + requestBody: body, + responses: { + '200': record, + '400': { description: 'Neplatny vstup' }, + '404': { description: 'Neexistuje' }, + ...denied, + }, + }, + delete: { + tags: ['Nastaveni'], + summary: `Smazat - ${entity.label}`, + security: [{ bearerAuth: [] }], + parameters: [id], + responses: { + '204': { description: 'Smazano' }, + '404': { description: 'Neexistuje' }, + ...denied, + }, + }, + }, + }; +} + +/** Entity, ktere se spravuji v Nastaveni. Jeden radek na entitu. */ +const settingsEntities = [ + { path: 'tenants', label: 'firmy', permission: 'tenant.manage' }, + { path: 'users', label: 'uzivatele', permission: 'user.manage' }, + { path: 'roles', label: 'role a prava', permission: 'role.manage' }, + { path: 'people', label: 'resitele', permission: 'people.manage' }, + { path: 'groups', label: 'skupiny resitelu', permission: 'group.manage' }, + { path: 'ticket-types', label: 'typy ticketu', permission: 'ticketType.manage' }, + { path: 'actions', label: 'akce na ticketu', permission: 'action.manage' }, + { path: 'widgets', label: 'vlastni widgety', permission: 'widget.manage' }, + { path: 'features', label: 'zalozky firmy', permission: 'tenant.manage' }, +]; + /** * OpenAPI popis API. * @@ -29,6 +134,8 @@ export function buildOpenApiDocument() { { name: 'Sluzby', description: 'Katalog toho, co umime napojit' }, { name: 'Konektory', description: 'Napojeni firmy na sluzbu vcetne pristupovych udaju' }, { 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' }, @@ -556,6 +663,11 @@ export function buildOpenApiDocument() { }, }, paths: { + // Petice endpointu za kazdou entitu v Nastaveni, viz `crudPaths` vyse. + ...settingsEntities.reduce( + (all, entity) => ({ ...all, ...crudPaths(entity) }), + {} as Record, + ), '/health': { get: { tags: ['Provoz'], @@ -954,6 +1066,374 @@ export function buildOpenApiDocument() { }, }, }, + '/api/dashboard/tickets/{id}/type': { + post: { + tags: ['Tickety'], + summary: 'Nastavit typ ticketu', + description: + 'Typ rozhoduje, ktera vlastni pole ticket ma a ktere akce se na nem ukazou. ' + + 'Pri zmene typu se hodnoty poli **nemazou**, jen prestanou byt videt.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['typeId'], + properties: { + typeId: { type: 'string', nullable: true, example: 'tt_order' }, + fields: { + type: 'object', + description: 'Hodnoty vlastnich poli. Klic je klic pole z typu ticketu.', + additionalProperties: true, + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Ulozeno', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, + }, + }, + '403': { description: 'Chybi pravo ticket.type.change' }, + '404': { description: 'Ticket nebo typ neexistuje' }, + }, + }, + }, + '/api/dashboard/tickets/{id}/tags': { + post: { + tags: ['Tickety'], + summary: 'Nastavit tagy', + description: 'Tagy se prepisuji cele. Prirustkova zmena by u vic lidi naraz kolidovala.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['tags'], + properties: { + tags: { type: 'array', maxItems: 20, items: { type: 'string', maxLength: 40 } }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Ulozeno', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, + }, + }, + '403': { description: 'Chybi pravo ticket.tag' }, + '404': { description: 'Neexistuje' }, + }, + }, + }, + '/api/dashboard/tickets/{id}/group': { + post: { + tags: ['Tickety'], + summary: 'Prehodit na skupinu resitelu', + description: + 'Prirazeni konkretnimu cloveku se **zrusi**. Kdyby zustalo, ticket by byl ' + + 've fronte skupiny i u cloveka a nikdo by nevedel, kdo to resi.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['groupId'], + properties: { groupId: { type: 'string', nullable: true } }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Ulozeno', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, + }, + }, + '403': { description: 'Chybi pravo ticket.assign.group' }, + '404': { description: 'Neexistuje' }, + }, + }, + }, + '/api/dashboard/tickets/{id}/actions': { + get: { + tags: ['Tickety'], + summary: 'Akce dostupne k ticketu', + description: + 'Vraci **jen akce, ktere v teto situaci opravdu jdou spustit**: sedi typ nebo ' + + 'tag, projdou podminky a volajici na ne ma pravo. Klient nefiltruje nic.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Akce', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + label: { type: 'string', example: 'Odeslat do iDokladu' }, + icon: { type: 'string' }, + style: { type: 'string', enum: ['primary', 'default', 'danger'] }, + confirm: { type: 'string', nullable: true }, + form: { type: 'array', items: { type: 'object' } }, + }, + }, + }, + }, + }, + }, + }, + }, + '404': { description: 'Ticket neexistuje' }, + }, + }, + }, + '/api/dashboard/tickets/{id}/actions/{actionId}': { + post: { + tags: ['Tickety'], + summary: 'Spustit akci', + description: + 'Vraci 200 **i kdyz akce selhala** - selhani akce neni chyba API. Cely prubeh ' + + 'vcetne toho, co sluzba vratila, se zapise do logu ticketu.', + security: [{ bearerAuth: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'actionId', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: false, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + form: { + type: 'object', + description: 'Hodnoty poli, ktera si akce vyzada.', + additionalProperties: { type: 'string' }, + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Akce probehla nebo selhala, viz ok', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { type: 'boolean' }, + summary: { type: 'string' }, + detail: { + type: 'string', + nullable: true, + description: 'Cele chybove hlaseni. Nikdy se nezkracuje.', + }, + durationMs: { type: 'integer' }, + }, + }, + }, + }, + }, + '403': { description: 'Chybi pravo na tuto akci' }, + '404': { description: 'Ticket nebo akce neexistuje' }, + }, + }, + }, + '/api/dashboard/widget-data': { + post: { + tags: ['Dashboard'], + summary: 'Data vlastnich widgetu', + description: + 'Jeden request na cely prehled. Deset dlazdic nesmi znamenat deset dotazu.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['widgetIds'], + properties: { widgetIds: { type: 'array', items: { type: 'string' } } }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Data po widgetech', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + }, + }, + }, + '/api/dashboard/widget-data/options': { + get: { + tags: ['Dashboard'], + summary: 'Co jde ve vlastnim widgetu nastavit', + description: 'Zdroje dat, mozna seskupeni a sirky. Aby to klient nemel v kodu.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Moznosti', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + }, + }, + }, + '/api/dashboard/settings/catalog': { + get: { + tags: ['Nastaveni'], + summary: 'Katalog prav a modulu', + description: + 'Seznam vsech prav a zalozek. Formular role tak nema seznam prav v kodu klienta.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Katalog', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + }, + }, + }, + '/api/dashboard/settings/users/{id}/password': { + patch: { + tags: ['Nastaveni'], + summary: 'Zmenit heslo', + description: + 'Svoje heslo si zmeni kazdy, cizi jen spravce platformy. Hash se nikdy nevraci.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['password'], + properties: { password: { type: 'string', minLength: 8 } }, + }, + }, + }, + }, + responses: { + '204': { description: 'Zmeneno' }, + '400': { description: 'Kratke heslo' }, + '403': { description: 'Cizi heslo bez prava' }, + }, + }, + }, + '/api/admin/impersonate': { + post: { + tags: ['Sprava platformy'], + summary: 'Prepnout se na jiny ucet', + description: + 'Vraci novy token s narokem `act`. Bez `writes` projde **jen GET**, cokoliv ' + + 'jineho vrati 403. Prepnuti i jeho ukonceni je v auditu.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['userId'], + properties: { + userId: { type: 'string' }, + allowWrites: { + type: 'boolean', + default: false, + description: 'true = i zapis. Musi se zapnout vedome.', + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Token na cizi ucet', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + '403': { description: 'Neni spravce platformy' }, + '404': { description: 'Ucet neexistuje' }, + }, + }, + }, + '/api/admin/impersonate/stop': { + post: { + tags: ['Sprava platformy'], + summary: 'Ukoncit prepnuti', + description: + 'Jen zaznam do auditu. Svuj puvodni token si drzi klient, server o nem nevi.', + security: [{ bearerAuth: [] }], + responses: { '204': { description: 'Zapsano' } }, + }, + }, + '/api/admin/impersonate/candidates': { + get: { + tags: ['Sprava platformy'], + summary: 'Koho lze prepnout', + description: 'Spravci platformy se nenabizeji.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Ucty', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + '403': { description: 'Neni spravce platformy' }, + }, + }, + }, + '/api/admin/audit': { + get: { + tags: ['Sprava platformy'], + summary: 'Audit', + description: + 'Kdo co udelal, vcetne odepreni a vcetne toho, kdo se za koho vydaval. ' + + 'Nejnovejsi nahore.', + security: [{ bearerAuth: [] }], + parameters: [ + { name: 'action', in: 'query', schema: { type: 'string' } }, + { name: 'result', in: 'query', schema: { type: 'string', enum: ['ok', 'denied'] } }, + { name: 'limit', in: 'query', schema: { type: 'integer', maximum: 500, default: 200 } }, + ], + responses: { + '200': { + description: 'Zaznamy', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + '403': { description: 'Neni spravce platformy' }, + }, + }, + }, '/api/dashboard/incidents': { get: { tags: ['Dashboard'], diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 97dd67e..5e6af37 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -217,13 +217,23 @@ dashboardRouter.delete('/layout', (req, res) => { // ------------------------------------------------------------------- tickety -/** Resitele vybrane firmy. Klient je potrebuje do nabidky prirazeni i do prehledu. */ +/** + * Resitele vybrane firmy. Klient je potrebuje do nabidky prirazeni i do prehledu. + * + * Skupiny jdou stejnym endpointem zamerne: kdo smi prirazovat ticket, smi ho + * prirazit i skupine, a druhy request na dve polozky nema smysl. Sprava skupin + * je jina vec a ma vlastni pravo v nastaveni. + */ dashboardRouter.get('/people', (req, res) => { const scope = scopeOrDeny(req, res); if (!scope) return; const me = findPersonByEmail(req.user!.email); - return res.json({ items: listPeople(scope.tenantIds), meId: me?.id ?? null }); + return res.json({ + items: listPeople(scope.tenantIds), + groups: listGroups(scope.tenantIds).map((group) => ({ id: group.id, name: group.name })), + meId: me?.id ?? null, + }); }); const ticketStatuses: TicketStatus[] = ['new', 'open', 'waiting', 'resolved']; diff --git a/web/src/auth/AuthContext.tsx b/web/src/auth/AuthContext.tsx index d75a668..46de216 100644 --- a/web/src/auth/AuthContext.tsx +++ b/web/src/auth/AuthContext.tsx @@ -2,11 +2,13 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } import type { ReactNode } from 'react'; import { apiFetch, getToken, setToken } from '@/lib/api'; -export type TenantRole = 'admin' | 'agent'; - +/** + * Clenstvi ve firme. `roleIds` je seznam, protoze role uz nejsou dva pevne + * stavy - viz src/data/permissions.ts na serveru. + */ export interface Membership { tenantId: string; - role: TenantRole; + roleIds: string[]; } export interface AuthUser { @@ -19,19 +21,39 @@ export interface AuthUser { memberships: Membership[]; } +/** Kdyz je prihlaseni prepnute na jiny ucet, tady je na koho. */ +export interface ImpersonationState { + userId: string; + userName: string; + allowWrites: boolean; +} + interface AuthState { user: AuthUser | null; /** true dokud probiha prvotni overeni ulozeneho tokenu */ loading: boolean; login: (email: string, password: string) => Promise; logout: () => Promise; + /** Vyplnene jen pri prepnuti na jiny ucet. */ + impersonation: ImpersonationState | null; + startImpersonation: (userId: string, allowWrites: boolean) => Promise; + stopImpersonation: () => void; } +/** + * Kde se drzi vlastni token pri prepnuti na jiny ucet. + * + * Server token impersonace nezneplatnuje - JWT je bezstavovy. Klient si proto + * svuj puvodni token odklada a pri ukonceni se k nemu vrati. + */ +const OWN_TOKEN_KEY = 'automia.ownToken'; + const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + const [impersonation, setImpersonation] = useState(null); // Pri startu zkusime obnovit session z ulozeneho tokenu. useEffect(() => { @@ -44,7 +66,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { } try { const data = await apiFetch<{ user: AuthUser }>('/api/auth/me'); - if (!cancelled) setUser(data.user); + if (!cancelled) { + setUser(data.user); + // Odlozeny vlastni token znamena, ze jsme prepnuti na jiny ucet. + if (sessionStorage.getItem(OWN_TOKEN_KEY)) { + setImpersonation({ + userId: data.user.id, + userName: data.user.name, + // Presny rezim vi jen server, klient jen ze je prepnuty. + allowWrites: false, + }); + } + } } catch (err) { // Expirovany/neplatny token - zahodime ho a pokracujeme jako neprihlaseny. console.warn('[auth] obnoveni session selhalo, mazu token:', err); @@ -71,6 +104,36 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(data.user); }, []); + const startImpersonation = useCallback(async (userId: string, allowWrites: boolean) => { + const data = await apiFetch<{ + token: string; + user: { id: string; name: string; email: string }; + allowWrites: boolean; + }>('/api/admin/impersonate', { method: 'POST', body: { userId, allowWrites } }); + + const own = getToken(); + if (own) sessionStorage.setItem(OWN_TOKEN_KEY, own); + + setToken(data.token); + setImpersonation({ + userId: data.user.id, + userName: data.user.name, + allowWrites: data.allowWrites, + }); + // Cela stranka se nacte znovu, aby se prekreslily zalozky i prava. + window.location.reload(); + }, []); + + const stopImpersonation = useCallback(() => { + const own = sessionStorage.getItem(OWN_TOKEN_KEY); + sessionStorage.removeItem(OWN_TOKEN_KEY); + // Server o konci jen informujeme kvuli auditu, token expiruje sam. + void apiFetch('/api/admin/impersonate/stop', { method: 'POST' }).catch(() => undefined); + setToken(own); + setImpersonation(null); + window.location.reload(); + }, []); + const logout = useCallback(async () => { try { await apiFetch('/api/auth/logout', { method: 'POST' }); @@ -79,13 +142,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { console.warn('[auth] logout na serveru selhal, odhlasuji lokalne:', err); } finally { setToken(null); + sessionStorage.removeItem(OWN_TOKEN_KEY); setUser(null); + setImpersonation(null); } }, []); const value = useMemo( - () => ({ user, loading, login, logout }), - [user, loading, login, logout], + () => ({ user, loading, login, logout, impersonation, startImpersonation, stopImpersonation }), + [user, loading, login, logout, impersonation, startImpersonation, stopImpersonation], ); return {children}; diff --git a/web/src/components/dashboard/DashboardLayout.tsx b/web/src/components/dashboard/DashboardLayout.tsx index f27067b..0fe9565 100644 --- a/web/src/components/dashboard/DashboardLayout.tsx +++ b/web/src/components/dashboard/DashboardLayout.tsx @@ -10,6 +10,7 @@ import { Blocks, ScrollText, Settings, + ShieldAlert, Workflow, X, } from 'lucide-react'; @@ -25,16 +26,23 @@ import { cn } from '@/lib/cn'; import { useApiQuery } from '@/lib/useApiQuery'; import type { Access } from '@/types/dashboard'; -const nav = [ - { to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true }, - { to: '/dashboard/automatizace', label: 'Automatizace', icon: Workflow, end: false }, - { to: '/dashboard/sluzby', label: 'Služby', icon: Blocks, end: false }, - { to: '/dashboard/konektory', label: 'Konektory', icon: Plug, end: false }, - { to: '/dashboard/skripty', label: 'Skripty', icon: ScrollText, end: false }, - { to: '/dashboard/tickety', label: 'Tickety', icon: LifeBuoy, end: false }, - { to: '/dashboard/incidenty', label: 'Incidenty', icon: AlarmClock, end: false }, - { to: '/dashboard/nastaveni', label: 'Nastavení', icon: Settings, end: false }, -]; +/** + * Mapa modulu na cesty a ikony. + * + * **Ktere zalozky se zobrazi, rozhoduje server** (`access.nav`) - je to prunik + * toho, co ma firma zapnute, a toho, na co ma uzivatel pravo. Klient zna jen + * to, kam ktery modul vede a jakou ma ikonu. + */ +const moduleRoutes: Record = { + overview: { to: '/dashboard', icon: LayoutDashboard, end: true }, + automations: { to: '/dashboard/automatizace', icon: Workflow, end: false }, + services: { to: '/dashboard/sluzby', icon: Blocks, end: false }, + connectors: { to: '/dashboard/konektory', icon: Plug, end: false }, + scripts: { to: '/dashboard/skripty', icon: ScrollText, end: false }, + tickets: { to: '/dashboard/tickety', icon: LifeBuoy, end: false }, + incidents: { to: '/dashboard/incidenty', icon: AlarmClock, end: false }, + settings: { to: '/dashboard/nastaveni', icon: Settings, end: false }, +}; /** * Shell klientskeho portalu: fixni sidebar (desktop) / vysouvaci (mobil) + obsah. @@ -54,8 +62,9 @@ function accessLabel(user: AuthUser | null, access: Access | null): string { if (user?.platformAdmin) return 'Správce platformy, vidí všechny firmy'; const count = access?.tenants.length ?? 0; if (count > 1) return `Přístup do ${count} firem`; - const role = user?.memberships[0]?.role; - return role === 'admin' ? 'Správce firmy' : 'Řešitel'; + // Role uz nejsou dva pevne stavy, takze se ukazuje pocet prav. + const permissions = access?.permissions.length ?? 0; + return permissions > 12 ? 'Správce firmy' : 'Řešitel'; } function DashboardShell() { @@ -97,13 +106,15 @@ function DashboardShell() {