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:
co-authored by
Claude Opus 5
parent
e7cf499a0b
commit
afbe948da3
+3
-1
@@ -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
@@ -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/
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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ý.
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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
@@ -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'];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user