Uloziste pro vsechna data, oprava .gitignore, dodelany navrh rozsireni

.gitignore mel vzorec `data/`, ktery se shodl i se `src/data/`. Sestnact
zdrojovych souboru tim tise chybelo v gitu vcetne cele slozky
`src/data/store/`. Opraveno na `/data/`, stejne v .dockerignore.

Tickety vcetne logu, automatizace, incidenty a rozlozeni dashboardu se po
kazde zmene ukladaji. Pomocnik `withMirror` je opak `withCache`: data se meni
v pameti a zapisuji cela, misto aby se po zapisu znovu nacitala. Citace ID se
pri startu dopocitaji z ulozenych zaznamu, takze novy ticket neprepise stary.

Detail ticketu umi typ, tagy, vlastni pole typu a prehozeni na skupinu.
Nastaveni ma prepnuti spravce na jiny ucet, vychozi jen pro cteni.
Skupiny resitelu chodi spolu s lidmi jednim requestem.

Dokumentace: rejstrik znovupouzitelnych funkci (15), navrh monetizace
a ceny za krok (16), popis nastaveni a prav (17). Doplneny endpointy
do openapi.ts, petice CRUD rout se generuje jednou funkci.

Overeno v rezimu souboru: zmeny prezily tvrde ukonceni procesu a po restartu
byly zpatky vcetne logu ticketu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 07:46:22 +02:00
co-authored by Claude Opus 5
parent e7cf499a0b
commit afbe948da3
37 changed files with 5594 additions and 106 deletions
+3 -1
View File
@@ -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/
+3 -1
View File
@@ -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/
+50 -8
View File
@@ -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 |
+57
View File
@@ -36,6 +36,11 @@ Vyzaduji `Authorization: Bearer <token>`:
| 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 <token>`:
| 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/<entita>`, 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.
+86
View File
@@ -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<T>(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<T>` | `lib/api.ts` | Jediná cesta na API: base path, token, `ApiError` s celým hlášením ze serveru. |
| `useApiQuery<T>` | `lib/useApiQuery.ts` | Načtení dat do stránky včetně `reload`. |
| `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`.
+95
View File
@@ -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.
+128
View File
@@ -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<T>` 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ý.
+54
View File
@@ -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<T>` 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.
+114
View File
@@ -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<string, unknown>;
result: 'ok' | 'denied';
}
export const auditStore = defineStore<AuditEntry>('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<string, unknown>;
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<void> {
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<AuditEntry[]> {
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);
}
+40 -4
View File
@@ -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<string, StoredAutomation>();
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<StoredAutomation>('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<void> {
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 {
+132
View File
@@ -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<TenantEntity>;
seed?: () => TenantEntity[];
}> = [
{ store: tenantStore as EntityStore<TenantEntity>, seed: seedTenants },
{ store: userStore as EntityStore<TenantEntity>, seed: seedUsers },
{ store: roleStore as EntityStore<TenantEntity>, seed: systemRoles },
{ store: personStore as EntityStore<TenantEntity>, seed: seedPeople },
{ store: groupStore as EntityStore<TenantEntity>, seed: seedGroups },
{ store: featuresStore as EntityStore<TenantEntity>, seed: seedFeatures },
{ store: ticketTypeStore as EntityStore<TenantEntity>, seed: seedTicketTypes },
{ store: actionStore as EntityStore<TenantEntity>, seed: seedActions },
{ store: customWidgetStore as EntityStore<TenantEntity>, seed: seedCustomWidgets },
// Audit vychozi sadu nema, zaznamy vznikaji az provozem.
{ store: auditStore as EntityStore<TenantEntity> },
];
/**
* 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<void> }> = [
{ 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<void> {
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<string> {
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;
}
+244
View File
@@ -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<Connector, 'values'> {
secrets: Record<string, unknown>;
}
export interface LocalConnectorsOptions {
store: SnapshotStore<StoredConnector>;
/** false = tajne hodnoty se do souboru nezapisuji, protoze neni cim sifrovat. */
canEncrypt: boolean;
}
export function createLocalConnectors(options: LocalConnectorsOptions): ConnectorRepository & {
init(): Promise<void>;
flush(): Promise<void>;
} {
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<string, number>();
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<void> {
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();
}
}
+152
View File
@@ -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>('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<void> {
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;
}
+31 -3
View File
@@ -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<string, LayoutItem[]>();
/**
* 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<StoredLayout>('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<void> {
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);
}
+47 -1
View File
@@ -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<StoredIncident>('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<void> {
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}`, {
+272
View File
@@ -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:<id>`
* 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>('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<string, { at: number; permissions: Set<PermissionKey> }>();
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<void> {
loadedRoles = await roleStore.listAll();
invalidatePermissions();
}
export function rolesById(): Map<string, Role> {
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<PermissionKey> {
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<PermissionKey>();
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));
}
+38
View File
@@ -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<void> | null = null;
let again = false;
export async function bootstrapDataRefresh(): Promise<void> {
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;
}
+146
View File
@@ -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<T> {
version: number;
savedAt: string;
items: T[];
}
const VERSION = 1;
const DEBOUNCE_MS = 200;
export interface SnapshotStore<T> {
readonly kind: 'memory' | 'file';
/** Popis pro portal a log. */
readonly location: string | null;
load(): Promise<T[]>;
/** Naplanuje zapis. Vraci hned, zapis probehne po slouceni. */
save(items: T[]): void;
/** Dokonci rozepsany zapis. Vola se pri ukonceni procesu. */
flush(): Promise<void>;
}
/** Uloziste, ktere nikam nezapisuje. Rezim pameti. */
export function memorySnapshot<T>(): SnapshotStore<T> {
return {
kind: 'memory',
location: null,
async load() {
return [];
},
save() {
// Zamerne nic. Pametovy rezim data neuklada.
},
async flush() {
// Zamerne nic.
},
};
}
export function fileSnapshot<T>(file: string): SnapshotStore<T> {
let pending: T[] | null = null;
let timer: NodeJS.Timeout | null = null;
let writing: Promise<void> = Promise.resolve();
async function writeNow(items: T[]): Promise<void> {
const payload: SnapshotFile<T> = {
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<T>;
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;
},
};
}
+53
View File
@@ -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<T extends TenantEntity> {
readonly store: EntityStore<T>;
/** Nacte kopii. Vola se pri startu a po kazdem zapisu. */
refresh(): Promise<void>;
/** Synchronni cteni vsech zaznamu. */
all(): T[];
find(predicate: (entity: T) => boolean): T | undefined;
byId(id: string): T | undefined;
}
export function withCache<T extends TenantEntity>(store: EntityStore<T>): CachedStore<T> {
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);
},
};
}
+97
View File
@@ -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>('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<string, EntityStore<TenantEntity>>();
/** 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<T extends TenantEntity>(kind: string): EntityStore<T> {
let backend: EntityStore<T> | null = null;
function resolve(): EntityStore<T> {
if (!backend) {
if (!ready) {
console.warn(`[stores] ${kind} se pouziva pred initStores, jede se v pameti`);
}
backend = useDatabase
? createPostgresStore<T>(kind)
: createLocalStore<T>({
kind,
file: config.dataDir === '' ? null : path.join(config.dataDir, `${kind}.json`),
});
}
return backend;
}
const store: EntityStore<T> = {
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<TenantEntity>);
return store;
}
/** Dopise vsechna uloziste na disk. Vola se pri ukonceni procesu. */
export async function flushStores(): Promise<void> {
await Promise.all([...registry.values()].map((store) => store.flush()));
}
/** Kolik druhu entit je zaregistrovanych. Pro diagnostiku. */
export function storeKinds(): string[] {
return [...registry.keys()].sort();
}
+110
View File
@@ -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<T extends TenantEntity>(
options: LocalStoreOptions,
): EntityStore<T> {
const snapshot: SnapshotStore<T> = options.file
? fileSnapshot<T>(options.file)
: memorySnapshot<T>();
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<TenantEntity>;
rows[index] = { ...rows[index], ...(rest as Partial<T>), 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();
},
};
}
+75
View File
@@ -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<StoredTicket>('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<T extends TenantEntity> {
readonly store: EntityStore<T>;
/**
* 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<T[]>;
/** Zapise cely zaznam, zalozi nebo prepise. Necekana se. */
save(entity: T): void;
/** Smaze zaznam. Necekana se. */
drop(id: string): void;
}
export function withMirror<T extends TenantEntity>(store: EntityStore<T>): MirroredStore<T> {
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));
},
};
}
+156
View File
@@ -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<string, unknown>;
created_at: Date;
updated_at: Date;
}
function toEntity<T extends TenantEntity>(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<T extends TenantEntity>(kind: string): EntityStore<T> {
/**
* 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<RecordRow>(
`SELECT id, tenant_id, data, created_at, updated_at FROM records
WHERE ${sql}
ORDER BY created_at`,
params,
);
return rows.map((row) => toEntity<T>(row));
},
async listAll() {
const rows = await query<RecordRow>(
`SELECT id, tenant_id, data, created_at, updated_at FROM records
WHERE kind = $1 ORDER BY created_at`,
[kind],
);
return rows.map((row) => toEntity<T>(row));
},
async get(id, options) {
const { sql, params } = scope(options);
const row = await queryOne<RecordRow>(
`SELECT id, tenant_id, data, created_at, updated_at FROM records
WHERE ${sql} AND id = $${params.length + 1}`,
[...params, id],
);
return row ? toEntity<T>(row) : undefined;
},
async getRaw(id) {
const row = await queryOne<RecordRow>(
`SELECT id, tenant_id, data, created_at, updated_at FROM records
WHERE kind = $1 AND id = $2`,
[kind, id],
);
return row ? toEntity<T>(row) : undefined;
},
async create(entity) {
const row = await queryOne<RecordRow>(
`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<T>(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<TenantEntity>;
const merged = { ...current, ...(rest as Partial<T>), updatedAt: nowIso() };
const row = await queryOne<RecordRow>(
`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<T>(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.
},
};
}
+80
View File
@@ -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<T extends TenantEntity> {
readonly kind: string;
/** Nacte data z uloziste. Vola se jednou pri startu. */
init(seed?: () => T[]): Promise<void>;
list(options: ListOptions): Promise<T[]>;
/** Vsechny zaznamy bez ohledu na firmu. Jen pro spravce platformy. */
listAll(): Promise<T[]>;
get(id: string, options: ListOptions): Promise<T | undefined>;
/** Zaznam bez kontroly firmy. Volajici si ji musi overit sam. */
getRaw(id: string): Promise<T | undefined>;
create(entity: T): Promise<T>;
/**
* 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<void>;
update(id: string, patch: Partial<T>, options: ListOptions): Promise<T | undefined>;
remove(id: string, options: ListOptions): Promise<boolean>;
/** Dokonci rozepsany zapis. Vola se pri ukonceni procesu. */
flush(): Promise<void>;
}
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);
}
+169
View File
@@ -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>('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<void> {
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);
}
+275
View File
@@ -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<string, string>;
}
/** 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<string, string> };
/** 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>('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<void> {
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<string, string | number | boolean | null>;
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;
}
+75 -7
View File
@@ -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<string, TicketTraceEntry[]>();
/**
* 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<PersistedTicket>('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<void> {
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 });
+167
View File
@@ -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>('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<void> {
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, unknown>,
): 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;
}
+480
View File
@@ -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<string, unknown>,
),
'/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'],
+12 -2
View File
@@ -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'];
+71 -6
View File
@@ -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<void>;
logout: () => Promise<void>;
/** Vyplnene jen pri prepnuti na jiny ucet. */
impersonation: ImpersonationState | null;
startImpersonation: (userId: string, allowWrites: boolean) => Promise<void>;
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<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const [impersonation, setImpersonation] = useState<ImpersonationState | null>(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<void>('/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<AuthState>(
() => ({ user, loading, login, logout }),
[user, loading, login, logout],
() => ({ user, loading, login, logout, impersonation, startImpersonation, stopImpersonation }),
[user, loading, login, logout, impersonation, startImpersonation, stopImpersonation],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
@@ -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<string, { to: string; icon: typeof LayoutDashboard; end: boolean }> = {
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() {
</div>
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
{nav.map((item) => {
const Icon = item.icon;
{(access.data?.nav ?? []).map((entry) => {
const route = moduleRoutes[entry.key];
if (!route) return null;
const Icon = route.icon;
return (
<NavLink
key={item.to}
to={item.to}
end={item.end}
key={entry.key}
to={route.to}
end={route.end}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm font-medium transition-colors',
@@ -114,7 +125,7 @@ function DashboardShell() {
}
>
<Icon className="size-4 shrink-0" />
{item.label}
{entry.label}
</NavLink>
);
})}
@@ -160,6 +171,8 @@ function DashboardShell() {
)}
<div className="lg:pl-64">
<ImpersonationBanner />
{/* Topbar */}
<header className="sticky top-0 z-30 flex h-18 items-center gap-4 border-b border-ink-600/60 bg-ink-900/85 px-5 backdrop-blur-xl sm:px-7">
<button
@@ -202,6 +215,34 @@ function DashboardShell() {
);
}
/**
* Pruh pri prepnuti na jiny ucet.
*
* Bez nej clovek zapomene a udela neco cizim jmenem. Proto je vyrazny a je
* na kazde strance.
*/
function ImpersonationBanner() {
const { impersonation, stopImpersonation } = useAuth();
if (!impersonation) return null;
return (
<div className="flex flex-wrap items-center gap-3 bg-warn-500/15 px-5 py-2 text-sm text-warn-400 sm:px-7">
<ShieldAlert className="size-4 shrink-0" />
<span>
Jste přihlášený za <strong>{impersonation.userName}</strong>
{impersonation.allowWrites ? ' včetně zápisu' : ' jen pro čtení'}.
</span>
<button
type="button"
onClick={stopImpersonation}
className="ml-auto rounded-lg border border-warn-400/50 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-warn-500/20"
>
Zpět na svůj účet
</button>
</div>
);
}
function initials(name: string | undefined): string {
if (!name) return '?';
return name
@@ -0,0 +1,540 @@
import { AlertTriangle, Pencil, Plus, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { apiFetch, ApiError } from '@/lib/api';
import { cn } from '@/lib/cn';
/**
* Obecna sprava zaznamu: tabulka, dialog na zalozeni a upravu, mazani.
*
* Deset stranek nastaveni by jinak znamenalo desetkrat tentyz kod na nacteni,
* formular, chybu a mazani. Rozdily jsou jen ve dvou vecech: jake sloupce
* ukazat a jaka pole vyplnit. Obojí je proto parametr.
*
* Serverova strana ma stejny princip, viz `src/routes/crud.ts`.
*/
export type FieldKind =
| 'text'
| 'textarea'
| 'number'
| 'checkbox'
| 'select'
| 'multiselect'
| 'tags'
| 'json';
export interface AdminField<T> {
/** Klic v tele requestu. */
name: string;
label: string;
kind: FieldKind;
required?: boolean;
hint?: string;
/** Volby u `select` a `multiselect`. */
options?: Array<{ value: string; label: string; group?: string }>;
/** Jen pri zakladani, napr. heslo. */
createOnly?: boolean;
/** false = pole se nezobrazi (napr. podle prav). */
visible?: (entity: T | null) => boolean;
/** Vychozi hodnota pri zakladani. */
initial?: unknown;
}
export interface AdminColumn<T> {
label: string;
render: (entity: T) => ReactNode;
/** Uzsi sloupec pro cisla a znacky. */
narrow?: boolean;
}
export interface EntityAdminProps<T extends { id: string }> {
title: string;
description?: string;
/** Cesta k API bez zaveru, napr. `/api/dashboard/settings/roles`. */
endpoint: string;
/** Odkud se bere seznam, kdyz je jiny nez `endpoint` (napr. vcetne vypnutych). */
listEndpoint?: string;
columns: Array<AdminColumn<T>>;
fields: Array<AdminField<T>>;
/** Sestavi telo requestu z hodnot formulare. */
toBody?: (values: Record<string, unknown>, entity: T | null) => Record<string, unknown>;
/** Naplni formular z existujiciho zaznamu. */
toForm?: (entity: T) => Record<string, unknown>;
/** false = zadny zapis, jen cteni. */
canWrite?: boolean;
/** false = zaznam nejde smazat (napr. systemova role). */
canDelete?: (entity: T) => boolean;
/** Doplnujici obsah nad tabulkou. */
children?: ReactNode;
/** Zavola se po kazdem uspesnem zapisu. */
onChanged?: () => void;
}
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
export function EntityAdmin<T extends { id: string }>({
title,
description,
endpoint,
listEndpoint,
columns,
fields,
toBody,
toForm,
canWrite = true,
canDelete,
children,
onChanged,
}: EntityAdminProps<T>) {
const [items, setItems] = useState<T[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<{ entity: T | null } | null>(null);
const load = useCallback(() => {
setLoading(true);
setError(null);
apiFetch<{ items: T[] }>(listEndpoint ?? endpoint)
.then((data) => setItems(data.items))
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Data se nepodařilo načíst.');
})
.finally(() => setLoading(false));
}, [endpoint, listEndpoint]);
useEffect(load, [load]);
async function remove(entity: T) {
try {
await apiFetch(`${endpoint}/${entity.id}`, { method: 'DELETE' });
load();
onChanged?.();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Smazání selhalo.');
}
}
return (
<section className="space-y-4">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-semibold text-white">{title}</h2>
{description && <p className="mt-0.5 max-w-2xl text-sm text-white/45">{description}</p>}
</div>
{canWrite && (
<Button size="sm" onClick={() => setEditing({ entity: null })}>
<Plus className="size-4" />
Přidat
</Button>
)}
</header>
{children}
<DataState loading={loading} error={error} onRetry={load} empty={(items?.length ?? 0) === 0}>
<div className="glass overflow-x-auto rounded-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-ink-600/60 text-left text-xs tracking-wide text-white/40 uppercase">
{columns.map((column) => (
<th key={column.label} className="px-4 py-2.5 font-semibold">
{column.label}
</th>
))}
{canWrite && <th className="px-4 py-2.5" />}
</tr>
</thead>
<tbody>
{(items ?? []).map((entity) => (
<tr key={entity.id} className="border-b border-ink-600/30 last:border-0">
{columns.map((column) => (
<td
key={column.label}
className={cn('px-4 py-2.5 align-top text-white/75', column.narrow && 'w-24')}
>
{column.render(entity)}
</td>
))}
{canWrite && (
<td className="px-4 py-2 text-right whitespace-nowrap">
<Button
size="sm"
variant="ghost"
onClick={() => setEditing({ entity })}
aria-label="Upravit"
>
<Pencil className="size-3.5" />
</Button>
{(canDelete?.(entity) ?? true) && (
<Button
size="sm"
variant="ghost"
onClick={() => remove(entity)}
aria-label="Smazat"
>
<Trash2 className="size-3.5" />
</Button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</DataState>
{editing && (
<EntityForm
title={editing.entity ? `Upravit: ${title}` : `Nový záznam: ${title}`}
endpoint={endpoint}
entity={editing.entity}
fields={fields}
toBody={toBody}
toForm={toForm}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
load();
onChanged?.();
}}
/>
)}
</section>
);
}
// -------------------------------------------------------------------- formular
function EntityForm<T extends { id: string }>({
title,
endpoint,
entity,
fields,
toBody,
toForm,
onClose,
onSaved,
}: {
title: string;
endpoint: string;
entity: T | null;
fields: Array<AdminField<T>>;
toBody?: (values: Record<string, unknown>, entity: T | null) => Record<string, unknown>;
toForm?: (entity: T) => Record<string, unknown>;
onClose: () => void;
onSaved: () => void;
}) {
const visible = useMemo(
() => fields.filter((field) => (field.visible ? field.visible(entity) : true)),
[fields, entity],
);
const [values, setValues] = useState<Record<string, unknown>>(() => {
if (entity) return toForm ? toForm(entity) : (entity as unknown as Record<string, unknown>);
const initial: Record<string, unknown> = {};
for (const field of fields) {
initial[field.name] = field.initial ?? defaultFor(field.kind);
}
return initial;
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [issues, setIssues] = useState<Array<{ field: string; message: string }>>([]);
function set(name: string, value: unknown) {
setValues((previous) => ({ ...previous, [name]: value }));
}
async function submit() {
setSaving(true);
setError(null);
setIssues([]);
// Pole jen pro zakladani se pri uprave neposilaji, jinak by prazdne heslo
// prepsalo to stavajici.
const payload = toBody
? toBody(values, entity)
: Object.fromEntries(
visible
.filter((field) => !(entity && field.createOnly))
.map((field) => [field.name, values[field.name]]),
);
try {
await apiFetch(entity ? `${endpoint}/${entity.id}` : endpoint, {
method: entity ? 'PATCH' : 'POST',
body: payload,
});
onSaved();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
if (err instanceof ApiError) setIssues(err.issues());
} finally {
setSaving(false);
}
}
return (
<Modal open onClose={onClose} title={title}>
<form
autoComplete="off"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
className="space-y-4 p-5"
>
{visible
.filter((field) => !(entity && field.createOnly))
.map((field) => (
<FieldInput
key={field.name}
field={field}
value={values[field.name]}
onChange={(value) => set(field.name, value)}
/>
))}
{error && (
<div className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
<p className="flex items-start gap-2 text-danger-400">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
{error}
</p>
{issues.length > 0 && (
<ul className="mt-1.5 ml-6 list-disc text-xs text-white/60">
{issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>{issue.message}</li>
))}
</ul>
)}
</div>
)}
<div className="flex gap-3 border-t border-ink-600/50 pt-4">
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Ukládám...' : entity ? 'Uložit' : 'Založit'}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={saving}>
Zrušit
</Button>
</div>
</form>
</Modal>
);
}
function defaultFor(kind: FieldKind): unknown {
if (kind === 'checkbox') return false;
if (kind === 'multiselect' || kind === 'tags') return [];
if (kind === 'number') return 0;
if (kind === 'json') return '';
return '';
}
/** Jedno pole podle druhu. Novy druh se pridava jen tady. */
function FieldInput<T>({
field,
value,
onChange,
}: {
field: AdminField<T>;
value: unknown;
onChange: (value: unknown) => void;
}) {
const label = (
<span className="mb-1 flex flex-wrap items-center gap-2 text-sm text-white/70">
{field.label}
{field.required && <span className="text-warn-400">*</span>}
</span>
);
if (field.kind === 'checkbox') {
return (
<label className="flex items-center gap-2 text-sm text-white/70">
<input
type="checkbox"
checked={value === true}
onChange={(event) => onChange(event.target.checked)}
className="size-4"
/>
{field.label}
{field.hint && <span className="text-xs text-white/35">{field.hint}</span>}
</label>
);
}
if (field.kind === 'multiselect') {
const selected = new Set(Array.isArray(value) ? (value as string[]) : []);
// Volby se sdruzuji do skupin, jinak je seznam dvaceti prav neprehledny.
const groups = new Map<string, typeof field.options>();
for (const option of field.options ?? []) {
const key = option.group ?? '';
groups.set(key, [...(groups.get(key) ?? []), option]);
}
return (
<div>
{label}
<div className="max-h-72 space-y-3 overflow-y-auto rounded-lg border border-ink-600/70 bg-ink-950/40 p-3">
{[...groups.entries()].map(([group, options]) => (
<div key={group}>
{group && (
<p className="mb-1 text-xs font-semibold tracking-wide text-white/40 uppercase">
{group}
</p>
)}
<div className="space-y-1">
{(options ?? []).map((option) => (
<label
key={option.value}
className="flex items-start gap-2 text-sm text-white/70"
>
<input
type="checkbox"
checked={selected.has(option.value)}
onChange={(event) => {
const next = new Set(selected);
if (event.target.checked) next.add(option.value);
else next.delete(option.value);
onChange([...next]);
}}
className="mt-0.5 size-3.5 shrink-0"
/>
{option.label}
</label>
))}
</div>
</div>
))}
</div>
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</div>
);
}
if (field.kind === 'tags') {
const tags = Array.isArray(value) ? (value as string[]) : [];
return (
<div>
{label}
<input
value={tags.join(', ')}
onChange={(event) =>
onChange(
event.target.value
.split(',')
.map((tag) => tag.trim())
.filter(Boolean),
)
}
placeholder="oddělte čárkou"
className={inputClass}
/>
{tags.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<Badge key={tag} tone="neutral">
{tag}
</Badge>
))}
</div>
)}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</div>
);
}
if (field.kind === 'select') {
return (
<label className="block">
{label}
<select
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
className={inputClass}
>
<option value="" className="bg-ink-850">
Vyberte
</option>
{(field.options ?? []).map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
</select>
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
);
}
if (field.kind === 'textarea' || field.kind === 'json') {
const isJson = field.kind === 'json';
const text = typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2);
const broken = isJson && text.trim() !== '' && !isValidJson(text);
return (
<label className="block">
{label}
<textarea
rows={isJson ? 8 : 3}
spellCheck={!isJson}
value={text}
onChange={(event) => onChange(event.target.value)}
className={cn(
inputClass,
'resize-y',
isJson && 'font-mono text-xs',
broken && 'border-warn-400/60',
)}
/>
{broken && <span className="mt-1 block text-xs text-warn-400">Tohle není platný JSON.</span>}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
);
}
return (
<label className="block">
{label}
<input
type={field.kind === 'number' ? 'number' : field.name.includes('password') ? 'password' : 'text'}
autoComplete={field.name.includes('password') ? 'new-password' : 'off'}
value={value === null || value === undefined ? '' : String(value)}
onChange={(event) =>
onChange(field.kind === 'number' ? Number(event.target.value) : event.target.value)
}
className={inputClass}
/>
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
);
}
function isValidJson(text: string): boolean {
try {
JSON.parse(text);
return true;
} catch {
return false;
}
}
/** Prevod textu z pole `json` na hodnotu. Rozbity JSON necha puvodni text. */
export function parseJsonField(value: unknown, fallback: unknown): unknown {
if (typeof value !== 'string') return value ?? fallback;
if (value.trim() === '') return fallback;
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
@@ -0,0 +1,503 @@
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { serviceIcon } from '@/lib/serviceIcons';
/**
* Tlacitka akci na ticketu.
*
* Seznam prichazi ze serveru **uz vyfiltrovany** podle typu, tagu, podminek
* a prav. Klient si nepocita, co ukazat - jinak by se to pocitalo na dvou
* mistech a jednou se rozejde.
*/
interface ActionFormField {
id: string;
label: string;
kind: 'text' | 'longtext' | 'choice';
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
}
interface TicketActionItem {
id: string;
label: string;
icon: string;
style: 'primary' | 'default' | 'danger';
confirm: string | null;
form: ActionFormField[];
}
interface RunResult {
ok: boolean;
summary: string;
detail: string | null;
durationMs: number;
}
/** Co z ticketu tahle komponenta potrebuje. Zamerne uzsi nez cely `Ticket`. */
export interface ActionableTicket {
id: string;
typeId: string | null;
tags: string[];
fields: Record<string, string | number | boolean | null>;
}
/**
* Typ ticketu tak, jak ho vraci nastaveni. Jen to, co je tady potreba.
*
* `type` je stejna sada jako u parametru kroku (`FieldType` na serveru),
* aby se na jedno pole dalo odkazovat z podminky i ze sablony.
*/
interface TicketTypeOption {
id: string;
name: string;
fields: Array<{
id: string;
key: string;
label: string;
type: 'string' | 'number' | 'boolean' | 'date' | 'object' | 'list';
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
}>;
}
export function TicketActions({
ticket,
onDone,
}: {
ticket: ActionableTicket;
onDone: () => void;
}) {
const ticketId = ticket.id;
const [items, setItems] = useState<TicketActionItem[]>([]);
const [pending, setPending] = useState<TicketActionItem | null>(null);
const [result, setResult] = useState<{ action: string; value: RunResult } | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
apiFetch<{ items: TicketActionItem[] }>(`/api/dashboard/tickets/${ticketId}/actions`)
.then((data) => setItems(data.items))
.catch((err: unknown) => {
// Chybejici akce nesmi zhasnout detail ticketu.
console.warn('[actions] seznam se nepodarilo nacist:', err);
setItems([]);
});
}, [ticketId]);
useEffect(load, [load]);
async function run(action: TicketActionItem, form: Record<string, string>) {
setError(null);
setPending(null);
try {
const value = await apiFetch<RunResult>(
`/api/dashboard/tickets/${ticketId}/actions/${action.id}`,
{ method: 'POST', body: { form } },
);
setResult({ action: action.label, value });
// Akce mohla zmenit ticket i jeho log, proto obnova detailu.
onDone();
load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Akci se nepodařilo spustit.');
}
}
const hasActionBlock = items.length > 0 || result !== null || error !== null;
return (
<div className="glass space-y-4 rounded-card p-4">
<TicketMeta
ticket={ticket}
onChanged={() => {
onDone();
// Zmena typu nebo tagu meni i seznam akci, proto se nacte znovu.
load();
}}
/>
{hasActionBlock && (
<div className="space-y-3 border-t border-ink-600/50 pt-4">
<p className="text-xs font-semibold tracking-wide text-white/45 uppercase">Akce</p>
<div className="flex flex-wrap gap-2">
{items.map((action) => {
const Icon = serviceIcon(action.icon);
return (
<Button
key={action.id}
size="sm"
variant={action.style === 'primary' ? 'primary' : 'secondary'}
onClick={() => {
// Bez potvrzeni a bez formulare se spusti hned.
if (!action.confirm && action.form.length === 0) void run(action, {});
else setPending(action);
}}
>
<Icon className="size-3.5" />
{action.label}
</Button>
);
})}
</div>
{error && <p className="text-sm text-danger-400">{error}</p>}
{result && (
<div
className={cn(
'rounded-lg border p-3 text-sm',
result.value.ok
? 'border-ok-500/40 bg-ok-500/8'
: 'border-danger-500/40 bg-danger-500/8',
)}
>
<p
className={cn(
'flex items-start gap-2',
result.value.ok ? 'text-ok-400' : 'text-danger-400',
)}
>
{result.value.ok ? (
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
) : (
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
)}
<span>
{result.action}: {result.value.summary}
<span className="mt-0.5 block text-xs text-white/40">
{result.value.durationMs} ms. Celý průběh je v logu ticketu níž.
</span>
</span>
</p>
{result.value.detail && (
<pre className="mt-2 max-h-48 overflow-auto rounded bg-ink-950/70 p-2 font-mono text-xs whitespace-pre-wrap text-white/60">
{result.value.detail}
</pre>
)}
</div>
)}
</div>
)}
{pending && (
<ActionDialog
action={pending}
onClose={() => setPending(null)}
onRun={(form) => void run(pending, form)}
/>
)}
</div>
);
}
/**
* Typ a tagy ticketu.
*
* Je to tady, a ne v postrannim panelu, protoze **na typu zavisi, ktera akce
* se ukaze**. Kdyz je oboji vedle sebe, je souvislost videt.
*/
function TicketMeta({
ticket,
onChanged,
}: {
ticket: ActionableTicket;
onChanged: () => void;
}) {
const [types, setTypes] = useState<TicketTypeOption[]>([]);
const [tags, setTags] = useState(ticket.tags.join(', '));
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
apiFetch<{ items: TicketTypeOption[] }>('/api/dashboard/settings/ticket-types')
.then((data) => setTypes(data.items))
// Bez prava na typy se jen nenabidnou, detail ticketu tim nespadne.
.catch(() => setTypes([]));
}, []);
// Po ulozeni se detail znovu nacte, textove pole musi jit za nim.
useEffect(() => setTags(ticket.tags.join(', ')), [ticket.tags]);
async function send(path: string, body: unknown) {
setBusy(true);
setError(null);
try {
await apiFetch(`/api/dashboard/tickets/${ticket.id}/${path}`, { method: 'POST', body });
onChanged();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Změnu se nepodařilo uložit.');
} finally {
setBusy(false);
}
}
const type = types.find((item) => item.id === ticket.typeId) ?? null;
return (
<div className="space-y-2">
<p className="text-xs font-semibold tracking-wide text-white/45 uppercase">Typ a tagy</p>
<div className="flex flex-wrap items-center gap-2">
<select
value={ticket.typeId ?? ''}
disabled={busy || types.length === 0}
onChange={(event) =>
void send('type', { typeId: event.target.value === '' ? null : event.target.value })
}
className={metaFieldClass}
>
<option value="" className="bg-ink-850">
Bez typu
</option>
{types.map((item) => (
<option key={item.id} value={item.id} className="bg-ink-850">
{item.name}
</option>
))}
</select>
<input
value={tags}
disabled={busy}
onChange={(event) => setTags(event.target.value)}
onBlur={() => {
const next = tags
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
// Bez zmeny se neposila nic, aby kazde opusteni pole nedelalo zapis.
if (next.join(',') !== ticket.tags.join(',')) void send('tags', { tags: next });
}}
placeholder="tagy oddělené čárkou"
className={cn(metaFieldClass, 'min-w-48 flex-1')}
/>
</div>
{ticket.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{ticket.tags.map((tag) => (
<Badge key={tag} tone="neutral">
{tag}
</Badge>
))}
</div>
)}
{type && type.fields.some((field) => field.type !== 'object' && field.type !== 'list') && (
<TypeFields
type={type}
values={ticket.fields}
busy={busy}
onSave={(fields) => void send('type', { typeId: type.id, fields })}
/>
)}
{error && <p className="text-xs text-danger-400">{error}</p>}
</div>
);
}
const metaFieldClass =
'rounded-lg border border-ink-600/70 bg-ink-900/70 px-2.5 py-1 text-sm text-white focus:border-brand-400/70 focus:outline-none';
/**
* Vlastni pole zvoleneho typu ticketu.
*
* Uklada se najednou, ne po jednom poli - jinak by kazde pismeno bylo zapis
* a v logu ticketu by vznikla stovka zaznamu o zmene typu.
*/
function TypeFields({
type,
values,
busy,
onSave,
}: {
type: TicketTypeOption;
values: Record<string, string | number | boolean | null>;
busy: boolean;
onSave: (fields: Record<string, string | number | boolean | null>) => void;
}) {
const [draft, setDraft] = useState(values);
// Po ulozeni prijde novy ticket, rozepsany stav se musi srovnat se serverem.
useEffect(() => setDraft(values), [values]);
/**
* Objekt a seznam sem plni automatizace, rucne se nevyplnuji - textarea
* s JSONem by tady byla past, ne pohodli.
*/
const editable = type.fields.filter((field) => field.type !== 'object' && field.type !== 'list');
const dirty = editable.some((field) => (draft[field.key] ?? '') !== (values[field.key] ?? ''));
const missing = editable.filter(
(field) => field.required && String(draft[field.key] ?? '').trim() === '',
);
function set(key: string, value: string | number | boolean | null) {
setDraft((prev) => ({ ...prev, [key]: value }));
}
return (
<div className="space-y-2 rounded-lg border border-ink-600/50 bg-ink-900/40 p-3">
<div className="grid gap-2 sm:grid-cols-2">
{editable.map((field) => {
const value = draft[field.key];
const text = value === null || value === undefined ? '' : String(value);
return (
<label key={field.id} className="block">
<span className="mb-1 flex items-center gap-1.5 text-xs text-white/55">
{field.label}
{field.required && <span className="text-warn-400">*</span>}
</span>
{field.type === 'boolean' ? (
<input
type="checkbox"
checked={value === true}
disabled={busy}
onChange={(event) => set(field.key, event.target.checked)}
className="size-4 accent-brand-400"
/>
) : field.options && field.options.length > 0 ? (
<select
value={text}
disabled={busy}
onChange={(event) => set(field.key, event.target.value)}
className={cn(metaFieldClass, 'w-full')}
>
<option value="" className="bg-ink-850">
Nevyplněno
</option>
{field.options.map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
</select>
) : (
<input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
value={text}
disabled={busy}
onChange={(event) =>
set(
field.key,
field.type === 'number'
? // Prazdne pole neni nula, ale nevyplneno.
event.target.value === ''
? null
: Number(event.target.value)
: event.target.value,
)
}
className={cn(metaFieldClass, 'w-full')}
/>
)}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
);
})}
</div>
{dirty && (
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" disabled={busy || missing.length > 0} onClick={() => onSave(draft)}>
Uložit pole
</Button>
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setDraft(values)}>
Zrušit změny
</Button>
{missing.length > 0 && (
<Badge tone="warn">Vyplňte: {missing.map((field) => field.label).join(', ')}</Badge>
)}
</div>
)}
</div>
);
}
function ActionDialog({
action,
onClose,
onRun,
}: {
action: TicketActionItem;
onClose: () => void;
onRun: (form: Record<string, string>) => void;
}) {
const [values, setValues] = useState<Record<string, string>>({});
const missing = action.form.filter(
(field) => field.required && (values[field.id] ?? '').trim() === '',
);
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white focus:border-brand-400/70 focus:outline-none';
return (
<Modal open onClose={onClose} title={action.label}>
<div className="space-y-4 p-5">
{action.confirm && <p className="text-sm text-white/70">{action.confirm}</p>}
{action.form.map((field) => (
<label key={field.id} className="block">
<span className="mb-1 flex items-center gap-2 text-sm text-white/70">
{field.label}
{field.required && <span className="text-warn-400">*</span>}
</span>
{field.kind === 'choice' ? (
<select
value={values[field.id] ?? ''}
onChange={(event) => setValues((p) => ({ ...p, [field.id]: event.target.value }))}
className={inputClass}
>
<option value="" className="bg-ink-850">
Vyberte
</option>
{(field.options ?? []).map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
</select>
) : field.kind === 'longtext' ? (
<textarea
rows={3}
value={values[field.id] ?? ''}
onChange={(event) => setValues((p) => ({ ...p, [field.id]: event.target.value }))}
className={cn(inputClass, 'resize-y')}
/>
) : (
<input
value={values[field.id] ?? ''}
onChange={(event) => setValues((p) => ({ ...p, [field.id]: event.target.value }))}
className={inputClass}
/>
)}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
))}
<div className="flex flex-wrap items-center gap-3 border-t border-ink-600/50 pt-4">
<Button size="sm" onClick={() => onRun(values)} disabled={missing.length > 0}>
Spustit
</Button>
<Button size="sm" variant="secondary" onClick={onClose}>
Zrušit
</Button>
{missing.length > 0 && (
<Badge tone="warn">Vyplňte: {missing.map((field) => field.label).join(', ')}</Badge>
)}
</div>
</div>
</Modal>
);
}
+915 -54
View File
@@ -1,79 +1,940 @@
import { Construction } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '@/auth/AuthContext';
import { EntityAdmin, parseJsonField, type AdminField } from '@/components/dashboard/EntityAdmin';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Access } from '@/types/dashboard';
/** Zamerne jen kostra - obsah nastaveni doresime pri stavbe dashboardu. */
/**
* Nastaveni portalu.
*
* Vsechny zalozky stoji na `EntityAdmin`, takze se tady pise jen to, co je
* u dane entity jine: sloupce tabulky a pole formulare. Nova entita znamena
* jeden zaznam v `tabs` nize.
*
* Slozitejsi struktury (pole typu ticketu, telo akce, zdroj widgetu) se upravuji
* jako JSON. Klikaci editor by u nich byl vetsi prace nez cely zbytek nastaveni
* a JSON je zatim citelnejsi nez deset vnorenych formularu.
*/
interface CatalogItem {
key: string;
label: string;
group?: string;
hint?: string;
platform?: boolean;
}
interface SettingsCatalog {
permissions: CatalogItem[];
modules: Array<{ key: string; label: string; required?: boolean; platform?: boolean }>;
defaultLimits: Record<string, number>;
platformAdmin: boolean;
}
interface NamedRecord {
id: string;
name?: string;
label?: string;
[key: string]: unknown;
}
export default function Settings() {
usePageMeta({ title: 'Nastavení — portál Automia' });
usePageMeta({ title: 'Nastavení - portál Automia' });
const { user } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
const catalog = useApiQuery<SettingsCatalog>('/api/dashboard/settings/catalog');
const [tab, setTab] = useState('ucet');
const can = useMemo(() => {
const owned = new Set(access.data?.permissions ?? []);
return (permission: string) => owned.has(permission);
}, [access.data]);
const permissionOptions = useMemo(
() =>
(catalog.data?.permissions ?? []).map((item) => ({
value: item.key,
label: item.hint ? `${item.label} (${item.hint})` : item.label,
group: item.group,
})),
[catalog.data],
);
const platformAdmin = user?.platformAdmin === true;
/**
* Zalozky. Kazda rika, cim je podminena - bez prava se ani nezobrazi,
* aby clovek neklikal do 403.
*/
const tabs = useMemo(() => {
const all: Array<{ key: string; label: string; show: boolean }> = [
{ key: 'ucet', label: 'Můj účet', show: true },
{ key: 'people', label: 'Řešitelé', show: can('people.manage') },
{ key: 'groups', label: 'Skupiny', show: can('group.manage') },
{ key: 'roles', label: 'Role a práva', show: can('role.manage') },
{ key: 'types', label: 'Typy ticketů', show: can('ticketType.manage') },
{ key: 'actions', label: 'Akce na ticketu', show: can('action.manage') },
{ key: 'widgets', label: 'Vlastní widgety', show: can('widget.manage') },
{ key: 'users', label: 'Uživatelé', show: platformAdmin },
{ key: 'tenants', label: 'Firmy', show: platformAdmin },
{ key: 'features', label: 'Záložky firmy', show: platformAdmin },
{ key: 'audit', label: 'Audit', show: platformAdmin },
];
return all.filter((item) => item.show);
}, [can, platformAdmin]);
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Nastavení</h1>
<p className="mt-1 text-sm text-white/50">Údaje o účtu a organizaci.</p>
<p className="mt-1 text-sm text-white/50">
Co která firma má, kdo co smí a co se dá na ticketu udělat.
</p>
</header>
<section className="glass rounded-card p-5 sm:p-6">
<h2 className="font-semibold text-white">Váš účet</h2>
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
<Row label="Jméno" value={user?.name ?? '—'} />
<Row label="E-mail" value={user?.email ?? '—'} />
<Row
label="Přístup"
value={user?.platformAdmin ? 'Správce platformy' : 'Účet firmy'}
/>
<Row label="Firem" value={String(user?.memberships.length ?? 0)} />
</dl>
</section>
<div className="flex flex-wrap gap-2">
{tabs.map((item) => (
<button
key={item.key}
type="button"
onClick={() => setTab(item.key)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
tab === item.key
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
)}
>
{item.label}
</button>
))}
</div>
<section className="glass rounded-card p-5 sm:p-6">
<h2 className="font-semibold text-white">Členství ve firmách</h2>
<p className="mt-1 text-sm text-white/45">
Role je vždy až uvnitř firmy. Ve dvou firmách můžete mít různá práva.
</p>
{access.data && access.data.tenants.length > 0 ? (
<ul className="mt-4 space-y-2">
{access.data.tenants.map((tenant) => {
const role = user?.memberships.find((m) => m.tenantId === tenant.id)?.role;
return (
<li
key={tenant.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-ink-600/50 bg-ink-850/50 px-4 py-3"
>
<span className="text-sm text-white/80">{tenant.name}</span>
<span className="text-xs text-white/45">
{role === 'admin' ? 'Správce firmy' : role === 'agent' ? 'Řešitel' : 'Přes platformu'}
</span>
</li>
);
})}
</ul>
) : (
<p className="mt-4 text-sm text-white/40">Účet nepatří do žádné firmy.</p>
)}
</section>
{tab === 'ucet' && <MyAccount />}
<section className="rounded-card border border-dashed border-ink-600/70 p-8 text-center">
<Construction className="mx-auto size-6 text-white/30" />
<h2 className="mt-3 font-semibold text-white">Připravujeme</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-white/50">
Změna hesla, dvoufaktorové ověření, správa uživatelů organizace, notifikace a API klíče.
Rozsah domluvíme při stavbě dashboardu.
</p>
</section>
{tab === 'people' && (
<EntityAdmin
title="Řešitelé"
description="Lidé, na které jde přehodit ticket. Nemusí mít přístup do portálu, spojka je e-mail."
endpoint="/api/dashboard/settings/people"
listEndpoint="/api/dashboard/settings/people-overview"
columns={[
{ label: 'Jméno', render: (person: NamedRecord) => String(person.name ?? '') },
{ label: 'E-mail', render: (person: NamedRecord) => String(person.email ?? '') },
{ label: 'Zaměření', render: (person: NamedRecord) => String(person.role ?? '') },
{ label: 'Kapacita', narrow: true, render: (person: NamedRecord) => String(person.capacity ?? '') },
{
label: 'Stav',
narrow: true,
render: (person: NamedRecord) =>
person.enabled === false ? <Badge tone="warn">vypnutý</Badge> : <Badge tone="ok">aktivní</Badge>,
},
]}
fields={[
{ name: 'name', label: 'Jméno', kind: 'text', required: true },
{ name: 'email', label: 'E-mail', kind: 'text', required: true },
{ name: 'role', label: 'Zaměření', kind: 'text', hint: 'Jen popisek, nic nerozhoduje.' },
{
name: 'capacity',
label: 'Kapacita',
kind: 'number',
initial: 8,
hint: 'Kolik nevyřízených ticketů je pro něj zdravá zátěž. Není to limit.',
},
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
]}
/>
)}
{tab === 'groups' && (
<EntityAdmin
title="Skupiny řešitelů"
description="Aby šlo říct: tohle je pro účetní, bez řešení, kdo z nich má dovolenou."
endpoint="/api/dashboard/settings/groups"
columns={[
{ label: 'Název', render: (group: NamedRecord) => String(group.name ?? '') },
{
label: 'Členů',
narrow: true,
render: (group: NamedRecord) => String((group.personIds as string[] | undefined)?.length ?? 0),
},
]}
fields={[
{ name: 'name', label: 'Název', kind: 'text', required: true },
{
name: 'personIds',
label: 'Členové',
kind: 'tags',
hint: 'ID řešitelů oddělená čárkou. Najdete je v záložce Řešitelé.',
},
]}
/>
)}
{tab === 'roles' && (
<EntityAdmin
title="Role a práva"
description="Práva jsou data, ne pevný seznam v kódu. Systémové role se nedají měnit."
endpoint="/api/dashboard/settings/roles"
columns={[
{
label: 'Role',
render: (role: NamedRecord) => (
<span className="flex flex-wrap items-center gap-2">
{String(role.name ?? '')}
{role.system === true && <Badge tone="neutral">systémová</Badge>}
</span>
),
},
{ label: 'Popis', render: (role: NamedRecord) => String(role.description ?? '') },
{
label: 'Práv',
narrow: true,
render: (role: NamedRecord) => String((role.permissions as string[] | undefined)?.length ?? 0),
},
]}
canDelete={(role: NamedRecord) => role.system !== true}
fields={[
{ name: 'name', label: 'Název role', kind: 'text', required: true },
{ name: 'description', label: 'Popis', kind: 'text' },
{
name: 'permissions',
label: 'Práva',
kind: 'multiselect',
options: permissionOptions,
hint: 'Práva k akcím na ticketu se objeví, jakmile akci založíte.',
},
]}
/>
)}
{tab === 'types' && (
<EntityAdmin
title="Typy ticketů"
description="Za typem stojí vlastní pole, proto se na typ vážou akce. Tag je jen nálepka."
endpoint="/api/dashboard/settings/ticket-types"
columns={[
{ label: 'Název', render: (type: NamedRecord) => String(type.name ?? '') },
{ label: 'Klíč', render: (type: NamedRecord) => <code>{String(type.key ?? '')}</code> },
{
label: 'Polí',
narrow: true,
render: (type: NamedRecord) => String((type.fields as unknown[] | undefined)?.length ?? 0),
},
]}
fields={[
{ name: 'name', label: 'Název', kind: 'text', required: true },
{
name: 'key',
label: 'Klíč',
kind: 'text',
required: true,
createOnly: true,
hint: 'Malá písmena a pomlčky, například order. Používá se ve filtrech.',
},
{ name: 'icon', label: 'Ikona', kind: 'text', initial: 'LifeBuoy' },
{
name: 'statuses',
label: 'Vlastní stavy',
kind: 'tags',
hint: 'Prázdné = výchozí čtveřice ticketu.',
},
{
name: 'fields',
label: 'Vlastní pole',
kind: 'json',
initial: '[]',
hint: 'Seznam polí: key, label, type (string/number/boolean/date), required.',
},
]}
toBody={(values, entity) => ({
...(entity ? {} : { key: values.key }),
name: values.name,
icon: values.icon,
statuses: values.statuses,
fields: parseJsonField(values.fields, []),
})}
toForm={(type: NamedRecord) => ({
...type,
fields: JSON.stringify(type.fields ?? [], null, 2),
})}
/>
)}
{tab === 'actions' && (
<EntityAdmin
title="Akce na ticketu"
description="Tlačítko na ticketu. Váže se na typ nebo tag a spouští operaci služby, strom nebo skript."
endpoint="/api/dashboard/settings/actions"
listEndpoint="/api/dashboard/settings/actions-overview"
columns={[
{ label: 'Akce', render: (action: NamedRecord) => String(action.label ?? '') },
{
label: 'Na čem',
render: (action: NamedRecord) => {
const types = (action.ticketTypeIds as string[] | undefined) ?? [];
const tags = (action.tags as string[] | undefined) ?? [];
return [types.length > 0 ? `typy: ${types.join(', ')}` : null, tags.length > 0 ? `tagy: ${tags.join(', ')}` : null]
.filter(Boolean)
.join(' | ');
},
},
{
label: 'Tělo',
narrow: true,
render: (action: NamedRecord) => {
const body = action.body as { kind?: string } | undefined;
return <code>{body?.kind ?? '?'}</code>;
},
},
{
label: 'Stav',
narrow: true,
render: (action: NamedRecord) =>
action.enabled === false ? <Badge tone="warn">vypnutá</Badge> : <Badge tone="ok">zapnutá</Badge>,
},
]}
fields={actionFields}
toBody={(values, _entity) => ({
label: values.label,
icon: values.icon,
style: values.style || 'default',
order: values.order,
ticketTypeIds: values.ticketTypeIds,
tags: values.tags,
confirm: values.confirm === '' ? null : values.confirm,
visibleWhen: parseJsonField(values.visibleWhen, []),
form: parseJsonField(values.form, []),
body: parseJsonField(values.body, { kind: 'tree', steps: [] }),
...(values.enabled !== undefined ? { enabled: values.enabled } : {}),
})}
toForm={(action: NamedRecord) => ({
...action,
confirm: action.confirm ?? '',
visibleWhen: JSON.stringify(action.visibleWhen ?? [], null, 2),
form: JSON.stringify(action.form ?? [], null, 2),
body: JSON.stringify(action.body ?? {}, null, 2),
})}
/>
)}
{tab === 'widgets' && (
<EntityAdmin
title="Vlastní widgety"
description="Dlaždice na přehled. Filtr je tentýž, který umí seznam ticketů."
endpoint="/api/dashboard/settings/widgets"
listEndpoint="/api/dashboard/settings/widgets-overview"
columns={[
{ label: 'Název', render: (widget: NamedRecord) => String(widget.name ?? '') },
{ label: 'Vykreslení', narrow: true, render: (widget: NamedRecord) => <code>{String(widget.render ?? '')}</code> },
{
label: 'Zdroj',
render: (widget: NamedRecord) => {
const source = widget.source as { kind?: string; groupBy?: string } | undefined;
return `${source?.kind ?? '?'}${source?.groupBy ? ` podle ${source.groupBy}` : ''}`;
},
},
{ label: 'Šířka', narrow: true, render: (widget: NamedRecord) => String(widget.size ?? '') },
]}
fields={widgetFields}
toBody={(values) => ({
name: values.name,
description: values.description,
render: values.render,
size: values.size,
...(values.target ? { target: Number(values.target) } : {}),
source: parseJsonField(values.source, { kind: 'ticketCount', filter: {} }),
})}
toForm={(widget: NamedRecord) => ({
...widget,
source: JSON.stringify(widget.source ?? {}, null, 2),
})}
/>
)}
{tab === 'users' && <UsersAdmin />}
{tab === 'tenants' && (
<EntityAdmin
title="Firmy"
description="Firmy, které portál používají. Firma je hranice viditelnosti."
endpoint="/api/dashboard/settings/tenants"
columns={[
{ label: 'Název', render: (tenant: NamedRecord) => String(tenant.name ?? '') },
{ label: 'ID', render: (tenant: NamedRecord) => <code>{tenant.id}</code> },
{ label: 'Poznámka', render: (tenant: NamedRecord) => String(tenant.note ?? '') },
{
label: 'Stav',
narrow: true,
render: (tenant: NamedRecord) =>
tenant.enabled === false ? <Badge tone="warn">vypnutá</Badge> : <Badge tone="ok">aktivní</Badge>,
},
]}
fields={[
{ name: 'name', label: 'Název firmy', kind: 'text', required: true },
{ name: 'note', label: 'Poznámka', kind: 'textarea', hint: 'Pro nás, klient ji nevidí.' },
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
]}
/>
)}
{tab === 'features' && <FeaturesAdmin catalog={catalog.data} />}
{tab === 'audit' && <AuditView />}
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
// ------------------------------------------------------------------ moje ucet
function MyAccount() {
const { user } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
const [password, setPassword] = useState('');
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function changePassword() {
setError(null);
setMessage(null);
try {
await apiFetch(`/api/dashboard/settings/users/${user!.id}/password`, {
method: 'PATCH',
body: { password },
});
setPassword('');
setMessage('Heslo změněno.');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Heslo se nepodařilo změnit.');
}
}
return (
<div className="rounded-xl border border-ink-600/50 bg-ink-850/50 px-4 py-3">
<dt className="text-xs text-white/35">{label}</dt>
<dd className="mt-0.5 text-sm text-white/80">{value}</dd>
<div className="space-y-4">
<div className="glass rounded-card p-5">
<h2 className="font-semibold text-white">{user?.name}</h2>
<p className="text-sm text-white/45">{user?.email}</p>
<dl className="mt-4 grid gap-3 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
<div>
<dt className="text-xs tracking-wide text-white/40 uppercase">Firmy</dt>
<dd className="mt-0.5 text-sm text-white/75">
{(access.data?.tenants ?? []).map((tenant) => tenant.name).join(', ') || 'žádná'}
</dd>
</div>
<div>
<dt className="text-xs tracking-wide text-white/40 uppercase">Práv</dt>
<dd className="mt-0.5 text-sm text-white/75">
{access.data?.permissions.length ?? 0}
{user?.platformAdmin && ' (správce platformy)'}
</dd>
</div>
</dl>
{access.data && access.data.permissions.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-xs text-white/45 hover:text-white">
Zobrazit moje práva
</summary>
<div className="mt-2 flex flex-wrap gap-1.5">
{access.data.permissions.map((permission) => (
<code key={permission} className="rounded bg-ink-950/60 px-1.5 py-0.5 text-xs text-white/60">
{permission}
</code>
))}
</div>
</details>
)}
</div>
<div className="glass rounded-card p-5">
<h3 className="text-sm font-semibold text-white">Změna hesla</h3>
<div className="mt-3 flex flex-wrap items-end gap-3">
<label className="block flex-1">
<span className="mb-1 block text-sm text-white/70">Nové heslo</span>
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white focus:border-brand-400/70 focus:outline-none"
/>
</label>
<Button size="sm" onClick={changePassword} disabled={password.length < 8}>
Změnit
</Button>
</div>
{message && <p className="mt-2 text-sm text-ok-400">{message}</p>}
{error && <p className="mt-2 text-sm text-danger-400">{error}</p>}
<p className="mt-2 text-xs text-white/35">Aspoň 8 znaků.</p>
</div>
{user?.platformAdmin && <AccountSwitch />}
</div>
);
}
/**
* Prepnuti na jiny ucet.
*
* Vychozi je **jen pro cteni** - podivat se, co klient vidi, je castejsi nez
* za nej neco menit, a nechtene zapisy pod cizim jmenem jsou to nejhorsi,
* co se tady muze stat. Zapis se musi zapnout vedome a je v auditu.
*/
function AccountSwitch() {
const { startImpersonation, impersonation } = useAuth();
const [candidates, setCandidates] = useState<Array<{ id: string; name: string; email: string }>>(
[],
);
const [userId, setUserId] = useState('');
const [allowWrites, setAllowWrites] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<{ items: Array<{ id: string; name: string; email: string }> }>(
'/api/admin/impersonate/candidates',
)
.then((data) => setCandidates(data.items))
.catch(() => setCandidates([]));
}, []);
if (impersonation) {
return (
<div className="glass rounded-card p-5">
<h3 className="text-sm font-semibold text-white">Přepnutí účtu</h3>
<p className="mt-2 text-sm text-white/50">
Právě jste přepnutí na {impersonation.userName}. Zpět na svůj účet se vrátíte pruhem
nahoře.
</p>
</div>
);
}
return (
<div className="glass rounded-card p-5">
<h3 className="text-sm font-semibold text-white">Přepnutí na jiný účet</h3>
<p className="mt-1 text-sm text-white/45">
Uvidíte portál jeho očima, včetně záložek a práv. Zaznamená se to do auditu.
</p>
<div className="mt-3 flex flex-wrap items-end gap-3">
<label className="block flex-1">
<span className="mb-1 block text-sm text-white/70">Účet</span>
<select
value={userId}
onChange={(event) => setUserId(event.target.value)}
className="w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white focus:border-brand-400/70 focus:outline-none"
>
<option value="" className="bg-ink-850">
Vyberte
</option>
{candidates.map((candidate) => (
<option key={candidate.id} value={candidate.id} className="bg-ink-850">
{candidate.name} ({candidate.email})
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 pb-2 text-sm text-white/70">
<input
type="checkbox"
checked={allowWrites}
onChange={(event) => setAllowWrites(event.target.checked)}
className="size-4 accent-brand-400"
/>
Povolit i zápis
</label>
<Button
size="sm"
disabled={userId === ''}
onClick={() => {
setError(null);
startImpersonation(userId, allowWrites).catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Přepnutí se nepodařilo.');
});
}}
>
Přepnout
</Button>
</div>
{error && <p className="mt-2 text-sm text-danger-400">{error}</p>}
{candidates.length === 0 && (
<p className="mt-2 text-xs text-white/35">Žádný účet k přepnutí není.</p>
)}
</div>
);
}
// ----------------------------------------------------------------- uzivatele
function UsersAdmin() {
const [tenants, setTenants] = useState<Array<{ id: string; name: string }>>([]);
useEffect(() => {
apiFetch<{ items: Array<{ id: string; name: string }> }>('/api/dashboard/settings/tenants')
.then((data) => setTenants(data.items))
.catch(() => setTenants([]));
}, []);
return (
<EntityAdmin
title="Uživatelé"
description="Kdo se přihlásí do portálu. Členství určuje, do kterých firem vidí a s jakými rolemi."
endpoint="/api/dashboard/settings/users"
listEndpoint="/api/dashboard/settings/users-overview"
columns={[
{ label: 'Jméno', render: (user: NamedRecord) => String(user.name ?? '') },
{ label: 'E-mail', render: (user: NamedRecord) => String(user.email ?? '') },
{
label: 'Členství',
render: (user: NamedRecord) => {
const memberships = (user.memberships as Array<{ tenantId: string; roleIds: string[] }>) ?? [];
return memberships
.map((membership) => {
const name = tenants.find((tenant) => tenant.id === membership.tenantId)?.name ?? membership.tenantId;
return `${name}: ${membership.roleIds.join(', ')}`;
})
.join(' | ');
},
},
{
label: 'Stav',
narrow: true,
render: (user: NamedRecord) => (
<span className="flex flex-wrap gap-1">
{user.platformAdmin === true && <Badge tone="warn">platforma</Badge>}
{user.enabled === false && <Badge tone="warn">vypnutý</Badge>}
</span>
),
},
]}
fields={[
{ name: 'name', label: 'Jméno', kind: 'text', required: true },
{ name: 'email', label: 'E-mail', kind: 'text', required: true },
{
name: 'password',
label: 'Heslo',
kind: 'text',
required: true,
createOnly: true,
hint: 'Aspoň 8 znaků. Změna hesla je pak zvlášť.',
},
{
name: 'platformAdmin',
label: 'Správce platformy',
kind: 'checkbox',
hint: 'Vidí napříč všemi firmami.',
},
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
{
name: 'memberships',
label: 'Členství',
kind: 'json',
initial: '[]',
hint: 'Seznam: tenantId a roleIds. Například [{"tenantId":"tnt_automia","roleIds":["agent"]}]',
},
]}
toBody={(values, entity) => ({
name: values.name,
email: values.email,
...(entity ? {} : { password: values.password }),
platformAdmin: values.platformAdmin === true,
enabled: values.enabled !== false,
memberships: parseJsonField(values.memberships, []),
})}
toForm={(user: NamedRecord) => ({
...user,
memberships: JSON.stringify(user.memberships ?? [], null, 2),
})}
/>
);
}
// ------------------------------------------------------------ zalozky firmy
function FeaturesAdmin({ catalog }: { catalog: SettingsCatalog | null }) {
const [features, setFeatures] = useState<{
modules: string[];
limits: Record<string, number>;
serviceIds: string[];
} | null>(null);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = () => {
apiFetch<{ features: typeof features }>('/api/dashboard/settings/features')
.then((data) => setFeatures(data.features))
.catch((err: unknown) => setError(err instanceof Error ? err.message : 'Nepodařilo se načíst.'));
};
useEffect(load, []);
async function save() {
if (!features) return;
setSaving(true);
setError(null);
setMessage(null);
try {
await apiFetch('/api/dashboard/settings/features', { method: 'PUT', body: features });
setMessage('Uloženo. Záložky se projeví po obnovení stránky.');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
} finally {
setSaving(false);
}
}
if (!features || !catalog) return <p className="text-sm text-white/45">Načítám...</p>;
return (
<div className="space-y-4">
<div className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Záložky firmy</h2>
<p className="mt-0.5 text-sm text-white/45">
Co má firma zapnuté. Vypnutý modul neexistuje ani pro jejího správce, protože ho
nemá. Kdo z jejich lidí to smí, řeší role.
</p>
<div className="mt-4 space-y-1.5 border-t border-ink-600/50 pt-4">
{catalog.modules
.filter((module) => !module.platform)
.map((module) => (
<label key={module.key} className="flex items-center gap-2 text-sm text-white/75">
<input
type="checkbox"
disabled={module.required}
checked={features.modules.includes(module.key)}
onChange={(event) =>
setFeatures({
...features,
modules: event.target.checked
? [...features.modules, module.key]
: features.modules.filter((key) => key !== module.key),
})
}
className="size-4"
/>
{module.label}
{module.required && <span className="text-xs text-white/35">povinné</span>}
</label>
))}
</div>
</div>
<div className="glass rounded-card p-5">
<h3 className="text-sm font-semibold text-white">Limity</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-4">
{Object.entries(features.limits).map(([key, value]) => (
<label key={key} className="block">
<span className="mb-1 block text-xs text-white/60">{key}</span>
<input
type="number"
value={value}
onChange={(event) =>
setFeatures({
...features,
limits: { ...features.limits, [key]: Number(event.target.value) },
})
}
className="w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-1.5 text-sm text-white focus:border-brand-400/70 focus:outline-none"
/>
</label>
))}
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm" onClick={save} disabled={saving}>
{saving ? 'Ukládám...' : 'Uložit'}
</Button>
{message && <span className="text-sm text-ok-400">{message}</span>}
{error && <span className="text-sm text-danger-400">{error}</span>}
</div>
</div>
);
}
// ---------------------------------------------------------------------- audit
interface AuditEntry {
id: string;
createdAt: string;
userEmail: string;
actedBy: string | null;
action: string;
target: string | null;
result: 'ok' | 'denied';
}
function AuditView() {
const [onlyDenied, setOnlyDenied] = useState(false);
const query = useApiQuery<{ items: AuditEntry[] }>(
`/api/admin/audit?limit=200${onlyDenied ? '&result=denied' : ''}`,
);
return (
<div className="space-y-4">
<div>
<h2 className="font-semibold text-white">Audit</h2>
<p className="mt-0.5 text-sm text-white/45">
Kdo co udělal. Zapisují se i odepření, jinak by pokusy o cizí firmu nikde
nezůstaly.
</p>
</div>
<label className="flex items-center gap-2 text-sm text-white/70">
<input
type="checkbox"
checked={onlyDenied}
onChange={(event) => setOnlyDenied(event.target.checked)}
className="size-4"
/>
Jen odepření
</label>
<div className="glass overflow-x-auto rounded-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-ink-600/60 text-left text-xs tracking-wide text-white/40 uppercase">
<th className="px-4 py-2.5 font-semibold">Kdy</th>
<th className="px-4 py-2.5 font-semibold">Kdo</th>
<th className="px-4 py-2.5 font-semibold">Co</th>
<th className="px-4 py-2.5 font-semibold">Nad čím</th>
<th className="px-4 py-2.5 font-semibold">Výsledek</th>
</tr>
</thead>
<tbody>
{(query.data?.items ?? []).map((entry) => (
<tr key={entry.id} className="border-b border-ink-600/30 last:border-0">
<td className="px-4 py-2 whitespace-nowrap text-white/50">
{new Date(entry.createdAt).toLocaleString('cs')}
</td>
<td className="px-4 py-2 text-white/75">
{entry.userEmail}
{entry.actedBy && (
<span className="block text-xs text-warn-400">za něj: {entry.actedBy}</span>
)}
</td>
<td className="px-4 py-2">
<code className="text-white/70">{entry.action}</code>
</td>
<td className="px-4 py-2 text-white/50">{entry.target ?? ''}</td>
<td className="px-4 py-2">
{entry.result === 'denied' ? (
<Badge tone="danger">odepřeno</Badge>
) : (
<Badge tone="ok">ok</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{(query.data?.items.length ?? 0) === 0 && (
<p className="py-6 text-center text-sm text-white/40">Žádné záznamy.</p>
)}
</div>
);
}
// ---------------------------------------------------- definice poli formularu
const actionFields: Array<AdminField<NamedRecord>> = [
{ name: 'label', label: 'Nápis na tlačítku', kind: 'text', required: true },
{ name: 'icon', label: 'Ikona', kind: 'text', initial: 'Play' },
{
name: 'style',
label: 'Vzhled',
kind: 'select',
initial: 'default',
options: [
{ value: 'primary', label: 'Hlavní' },
{ value: 'default', label: 'Běžný' },
{ value: 'danger', label: 'Nebezpečný' },
],
},
{ name: 'order', label: 'Pořadí', kind: 'number', initial: 10 },
{
name: 'ticketTypeIds',
label: 'Typy ticketu',
kind: 'tags',
hint: 'ID typů oddělená čárkou. Za typem stojí pole, takže akce z nich může čerpat.',
},
{
name: 'tags',
label: 'Tagy',
kind: 'tags',
hint: 'Za tagem nestojí žádná pole, takže akce umí jen vestavěná pole ticketu.',
},
{ name: 'confirm', label: 'Text potvrzení', kind: 'text', hint: 'Prázdné = spustí se hned.' },
{
name: 'visibleWhen',
label: 'Kdy se tlačítko ukáže',
kind: 'json',
initial: '[]',
hint: 'Podmínky: fieldId, operator, value. Například [{"fieldId":"ticket.status","operator":"neq","value":"resolved"}]',
},
{
name: 'form',
label: 'Doptávací formulář',
kind: 'json',
initial: '[]',
hint: 'Na co se zeptat před spuštěním: id, label, kind, required.',
},
{
name: 'body',
label: 'Co akce dělá',
kind: 'json',
initial: '{\n "kind": "operation",\n "serviceId": "idoklad",\n "operationId": "create-issued-invoice",\n "connectorId": null,\n "inputs": {}\n}',
hint: 'kind: operation (jeden krok služby), tree (vlastní strom) nebo script.',
},
{ name: 'enabled', label: 'Zapnutá', kind: 'checkbox', initial: true },
];
const widgetFields: Array<AdminField<NamedRecord>> = [
{ name: 'name', label: 'Název', kind: 'text', required: true },
{ name: 'description', label: 'Popis', kind: 'text' },
{
name: 'render',
label: 'Vykreslení',
kind: 'select',
initial: 'stat',
options: [
{ value: 'stat', label: 'Jedno číslo' },
{ value: 'table', label: 'Tabulka' },
{ value: 'list', label: 'Seznam' },
{ value: 'chart', label: 'Graf' },
{ value: 'gauge', label: 'Ukazatel' },
],
},
{
name: 'size',
label: 'Šířka',
kind: 'select',
initial: 'third',
options: [
{ value: 'third', label: 'Třetina' },
{ value: 'half', label: 'Polovina' },
{ value: 'full', label: 'Celá šířka' },
],
hint: 'Číslo a ukazatel umí třetinu a polovinu, ostatní polovinu a celou šířku.',
},
{ name: 'target', label: 'Cílová hodnota', kind: 'number', hint: 'Jen u ukazatele.' },
{
name: 'source',
label: 'Zdroj dat',
kind: 'json',
initial: '{\n "kind": "ticketCount",\n "filter": { "status": ["new", "open", "waiting"] }\n}',
hint: 'kind: ticketCount, ticketList, ticketSeries nebo workload. U ticketCount lze groupBy.',
},
];
+44
View File
@@ -16,6 +16,7 @@ import {
TicketPriorityBadge,
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { TicketActions } from '@/components/dashboard/TicketActions';
import { TicketTrace, TraceSummary } from '@/components/dashboard/TicketTrace';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
@@ -133,6 +134,12 @@ export default function TicketDetail() {
</p>
)}
{/*
Akce prichazi ze serveru uz vyfiltrovane podle typu, tagu,
podminek a prav. Klient si nepocita, co ukazat.
*/}
<TicketActions ticket={ticket.data} onDone={ticket.reload} />
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
<div className="space-y-6">
<section className="glass rounded-card p-5 sm:p-6">
@@ -243,6 +250,43 @@ export default function TicketDetail() {
))}
</select>
{/*
Skupina je vedle resitele, ne misto nej. Ticket muze byt
"u ucetnich" a zaroven u konkretniho cloveka z nich.
*/}
{(people.data?.groups ?? []).length > 0 && (
<>
<label
htmlFor="ticket-group"
className="mt-4 block text-xs font-semibold tracking-wide text-white/45 uppercase"
>
Skupina
</label>
<select
id="ticket-group"
value={ticket.data.assigneeGroupId ?? ''}
disabled={busy}
onChange={(event) =>
void mutate(
'/group',
{ groupId: event.target.value === '' ? null : event.target.value },
'Přiřazení skupině se nepodařilo.',
)
}
className={cn(selectClass, 'mt-2 w-full')}
>
<option value="" className="bg-ink-850">
Bez skupiny
</option>
{(people.data?.groups ?? []).map((group) => (
<option key={group.id} value={group.id} className="bg-ink-850">
{group.name}
</option>
))}
</select>
</>
)}
<label
htmlFor="ticket-status"
className="mt-4 block text-xs font-semibold tracking-wide text-white/45 uppercase"
+21 -1
View File
@@ -26,12 +26,22 @@ export interface Tenant {
export type TicketScope = 'all' | 'tenant' | 'mine';
/** Co uzivatel smi. Klient si to nesmi dovozovat sam. */
export interface NavEntry {
key: string;
label: string;
}
export interface Access {
scopes: TicketScope[];
tenants: Tenant[];
tenants: Array<{ id: string; name: string }>;
defaultTenantId: string | null;
canAssignOthers: boolean;
personId: string | null;
/** Efektivni prava. Klient podle nich kresli tlacitka, nedovozuje si je sam. */
permissions: string[];
/** Zalozky ze serveru: prunik toho, co firma ma, a toho, na co ma pravo. */
nav: NavEntry[];
platformAdmin: boolean;
}
/** Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail. */
@@ -68,8 +78,16 @@ export interface Ticket {
priority: TicketPriority;
/** Kdo ma ticket u sebe. null = ceka ve fronte. */
assignee: { id: string; name: string } | null;
/** Skupina resitelu, ktera ma ticket u sebe. */
assigneeGroupId: string | null;
/** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */
automationId: string | null;
/** Typ ticketu. Rozhoduje o vlastnich polich a o tom, ktere akce se ukazou. */
typeId: string | null;
/** Hodnoty vlastnich poli daneho typu. */
fields: Record<string, string | number | boolean | null>;
/** Volne tagy. Akce se muze vazat na typ **nebo** na tag. */
tags: string[];
createdAt: string;
updatedAt: string;
}
@@ -108,6 +126,8 @@ export interface TicketListResponse {
export interface PeopleResponse {
items: Person[];
/** Skupiny resitelu te same firmy. Chodi spolu s lidmi, viz GET /people. */
groups: Array<{ id: string; name: string }>;
meId: string | null;
}