Revize projektu: prava, vykon, runtime, portal a ARES
Prava a bezpecnost: spravce firmy uz nemuze nastavit priznak spravce platformy ani clenstvi v cizi firme; pozvanky, konektory a automatizace kontroluji sve pravo; cizi firma v query je 404; zivy stream posila udalosti jen firmam, kterych se tykaji; akce nad ticketem maji kontrolu prava za firmu ticketu a strop viditelnosti; tokeny se nelogujou; limit pokusu na prihlaseni, kontakt a pozvanky; bezpecnostni hlavicky; zachyceni chyb v async handlerech; timing-safe porovnani tokenu. Vykon: audit neskenuje celou kolekci pri kazdem zapisu a konecne maze firemni zaznamy; ticket se uklada jednou misto trikrat; zapisy do Postgresu jsou serializovane podle ID; prava se pocitaji jednou na request; widgety nacitaji tickety jednou; strankovani seznamu; worker je pool misto kol; na webu udalost ze streamu neodmontuje stranku, dotazy maji spolecny debounce a cache, ciselniky drzi typovany sklad. Runtime: opakuji se jen chyby oznacene retryable; smycka nenarazi na strop 50 kroku (novy strop 1000 akci); podminka nad datem funguje; vystup MCP nastroje neprepisuje spoustec; sandbox skriptu firmy nejde opustit; MCP session id se drzi mezi volanimi; incident z kroku patri firme; jedno rozhodnuti o rezimu uloziste; snapshot neprepise soubor po chybe cteni. Refaktory: sdilene typy API v src/shared (web nic nekopiruje, osm rozjetych tvaru sjednoceno); spolecny modul net/guard pro volani ven; formularova vrstva ui/form; rozdeleni Connectors a FlowCanvas; jeden helper pro firmu z query, validaci a CRUD udalosti; pomucky ctx.util pro skripty konektoru; i18n verejneho webu vcetne anglictiny. Nova funkce: zalozeni firmy z registru ARES v Nastaveni (IC nebo nazev, dotazeni IC, DIC, sidla a pravni formy, vyber soucasnych statutarnich zastupcu a prokury, ucty spravce firmy s nahradnim e-mailem IC-poradi@placeholder.cz). Dokumentace: zaznam v 99-zmeny.md a aktualizace 15 dalsich dokumentu. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
0c405ea55a
commit
104ae36783
+4
-2
@@ -1,7 +1,9 @@
|
||||
FROM node:20-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
# `npm ci` instaluje presne to, co je v lockfile. `npm install` by ho mohl
|
||||
# prepsat a build by pak jel na jinych verzich nez vyvoj.
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
# Zbuilduje server (tsc -> dist) i web (vite -> dist/public)
|
||||
RUN npm run build
|
||||
@@ -12,7 +14,7 @@ ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=build /app/dist ./dist
|
||||
# Skripty konektoru jsou obycejny JavaScript, nekompiluji se. Musi se ale
|
||||
# dostat do image, jinak by konektory nemely zadnou vykonnou cast.
|
||||
|
||||
@@ -86,6 +86,38 @@ s tim, co uzivatel smi. Dvoji vypocet se jednou rozejde.
|
||||
firmu jako povinny argument. Clovek muze byt spravce v jedne firme a bezny
|
||||
uzivatel v druhe.
|
||||
|
||||
**Pravo se kontroluje u kazde route, za firmu zaznamu.** Clenstvi ve firme
|
||||
neni pravo. Konektor chce `connector.manage`, automatizace `automation.edit`,
|
||||
uzivatele `user.manage` a jen ve sve firme, firmy jen spravce platformy.
|
||||
Vestavene akce na ticketu jdou pres `builtinAction` v `ticketActions.ts`, kde
|
||||
se pravo pta za firmu **toho ticketu** a ticket se nejdriv hleda pres strop
|
||||
viditelnosti. Pristup se pocita jednou na request (`attachAccess`,
|
||||
`req.access`) a routy si firmu berou pres `tenantOrDeny`.
|
||||
|
||||
**Udalost nese firmu.** `publish(kind, message, payload, tenantId)` ma
|
||||
`tenantId` povinny a posledni, `null` znamena cela platforma. Stream ji
|
||||
pouziva k filtru, takze udalost bez firmy by videl kazdy. Zmena entity
|
||||
v nastaveni publikuje `<druh>.created|updated|deleted` s celym zaznamem, aby
|
||||
klient nemusel po kazde udalosti znovu nacitat.
|
||||
|
||||
**Ciselniky ma klient ve skladu, provozni data ne.** Lide, skupiny, typy,
|
||||
sluzby, konektory a pristup se berou z `lib/collections.tsx`
|
||||
(`useCollection`), ktery se opravuje z udalosti. Tickety, behy a statistiky
|
||||
zustavaji `useApiQuery` se strankovanim - jsou velke, meni se porad a strop
|
||||
viditelnosti pocita server. Nova stranka nema volat `apiFetch` na ciselnik
|
||||
primo.
|
||||
|
||||
**Opakuje se jen to, co samo rekne `retryable`.** Vychozi je ne. Chyba
|
||||
spojeni, timeout pred odeslanim, 5xx a 429 ano; 401, 403, 404, spatny vstup,
|
||||
`ctx.fail` a chyba v kodu ne - ty konci hned a zakladaji incident. Timeout uz
|
||||
odeslaneho volani MCP se neopakuje, protoze MCP nema idempotencni klic.
|
||||
Pravidlo je nad `StepResult` v `executor.ts` a plati pro kazdy novy druh kroku.
|
||||
|
||||
**Typ odpovedi API je jednou, v `src/shared`.** Web ho nekopiruje, bere ho
|
||||
pres alias `@shared/*`; `web/src/types/` je jen fasada. Kopie na klientovi se
|
||||
rozejde se serverem a prekladac to nepozna. Prevod je hotovy: 16 modulu
|
||||
v `src/shared` vcetne uctu (`users.ts`), web nema zadny vlastni typ API.
|
||||
|
||||
## Jak vznika operace, kterou clovek vybere v builderu
|
||||
|
||||
Tri cesty, kazda ma svuj duvod:
|
||||
@@ -131,14 +163,16 @@ tam je operace vlastnost sluzby, ne napojeni.
|
||||
|
||||
## Uloziste ma tri rezimy
|
||||
|
||||
| Rezim | Kdy | Prezije |
|
||||
| ---------- | ----------------------------------- | -------------------- |
|
||||
| `postgres` | je `DATABASE_URL` i `SECRETS_KEY` | vse |
|
||||
| `file` | neni databaze, ale je datova slozka | restart, ne redeploy |
|
||||
| `memory` | ani jedno | nic |
|
||||
| Rezim | Kdy | Prezije |
|
||||
| ---------- | ------------------------------------------------------ | -------------------- |
|
||||
| `postgres` | je `DATABASE_URL`, migrace prosly a je cim sifrovat | vse |
|
||||
| `file` | neni databaze, ale je datova slozka | restart, ne redeploy |
|
||||
| `memory` | ani jedno | nic |
|
||||
|
||||
**Rozhodnuti je na jednom miste** (`connectorStore.ts`). Kdyby se rozlezlo po
|
||||
kodu, jedno misto by se zapomnelo a chovalo by se pak jinak nez zbytek.
|
||||
**Rozhodnuti je na jednom miste** (`initStores` v `store/index.ts`) a plati
|
||||
pro vsechna uloziste vcetne konektoru. Kdyby se rozlezlo po kodu, jedno misto
|
||||
by se zapomnelo a chovalo by se pak jinak nez zbytek - presne to se stalo,
|
||||
kdyz mely konektory vlastni rozhodnuti.
|
||||
|
||||
Nasazeni bezi v rezimu `file`. Ma to dusledek, ktery je poznat az pri zatezi:
|
||||
**kazda zmena prepisuje celou kolekci** jako formatovany JSON. U ticketu, ktere
|
||||
@@ -167,6 +201,17 @@ ho napsal.
|
||||
**Novy text v portalu** patri do `web/src/i18n/cs.ts` a klic do `en.ts`.
|
||||
Anglictina je `Partial`, takze nemusi byt uplna - co chybi, spadne na cestinu.
|
||||
|
||||
**Novy typ odpovedi** patri do `src/shared`, web ho nekopiruje.
|
||||
|
||||
**Nova route** vznika pres `safeRouter`, ne `Router()`, aby odmitnuta promise
|
||||
skoncila jako 500 a ne padem procesu. Chyba validace jde pres
|
||||
`validationError`, aby mela `issues` ve stejnem tvaru jako zbytek. Kazde
|
||||
volani ven (HTTP, MCP, SMTP, ARES) cte telo pres `src/net/guard.ts`.
|
||||
|
||||
**Novy formular** sklada `Field`, `Input`, `Select`, `Textarea`
|
||||
z `components/ui/form/` a odeslani pres `useSubmit`. Vlastni tridy vstupniho
|
||||
pole ve strance jsou to, co se prave odstranilo z patnacti mist.
|
||||
|
||||
**Novy sloupec** znamena novou migraci **a** doplneni obou uloziste,
|
||||
souborového i databazoveho. Rozhrani je jedno, implementace dve.
|
||||
|
||||
|
||||
@@ -67,6 +67,16 @@ React aplikaci ze slozky `dist/public`.
|
||||
| Helpdesk pro zadavatele | hotovo | pozadavek vidi zadavatel i resitel, kazdy ze sve strany |
|
||||
| Odesilani e-mailu pres SMTP | hotovo | konektor se schrankou firmy, HTML telo s promennymi |
|
||||
| Odesilani e-mailu z formulare | chybi | poptavka se zatim jen loguje |
|
||||
| Jeden rezim uloziste | hotovo | `initStores` rozhoduje pro vsechna uloziste naraz |
|
||||
| Zapisy serazene za sebou | hotovo | `withMirror` radi zapisy tehoz zaznamu, audit se oreza |
|
||||
| Worker jako pool | hotovo | ctyri behy naraz nezavisle, tlukot, opakovani jen kdyz ma smysl |
|
||||
| Prava za firmu u kazde route | hotovo | firmy, uzivatele, konektory, automatizace, akce ticketu |
|
||||
| Udalosti za firmu | hotovo | stream filtruje, entity hlasi vznik, zmenu a smazani |
|
||||
| Strankovani ticketu a behu | hotovo | `limit`, `offset`, `X-Total-Count` |
|
||||
| Klientsky sklad ciselniku | hotovo | lide, skupiny, typy, sluzby, konektory, pristup |
|
||||
| Formularova vrstva | hotovo | `ui/form`, `useSubmit`, `options`, jedna sada trid |
|
||||
| Firma z registru ARES | hotovo | IC nebo nazev, statutari jako ucty, jen spravce platformy |
|
||||
| Sdilene typy `src/shared` | hotovo | web je re-exportuje pres `@shared/*`, nic nekopiruje |
|
||||
|
||||
## Znama omezeni
|
||||
|
||||
@@ -74,17 +84,24 @@ 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 |
|
||||
| Rezim | Kdy | Nasledek |
|
||||
| ---------- | ------------------------------------------------- | ---------------------------- |
|
||||
| `postgres` | je `DATABASE_URL`, migrace prosly a je cim sifrovat | data se neztraci |
|
||||
| `file` | neni databaze, je `DATA_DIR` | prezije restart, ne redeploy |
|
||||
| `memory` | neni ani `DATA_DIR` | ztrati se pri restartu |
|
||||
|
||||
Beh automatizaci uz existuje, ale je **synchronni v requestu**: webhook ceka,
|
||||
nez cely strom dobehne, a pri padu procesu se rozdelany beh ztrati. Neni fronta
|
||||
ani opakovani. Co to znamena pro vetsi provoz a co s tim, je zmerene
|
||||
v [19-kapacita-200-firem.md](19-kapacita-200-firem.md), navrh fronty
|
||||
v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md).
|
||||
Rezim je jeden pro vsechna uloziste vcetne konektoru. Driv se mohlo stat, ze
|
||||
konektory jely z databaze a tickety ze souboru.
|
||||
|
||||
Beh automatizaci jde pres frontu a worker: webhook odpovi 202 a strom se
|
||||
vykona na pozadi, pri chybe se opakuje jen to, co muze pominout. Fronta je ale
|
||||
**v pameti jednoho procesu**: vic instanci by si vzalo tentyz beh, nad
|
||||
Postgresem to chce `SKIP LOCKED`. Popis je
|
||||
v [20-fronta-a-runtime.md](20-fronta-a-runtime.md), rozbor kapacity
|
||||
v [19-kapacita-200-firem.md](19-kapacita-200-firem.md).
|
||||
|
||||
Limity requestu (prihlaseni, kontakt, pozvanky) jsou v pameti jedne instance,
|
||||
stejne jako stream. Pri vice instancich by kazda pocitala zvlast.
|
||||
|
||||
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
|
||||
@@ -99,21 +116,17 @@ co ukazat i na prazdne instanci.
|
||||
|
||||
## Dalsi krok
|
||||
|
||||
Nejuzitecnejsi pristavek je runtime. Strom uz nese vsechno potrebne: spoustec
|
||||
s parametry, podminky a u ticketu i kanalu nastavena pole se sablonami. Chybi
|
||||
jen to, co ho vykona. Do te doby je ulozena automatizace popis zameru, ne provoz.
|
||||
Runtime je hotovy: fronta, worker jako pool, opakovani jen u chyb, ktere
|
||||
mohou pominout, incident z koncove chyby. Dalsi krok je **vic instanci**, tedy
|
||||
vyber z fronty nad Postgresem se `SKIP LOCKED`, sdileny kanal pro stream
|
||||
a sdileny citac limitu requestu.
|
||||
|
||||
Vedle toho zbyva prevest na `inputs` i ostatni konektory a doplnit odkazy
|
||||
na vystup predchoziho kroku, ne jen na spoustec. Podrobnosti
|
||||
Vedle toho zbyva prevest na `inputs` i ostatni konektory. Podrobnosti
|
||||
v [05-dashboard-a-builder.md](05-dashboard-a-builder.md).
|
||||
|
||||
Za rozmysleni stoji evidence bugs a wishes. Zamerne to nejsou tickety,
|
||||
duvod je v [06-tickety.md](06-tickety.md).
|
||||
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
@@ -31,28 +31,44 @@ image jen `dist`, takze staci jedna slozka.
|
||||
| `src/config.ts` | cteni environment variables, normalizace `ROOT_PATH` |
|
||||
| `src/openapi.ts` | OpenAPI definice vcetne `servers` s prefixem proxy |
|
||||
| `src/types.ts` | typy uzivatele a JWT payloadu |
|
||||
| `src/middleware/auth.ts` | `requireAuth`, `requireRole` |
|
||||
| `src/events/bus.ts` | sbernice udalosti, ze ktere cerpa SSE stream |
|
||||
| `src/shared/` | ciste typove moduly API, jediny zdroj typu pro server i web |
|
||||
| `src/middleware/auth.ts` | `requireAuth`, `requireRole`, `requirePlatformAdmin` |
|
||||
| `src/middleware/asyncHandler.ts` | `wrap`, `safeRouter`: odchyceni odmitnute promise v handleru |
|
||||
| `src/middleware/tenant.ts` | `attachAccess`, `tenantOrDeny`, `scopeOrDeny`: firma requestu na jednom miste |
|
||||
| `src/middleware/rateLimit.ts` | limit requestu v pameti, 429 s `Retry-After` |
|
||||
| `src/middleware/validation.ts` | `validationError`, jeden tvar chyby validace |
|
||||
| `src/lib/secure.ts` | `timingSafeEqualString` pro tokeny v adrese |
|
||||
| `src/net/guard.ts` | kontrola adresy, cteni tela s limitem, popis chyby site - pro vsechno, co vola ven |
|
||||
| `src/events/bus.ts` | sbernice udalosti, ze ktere cerpa SSE stream, udalost nese firmu |
|
||||
| `src/routes/auth.ts` | prihlaseni, odhlaseni, kdo jsem |
|
||||
| `src/routes/dashboard.ts` | data portalu, katalog konektoru, CRUD automatizaci |
|
||||
| `src/routes/stream.ts` | SSE stream zmen |
|
||||
| `src/routes/simulate.ts` | vyvolani provoznich udalosti |
|
||||
| `src/routes/dashboard.ts` | data portalu, tickety, behy, CRUD automatizaci |
|
||||
| `src/routes/ticketActions.ts` | akce nad ticketem vcetne vestavenych, pravo za firmu ticketu |
|
||||
| `src/routes/settings.ts` | CRUD entit pres `crud.ts`, uzivatele, ARES |
|
||||
| `src/routes/ares.ts` | firma z registru ARES, jen spravce platformy |
|
||||
| `src/routes/connectors.ts` | konektory firmy, overeni, nastroje MCP |
|
||||
| `src/routes/stream.ts` | SSE stream zmen, filtr podle firem uzivatele |
|
||||
| `src/routes/webhook.ts` | verejny prijem dat do automatizace |
|
||||
| `src/routes/contact.ts` | poptavkovy formular z webu |
|
||||
| `src/ares/client.ts` | klient verejneho API ARES |
|
||||
| `src/data/store/` | tri rezimy uloziste, `withCache`, `withMirror`, `initStores` |
|
||||
| `src/data/snapshot.ts` | atomicky zapis JSONu pro rezim `file` |
|
||||
| `src/data/ticketStore.ts` | tickety, jejich resitele, log prubehu, prehled vytizeni |
|
||||
| `src/data/people.ts` | resitele ticketu - oddeleni od uzivatelu portalu |
|
||||
| `src/data/tenants.ts` | firmy, ktere portal pouzivaji |
|
||||
| `src/data/tenants.ts` | firmy, ktere portal pouzivaji, vcetne udaju z ARES |
|
||||
| `src/data/access.ts` | kdo co vidi - jedno misto pro cely portal |
|
||||
| `src/data/widgets.ts` | katalog widgetu prehledu |
|
||||
| `src/data/dashboardLayouts.ts` | rozlozeni dashboardu za dvojici uzivatel a firma |
|
||||
| `src/data/incidentStore.ts` | incidenty vcetne zmen a udalosti |
|
||||
| `src/data/incidentStore.ts` | incidenty vcetne zmen a udalosti, filtr na firmu povinny |
|
||||
| `src/data/automationStore.ts` | automatizace, strom akci, tokeny webhooku |
|
||||
| `src/data/connectors.ts` | katalog konektoru, jejich spousteču a akci |
|
||||
| `src/data/services.ts` | katalog sluzeb, jejich spousteču a akci |
|
||||
| `src/data/conditions.ts` | typy parametru a operatory podminek |
|
||||
| `src/data/templates.ts` | sablony `{{parametr}}` v nastaveni kroku |
|
||||
| `src/data/flowScope.ts` | co je videt v kterem miste stromu |
|
||||
| `src/data/users.ts` | demo uzivatele |
|
||||
| `src/data/users.ts` | uzivatele portalu, demo ucty |
|
||||
| `src/data/mock.ts` | souhrn pro prehled a casova rada grafu |
|
||||
| `src/runtime/` | fronta, worker, executor stromu, vestavene kroky, sandbox skriptu firmy |
|
||||
| `src/scripts/` | skripty konektoru: registr, runner, HTTP, pomocne funkce |
|
||||
| `src/mcp/` | klient MCP, prihlaseni, dialekty, `errors.ts` se spolecnou chybou prihlaseni |
|
||||
|
||||
## Mapa kodu - web
|
||||
|
||||
@@ -62,16 +78,25 @@ image jen `dist`, takze staci jedna slozka.
|
||||
| `web/src/App.tsx` | routovani, portal se nacita lazy |
|
||||
| `web/src/index.css` | design tokeny a vlastni utility Tailwindu |
|
||||
| `web/src/config/brand.ts` | vsechny firemni udaje na jednom miste |
|
||||
| `web/src/lib/api.ts` | fetch wrapper, sprava tokenu, skladani adres |
|
||||
| `web/src/lib/api.ts` | fetch wrapper, sprava tokenu, skladani adres, `auth:expired` na 401 |
|
||||
| `web/src/lib/eventStream.ts` | cteni SSE streamu pres fetch |
|
||||
| `web/src/lib/useApiQuery.ts` | nacitani dat vcetne obnoveni pri udalosti |
|
||||
| `web/src/lib/useApiQuery.ts` | nacitani dat, cache, spolecny debounce, `refreshing` misto odmontovani |
|
||||
| `web/src/lib/collections.tsx` | klientsky sklad ciselniku za firmu, opravovany z udalosti |
|
||||
| `web/src/lib/ticketEvents.ts` | oprava seznamu ticketu z `payload.ticket` bez dotazu |
|
||||
| `web/src/lib/useSubmit.ts` | odeslani formulare: `saving`, chyba, reset na jednom miste |
|
||||
| `web/src/lib/options.ts` | pevne ciselniky (priority) |
|
||||
| `web/src/lib/useUnsavedChanges.ts` | varovani pri odchodu z rozepsaneho formulare |
|
||||
| `web/src/lib/flow.ts` | ciste funkce nad stromem automatizace |
|
||||
| `web/src/components/dashboard/` | shell portalu, dlazdice, graf, stream, simulace |
|
||||
| `web/src/components/dashboard/flow/` | strom akci, vyber kroku, nastaveni poli akce |
|
||||
| `web/src/types/` | fasada nad `src/shared` (alias `@shared/*`), zadne vlastni typy API |
|
||||
| `web/src/components/ui/` | zakladni prvky, `Chip` |
|
||||
| `web/src/components/ui/form/` | `Field`, `Input`, `Select`, `Textarea`, `controlClass`: jedna sada trid |
|
||||
| `web/src/components/dashboard/` | shell portalu, dlazdice, graf, stream, `TicketCard` |
|
||||
| `web/src/components/dashboard/flow/` | strom akci: `FlowCanvas` a karty `ActionCard`, `ConditionCard`, `ForeachCard`, `StepControls` |
|
||||
| `web/src/components/dashboard/TicketTrace.tsx` | log ticketu jako strom |
|
||||
| `web/src/components/dashboard/TicketWorkload.tsx` | prehled, kdo co ma u sebe |
|
||||
| `web/src/components/home/` | sekce homepage |
|
||||
| `web/src/pages/` | jedna stranka je jeden soubor |
|
||||
| `web/src/pages/dashboard/connectors/` | casti stranky Konektory: karta, editor, log, nastroje |
|
||||
|
||||
## Klicova rozhodnuti
|
||||
|
||||
@@ -88,8 +113,22 @@ Cenou je rucni parsovani a rucni znovupripojeni v `web/src/lib/eventStream.ts`.
|
||||
**Ceske cesty v URL.** `/sluzby`, `/o-nas`, `/prihlaseni`, `/dashboard/tickety`.
|
||||
Kod zustava anglicky.
|
||||
|
||||
**Data v pameti.** Vedome zjednoduseni prototypu. Uloziste jsou oddelena od rout,
|
||||
takze napojeni na databazi znamena prepsat soubory v `src/data/`, ne endpointy.
|
||||
**Uloziste je za rozhranim.** Tri rezimy (Postgres, soubor, pamet), jedno
|
||||
rozhrani a **jedno rozhodnuti** v `initStores`. Routy nevedi, ktery rezim jede.
|
||||
Podrobnosti v [14-databaze.md](14-databaze.md).
|
||||
|
||||
**Typy API jsou jednou.** Ciste typove moduly v `src/shared` ctou server i web,
|
||||
web pres alias `@shared/*`. Kopie typu na klientovi se jednou rozejde se
|
||||
serverem a prekladac to nepozna. `web/src/types/` je jen fasada, ktera je
|
||||
re-exportuje; ucet uzivatele bere i `AuthContext` ze `@shared/users`.
|
||||
|
||||
**Kazdy async handler je odchyceny.** Routy vznikaji pres `safeRouter`,
|
||||
odmitnuta promise skonci jako 500 s logem, ne padem procesu. Bez toho stacil
|
||||
jeden zapomenuty `try` a AppFactory restartovala container.
|
||||
|
||||
**Firma requestu se pocita jednou.** `attachAccess` da do `req.access`, co
|
||||
uzivatel smi, a `tenantOrDeny` z toho odvodi firmu. Kazda route, ktera si to
|
||||
pocitala sama, to delala trochu jinak.
|
||||
|
||||
**Filtr na firmu je povinny argument.** `listTickets`, `listPeople`
|
||||
i `listAutomations` vyzaduji `tenantIds`. Zapomenuty filtr tak neznamena "vse",
|
||||
|
||||
+93
-11
@@ -85,6 +85,9 @@ Vyzaduji `Authorization: Bearer <token>`:
|
||||
| GET | `/api/dashboard/settings/people-overview` |
|
||||
| GET | `/api/dashboard/settings/users-overview` |
|
||||
| PATCH | `/api/dashboard/settings/users/:id/password` |
|
||||
| GET | `/api/dashboard/settings/ares/companies` |
|
||||
| GET | `/api/dashboard/settings/ares/companies/:ico/persons` |
|
||||
| POST | `/api/dashboard/settings/ares/tenants` |
|
||||
| POST | `/api/admin/impersonate` |
|
||||
| POST | `/api/admin/impersonate/stop` |
|
||||
| GET | `/api/admin/impersonate/candidates` |
|
||||
@@ -102,20 +105,35 @@ fabrika (`src/routes/crud.ts`):
|
||||
Jednotny pro cele API:
|
||||
|
||||
```json
|
||||
{ "error": "validation_error", "message": "Zadejte platny e-mail." }
|
||||
{
|
||||
"error": "validation_error",
|
||||
"message": "Zadejte platny e-mail.",
|
||||
"issues": [{ "field": "email", "message": "Zadejte platny e-mail." }]
|
||||
}
|
||||
```
|
||||
|
||||
| HTTP | `error` | Kdy |
|
||||
| ---- | --------------------- | ------------------------------------------- |
|
||||
| 400 | `validation_error` | vstup neprosel schematem |
|
||||
| 400 | `validation_error` | vstup neprosel schematem, `issues` po polich |
|
||||
| 401 | `unauthorized` | chybi nebo neplatny token |
|
||||
| 401 | `invalid_credentials` | spatny e-mail nebo heslo |
|
||||
| 403 | `forbidden` | nedostatecna role |
|
||||
| 404 | `not_found` | zaznam nebo endpoint neexistuje |
|
||||
| 403 | `forbidden` | nedostatecne pravo v dane firme |
|
||||
| 404 | `not_found` | zaznam nebo endpoint neexistuje, nebo je cizi firmy |
|
||||
| 409 | ruzne | operace nedava v danem stavu smysl |
|
||||
| 429 | `too_many_requests` | prekrocen limit requestu, hlavicka `Retry-After` |
|
||||
| 500 | `internal_error` | neodchycena chyba, detail jen mimo produkci |
|
||||
|
||||
`message` je vzdy cesky a je urcena k zobrazeni uzivateli.
|
||||
`message` je vzdy cesky a je urcena k zobrazeni uzivateli. Chybu validace
|
||||
sklada `validationError` v `src/middleware/validation.ts`, aby `issues` mely
|
||||
vsude stejny tvar a formular umel chybu ukazat u pole.
|
||||
|
||||
Limity (`src/middleware/rateLimit.ts`) jsou jen na verejnych endpointech, kde
|
||||
se da hadat: prihlaseni 20 pokusu za 15 minut, kontakt 5 za hodinu, prijeti
|
||||
pozvanky 5 za 15 minut. Pocita se podle adresy klienta, proto ma Express
|
||||
`trust proxy` = 1 - bez toho by vsichni za Caddy sdileli jeden limit.
|
||||
|
||||
Kazdy asynchronni handler je obaleny (`safeRouter` v `src/middleware/asyncHandler.ts`).
|
||||
Odmitnuta promise je 500 s logem, ne pad procesu.
|
||||
|
||||
## Autentizace
|
||||
|
||||
@@ -139,6 +157,21 @@ Typy udalosti: `ticket.created`, `ticket.updated`, `ticket.assigned`,
|
||||
`automation.created`, `automation.updated`, `automation.deleted`,
|
||||
`automation.run`, `webhook.received`.
|
||||
|
||||
K tomu udalosti entit `tenant`, `user`, `role`, `person`, `group`,
|
||||
`ticketType`, `action`, `widget`, `connector`, `feature` s priponou
|
||||
`.created`, `.updated`, `.deleted`. Payload je `{ id, <druh>: zaznam }`,
|
||||
u smazani jen `{ id }`. Publikuje je `crudRouter` (volba `event`), routy
|
||||
konektoru a PUT features. Klient z nich opravuje sklad ciselniku bez dotazu.
|
||||
|
||||
**Kazda udalost nese `tenantId`** (`null` = cela platforma). Stream posila jen
|
||||
udalosti firem, do kterych uzivatel patri, a to i v historii po pripojeni.
|
||||
Udalost s `payload.userId` jde jen tomu cloveku. Spravce platformy vidi vse.
|
||||
Driv videl kazdy prihlaseny udalosti vsech firem - nazev ticketu cizi firmy
|
||||
v bubline je unik dat, i kdyz se na ticket nedostane.
|
||||
|
||||
`ticket.updated`, `ticket.assigned` a `ticket.resolved` nesou
|
||||
v `payload.ticket` cely ticket, aby klient opravil seznam na miste.
|
||||
|
||||
Klient se pripojuje pres fetch s hlavickou `Authorization`, ne pres EventSource.
|
||||
Duvod je v [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md).
|
||||
|
||||
@@ -166,8 +199,9 @@ curl -X POST https://services.csbot.cz/apps/<app-id>/webhook/<token> \
|
||||
-d '{"customer":"Nordis","score":18}'
|
||||
```
|
||||
|
||||
Prototyp pozadavek prijme, zvaliduje a zapocita do metrik, ale strom akci
|
||||
nevykona - runtime neexistuje.
|
||||
Token se porovnava v konstantnim case (`timingSafeEqualString`
|
||||
v `src/lib/secure.ts`), stejne jako token prijmu a kod pozvanky. V logu
|
||||
requestu je z tokenu videt jen prvnich sest znaku.
|
||||
|
||||
## Firmy a pohledy
|
||||
|
||||
@@ -192,6 +226,11 @@ bez vysvetleni.
|
||||
Odpoved nese vedle `items` jeste `meId`. Klient podle nej pozna, ktere tickety
|
||||
jsou jeho, a jestli ma vubec smysl nabizet filtr "moje".
|
||||
|
||||
**Strankovani.** `/tickets` a `/runs` berou `limit` a `offset`, `limit` nejvys
|
||||
500. Celkovy pocet je v hlavicce `X-Total-Count`, u ticketu i v tele jako
|
||||
`total`. Bez `limit` se vraci vse jako driv (u behu poslednich 50), aby se
|
||||
nerozbily stavajici odkazy. Klient cte hlavicku pres `apiFetchWithMeta`.
|
||||
|
||||
Filtrovani dela **server**, ne klient. Seznam a prehled vytizeni tak nikdy
|
||||
neukazuji jina cisla. Vyhledavaci pole v portalu je jina vec - to jen dohledava
|
||||
v uz nactenem seznamu.
|
||||
@@ -246,7 +285,26 @@ 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.
|
||||
zvlast: meni ticket sam, ne cizi sluzbu, a kazda ma vlastni pravo. Vsechny
|
||||
vcetne `claim` jdou pres `builtinAction` v `src/routes/ticketActions.ts`, kde
|
||||
se pravo pta za firmu ticketu a ticket se nejdriv najde pres strop
|
||||
viditelnosti (`visibleTicketOrDeny`). Driv mely `assign`, `status` a `comment`
|
||||
vlastni handlery a kazdy se ptal jinak.
|
||||
|
||||
## Firma z registru ARES
|
||||
|
||||
Jen spravce platformy (`/api/dashboard/settings/ares`). Popis rozhodnuti je
|
||||
v [07-firmy-a-prava.md](07-firmy-a-prava.md).
|
||||
|
||||
| Endpoint | Co vraci |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `GET ares/companies?query=` | same cislice (1 az 8) hledaji IC presne, jinak nazev, nejvys 10. U firmy, ktera uz v portalu je, `existingTenantId` |
|
||||
| `GET ares/companies/{ico}/persons` | soucasni statutari a prokura z verejneho rejstriku, u kazdeho navrzeny e-mail `IC-poradi@placeholder.cz` |
|
||||
| `POST ares/tenants` | zalozi firmu a ucty vybranych osob, vraci firmu a seznam uctu |
|
||||
|
||||
Chyba registru je `ares_error` s kodem podle toho, co ARES vratil - neni to
|
||||
chyba naseho API a nema se opakovat automaticky. Adresa registru je
|
||||
`ARES_BASE_URL`.
|
||||
|
||||
## Prijem udalosti do ticketu
|
||||
|
||||
@@ -284,9 +342,33 @@ a kdy se to zkusi znovu.
|
||||
## 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.
|
||||
roli), `nav` (zalozky, ktere ma volajici videt), `platformAdmin` a `roleNames`
|
||||
(nazvy roli v prepnute firme, pro popisek u uctu). Klient podle toho kresli,
|
||||
ale **nic si nedovozuje** - kdo co smi, rozhoduje server u kazdeho requestu
|
||||
znovu.
|
||||
|
||||
### Kdo co smi, po routach
|
||||
|
||||
Pravo se vzdy pta **za firmu zaznamu**, ne za prepnutou firmu. Cizi firma je
|
||||
404, chybejici pravo 403.
|
||||
|
||||
| Co | Pravo |
|
||||
| ----------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| firmy CRUD, ARES | spravce platformy |
|
||||
| uzivatele CRUD | spravce platformy, nebo `user.manage` jen v ramci sve firmy |
|
||||
| pozvanky | `user.manage`, role jen z te firmy |
|
||||
| konektory create, update, delete, test | `connector.manage` |
|
||||
| automatizace create, update, delete, regenerate | `automation.edit` |
|
||||
| `/services`, `/connectors/services` | clenstvi ve firme |
|
||||
| assign, status, comment, claim na ticketu | prava vestavene akce za firmu ticketu plus strop viditelnosti |
|
||||
| `/api/admin/impersonate*` | `impersonate` |
|
||||
| `/api/admin/audit` | `audit.view` |
|
||||
| `/storage`, `/scripts` s cestami na serveru | cesty jen spravci platformy, ostatni dostanou odpoved bez nich |
|
||||
|
||||
Spravce firmy s `user.manage` **nenastavi `platformAdmin`**, neprida clenstvi
|
||||
v jine firme, nesahne na spravce platformy a nesmaze cloveka, ktery je i
|
||||
v jine firme - ten ucet neni jen jeho. Podrobnosti
|
||||
v [07-firmy-a-prava.md](07-firmy-a-prava.md).
|
||||
|
||||
`GET /api/dashboard/settings/catalog` vraci katalog prav a modulu, aby formular
|
||||
role nemel seznam prav napsany v kodu klienta.
|
||||
|
||||
@@ -25,10 +25,49 @@ Portal drzi jedno SSE spojeni pro celou aplikaci. Zajistuje ho
|
||||
- Prichozi udalosti ukazuje `EventToasts` jako bubliny vpravo dole.
|
||||
- Data se obnovuji sama. `useApiQuery` ma volitelny `refetchOn` se seznamem typu
|
||||
udalosti, po kterych se ma dotaz zopakovat. Vice udalosti tesne po sobe se
|
||||
slouci do jednoho nacteni.
|
||||
slouci do jednoho nacteni - debounce 150 ms je **spolecny pro vsechny
|
||||
hooky**, takze jedna udalost je jedna vlna requestu, ne pet v peti chvilich.
|
||||
|
||||
Pri vypadku se stream znovu pripojuje s exponencialne rostoucim odstupem
|
||||
az do 15 sekund, aby pri vypadku serveru neubijel provoz.
|
||||
az do 15 sekund, aby pri vypadku serveru neubijel provoz. Na 401 a 403 se
|
||||
pripojovat prestane: token vyprsel a klient vyvola `auth:expired`, po kterem
|
||||
se portal odhlasi a Login rekne, ze prihlaseni vyprselo.
|
||||
|
||||
### Obnova neodmontuje stranku
|
||||
|
||||
`useApiQuery` vraci `loading` jen **do prvnich dat**, potom uz `refreshing`.
|
||||
`DataState` pri `refreshing` nechava deti vykreslene a jen ukaze, ze bezi
|
||||
obnova. Prvni verze prepnula `loading` pri kazde udalosti ze streamu a
|
||||
`DataState` vykreslil spinner misto obsahu - pri behu automatizace se stranka
|
||||
nekolikrat za sekundu odmontovala a namontovala, vcetne ztraty kurzoru
|
||||
v rozepsanem poli.
|
||||
|
||||
```ts
|
||||
useApiQuery<T>(path, { refetchOn?, body?, enabled?, patchOn? })
|
||||
-> { data, loading, refreshing, error, total, reload }
|
||||
```
|
||||
|
||||
Dotazy sdili cache modulu a deduplikaci bezicich requestu (klic je firma,
|
||||
cesta a telo), takze dve komponenty se stejnym dotazem se ptaji jednou.
|
||||
`patchOn` opravi data v cache primo z udalosti: `lib/ticketEvents.ts` bere
|
||||
`payload.ticket` z `ticket.updated` a vymeni radek v seznamu bez dotazu.
|
||||
|
||||
### Ciselniky jsou v klientskem skladu
|
||||
|
||||
Lide, skupiny, typy ticketu, sluzby, konektory a pristup se nectou stranka po
|
||||
strance, ale ze skladu `lib/collections.tsx`: `useCollection(key)`,
|
||||
`useAccess()`, `useCollectionSelector`. Kolekce se nacte pri prvnim pouziti,
|
||||
vymaze se pri prepnuti firmy a odhlaseni a **opravuje se z udalosti entit**
|
||||
(`person.updated` a podobne): zaznam z payloadu se vlozi nebo smaze, a kdyz
|
||||
payload zaznam nenese, nacte se ta jedna kolekce znovu.
|
||||
|
||||
Rozhodnuti majitele produktu je stredni cesta: ciselniky do skladu, **tickety,
|
||||
behy a statistiky zustavaji dotazy na server** se strankovanim. Jsou velke,
|
||||
meni se porad a strop viditelnosti pocita server - klientska kopie by je
|
||||
ukazovala jinak nez prehled vytizeni.
|
||||
|
||||
Filtry seznamu ticketu jsou v URL. Nalez jde poslat kolegovi a tlacitko zpet
|
||||
vrati predchozi filtr; driv byl filtr stav stranky a po obnoveni zmizel.
|
||||
|
||||
## Simulace provozu
|
||||
|
||||
@@ -102,6 +141,15 @@ Vetve ANO a NE jsou vedle sebe jen tehdy, kdyz je na to v dane karte misto.
|
||||
Rozhoduje **sirka karty, ne sirka okna** - pouzivaji se container queries
|
||||
(`@container` a `@2xl:grid-cols-2` v `FlowCanvas.tsx`).
|
||||
|
||||
Karty jsou rozdelene po druhu kroku: `flow/ActionCard.tsx`,
|
||||
`ConditionCard.tsx`, `ForeachCard.tsx`, `StepControls.tsx`, spolecne typy
|
||||
v `canvasTypes.ts`. `FlowCanvas.tsx` uz jen sklada. Karty jsou v `memo`,
|
||||
callbacky se predavaji podle ID kroku a `collectScopes` je memoizovane -
|
||||
u stromu o padesati krocich byl driv kazdy stisk klavesy v poli prekreslenim
|
||||
vseho.
|
||||
|
||||
Odchod z rozepsaneho stromu hlida `lib/useUnsavedChanges.ts`.
|
||||
|
||||
Duvod: kazde zanoreni pulí dostupnou sirku. S beznym `lg:grid-cols-2` vypadal
|
||||
strom na sirokem monitoru dobre v prvni urovni a ve treti uz mel karty siroke
|
||||
par desitek pixelu, takze se popisy lamaly po jednom slove. Container query se
|
||||
@@ -157,8 +205,10 @@ je vypise. Rozdelana prace se nikdy nezahazuje.
|
||||
|
||||
## Pridani konektoru
|
||||
|
||||
1. Pridat zaznam do `connectors` v `src/data/connectors.ts` vcetne `triggers`
|
||||
a `actions`.
|
||||
1. Pridat zaznam do katalogu v `src/data/services.ts` vcetne `triggers`
|
||||
a `actions`. ID operace musi byt v ramci sluzby unikatni,
|
||||
`checkOperationIds()` duplicitu pri nacteni zaloguje - druha by tise
|
||||
prekryla prvni.
|
||||
2. Pokud pouziva novou ikonu, doplnit klic do `web/src/lib/connectorIcons.ts`.
|
||||
Musi existovat v `lucide-react`.
|
||||
3. Pokud patri do nove kategorie, doplnit ji do `connectorCategories` a do typu
|
||||
@@ -173,11 +223,8 @@ Builder i katalog ji vezmou automaticky.
|
||||
| Chybi | Poznamka |
|
||||
| ---------------------------- | --------------------------------------------------------- |
|
||||
| `inputs` u zbylych konektoru | zatim ticket, kanaly, CRM a AI, ostatni maji jen `fields` |
|
||||
| Vazba logu ticketu na beh | log plni simulace, ne vykonany strom |
|
||||
| Kombinovane podminky | jedna podminka je jedno porovnani, AND a OR jen vnorenim |
|
||||
| Beh automatizaci | ulozeny strom se nevykonava |
|
||||
| Historie behu a logy | prazdne, chybi runtime |
|
||||
| Drag and drop | presouvani je zatim tlacitky nahoru a dolu |
|
||||
| Strankovani v portalu | server `limit` a `offset` umi, seznam ticketu si zatim bere vse |
|
||||
|
||||
## Co je videt v kterem kroku
|
||||
|
||||
|
||||
@@ -132,10 +132,19 @@ rozlezlo po routach, driv nebo pozdeji vznikne endpoint, ktery filtr zapomene.
|
||||
"tenants": [{ "id": "tnt_automia", "name": "Automia" }],
|
||||
"defaultTenantId": "tnt_automia",
|
||||
"canAssignOthers": true,
|
||||
"personId": "ppl_uhlir"
|
||||
"personId": "ppl_uhlir",
|
||||
"roleNames": ["Správce"]
|
||||
}
|
||||
```
|
||||
|
||||
Pristup se pocita **jednou na request** (`attachAccess`
|
||||
v `src/middleware/tenant.ts`, vysledek v `req.access`) a routy si z nej berou
|
||||
firmu pres `tenantOrDeny`. Kazda route, ktera si to pocitala sama, to delala
|
||||
o neco jinak a jedna z nich spatne.
|
||||
|
||||
Stream udalosti tutez informaci pouziva k filtru: uzivatel dostane jen udalosti
|
||||
svych firem, viz [04-api.md](04-api.md).
|
||||
|
||||
Klient podle toho kresli prepinac. **Nesmi si to dovozovat sam** - jinak by se
|
||||
prava pocitala na dvou mistech a jednou se rozejdou.
|
||||
|
||||
@@ -197,15 +206,57 @@ Heslo je u vsech `demo1234`.
|
||||
Druhy ucet je ten zajimavy: ukazuje prepinac firem i to, ze prava se lisi
|
||||
podle toho, ktera firma je prave zvolena.
|
||||
|
||||
## Kdo koho zaklada
|
||||
|
||||
Rozhodnuti z revize v zari 2026: **firmy zaklada spravce platformy, lidi ve
|
||||
firme spravuje spravce firmy.** Je to hranice mezi nasim pravem a zakaznickym
|
||||
a drzi se vsude, kde se neco zaklada:
|
||||
|
||||
| Co | Kdo smi | Kde se to kontroluje |
|
||||
| -------------------------- | ------------------------------------------------------------ | ---------------------------------------- |
|
||||
| firma | jen spravce platformy (`platformOnly` u CRUD firem) | `src/routes/settings.ts` |
|
||||
| firma z registru ARES | jen spravce platformy | `src/routes/ares.ts` |
|
||||
| uzivatel | spravce platformy, nebo `user.manage` jen ve sve firme | `src/routes/settings.ts` |
|
||||
| pozvanka | `user.manage`, role jen z te firmy | `src/routes/invites.ts` |
|
||||
| konektor | `connector.manage` | `src/routes/connectors.ts`, `dashboard.ts` |
|
||||
| automatizace | `automation.edit` za firmu automatizace | `src/routes/dashboard.ts` |
|
||||
| akce nad ticketem | pravo akce za firmu ticketu a strop viditelnosti | `src/routes/ticketActions.ts` |
|
||||
|
||||
Spravce firmy s `user.manage` ma **jen svou firmu**: nenastavi `platformAdmin`,
|
||||
neprida cizi clenstvi, nesahne na spravce platformy a nesmaze cloveka, ktery
|
||||
je i v jine firme. Posledni bod neni formalita - ucet ve dvou firmach neni jen
|
||||
jeho a smazat ho znamena vzit pristup i te druhe.
|
||||
|
||||
Driv u vetsiny rout stacilo clenstvi ve firme. Clen s roli `viewer` tak mohl
|
||||
zalozit konektor nebo smazat automatizaci. Prava v katalogu byla, jen se na ne
|
||||
routy neptaly.
|
||||
|
||||
### Firma z registru ARES
|
||||
|
||||
Zalozit firmu rucne znamena opsat nazev, IC, DIC a adresu a pak zvlast
|
||||
zakladat ucty lidem, kteri ji povedou. Proto `Nastaveni`, firma z ARES:
|
||||
spravce platformy zada IC nebo nazev, vybere firmu a z verejneho rejstriku
|
||||
dostane **soucasne cleny statutarniho organu a prokuru**. Vybrani dostanou
|
||||
ucet s roli `role_admin` v nove firme a nahodnym heslem.
|
||||
|
||||
Rejstrik e-maily nezna. Kazda osoba proto dostane zastupnou adresu
|
||||
`IC-poradi@placeholder.cz`, pokud spravce nezada skutecnou. **Zastupna adresa
|
||||
se musi nahradit** - s ni se clovek neprihlasi a nedostane pozvanku. Portal ji
|
||||
pozna (`isPlaceholderEmail`), aby slo upozornit.
|
||||
|
||||
Firma nese `ico`, `dic`, `address` a `legalForm`; IC je unikatni a CRUD firem
|
||||
to hlida, takze tataz firma nevznikne dvakrat. Hledani v ARES u uz zalozene
|
||||
firmy vrati `existingTenantId` misto tlacitka Zalozit.
|
||||
|
||||
Od te chvile je to na spravci firmy: skupiny, vedouci, clenove, pozvanky.
|
||||
Spravce platformy do firmy nesaha, pokud nemusi.
|
||||
|
||||
## Co chybi
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| ------------------------- | ------------------------------------------------ |
|
||||
| Sprava clenstvi z portalu | memberships jdou zmenit jen v kodu |
|
||||
| Pozvanky uzivatelu | zadny onboarding |
|
||||
| Tenant u incidentu | incidenty jsou zatim spolecne, nefiltruji se |
|
||||
| Tenant u konektoru | katalog je spolecny, napojeni se zatim neeviduje |
|
||||
| Audit pristupu | odepreni se jen loguje, nikde se neuklada |
|
||||
| Nahrada zastupnych adres | portal je pozna, ale nenuti spravce je vymenit |
|
||||
|
||||
## Strop viditelnosti
|
||||
|
||||
|
||||
@@ -19,8 +19,10 @@ Dokud se neklikne na Ulozit, nic se neuklada. Zrusit vrati puvodni rozlozeni.
|
||||
Ne za uzivatele. Clovek ve dvou firmach chce v kazde videt neco jineho
|
||||
a smichat mu to dohromady by bylo horsi nez zadne nastaveni.
|
||||
|
||||
Klic je `${userId}:${tenantId}`, uloziste je `src/data/dashboardLayouts.ts`.
|
||||
Kdo si dashboard jeste neupravil, dostane vychozi rozlozeni a `custom: false`.
|
||||
Klic je `${userId}:${tenantId}`, uloziste je `src/data/dashboardLayouts.ts`
|
||||
(pres `withMirror`, prezije restart). Kdo si dashboard jeste neupravil,
|
||||
dostane vychozi rozlozeni a `custom: false`. Ulozene rozlozeni si drzi
|
||||
`createdAt` i po uprave.
|
||||
|
||||
## Katalog widgetu
|
||||
|
||||
@@ -54,6 +56,18 @@ deset stejnych dotazu na server.
|
||||
Prehled drzi ctyri dotazy (souhrn, tickety, incidenty, vytizeni) a rozdava je
|
||||
vsem widgetum. Dlazdice, ktera zadna data nepotrebuje, o nich proste nevi.
|
||||
|
||||
Totez plati na serveru: `POST /widget-data` nacte seznam ticketu **jednou na
|
||||
request** a kazdy widget si z nej filtruje svoje. Driv sel `listTickets` za
|
||||
kazdy widget zvlast, tedy desetkrat za otevreni prehledu.
|
||||
|
||||
Pri udalosti ze streamu se dotazy obnovi na pozadi (`refreshing`) a dlazdice
|
||||
zustavaji vykreslene. Driv se cely prehled pri kazde udalosti odmontoval
|
||||
a ukazal spinner, viz [05-dashboard-a-builder.md](05-dashboard-a-builder.md).
|
||||
|
||||
Ukazkove widgety "Moje tickety" a "Fronta bez resitele" filtruji
|
||||
`closed: false`, ne podle nazvu stavu - stav je volny retezec a slovnik
|
||||
`defaultStatuses` je cesky (Novy, V reseni, Ceka na klienta, Vyreseno).
|
||||
|
||||
## Mrizka
|
||||
|
||||
Sest sloupcu, sirky mapuji na `col-span`: tretina 2, polovina 3, cela 6.
|
||||
@@ -96,4 +110,3 @@ Nabidka i rozlozeni ho vezmou automaticky.
|
||||
| Nastaveni jednotlivych widgetu | napr. kolik radku ukazat, za jake obdobi |
|
||||
| Vlastni metriky | katalog je pevny, nejde si nadefinovat vlastni |
|
||||
| Sdilene rozlozeni pro firmu | kazdy si upravuje jen to svoje |
|
||||
| Databaze | rozlozeni je v pameti, restart je vrati na vychozi |
|
||||
|
||||
@@ -132,7 +132,7 @@ export async function run(inputs, ctx) { /* ... */ }
|
||||
| `ctx.http` | `get`, `post`, `patch`, `put`, `del`, `postForm` nad adresou napojeni |
|
||||
| `ctx.util` | pomocne funkce, viz nize |
|
||||
| `ctx.log` | radek do logu behu, vzdy zredigovany a zkraceny |
|
||||
| `ctx.config` | necitliva cast nastaveni napojeni |
|
||||
| `ctx.config` | necitliva cast nastaveni napojeni, tajna pole **nikdy** (`scriptConfig`) |
|
||||
| `ctx.idempotencyKey` | stabilni pres vsechny pokusy tehoz kroku |
|
||||
| `ctx.fail` | koncova chyba, neopakuje se |
|
||||
| `ctx.retry` | docasna chyba, ma smysl zkusit znovu |
|
||||
@@ -175,6 +175,20 @@ z nich by to resil spatne.
|
||||
| `text`, `num`, `bool`, `date` | prevody s fallbackem |
|
||||
| `round(value, decimals)` | zaokrouhleni, uctuje se v halerich |
|
||||
| `need(value, label)` | vrati hodnotu, nebo skonci citelnou chybou |
|
||||
| `day(value)` | datum bez casu, `YYYY-MM-DD`, jinak prazdny retezec |
|
||||
| `list(body, ...names)` | seznam z odpovedi: pole primo, nebo pod danym klicem, `data`, `content`, `items`, `results`. Jinak `null` |
|
||||
| `addresses(value)` | adresy z pole "Prijemci" oddelene carkou nebo strednikem, bez prazdnych |
|
||||
| `quote(value)` | hodnota v jednoduchych uvozovkach pro filtr OData nebo SQL, apostrof zdvojeny |
|
||||
|
||||
Ctyri posledni pribyly v zari 2026, kdyz se ukazalo, ze osm skriptu ma kazdy
|
||||
svou verzi. Sablona `scripts/_sablona.js` je vsechny vypisuje, aby se nehledaly
|
||||
v kodu serveru.
|
||||
|
||||
`ctx.config` je slozene z `scriptConfig` v `src/scripts/connections.ts`:
|
||||
z nastaveni napojeni vynecha kazde pole, jehoz hodnota je mezi tajnymi. Skript
|
||||
tedy heslo SMTP ani tajemstvi OAuth nedostane ani omylem, i kdyz je runtime
|
||||
(SMTP, MCP) potrebuje - ty si je berou z `serviceConfig`, ke kteremu skript
|
||||
nema pristup.
|
||||
|
||||
## Chyby: opakovatelne a koncove
|
||||
|
||||
@@ -225,6 +239,20 @@ chyby, projde nahradou znamych tajnych hodnot za hvezdicky.
|
||||
|
||||
Neni to volitelne dolazeni, je to soucast zapisu.
|
||||
|
||||
Redaktor (`createRedactor`) maskuje tajemstvi ve **ctyrech tvarech**:
|
||||
`Bearer abc`, hole `abc`, URL-encoded a JSON-escaped. Cizi sluzby vraceji
|
||||
prijaty token v chybe i uvnitr adresy nebo v zaescapovanem JSONu, a tam by
|
||||
hola hodnota nesedela.
|
||||
|
||||
Zkracovani ma jednu konstantu, `DETAIL_BYTES` v `src/scripts/util.ts`
|
||||
(`SCRIPT_ERROR_DETAIL_BYTES`, vychozi 8 kB). Detail chyby MCP mel driv vlastnich
|
||||
600 znaku a prave u nej byla cela odpoved potreba nejvic.
|
||||
|
||||
Cteni tela odpovedi jde pres `readBodyLimited` v `src/net/guard.ts`: cte
|
||||
**proudem a usekne se u limitu**. Prvni verze nacetla cele telo a teprve pak
|
||||
ho zmerila, takze `SCRIPT_MAX_RESPONSE_BYTES` chranil zaznam behu, ale ne
|
||||
pamet procesu.
|
||||
|
||||
## Bezpecnostni hranice a co jeste chybi
|
||||
|
||||
Skripty ve slozce jsou **nase**, prosly gitem a code review. Bezi proto v procesu
|
||||
@@ -241,6 +269,21 @@ u zakaznickych ne - ti musi bezet v izolovanem enginu ve vlastnim vlakne.
|
||||
Podrobnosti v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md), sekce
|
||||
o skriptech.
|
||||
|
||||
### Vlastni skripty firmy bezi jinde
|
||||
|
||||
Skript, ktery si napise spravce firmy v portalu (krok "Vlastni skript"), neni
|
||||
tenhle druh skriptu. Je to **cisty prevod hodnot** bez site a bezi
|
||||
v `node:vm` (`src/runtime/sandbox.ts`) s casovym limitem. Od zari 2026 se
|
||||
`utils` i `input` stavi **uvnitr kontextu vm**, ne v hostiteli: funkce
|
||||
hostitele predane dovnitr nesly s sebou svuj `constructor` a pres
|
||||
`constructor('return process')` se z nich dalo dostat ven. Zkompilovane
|
||||
skripty se drzi v LRU cache (100) podle otisku kodu, aby se stejny skript
|
||||
nekompiloval pri kazdem behu.
|
||||
|
||||
Porad plati, co je v komentari toho souboru: `node:vm` je izolace proti
|
||||
nehode, ne proti utocnikovi. Skript pise spravce firmy, ktery jeji data uz
|
||||
vidi.
|
||||
|
||||
## Napojeni do katalogu
|
||||
|
||||
Skript se domeri do katalogu sluzeb jako akce s `implementation: 'script'`
|
||||
@@ -330,9 +373,8 @@ Katalog, builder i stranka skriptu si ho vezmou samy. Nic se nerestartuje.
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| ---------------------- | ---------------------------------------------------------- |
|
||||
| Skripty od zakazniku | potrebuji sandbox a vlastni vlakno, viz vyse |
|
||||
| Skripty od zakazniku s pristupem ven | dnes jen prevod hodnot ve `vm`, volani ven chce vlastni proces |
|
||||
| Verzovani skriptu | uprava prepise soubor, historie je jen v gitu |
|
||||
| Vykonavani ze stromu | runner je hotovy, ale runtime automatizaci neni |
|
||||
| Skripty jako spoustece | zatim jen akce, spoustec potrebuje runtime |
|
||||
| Skripty jako spoustece | zatim jen akce |
|
||||
| Metriky pro widgety | manifest to zatim nezna, viz bod 4 navrhu |
|
||||
| Ulozeni uprav mimo git | portal zapisuje do souboru v containeru, redeploy je vrati |
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
# 14 - Databaze
|
||||
|
||||
Naprogramovano a overeno. Zatim se ukladaji **konektory**, tedy pristupove udaje
|
||||
k sluzbam. Zbytek je v pameti procesu, poradi dalsich kroku je na konci.
|
||||
Naprogramovano a overeno. Uklada se vsechno: konektory, tickety, automatizace,
|
||||
incidenty, rozlozeni dashboardu, entity nastaveni, audit a notifikace.
|
||||
|
||||
Ukladat jde tremi zpusoby a rezim se vybira sam podle toho, co je k dispozici.
|
||||
|
||||
## Tri rezimy, jedno rozhrani
|
||||
## Tri rezimy, jedno rozhrani, jedno rozhodnuti
|
||||
|
||||
Rozdil se resi **na jednom miste**, v `src/data/connectorStore.ts`. Nikde jinde
|
||||
se nezjistuje, ktery rezim jede - kdyby se to rozlezlo po kodu, jedno misto by
|
||||
se zapomnelo a chovalo by se pak jinak nez zbytek.
|
||||
Rozdil se resi **na jednom miste**, v `initStores` v `src/data/store/index.ts`.
|
||||
Nikde jinde se nezjistuje, ktery rezim jede - kdyby se to rozlezlo po kodu,
|
||||
jedno misto by se zapomnelo a chovalo by se pak jinak nez zbytek.
|
||||
|
||||
| Rezim | Kdy | Prezije restart | Prezije redeploy |
|
||||
| ---------- | --------------------------------- | --------------- | ---------------- |
|
||||
| `postgres` | je `DATABASE_URL` i `SECRETS_KEY` | ano | ano |
|
||||
| `file` | neni databaze, ale je `DATA_DIR` | ano | **ne** |
|
||||
| `memory` | ani jedno, nebo nejde zapsat | ne | ne |
|
||||
Presne to se stalo: konektory mely vlastni rozhodnuti v `connectorStore.ts`
|
||||
s jinymi podminkami nez zbytek, takze konektory mohly jet z databaze a tickety
|
||||
ze souboru. Ted `connectorStore` jen vola `initStores` a rezim je **jeden pro
|
||||
vsechna uloziste**.
|
||||
|
||||
| Rezim | Kdy | Prezije restart | Prezije redeploy |
|
||||
| ---------- | --------------------------------------------------------- | --------------- | ---------------- |
|
||||
| `postgres` | je `DATABASE_URL`, migrace prosly a je cim sifrovat (`SECRETS_KEY`) | ano | ano |
|
||||
| `file` | neni databaze, ale je `DATA_DIR` | ano | **ne** |
|
||||
| `memory` | ani jedno, nebo nejde zapsat | ne | ne |
|
||||
|
||||
Rezim `file` je pro mockup. Filesystem containeru je docasny, takze soubor
|
||||
prezije restart procesu i containeru, ale nove nasazeni ho smaze. Je to
|
||||
@@ -87,8 +92,38 @@ Kdyby to byly dve implementace, jedna by se casem opravila a druha ne.
|
||||
| Slucovani zapisu | deset uprav za sebou znamena jeden zapis na disk |
|
||||
| Zapis pri ukonceni | `SIGTERM` dokonci rozepsany zapis, jinak by se posledni zmena ztratila |
|
||||
| Rozbity soubor | prejmenuje se na `.broken`, zaloguje a jede se s prazdnymi daty. Aplikace, ktera nenastartuje, je pro AppFactory nefunkcni sluzba |
|
||||
| Necitelny soubor | jen `ENOENT` je prvni start. Jina chyba cteni (prava, plny disk) **zamkne zapisy** a zaloguje se - jinak by se soubor s daty prepsal prazdnym |
|
||||
| Sifrovani | tajne hodnoty jsou v souboru zasifrovane, plaintext nikdy |
|
||||
|
||||
Rozdil mezi poslednimi dvema radky je zamer. Rozbity JSON je zalozeny bokem
|
||||
a nic se neztrati. Chyba cteni ale neznamena, ze data neexistuji - a start
|
||||
s prazdnem, ktery by je pri prvnim zapisu prepsal, je jedina cesta, jak
|
||||
o ne v rezimu `file` opravdu prijit.
|
||||
|
||||
## Vrstvy nad ulozistem
|
||||
|
||||
Dve obalky, kazda pro jiny druh dat (podrobne
|
||||
v [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md)):
|
||||
|
||||
**`withCache`** pro entity, ktere se ctou pri kazdem requestu a meni zridka.
|
||||
Kopie v pameti, `byId` je `Map`. Vsechny cache jsou v registru
|
||||
(`src/data/store/cached.ts`): `refreshCache(kind)` obnovi jednu,
|
||||
`refreshAllCaches()` vsechny. Route nastaveni driv po kazdem zapisu obnovovala
|
||||
vsechny cache, ted jen entitu, do ktere psala (`bootstrapDataRefresh(route)`
|
||||
v `src/data/refresh.ts`). `listByTenant(tenantIds, sortBy)` je jeden filtr
|
||||
a razeni misto sedmi kopii v modulech.
|
||||
|
||||
**`withMirror`** pro provozni data: meni se v pameti, po zmene se zapise cely
|
||||
zaznam. Zapisy tehoz ID jsou **serazene za sebou** retezem promise. Bez toho
|
||||
mohl Postgres potvrdit dva `put` tehoz ticketu v opacnem poradi, nez prisly,
|
||||
a v tabulce zustala starsi verze - v pameti to nebylo videt, po restartu ano.
|
||||
Tickety navic slucuji vic zmen v jednom tiku do jednoho zapisu
|
||||
(`persist` / `flushPersist`).
|
||||
|
||||
**Audit** se jen pripisuje a oreza se davkou (`removeMany`) po 50 zapisech
|
||||
nebo nejvys jednou za minutu. Prvni verze mazala jen radky platformy
|
||||
(`tenantId: null`) a audit firem rostl donekonecna.
|
||||
|
||||
### Klic mimo databazi
|
||||
|
||||
Bez `SECRETS_KEY` si aplikace v rezimu `file` vygeneruje klic do
|
||||
@@ -221,10 +256,7 @@ Proti Postgresu 16 v kontejneru:
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| Automatizace v ulozisti | dalsi na rade, je to to, co si clovek nastavi. Pujde do souboru i do databaze |
|
||||
| Rozlozeni dashboardu | male a samostatne, hned po automatizacich |
|
||||
| Tickety a incidenty | naposled, dnes je generuje simulace |
|
||||
| Uzivatele, firmy, resitele | v prototypu je to spis konfigurace nez data |
|
||||
| Fronta nad Postgresem | vyber behu je v pameti jedne instance, chce to `SKIP LOCKED` |
|
||||
| Sbernice udalosti pres LISTEN/NOTIFY | dnes `EventEmitter` v pameti jedne instance |
|
||||
| Vymena klice (rotace) | `v` je pripravene, prevod dat napsany neni |
|
||||
| Retence a partitionovani | az u tabulek behu, viz dokument 10 |
|
||||
|
||||
@@ -15,7 +15,11 @@ 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. |
|
||||
| `initStores({databaseReady})` | `src/data/store/index.ts` | Vybere režim pro **všechna** úložiště včetně konektorů. Volá se jednou při startu, nikde jinde. |
|
||||
| `refreshCache(kind)`, `refreshAllCaches()` | `src/data/store/cached.ts` | Registr cache. Po zápisu obnovit jen tu entitu, ne všechny. |
|
||||
| `listByTenant(tenantIds, sortBy)` | `src/data/store/cached.ts` | Filtr na firmu plus řazení nad cache. Místo sedmi kopií `filter` + `sort` v modulech entit. |
|
||||
| `nowIso`, `minutesAgo`, `highestNumber`, `writableOrWarn` | `src/data/store/types.ts` | Drobnosti pro moduly úložišť: časová značka, čas před N minutami, nejvyšší číslo ID pro čítač, varování při zápisu do úložiště jen pro čtení. |
|
||||
| `mergeValues(current, patch)` | `src/data/connectors/types.ts` | Sloučení hodnot konektoru při `PATCH`: prázdný řetězec maže, chybějící klíč nechává. Jedna implementace pro soubor i Postgres. |
|
||||
| `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í). |
|
||||
@@ -38,7 +42,17 @@ Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět.
|
||||
| `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. |
|
||||
| `enqueue(input)` | `src/runtime/queue.ts` | Zařadí běh. Klíč proti dvojímu zařazení drží jeden běh na jednu událost. |
|
||||
| `claimBatch(limit)` | `src/runtime/queue.ts` | Vezme další práci, spravedlivě po firmách. Místo, kde nad Postgresem musí být SKIP LOCKED. |
|
||||
| `claimBatch(limit, active)` | `src/runtime/queue.ts` | Vezme další práci, spravedlivě po firmách, přeskočí to, co už běží. Místo, kde nad Postgresem musí být SKIP LOCKED. |
|
||||
| `touchClaim(item)` | `src/runtime/queue.ts` | Tlukot běžícího běhu. Bez něj se dlouhý běh po 30 minutách považuje za zaseknutý a vykoná se podruhé. |
|
||||
| `findOpenIncident(source, tenantIds)` | `src/data/incidentStore.ts` | Otevřený incident téže příčiny v téže firmě. Stejná chyba nezakládá druhý. |
|
||||
| `attachAccess`, `tenantOrDeny`, `optionalTenantOrDeny`, `scopeOrDeny` | `src/middleware/tenant.ts` | Přístup jednou na request do `req.access`, firma requestu z něj. Route si to nepočítá sama. |
|
||||
| `safeRouter()`, `wrap(handler)` | `src/middleware/asyncHandler.ts` | Router, ve kterém odmítnutá promise skončí jako 500 s logem. Každá nová route vzniká tady. |
|
||||
| `rateLimit({name, windowMs, max})` | `src/middleware/rateLimit.ts` | Limit requestů v paměti, 429 s `Retry-After`. Jen na veřejných endpointech, kde se dá hádat. |
|
||||
| `validationError(res, zodError, message?)` | `src/middleware/validation.ts` | Jeden tvar chyby validace: z chyby zodu udělá `issues` po polích. |
|
||||
| `timingSafeEqualString(a, b)` | `src/lib/secure.ts` | Porovnání tokenu v konstantním čase. Webhook, příjem, pozvánky. |
|
||||
| `assertAllowedUrl`, `readBodyLimited`, `readJsonLimited`, `describeFetchError` | `src/net/guard.ts` | Vše, co volá ven: zákaz vnitřní sítě, čtení těla proudem s limitem, čitelný popis chyby sítě. HTTP skriptů, MCP, SMTP i ARES. |
|
||||
| `publishOutputs(...)` | `src/runtime/executor.ts` | Zápis výstupů kroku do kontextu, jednou pro vestavěné kroky i skripty. Holé jméno nepřepíše, co už v kontextu je. |
|
||||
| `lookupCompany`, `searchCompanies`, `listCompanyPersons`, `placeholderEmail`, `isPlaceholderEmail` | `src/ares/client.ts` | Registr ARES: firma podle IČ nebo názvu, statutáři, zástupná adresa a její rozpoznání. |
|
||||
| `onTicketEvent(kind, ticket)` | `src/runtime/triggers.ts` | Změna ticketu zařadí navázané automatizace, včetně ochrany proti smyčce. |
|
||||
| `withRun(marker, work)` | `src/runtime/context.ts` | Označí, který běh práci způsobil. Bez toho automatizace spouští sama sebe. |
|
||||
| `findBuiltinStep(...)` | `src/runtime/builtinSteps.ts` | Kroky, které sahají do našeho úložiště, ne ven přes HTTP. |
|
||||
@@ -50,7 +64,7 @@ Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět.
|
||||
| `getAgentStats(...)` | `src/data/ticketStore.ts` | Výkon řešitelů: odbavené, mediány časů, vrácené, fronta. Používá to widget i detail osoby, aby čísla seděla. |
|
||||
| `findByExternalId(...)` | `src/data/ticketStore.ts` | Ticket firmy podle externího ID. Klíč je dvojice firma a ID. |
|
||||
| `findByIntakeToken(token)` | `src/data/tenants.ts` | Firma podle tokenu příjmu. Určuje i to, v jakém rozsahu je externí ID unikátní. |
|
||||
| `refreshCaches()` | `src/data/bootstrap.ts` | Obnoví všechny kopie v paměti. Volá se po zápisu, který je může změnit. |
|
||||
| `refreshCaches()`, `refreshEntity(kind)` | `src/data/bootstrap.ts` | Obnoví všechny kopie v paměti, nebo jen jednu entitu. Route nastavení volá `bootstrapDataRefresh(route)` v `src/data/refresh.ts`, která vybere tu jednu. |
|
||||
| `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)
|
||||
@@ -61,8 +75,11 @@ Viz [11-skripty-konektoru.md](11-skripty-konektoru.md).
|
||||
| ------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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. |
|
||||
| `scriptUtil` | `src/scripts/util.ts` | Nádobíčko pro skripty: `pick`, `first`, `num`, `date`, `day`, `list`, `addresses`, `quote`, `need`, `get`, `applyRules`, `fillJson`. Skript nemá sahat na nic jiného. |
|
||||
| `pick`, `pickText`, `jwtExpiry`, `parseBool`, `parseNumber` | `src/scripts/util.ts` | Totéž pro server: pole bez ohledu na velikost písmen, `exp` z JWT, převody. Než napíšeš `Number(x)` s kontrolou `NaN`, je to tady. |
|
||||
| `DETAIL_BYTES`, `truncate(value)` | `src/scripts/util.ts` | Jeden limit na zkracování detailu chyby pro všechny vrstvy. Žádné vlastní `slice(0, 600)`. |
|
||||
| `createRedactor(...)` | `src/scripts/util.ts` | Vyškrtá tajemství z textu **před** logováním, i v URL-encoded a JSON-escaped tvaru. Používá se u všeho, co jde do logu. |
|
||||
| `scriptConfig(target)` | `src/scripts/connections.ts` | Nastavení napojení bez tajných polí. Jediné, co skript dostane jako `ctx.config`. |
|
||||
| `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. |
|
||||
@@ -92,7 +109,18 @@ Viz [11-skripty-konektoru.md](11-skripty-konektoru.md).
|
||||
| `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`. S `body` pošle POST (dávkové načtení), s `enabled: false` se neptá vůbec. |
|
||||
| `useApiQuery<T>` | `lib/useApiQuery.ts` | Načtení dat do stránky včetně `reload`, `refreshing` a `total`. S `body` pošle POST, s `enabled: false` se neptá, `patchOn` opraví data z události bez dotazu. |
|
||||
| `useCollection(key)`, `useAccess()`, `useCollectionSelector` | `lib/collections.tsx` | Číselníky za firmu (lidé, skupiny, typy, služby, konektory, přístup) ze sdíleného skladu, opravované z událostí. Ne `apiFetch` na číselník ze stránky. |
|
||||
| `patchTicketList(...)` | `lib/ticketEvents.ts` | Oprava seznamu ticketů z `payload.ticket` v události. Použít jako `patchOn`. |
|
||||
| `apiFetchWithMeta<T>` | `lib/api.ts` | Jako `apiFetch`, ale vrací i `X-Total-Count`. Pro stránkované seznamy. |
|
||||
| `useSubmit(fn)` | `lib/useSubmit.ts` | Odeslání formuláře: `saving`, chyba, reset. Dvanáct řádků, které si dřív psal každý formulář zvlášť. |
|
||||
| `useUnsavedChanges(dirty)` | `lib/useUnsavedChanges.ts` | Varování při odchodu z rozepsaného formuláře nebo stromu. |
|
||||
| `priorities`, `priorityLabel` | `lib/options.ts` | Pevné číselníky. Stavy a kanály se berou ze serveru (`/widget-data/options`). |
|
||||
| `plural(count, forms)` | `lib/format.ts` | Skloňování počtu (1 ticket, 2 tickety, 5 ticketů). |
|
||||
| `Field`, `Input`, `Select`, `Textarea` | `components/ui/form/` | Formulářové prvky s jednou sadou tříd (`controlClass`). Vlastní `inputClass` ve stránce je chyba. |
|
||||
| `Chip` | `components/ui/Chip.tsx` | Štítek. |
|
||||
| `TicketCard` | `components/dashboard/TicketCard.tsx` | Karta ticketu pro dlaždice a mobil, varianta `compact` pro widgety. |
|
||||
| `useMediaQuery(query)` | `lib/useMediaQuery.ts` | Tabulka nebo karty podle šířky. `TicketTable` podle toho kreslí obojí, druhá komponenta není. |
|
||||
| `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. |
|
||||
|
||||
@@ -24,6 +24,11 @@ Nová entita v nastavení pak znamená: `defineStore` v modulu entity, jeden ř
|
||||
v `bootstrap.ts`, jeden `crudRouter` v `settings.ts`, jeden popis v
|
||||
`Settings.tsx`. Nic víc.
|
||||
|
||||
`crudRouter` navic s volbou `event` publikuje `<druh>.created`, `.updated`
|
||||
a `.deleted` s celym zaznamem v payloadu, takze klientsky sklad ciselniku
|
||||
(`lib/collections.tsx`) se opravi bez dotazu. Po zapisu se obnovi **jen cache
|
||||
te entity** (`bootstrapDataRefresh(route)`), ne vsechny.
|
||||
|
||||
## Práva jsou data
|
||||
|
||||
Práv je 26 a jsou v katalogu (`src/data/permissions.ts`). Role je **záznam**,
|
||||
@@ -37,6 +42,36 @@ 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í.
|
||||
|
||||
### Prava se kontroluji u kazde route, za firmu zaznamu
|
||||
|
||||
Do zari 2026 se vetsina rout ptala jen "je clen firmy". Prava v katalogu
|
||||
byla, ale `viewer` mohl zalozit konektor nebo smazat automatizaci. Ted:
|
||||
|
||||
| Co | Pravo |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
| firmy | jen spravce platformy |
|
||||
| uzivatele | spravce platformy, nebo `user.manage` jen ve sve firme |
|
||||
| pozvanky | `user.manage`, role jen z te firmy |
|
||||
| konektory (zalozeni, uprava, smazani, test) | `connector.manage` |
|
||||
| automatizace (zalozeni, uprava, smazani, novy token) | `automation.edit` |
|
||||
| vestavene akce na ticketu | pravo akce za firmu ticketu plus strop viditelnosti |
|
||||
| prepnuti na jiny ucet, audit | `impersonate`, `audit.view` |
|
||||
|
||||
Spravce firmy s `user.manage` nenastavi `platformAdmin`, neprida clenstvi
|
||||
v jine firme, nesahne na spravce platformy a nesmaze cloveka, ktery je i
|
||||
v jine firme. Duvody a rozhodnuti "kdo koho zaklada" jsou
|
||||
v [07-firmy-a-prava.md](07-firmy-a-prava.md).
|
||||
|
||||
### Firma z registru ARES
|
||||
|
||||
Zalozka Firmy ma vedle rucniho zalozeni cestu pres ARES
|
||||
(`components/dashboard/AresTenantDialog.tsx`), jen pro spravce
|
||||
platformy: IC nebo nazev, vyber firmy, vyber statutaru, kteri dostanou ucet
|
||||
s roli spravce a zastupnou adresou `IC-poradi@placeholder.cz`. Zastupne
|
||||
adresy se musi nahradit skutecnymi, jinak se ti lide neprihlasi. Firma pak
|
||||
nese `ico`, `dic`, `address` a `legalForm`, IC je unikatni. Endpointy jsou
|
||||
v [04-api.md](04-api.md).
|
||||
|
||||
## Navigace chodí ze serveru
|
||||
|
||||
Co uživatel vidí za záložky, je průnik dvou věcí:
|
||||
@@ -119,9 +154,9 @@ takže ho nemůže ani omylem prodloužit.
|
||||
|
||||
| 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. |
|
||||
| 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 (`byId` je `Map`), obnova jen te entity 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. Zápisy téhož ID jdou za sebou, ne naráz. |
|
||||
| Audit | přímo do úložiště | Jen se připisuje, nikdy nečte při každém requestu. Oreza se davkou po 50 zapisech nebo jednou za minutu. |
|
||||
| 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
|
||||
|
||||
@@ -84,7 +84,23 @@ odmítnout ho kvůli tomu by znamenalo, že webhook nejde zapojit.
|
||||
těla. V portálu je u adresy vidět totéž včetně metody, a kopíruje se **celá
|
||||
adresa včetně domény**.
|
||||
|
||||
## Opakování a vzdání se
|
||||
## Worker je pool
|
||||
|
||||
`CONCURRENCY` (4) behu naraz, ale **nezavisle na sobe**. Worker drzi mnozinu
|
||||
`active`, kazde kolo si vezme `CONCURRENCY - active.size` behu
|
||||
(`claimBatch(limit, active)` to, co uz bezi, preskoci) a spusti je bez cekani
|
||||
na ostatni. Prvni verze brala davku a cekala, az dobehne cela: jeden pomaly
|
||||
krok MCP na deset minut blokoval tri prazdne sloty.
|
||||
|
||||
Bezici beh posila kazdou minutu **tlukot** (`touchClaim`). Za zaseknuty se
|
||||
povazuje az 30 minut od posledniho tlukotu (`STUCK_AFTER_MS` v `queue.ts`),
|
||||
ne od vzeti z fronty. Driv to bylo deset minut od vzeti, takze beh s dlouhou
|
||||
ulohou MCP se vratil do fronty a **vykonal se podruhe**, i kdyz porad bezel.
|
||||
|
||||
Planovac zarazuje behy s triggerem `poll`, ne `manual`, aby slo v seznamu
|
||||
behu poznat, co spustil clovek a co cas.
|
||||
|
||||
## Opakovani a vzdani se
|
||||
|
||||
| Pokus | Kdy |
|
||||
| ----- | --------- |
|
||||
@@ -94,12 +110,52 @@ adresa včetně domény**.
|
||||
| 4. | za 10 min |
|
||||
| 5. | za hodinu |
|
||||
|
||||
Pak běh skončí jako `failed` a zůstane k nahlédnutí. Nemaže se: bez záznamu
|
||||
by nikdo nezjistil, že se něco nestalo.
|
||||
Pak beh skonci jako `failed` a zustane k nahlednuti. Nemaze se: bez zaznamu
|
||||
by nikdo nezjistil, ze se neco nestalo.
|
||||
|
||||
**Marná chyba se neopakuje vůbec.** Chybějící skript nebo neexistující skupina
|
||||
za minutu existovat nezačne, takže se běh rovnou vzdá. Opakuje se jen to, co
|
||||
může pominout: nedostupná služba, timeout.
|
||||
**Opakuje se jen to, co samo rekne `retryable`.** Vychozi je "ne". Pravidlo
|
||||
je stejne ve vsech vrstvach a je napsane v komentari nad `StepResult`
|
||||
v `executor.ts`:
|
||||
|
||||
| Opakuje se | Konci hned a zaklada incident |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| chyba spojeni, timeout pred odeslanim | 401, 403, 404 |
|
||||
| 5xx a 429 od cizi sluzby | spatny vstup, chybejici vystup, `ctx.fail` |
|
||||
| vypadek uloziste konektoru | chybejici nebo pozastavena automatizace, chybejici skupina |
|
||||
| MCP: chyba spojeni, 408, 425, 429, 502, 503, 504 | MCP: timeout uz odeslaneho `tools/call`, chyba v kodu |
|
||||
|
||||
Driv se opakovala **kazda** chyba skriptu, petkrat za 72 minut. Spatny vstup
|
||||
tak petkrat zopakoval tutez hlasku a u kroku, ktery neni idempotentni, mohl
|
||||
cizi sluzbu zavolat podruhe. Timeout uz odeslaneho volani MCP je proto
|
||||
neopakovatelny: MCP nema idempotencni klic a jestli druhy pokus znamena druhou
|
||||
objednavku, vi jen server, ktery neni nas.
|
||||
|
||||
## Dva stropy na velikost behu
|
||||
|
||||
| Strop | Co pocita |
|
||||
| -------------------- | --------------------------------------------------- |
|
||||
| `MAX_STEPS = 50` | kroky ve stromu, kontroluje se pri ulozeni |
|
||||
| `MAX_ACTIONS = 1000` | vykonane kroky vcetne pruchodu smyckou, za behu |
|
||||
|
||||
Jeden strop nestacil: smycka se dvema kroky nad 26 polozkami je 52 vykonanych
|
||||
kroku a beh padal na limitu 50, i kdyz strom mel kroky ctyri. Kdyz se strop
|
||||
vycerpa, beh se zastavi a rekne to; tise useknout smycku by vypadalo jako
|
||||
uspech.
|
||||
|
||||
## Podminka nad datem
|
||||
|
||||
Pole typu `date` a hodnoty, ktere vypadaji jako ISO datum, se v podmince
|
||||
porovnavaji pres `Date.parse`. Driv slo vsechno pres `Number()`, ISO retezec
|
||||
vysel jako `NaN` a `gt` i `lt` nad datem byly **vzdycky nesplnene**, bez chyby
|
||||
a bez radku v logu.
|
||||
|
||||
## Vystupy kroku
|
||||
|
||||
`publishOutputs()` v `executor.ts` je jedno misto pro vestavene kroky
|
||||
i skripty. Vystup se zapise pod jmenem kroku (`st_x.status`) vzdycky, **hole
|
||||
jmeno (`status`) jen kdyz v kontextu jeste neni**. Nastroj MCP, ktery vraci
|
||||
`status`, driv prepsal `status` spoustece a podminka za nim se ptala na
|
||||
spatnou hodnotu.
|
||||
|
||||
## Incident z chyby
|
||||
|
||||
@@ -118,6 +174,12 @@ informace pro zákazníka.
|
||||
Stejná příčina nezakládá druhý incident, dokud je první otevřený. Jinak by
|
||||
deset stejných chyb znamenalo deset incidentů a nikdo by se v tom nevyznal.
|
||||
|
||||
Incident patri **firme behu**. `findOpenIncident(source, tenantIds)` hleda jen
|
||||
v ni a krok `incident/create` predava `tenantId` - prvni verze zakladala
|
||||
globalni incident, ktery videly vsechny firmy. Neocekavana vyjimka v kroku
|
||||
(chyba v kodu) je taky koncova: neopakuje se a zaklada incident, protoze za
|
||||
minutu nezmizi.
|
||||
|
||||
## Ochrana proti smyčce
|
||||
|
||||
Automatizace navázaná na změnu ticketu ticket změní, čímž se spustí znovu.
|
||||
@@ -205,8 +267,7 @@ Ověřeno 19 kontrolami proti běžícímu serveru.
|
||||
to musí být `SELECT ... FOR UPDATE SKIP LOCKED`, jinak si dva workery
|
||||
vezmou tentýž běh. Místo je označené v `runtime/queue.ts`.
|
||||
- **Strop souběžných volání na dvojici firma a služba** a vypnutí služby po
|
||||
sérii chyb. Timeout a rozlišení "zkusit znovu / marné" už ve
|
||||
`scripts/http.ts` je.
|
||||
sérii chyb. Rozliseni "zkusit znovu / marne" uz plati ve vsech vrstvach.
|
||||
- **Dlouhé čekání** (pošli e-mail za tři dny) přes běh naplánovaný na později.
|
||||
Krok `flow/pause` umí nejvýš minutu, protože blokuje běh.
|
||||
|
||||
|
||||
+14
-14
@@ -1,6 +1,7 @@
|
||||
# 23 - Jazyky
|
||||
|
||||
Rozdelane. Mechanismus je hotovy a overeny, prevod obsahu bezi po castech.
|
||||
Mechanismus je hotovy a overeny. Verejny web je prelozeny cely, portal za
|
||||
prihlasenim jen ve spolecnych castech.
|
||||
|
||||
## Jak to funguje
|
||||
|
||||
@@ -51,19 +52,18 @@ v nadpisu.
|
||||
|
||||
## Co uz je prelozene
|
||||
|
||||
| Cast | Stav |
|
||||
| ----------------------------------- | ------------------------------------ |
|
||||
| Hlavicka a mobilni menu | hotovo |
|
||||
| Hero vcetne snimku portalu | hotovo |
|
||||
| Pas s logy klientu | hotovo |
|
||||
| Cisla | hotovo |
|
||||
| Zaverecna vyzva | hotovo |
|
||||
| Paticka | castecne, sloupce odkazu zatim cesky |
|
||||
| Sekce Produkty, Reference, Postup | ne |
|
||||
| Stranky O nas, Sluzby, Kontakt, 404 | ne |
|
||||
| Portal za prihlasenim | ne |
|
||||
| Cast | Stav |
|
||||
| ----------------------------------------- | --------------------------------------------- |
|
||||
| Hlavicka, navigace a mobilni menu | hotovo |
|
||||
| Homepage vcetne Produktu, Referenci a Postupu | hotovo |
|
||||
| Paticka | hotovo |
|
||||
| Stranky O nas, Sluzby, Kontakt, 404 | hotovo |
|
||||
| Prihlaseni | hotovo, vcetne hlasky o vyprselem prihlaseni |
|
||||
| `DataState` a `ErrorBoundary` | hotovo - stavy nacitani a chyby jsou vsude |
|
||||
| Portal za prihlasenim | ne, stranky dashboardu maji texty v kodu |
|
||||
|
||||
Prevod zbytku je mechanicky: text ven do `cs.ts`, klic do `en.ts`, v komponente
|
||||
Pro prelozene casti je `en.ts` **uplna**, nikde nespada na cestinu. Zbytek je
|
||||
mechanicky: text ven do `cs.ts`, klic do `en.ts`, v komponente
|
||||
`const t = useT()` a `{t('klic')}`. Zadna dalsi prace na vrstve uz potreba neni.
|
||||
|
||||
## Co se neprekada
|
||||
@@ -78,7 +78,7 @@ Claim se **prekada** (`brand.claim`), protoze to je text, ne udaj.
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| ---------------------- | ----------------------------------------------------------------- |
|
||||
| Prevod zbytku obsahu | viz tabulka vys |
|
||||
| Stranky portalu | viz tabulka vys |
|
||||
| Jazyk v adrese | `/en/sluzby` misto volby v prohlizeci. Chce to kvuli vyhledavacum |
|
||||
| Preklad dat ze serveru | nazvy sluzeb, stavu a chybovych hlasek chodi z API cesky |
|
||||
| Format cisel a datumu | `toLocaleString` se zatim vola natvrdo s `cs-CZ` |
|
||||
|
||||
@@ -202,6 +202,41 @@ vedet, jak s nimi portal nalozil, nez zacne hledat chybu jinde.
|
||||
token**, ne pred kazdym volanim. Server drzi sezeni u tokenu, takze po jeho
|
||||
vymene se to musi zopakovat, jinak odpovi, ze relace neni inicializovana.
|
||||
|
||||
Plati to i pro server **bez prihlaseni** - i ten ma sezeni a handshake stoji
|
||||
dve volani. Prvni verze cachovala jen podle tokenu, takze server bez tokenu
|
||||
dostaval handshake pred kazdym krokem.
|
||||
|
||||
`Mcp-Session-Id`, ktery server vyda pri handshaku, se **uklada s handshakem**
|
||||
a posila v kazdem dalsim volani (u dialektu, ktere hlavicku pouzivaji).
|
||||
Sezeni bez ID by server nepoznal a kazde volani by zacinalo znovu. Kdyz
|
||||
server na preskoceny handshake odpovi 400 nebo 404, znamena to podle
|
||||
specifikace "sezeni neznam": handshake se zopakuje **jednou** a volani se
|
||||
posle znovu. Vic nez jednou ne - kdyby to nepomohlo, je chyba jinde a smycka
|
||||
by ji jen schovala.
|
||||
|
||||
Prihlaseni OAuth u obecne sluzby sdili **jednu rozdelanou operaci na
|
||||
konektor**, stejne jako EasyWeb: dva behy nad tymz napojenim udelaji jedno
|
||||
prihlaseni, ne dve.
|
||||
|
||||
## Co se opakuje a co ne
|
||||
|
||||
| Situace | Opakuje se |
|
||||
| ----------------------------------------------- | ---------- |
|
||||
| spojeni se nenavazalo, DNS, TLS | ano |
|
||||
| 408, 425, 429, 502, 503, 504 | ano |
|
||||
| timeout **uz odeslaneho** `tools/call` | **ne** |
|
||||
| 401, 403, 404, chyba prihlaseni | ne |
|
||||
| `isError` v odpovedi nastroje | ne |
|
||||
|
||||
Timeout po odeslani je ta zvlastni radka. MCP nema idempotencni klic, takze
|
||||
druhy pokus by nastroj provedl podruhe - a jestli to znamena druhou
|
||||
objednavku, vi jen server, ktery neni nas. Rozhoduje `McpFailure.retryable`,
|
||||
stejne pravidlo jako v [20-fronta-a-runtime.md](20-fronta-a-runtime.md).
|
||||
|
||||
Chyby prihlaseni maji spolecneho predka `AuthFailure` v `src/mcp/errors.ts`,
|
||||
`EasyWebAuthError` z nej dedi. Krok tak pozna chybu prihlaseni jednou
|
||||
kontrolou a detail je vzdy zredigovany, at prisel odkudkoliv.
|
||||
|
||||
## Odpoved muze byt stream
|
||||
|
||||
Server si sam vybira, jestli odpovi JSON telem, nebo SSE streamem, a **streamem
|
||||
@@ -348,11 +383,14 @@ a krok to rekne misto toho, aby predstiral selhani.
|
||||
ostatni tajne hodnoty.
|
||||
- **Cizi napojeni se chova jako neexistujici.** Krok si konektor nacita pres
|
||||
filtr na firmu, takze strom s cizim ID konektoru selze.
|
||||
- **Krok se neopakuje.** MCP nema idempotencni klic, takze druhy pokus po
|
||||
timeoutu by nastroj provedl podruhe - a jestli to znamena druhou objednavku,
|
||||
vi jen server, ktery neni nas.
|
||||
- **Odeslane volani se neopakuje.** MCP nema idempotencni klic, takze druhy
|
||||
pokus po timeoutu by nastroj provedl podruhe. Opakuje se jen to, co selhalo
|
||||
pred odeslanim nebo co server odmitl docasne, viz vyse.
|
||||
- Plati stejny strop na velikost odpovedi jako u skriptu
|
||||
(`SCRIPT_MAX_RESPONSE_BYTES`), a to i u streamu, kde se pocita prubezne.
|
||||
Cte se pres `readBodyLimited` z `src/net/guard.ts`, stejne jako u HTTP
|
||||
skriptu a SMTP. Detail chyby ma stejny limit jako vsechno ostatni
|
||||
(`DETAIL_BYTES`), driv mel vlastnich 600 znaku.
|
||||
- Seznamy se strankuji nejvys stokrat, volani nastroje dvacetkrat.
|
||||
|
||||
## Co se **nedela**
|
||||
@@ -373,6 +411,8 @@ a krok to rekne misto toho, aby predstiral selhani.
|
||||
| Rozdily serveru | `src/mcp/dialect.ts` |
|
||||
| Protokol | `src/mcp/client.ts` |
|
||||
| Prihlaseni obecne | `src/mcp/auth.ts` |
|
||||
| Chyby prihlaseni | `src/mcp/errors.ts` |
|
||||
| Sit a limity tela | `src/net/guard.ts` |
|
||||
| Klice EasyWebu | `src/mcp/easyweb/crypto.ts` |
|
||||
| Zarizeni u konektoru | `src/mcp/easyweb/device.ts` |
|
||||
| Tokeny EasyWebu | `src/mcp/easyweb/session.ts` |
|
||||
@@ -382,7 +422,7 @@ a krok to rekne misto toho, aby predstiral selhani.
|
||||
| Nacteni nastroju | `src/routes/connectors.ts` |
|
||||
| Vykonna cast kroku | `src/runtime/builtinSteps.ts`, `runMcpTool` |
|
||||
| Ulozeni u konektoru | `src/data/connectors/*`, migrace `004` |
|
||||
| Portal | `web/src/pages/dashboard/Connectors.tsx` |
|
||||
| Portal | `web/src/pages/dashboard/Connectors.tsx` a `connectors/{ConnectorCard,ConnectorEditor,ConnectorLogs,ConnectorTools}.tsx` |
|
||||
|
||||
## Co jeste chybi
|
||||
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
[07-firmy-a-prava.md](07-firmy-a-prava.md),
|
||||
- sekce 6, kontrakt webhooku,
|
||||
- z prvni sekce dlazdice "Moje tickety" a "Fronta bez resitele" vcetne noveho
|
||||
vychoziho rozlozeni.
|
||||
vychoziho rozlozeni,
|
||||
- sekce 2, formularova vrstva (zari 2026, viz zacatek te sekce),
|
||||
- ze sekce 3 strankovani na serveru: `/tickets` bere `limit` a `offset`
|
||||
a vraci `X-Total-Count`, viz [04-api.md](04-api.md).
|
||||
|
||||
Zbyva widget akci, "Zaciname", formularova vrstva a hledani. Az se cast udela,
|
||||
Zbyva widget akci, "Zaciname" a hledani. Az se cast udela,
|
||||
prepise se do prislusneho souboru dokumentace a odsud zmizi - stejne pravidlo
|
||||
jako u [09-navrh-rozsireni.md](09-navrh-rozsireni.md).
|
||||
|
||||
@@ -170,9 +173,23 @@ coz je cil, to sedi.
|
||||
|
||||
---
|
||||
|
||||
## 2 - Formularova vrstva
|
||||
## 2 - Formularova vrstva (hotovo)
|
||||
|
||||
### Cim to je
|
||||
**Naprogramovano v zari 2026**, presne podle navrhu nize:
|
||||
|
||||
| Navrh | Kde to je |
|
||||
| ------------------------------- | ---------------------------------------------------------------- |
|
||||
| `Field`, `Input`, `Select`, `Textarea` | `components/ui/form/`, tridy v `controlClass.ts` |
|
||||
| `useSubmit` | `lib/useSubmit.ts` |
|
||||
| ciselniky ven | `lib/options.ts` (priority), stavy a kanaly z `/widget-data/options` |
|
||||
| kompaktni karta ticketu | `components/dashboard/TicketCard.tsx`, varianta `compact` |
|
||||
| stitek | `components/ui/Chip.tsx` |
|
||||
| sklonovani poctu | `plural()` v `lib/format.ts` |
|
||||
|
||||
Zmizelo 15 kopii trid vstupniho pole. Navrh zustava nize jako zduvodneni,
|
||||
proc to vypada tak, jak vypada.
|
||||
|
||||
### Cim to bylo
|
||||
|
||||
V `components/ui/` je Badge, Button, Card, Container, Modal, PageHeader, Section
|
||||
a Spinner. **Zadny formularovy prvek.** Takze si ho kazda stranka pise znovu.
|
||||
@@ -278,12 +295,14 @@ Klientsky filtr pres ctyri veci: `id`, `subject`, `customer.company`,
|
||||
polich**, ve **stitcich** ani v **externim ID**. To posledni je zrovna to, cim se
|
||||
dohledava hovor: clovek ma `CAbc75a8...` a chce ten ticket. Dnes ho nenajde.
|
||||
|
||||
A funguje to jen proto, ze `GET /api/dashboard/tickets` vraci **vsechny tickety
|
||||
firmy najednou**, bez limitu a bez strankovani. Pri par desitkach to nevadi, pri
|
||||
deseti tisicich jsou to megabajty do prohlizece pri kazdem otevreni seznamu.
|
||||
A funguje to jen proto, ze seznam v portalu si bere **vsechny tickety firmy
|
||||
najednou**. Server uz strankovani umi (`limit`, `offset`, `X-Total-Count`),
|
||||
klient ho zatim nepouziva. Pri par desitkach to nevadi, pri deseti tisicich
|
||||
jsou to megabajty do prohlizece pri kazdem otevreni seznamu.
|
||||
|
||||
Hledani pres modal proto znamena **serverovy endpoint**, a je to zaroven
|
||||
prilezitost prestat posilat vsechno.
|
||||
chvile, kdy seznam prejde na strankovani - jinak by klientske hledani
|
||||
a serverove ukazovaly jina cisla.
|
||||
|
||||
### Tvar
|
||||
|
||||
@@ -576,14 +595,10 @@ prestane stacit.
|
||||
|
||||
## Poradi praci
|
||||
|
||||
1. **Formularove primitivy** a rozdeleni dvou formularu (ticket, helpdesk). Nic
|
||||
dalsiho na nich nestoji, ale stoji na nich vsechno ostatni v rozhrani.
|
||||
2. **"Moje tickety" a "Fronta bez resitele".** Formulare nepotrebuji, jsou skoro
|
||||
zadarmo a jsou hned videt v provozu.
|
||||
3. **Viditelnost.** Model, strop v `resolveScope`, vynuceni v jedne ceste ke
|
||||
ticketum, panel skupiny. Delat to pred hledanim, ne po nem, jinak se hledani
|
||||
pise dvakrat.
|
||||
4. **Modal hledani** a serverovy endpoint se strankovanim.
|
||||
1. **Formularove primitivy** - hotovo.
|
||||
2. **"Moje tickety" a "Fronta bez resitele"** - hotovo.
|
||||
3. **Viditelnost** - hotovo, viz [07-firmy-a-prava.md](07-firmy-a-prava.md).
|
||||
4. **Modal hledani** a prechod seznamu na strankovani, ktere server uz umi.
|
||||
5. **Widget akci** a "Zaciname". Sahne uz jen na hotove formulare.
|
||||
|
||||
Postgres kdykoliv mezi tim, nezavisle na ostatnim.
|
||||
|
||||
@@ -2,6 +2,252 @@
|
||||
|
||||
Nejnovejsi nahore.
|
||||
|
||||
## 2026-09-09 - Revize projektu: prava, vykon, runtime, portal a ARES
|
||||
|
||||
Velka sada oprav napric celym projektem. Zadna nova obrazovka, ale skoro kazda
|
||||
vrstva se zmenila v tom, **co dela pri zatezi a pri chybe**. K tomu jedna nova
|
||||
funkce: zalozeni firmy podle registru ARES. Zaznam je dlouhy schvalne - tohle
|
||||
je misto, kde se za pul roku hleda, proc se neco chova tak, jak se chova.
|
||||
|
||||
### Uloziste: jeden rezim pro vsechno
|
||||
|
||||
Rozhodnuti o rezimu (`postgres`, `file`, `memory`) delal `connectorStore.ts`
|
||||
pro konektory a `initStores` pro zbytek, kazdy podle svych podminek. Mohlo se
|
||||
stat, ze konektory jely z databaze a tickety ze souboru. Ted rozhoduje
|
||||
**jen `initStores` v `src/data/store/index.ts`**: Postgres jen kdyz je
|
||||
`DATABASE_URL`, migrace prosly a je cim sifrovat (`SECRETS_KEY`), jinak soubor
|
||||
nebo pamet pro vsechna uloziste vcetne konektoru. `connectorStore` uz jen vola
|
||||
`initStores`.
|
||||
|
||||
Dalsi opravy v ulozisti, kazda ma za sebou konkretni problem:
|
||||
|
||||
| Co | Proc |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| audit se maze davkou (`removeMany`) | orezavani mazalo jen radky platformy, audit firem rostl donekonecna. Bezi po 50 zapisech nebo nejvys jednou za minutu |
|
||||
| `markRead` pres `updateMany` | notifikace delaly jeden zapis a jednu obnovu cache na kazdy zaznam |
|
||||
| `persist` / `flushPersist` u ticketu | vic zmen tehoz ticketu v jednom tiku je jeden zapis, ne pet |
|
||||
| `withMirror` radi zapisy za sebou | dva `put` tehoz zaznamu mohl Postgres potvrdit v opacnem poradi a v tabulce zustala starsi verze. Ted je na kazde ID retez promise |
|
||||
| `issues` a `stepCount` ulozene | `listAutomations` validoval vsechny stromy pri kazdem cteni. Ted se spocitaji pri ulozeni (`withDerived`) a jednou pri startu |
|
||||
| registr cache (`refreshCache(kind)`) | route nastaveni obnovovala vsechny cache, ted jen tu entitu, do ktere psala (`bootstrapDataRefresh(route)`) |
|
||||
| `listByTenant(tenantIds, sortBy)` | sedm kopii filtr + razeni v modulech entit |
|
||||
| mapy misto `find` | `withCache.byId` je `Map`, ticketStore ma `ticketsById` a `ticketsByExternal`, vytizeni a statistiky se seskupi jednim pruchodem |
|
||||
| `create` v lokalnim ulozisti hazi na duplicitu | Postgres to delal, soubor tise prepsal |
|
||||
| `snapshot.ts` prepise soubor jen pri ENOENT | jina chyba cteni (prava, plny disk) driv znamenala start s prazdnymi daty a **prepsani souboru prazdnym obsahem**. Ted se zapisy zamknou a zaloguje se to |
|
||||
| `listIncidents(tenantIds)` povinne | stejne pravidlo jako u ticketu, incident byl posledni seznam bez filtru |
|
||||
|
||||
Slovnik stavu ticketu je sjednoceny na cesky `defaultStatuses` (Novy, V reseni,
|
||||
Ceka na klienta, Vyreseno) a ukazkove widgety filtruji `closed: false`, ne
|
||||
podle nazvu stavu. Ukazkove tickety TK-4817 a TK-4812 vznikaji jen se
|
||||
`SEED_DEMO=1`. V `services.ts` byla dvakrat operace `set-status`, druha tise
|
||||
prekryvala prvni; `checkOperationIds()` to ted pri nacteni zaloguje.
|
||||
|
||||
Spolecne pomocne funkce, aby se nepsaly po modulech: `nowIso`, `minutesAgo`,
|
||||
`highestNumber`, `writableOrWarn` v `store/types.ts`, `mergeValues`
|
||||
v `connectors/types.ts`.
|
||||
|
||||
### Runtime: worker je pool a opakuje se jen to, co muze pominout
|
||||
|
||||
Worker bral davku ctyr behu a cekal, az dobehnou vsechny. Jeden pomaly beh
|
||||
tak blokoval tri volne sloty. A beh delsi nez deset minut se povazoval za
|
||||
zaseknuty, vratil se do fronty a **vykonal se podruhe**. Ted:
|
||||
|
||||
- `active` je mnozina bezicich ID, kazde kolo si vezme `CONCURRENCY - active.size`
|
||||
behu a spusti je bez cekani na ostatni,
|
||||
- `claimBatch(limit, active)` preskakuje to, co uz bezi,
|
||||
- beh kazdou minutu posle tlukot (`touchClaim`) a za zaseknuty se povazuje az
|
||||
30 minut od posledniho tlukotu (`STUCK_AFTER_MS`), ne od vzeti z fronty.
|
||||
|
||||
**Opakovani.** Kazda chyba skriptu se opakovala petkrat za 72 minut, i 403
|
||||
a spatny vstup. Ted se krok opakuje jen kdyz sam rekne `retryable`: chyba
|
||||
spojeni, timeout, 5xx a 429, vypadek uloziste konektoru. 401, 403, 404,
|
||||
validace a `ctx.fail` konci hned a zakladaji incident. U MCP jsou opakovatelne
|
||||
chyby spojeni a 408, 425, 429, 502, 503, 504; **timeout uz odeslaneho
|
||||
`tools/call` opakovatelny neni**, protoze MCP nema idempotencni klic a nastroj
|
||||
by se provedl podruhe. Pravidlo je v komentari nad `StepResult`
|
||||
v `executor.ts`, aby ho nasel kazdy, kdo pise novy druh kroku.
|
||||
|
||||
Dalsi zmeny v behu:
|
||||
|
||||
| Co | Proc |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `MAX_ACTIONS = 1000` | `MAX_STEPS = 50` pocita staticky strom. Smycka se dvema kroky nad 26 polozkami narazila na 50 a beh spadl. Vykonane kroky maji vlastni strop |
|
||||
| datum v podmince pres `Date.parse` | `gt` a `lt` nad datem prevadely ISO retezec cislem, vyslo NaN a podminka byla vzdycky nesplnena |
|
||||
| `publishOutputs()` spolecne | vestavene kroky a skripty publikovaly vystupy kazdy jinak. Hole jmeno se zapise jen kdyz v kontextu jeste neni - nastroj MCP vracejici `status` prepisoval `status` spoustece |
|
||||
| sandbox stavi `utils` i `input` uvnitr vm | funkce hostitele prosakovaly do skriptu firmy a `constructor('return process')` z nich utekl ven. Zkompilovane skripty se cachuji (LRU 100) |
|
||||
| chybejici nebo pozastavena automatizace | neopakovatelna chyba plus incident, driv se to zkouselo dokola |
|
||||
| `incident/create` nese `tenantId` | krok zakladal globalni incident, ktery videly vsechny firmy |
|
||||
| planovac zarazuje s triggerem `poll` | bylo `manual`, takze se v behu nedalo poznat, ze to spustil planovac |
|
||||
|
||||
### Sit a tajemstvi
|
||||
|
||||
Novy `src/net/guard.ts` sdruzuje to, co melo kazde volani ven zvlast:
|
||||
`assertAllowedUrl` (zakaz vnitrni site), `describeFetchError`,
|
||||
`readBodyLimited` a `readJsonLimited`. Telo se **cte proudem a usekne se
|
||||
u limitu** - driv se nacetlo cele a teprve pak zmerilo, takze limit nechranil
|
||||
pamet. Pouziva to HTTP skriptu, klient MCP, prihlaseni MCP i SMTP.
|
||||
|
||||
Redaktor masky navic maskuje tajemstvi v **URL-encoded a JSON-escaped** tvaru,
|
||||
protoze cizi sluzby je v chybach vraceji i tak. `ctx.config` skriptu uz nikdy
|
||||
neobsahuje tajna pole (`scriptConfig`). `EasyWebAuthError` dedi z `AuthFailure`
|
||||
(novy `src/mcp/errors.ts`), takze se chyby prihlaseni poznaji jednou
|
||||
kontrolou a detail je vzdy zredigovany.
|
||||
|
||||
Klient MCP: handshake se cachuje i pro server bez prihlaseni, `Mcp-Session-Id`
|
||||
se uklada s handshakem a posila znovu, 400 nebo 404 po preskocenem handshaku
|
||||
vyvola jeden novy handshake. OAuth prihlaseni sdili rozdelanou operaci na
|
||||
konektor. V `delay()` unikal posluchac abortu.
|
||||
|
||||
`src/scripts/util.ts` dostal `pick`, `pickText`, `jwtExpiry`, `parseBool`,
|
||||
`parseNumber` a jednu konstantu `DETAIL_BYTES` na zkracovani (MCP mel 600
|
||||
znaku, zbytek 8 kB). `ctx.util` skriptu ma navic `day`, `list`, `addresses`,
|
||||
`quote`; sablona `scripts/_sablona.js` je vypisuje a osm skriptu je pouziva.
|
||||
|
||||
### API: prava se kontroluji za firmu a u kazde route
|
||||
|
||||
Prava byla ve vetsine rout jen "je prihlaseny" nebo "je clen firmy". Ted:
|
||||
|
||||
| Route | Kdo smi |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| firmy CRUD | jen spravce platformy |
|
||||
| uzivatele CRUD | spravce platformy vse. Spravce firmy (`user.manage`) jen lidi sve firmy, nenastavi `platformAdmin`, neprida clenstvi jinde, nesahne na spravce platformy a nesmaze cloveka, ktery je i v jine firme |
|
||||
| pozvanky | `user.manage` a jen role te firmy |
|
||||
| konektory create, update, delete, test | `connector.manage` |
|
||||
| automatizace create, update, delete, regenerate | `automation.edit` za firmu automatizace |
|
||||
| `/services`, `/connectors/services` | clenstvi ve firme, cizi firma je 404 |
|
||||
| assign, status, comment, claim | `builtinAction` v `ticketActions.ts`, pravo za firmu ticketu a strop viditelnosti (`visibleTicketOrDeny`) |
|
||||
| `/api/admin/*` | `impersonate` a `audit.view` se ted opravdu kontroluji |
|
||||
|
||||
Nove middleware, kazde s jednim ukolem:
|
||||
|
||||
| Soubor | Co |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `middleware/asyncHandler.ts` | `wrap`, `safeRouter`: odmitnuta promise v handleru driv zabila proces. `unhandledRejection` se loguje, `uncaughtException` loguje a ukonci |
|
||||
| `middleware/rateLimit.ts` | klouzave okno v pameti: login 20 za 15 min, kontakt 5 za hodinu, prijeti pozvanky 5 za 15 min. 429 s `Retry-After` |
|
||||
| `middleware/tenant.ts` | `attachAccess` spocita pristup jednou na request do `req.access`; `tenantOrDeny`, `optionalTenantOrDeny`, `scopeOrDeny` misto kopii v routach |
|
||||
| `middleware/validation.ts` | `validationError`, jeden tvar `{ error, message, issues: [{ field, message }] }` |
|
||||
| `lib/secure.ts` | `timingSafeEqualString` pro tokeny webhooku, prijmu a pozvanek |
|
||||
|
||||
V `index.ts`: log requestu maskuje tokeny za `/webhook/`, `/webhook/ticket/`
|
||||
a `/invites/`; bezpecnostni hlavicky (nosniff, `X-Frame-Options SAMEORIGIN`,
|
||||
`Referrer-Policy`, `Permissions-Policy`); `trust proxy` = 1, aby limit
|
||||
pocital s adresou klienta a ne proxy; rozpoznani API 404 bere
|
||||
`config.rootPath` misto natvrdo `/apps/`. Dockerfile instaluje `npm ci`.
|
||||
|
||||
Vykon: data widgetu nactou seznam ticketu jednou na request, ne za kazdy
|
||||
widget; `/tickets` a `/runs` berou `limit` a `offset` (nejvys 500) a vraceji
|
||||
`X-Total-Count`, `/tickets` i `total` v tele; `hashPassword` je asynchronni,
|
||||
aby bcrypt neblokoval smycku; `/people/:id` prochazi tickety jednou.
|
||||
|
||||
`GET /api/dashboard/access` vraci `roleNames`, aby klient nehadal popisek role.
|
||||
`/storage` a `/scripts` vraceji cesty na serveru jen spravci platformy.
|
||||
V `openapi.ts` pribylo 22 chybejicich cest, `/whoami` je opraveny a `features`
|
||||
uz nejsou popsane jako CRUD.
|
||||
|
||||
### Udalosti nesou firmu
|
||||
|
||||
`DashboardEvent.tenantId` (null = cela platforma). Stream SSE filtruje zive
|
||||
udalosti i historii podle firem uzivatele, udalost s `payload.userId` jde jen
|
||||
tomu cloveku. Driv videl kazdy prihlaseny udalosti vsech firem.
|
||||
|
||||
Nove udalosti entit `tenant|user|role|person|group|ticketType|action|widget|connector|feature`
|
||||
s `.created|.updated|.deleted`, publikuje je `crudRouter` (volba `event`),
|
||||
routy konektoru a PUT features. Payload je `{ id, <druh>: zaznam }`, u smazani
|
||||
`{ id }`. Udalosti ticketu `ticket.updated`, `ticket.assigned`,
|
||||
`ticket.resolved` nesou v `payload.ticket` cely ticket, takze klient opravi
|
||||
seznam na miste a nemusi se ptat znovu.
|
||||
|
||||
### Portal: obnova bez odmontovani a klientsky sklad ciselniku
|
||||
|
||||
Kazda udalost ze streamu odmontovala stranku: `useApiQuery` prepnul `loading`
|
||||
a `DataState` vykreslil spinner misto deti. Ted je `loading` jen do prvnich
|
||||
dat, potom `refreshing`, a deti zustavaji. Hooky sdili jeden debounce 150 ms,
|
||||
cache modulu a deduplikaci bezicich dotazu (klic firma + cesta + telo).
|
||||
`patchOn` opravi data v cache z udalosti (`lib/ticketEvents.ts` bere
|
||||
`payload.ticket`). Rozhrani:
|
||||
|
||||
```ts
|
||||
useApiQuery<T>(path, { refetchOn?, body?, enabled?, patchOn? })
|
||||
-> { data, loading, refreshing, error, total, reload }
|
||||
```
|
||||
|
||||
**Rozhodnuti majitele produktu: stredni cesta.** Ciselniky (lide, skupiny,
|
||||
typy ticketu, sluzby, konektory, pristup) jsou v klientskem skladu
|
||||
`lib/collections.tsx`: nacitaji se line pri prvnim pouziti, mazou se pri
|
||||
prepnuti firmy a odhlaseni, opravuji se z udalosti entit (upsert nebo smazani
|
||||
ze zaznamu v payloadu, jinak jedno nacteni te kolekce). Tickety, behy
|
||||
a statistiky **zustavaji dotazy na server** se strankovanim - jsou velke
|
||||
a meni se porad. Hooky: `useCollection(key)`, `useAccess()`,
|
||||
`useCollectionSelector`.
|
||||
|
||||
Dalsi opravy klienta:
|
||||
|
||||
| Co | Proc |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
|
||||
| 401 maze token a vyvola `auth:expired` | po vyprseni tokenu portal ukazoval prazdne stranky. Login rekne "Prihlaseni vyprselo", stream se prestane pripojovat na 401 a 403 |
|
||||
| `restore()` maze token jen na 401 | vypadek site pri startu odhlasoval |
|
||||
| `onClose` modalu v ref | fokus se pri kazdem prekresleni vracel na zacatek |
|
||||
| toast ma jeden casovac | dva toasty za sebou si rusily odpocet |
|
||||
| zrusene asynchronni efekty | odpoved pro uz odmontovanou stranku prepisovala stav te nove |
|
||||
| tiche `catch` nahrazene chybou | pravidlo "zadna ticha selhani" platilo na serveru, na klientovi ne vsude |
|
||||
| filtry ticketu v URL | nalez slo poslat kolegovi a vratit se pres zpet |
|
||||
| detail ticketu neblokuje chyba `/people` | jeden padly dotaz na ciselnik schoval cely ticket |
|
||||
| `MappingEditor` stabilni klice radku | smazani radku prekreslilo vsechny nasledujici a ztratil se kurzor |
|
||||
| `lib/useUnsavedChanges.ts` | odchod z rozepsaneho builderu bez varovani |
|
||||
| `DashboardLayout` lazy, sourcemapy vypnute | verejny web nenacital kod portalu, produkce neposila zdrojaky |
|
||||
| `TicketTable` tabulka nebo karty | `useMediaQuery` misto duplicitni komponenty |
|
||||
|
||||
**Builder.** `collectScopes` memoizovane, karty v `memo`, callbacky podle ID
|
||||
kroku; `FlowCanvas` je rozdeleny do `flow/{ActionCard,ConditionCard,ForeachCard,StepControls}.tsx`
|
||||
a `canvasTypes.ts`. Stranka Konektory je rozdelena do
|
||||
`pages/dashboard/connectors/{ConnectorCard,ConnectorEditor,ConnectorLogs,ConnectorTools}.tsx`.
|
||||
Pred tim byl kazdy stisk klavesy ve strome o padesati krocich prekresleni
|
||||
vseho.
|
||||
|
||||
**Formularova vrstva** podle navrhu v dokumentu 25, sekce 2, je hotova:
|
||||
`components/ui/form/{controlClass,Field,Input,Select,Textarea}.tsx`,
|
||||
`lib/useSubmit.ts`, `lib/options.ts`, `components/ui/Chip.tsx`,
|
||||
`components/dashboard/TicketCard.tsx` (kompaktni varianta), `plural()`
|
||||
v `lib/format.ts`. Zmizelo 15 kopii trid vstupniho pole.
|
||||
|
||||
**Jazyky.** Verejne stranky (Postup, Produkty, Reference, O nas, Kontakt, 404,
|
||||
Prihlaseni, Sluzby, paticka, navigace) plus `DataState` a `ErrorBoundary` jdou
|
||||
pres i18n a `en.ts` je pro ne uplna.
|
||||
|
||||
**Sdilene typy**: ciste typove moduly v `src/shared/*.ts` (16 souboru,
|
||||
vcetne `users.ts` pro ucet a clenstvi) jsou jediny zdroj typu API.
|
||||
`web/src/types/dashboard.ts`, `events.ts` i `AuthContext` je re-exportuji
|
||||
pres alias `@shared/*` (`web/tsconfig.json` paths a `vite.config.ts` alias).
|
||||
Pri prevodu se nasly rozjete tvary, vsechny vyresene ve prospech serveru:
|
||||
webovy `Ticket` nemel `createdById`, `Access.roleNames` bylo nepovinne,
|
||||
`Person` nemel `enabled`, `Incident` nemel `tenantId` ani `source`, `Service`
|
||||
neznal kategorii `transformace`, seznam operatoru podminky u typu `list`
|
||||
na webu nemel `contains`, takze builder nenabizel podminku nad stitky, ktera
|
||||
na serveru funguje. Serverovy ulozeny tvar (`StoredTicket`, `Connector`
|
||||
s `values`) zustava na serveru; web dostava `PublicConnector` jako `Connector`.
|
||||
Pravidlo od ted: novy typ odpovedi patri do `src/shared`, web ho nekopiruje.
|
||||
|
||||
### Nova funkce: firma z registru ARES
|
||||
|
||||
Zalozit firmu znamenalo opsat nazev, IC, DIC a adresu rucne a pak zvlast
|
||||
zakladat ucty. Ted je to na `/api/dashboard/settings/ares`, jen pro spravce
|
||||
platformy:
|
||||
|
||||
| Endpoint | Co |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET ares/companies?query=` | podle IC presne, jinak podle nazvu. U firmy, ktera uz v portalu je, vraci `existingTenantId` |
|
||||
| `GET ares/companies/{ico}/persons` | soucasni clenove statutarniho organu a prokura z verejneho rejstriku, kazdy s navrzenym e-mailem `IC-poradi@placeholder.cz` |
|
||||
| `POST ares/tenants` | zalozi firmu s `ico`, `dic`, `address`, `legalForm` z ARES a ucty vybranych osob, vsechny s roli `role_admin` v nove firme, nahodne heslo, audit `tenant.create.ares` |
|
||||
|
||||
Zaznam firmy ma nove nepovinne `ico`, `dic`, `address`, `legalForm`; CRUD
|
||||
firem hlida unikatni IC. Adresa registru je `ARES_BASE_URL`, vychozi
|
||||
`https://ares.gov.cz/ekonomicke-subjekty-v-be/rest`. Klient ARES pouziva
|
||||
tentyz `net/guard.ts` jako vsechno ostatni, co vola ven.
|
||||
|
||||
**Rozhodnuti o rolich:** spravce platformy zaklada firmy, spravce firmy pak
|
||||
spravuje skupiny, vedouci a cleny uvnitr firmy. Rejstrik nezna e-maily, proto
|
||||
zastupne adresy - **spravce je musi nahradit skutecnymi**, jinak se ti lide
|
||||
neprihlasi a nedostanou pozvanku.
|
||||
|
||||
## 2026-09-08 - Otevrena stranka sekala prehravani videa
|
||||
|
||||
Pri otevrenem portalu zacalo vedle nej sekat prehravani videa, po zavreni
|
||||
|
||||
+4
-2
@@ -60,8 +60,10 @@ export const manifest = {
|
||||
* @param {{
|
||||
* http: { get: Function, post: Function, patch: Function, put: Function, del: Function,
|
||||
* postForm: Function },
|
||||
* util: { unwrap: Function, pick: Function, first: Function, text: Function,
|
||||
* num: Function, bool: Function, date: Function, round: Function, need: Function },
|
||||
* util: { unwrap: Function, pick: Function, first: Function, list: Function,
|
||||
* text: Function, num: Function, bool: Function, date: Function, day: Function,
|
||||
* round: Function, need: Function, get: Function, addresses: Function,
|
||||
* quote: Function, applyRules: Function, fillJson: Function },
|
||||
* log: Function, config: Record<string, string>, idempotencyKey: string,
|
||||
* fail: Function, retry: Function,
|
||||
* }} ctx
|
||||
|
||||
@@ -60,26 +60,12 @@ function digits(value) {
|
||||
return String(value ?? '').replace(/[^0-9]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam z odpovedi banky.
|
||||
*
|
||||
* COBS obaluje kolekci pod vlastni nazev (`accounts`, `transactions`), jiny
|
||||
* standard pouziva `content` nebo `items`. Hadat jeden tvar by znamenalo, ze
|
||||
* pri zmene obalky krok tise vrati nula zaznamu misto chyby.
|
||||
*/
|
||||
function collection(ctx, body, ...names) {
|
||||
if (Array.isArray(body)) return body;
|
||||
const found = ctx.util.pick(body, ...names, 'data', 'content', 'items');
|
||||
if (Array.isArray(found)) return found;
|
||||
const unwrapped = ctx.util.unwrap(body);
|
||||
return Array.isArray(unwrapped) ? unwrapped : null;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, need } = ctx.util;
|
||||
|
||||
const { body: accountsBody } = await ctx.http.get('/accounts', { query: { size: 50 } });
|
||||
const accounts = collection(ctx, accountsBody, 'accounts');
|
||||
// COBS obaluje kolekci pod vlastni nazev, `list` zkusi i obvykle obalky.
|
||||
const accounts = ctx.util.list(accountsBody, 'accounts');
|
||||
if (accounts === null) ctx.fail('Odpověď banky nemá seznam účtů.', accountsBody);
|
||||
if (accounts.length === 0) {
|
||||
ctx.fail('Souhlas nedosáhne na žádný účet. Zkontrolujte platnost souhlasu v ČSOB.');
|
||||
@@ -117,7 +103,7 @@ export async function run(inputs, ctx) {
|
||||
},
|
||||
});
|
||||
|
||||
const transactions = collection(ctx, body, 'transactions');
|
||||
const transactions = ctx.util.list(body, 'transactions');
|
||||
if (transactions === null) ctx.fail('Odpověď banky nemá seznam pohybů.', body);
|
||||
|
||||
return {
|
||||
|
||||
@@ -56,14 +56,6 @@ export const manifest = {
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
/** Adresy z jednoho radku. Clovek pise carky, Gmail chce jeden retezec. */
|
||||
function addresses(value) {
|
||||
return String(value ?? '')
|
||||
.split(/[,;]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '');
|
||||
}
|
||||
|
||||
/** Base64 z textu. Pres bajty, aby diakritika prezila. */
|
||||
function base64(value) {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
@@ -86,8 +78,9 @@ function header(value) {
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, need } = ctx.util;
|
||||
const { pick, text, need, addresses } = ctx.util;
|
||||
|
||||
// Clovek pise carky, Gmail chce jeden retezec.
|
||||
const to = addresses(inputs.to);
|
||||
if (to.length === 0) ctx.fail('Nezůstal žádný příjemce, zkontrolujte pole Příjemci.');
|
||||
const cc = addresses(inputs.cc);
|
||||
|
||||
@@ -88,11 +88,6 @@ export const manifest = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Datum ve tvaru, ktery iDoklad ceka. */
|
||||
function isoDay(value) {
|
||||
return new Date(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(value, days) {
|
||||
const date = new Date(value);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
@@ -100,7 +95,8 @@ function addDays(value, days) {
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, text, num, date, need } = ctx.util;
|
||||
// `day` dava datum ve tvaru, ktery iDoklad ceka (2026-08-20).
|
||||
const { unwrap, pick, text, num, date, need, day } = ctx.util;
|
||||
|
||||
if (inputs.unitPrice < 0) ctx.fail('Cena za jednotku nemůže být záporná.');
|
||||
const amount = inputs.amount ?? 1;
|
||||
@@ -120,9 +116,9 @@ export async function run(inputs, ctx) {
|
||||
...defaults,
|
||||
partnerId: inputs.partnerId,
|
||||
description: inputs.description,
|
||||
dateOfIssue: isoDay(issuedAt),
|
||||
dateOfTaxing: isoDay(issuedAt),
|
||||
dateOfMaturity: isoDay(maturityAt),
|
||||
dateOfIssue: day(issuedAt),
|
||||
dateOfTaxing: day(issuedAt),
|
||||
dateOfMaturity: day(maturityAt),
|
||||
items: [
|
||||
{
|
||||
name: inputs.itemName,
|
||||
|
||||
@@ -52,12 +52,8 @@ export const manifest = {
|
||||
],
|
||||
};
|
||||
|
||||
function isoDay(value) {
|
||||
return new Date(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, num, date, need } = ctx.util;
|
||||
const { unwrap, pick, num, date, need, day } = ctx.util;
|
||||
|
||||
if (inputs.amount !== null && inputs.amount <= 0) {
|
||||
ctx.fail('Uhrazená částka musí být větší než nula.');
|
||||
@@ -81,7 +77,7 @@ export async function run(inputs, ctx) {
|
||||
...defaults,
|
||||
invoiceId: inputs.invoiceId,
|
||||
paymentAmount: amount,
|
||||
dateOfPayment: isoDay(paidAt),
|
||||
dateOfPayment: day(paidAt),
|
||||
sendPaymentConfirmation: inputs.sendConfirmation ?? false,
|
||||
};
|
||||
|
||||
|
||||
@@ -66,16 +66,10 @@ export const manifest = {
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
/** Seznam adres z jednoho radku. Graph chce pole objektu, clovek pise carky. */
|
||||
function addresses(value) {
|
||||
return String(value ?? '')
|
||||
.split(/[,;]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
.map((address) => ({ address }));
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
// Graph chce pole objektu, clovek pise carky.
|
||||
const addresses = (value) => ctx.util.addresses(value).map((address) => ({ address }));
|
||||
|
||||
const to = addresses(inputs.to);
|
||||
if (to.length === 0) ctx.fail('Nezůstal žádný příjemce, zkontrolujte pole Příjemci.');
|
||||
|
||||
|
||||
@@ -53,11 +53,6 @@ export const manifest = {
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
/** Retezec do OData filtru. Apostrof se zdvojuje, jinak dotaz spadne. */
|
||||
function quote(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
const notFound = {
|
||||
found: false,
|
||||
cardCode: null,
|
||||
@@ -76,9 +71,9 @@ export async function run(inputs, ctx) {
|
||||
|
||||
// Poradi je zamer: kod je klic, ICO je jednoznacne, nazev se pise pokazde jinak.
|
||||
const attempts = [];
|
||||
if (inputs.cardCode) attempts.push([`CardCode eq ${quote(inputs.cardCode)}`, 'kód']);
|
||||
if (inputs.taxId) attempts.push([`FederalTaxID eq ${quote(inputs.taxId)}`, 'IČO']);
|
||||
if (inputs.name) attempts.push([`contains(CardName,${quote(inputs.name)})`, 'název']);
|
||||
if (inputs.cardCode) attempts.push([`CardCode eq ${ctx.util.quote(inputs.cardCode)}`, 'kód']);
|
||||
if (inputs.taxId) attempts.push([`FederalTaxID eq ${ctx.util.quote(inputs.taxId)}`, 'IČO']);
|
||||
if (inputs.name) attempts.push([`contains(CardName,${ctx.util.quote(inputs.name)})`, 'název']);
|
||||
|
||||
for (const [filter, label] of attempts) {
|
||||
const { body } = await ctx.http.get('/api/business-partners', {
|
||||
|
||||
@@ -51,13 +51,10 @@ export const manifest = {
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
function quote(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num, text, round } = ctx.util;
|
||||
const { pick, num, text, round, quote } = ctx.util;
|
||||
|
||||
// Apostrof v OData filtru se zdvojuje, to dela `quote`.
|
||||
const conditions = [];
|
||||
if (inputs.cardCode) conditions.push(`CardCode eq ${quote(inputs.cardCode)}`);
|
||||
if (inputs.dateFrom) conditions.push(`DocDate ge ${quote(inputs.dateFrom.slice(0, 10))}`);
|
||||
|
||||
@@ -66,11 +66,7 @@ function statRows(ctx, body) {
|
||||
});
|
||||
}
|
||||
|
||||
const stats = pick(body, 'stats');
|
||||
if (Array.isArray(stats)) return stats;
|
||||
|
||||
const data = pick(body, 'data');
|
||||
return Array.isArray(data) ? data : [];
|
||||
return ctx.util.list(body, 'stats') ?? [];
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Klient registru ARES (ekonomicke subjekty CR).
|
||||
*
|
||||
* Slouzi spravci platformy k zalozeni firmy: podle IC nebo nazvu se dotahne
|
||||
* nazev, DIC, adresa a pravni forma, a z verejneho rejstriku osoby, ktere
|
||||
* za firmu jednaji. ARES je verejne API bez klice, takze tu neni zadny
|
||||
* pristupovy udaj ani konektor.
|
||||
*
|
||||
* Tri volani, kazde s vlastnim tvarem odpovedi:
|
||||
* - GET /ekonomicke-subjekty/{ico} zakladni udaje (registr ROS)
|
||||
* - POST /ekonomicke-subjekty/vyhledat hledani podle nazvu
|
||||
* - GET /ekonomicke-subjekty-vr/{ico} verejny rejstrik vcetne organu
|
||||
*
|
||||
* Odpovedi se nekesuji: zaklada se jedna firma jednou a ARES ma svuj limit
|
||||
* pocitany na IP, ktery tenhle objem nedosahne.
|
||||
*/
|
||||
|
||||
import { config } from '../config.js';
|
||||
import { describeFetchError, readJsonLimited, tooLargeMessage } from '../net/guard.js';
|
||||
|
||||
/** Odpoved ARES ma desitky kB i u male firmy (historie zapisu), proto vyssi strop. */
|
||||
const BODY_LIMIT = 4_000_000;
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
export class AresError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** HTTP stav od ARES, 0 = chyba spojeni. */
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AresError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface AresCompany {
|
||||
ico: string;
|
||||
name: string;
|
||||
/** null = neni platce DPH nebo ARES DIC neuvadi. */
|
||||
dic: string | null;
|
||||
/** Textova adresa sidla tak, jak ji sklada ARES. */
|
||||
address: string;
|
||||
/** Kod pravni formy podle ciselniku ARES, napr. 112. */
|
||||
legalFormCode: string;
|
||||
/** Nazev pravni formy, kdyz kod zname; jinak samotny kod. */
|
||||
legalForm: string;
|
||||
}
|
||||
|
||||
export interface AresPerson {
|
||||
/** Jmeno v beznem tvaru ("Jan Novak"), ARES ho vraci velkymi pismeny. */
|
||||
name: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
/** Organ a funkce, napr. "jednatel" nebo "clen predstavenstva". */
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nejcastejsi pravni formy. ARES ma vlastni ciselnik na dalsim endpointu,
|
||||
* ale kvuli popisku v jednom formulari ho nema smysl stahovat; neznamy kod
|
||||
* se ukaze jako cislo a nic se tim nerozbije.
|
||||
*/
|
||||
const LEGAL_FORMS: Record<string, string> = {
|
||||
'100': 'Podnikající fyzická osoba',
|
||||
'101': 'Fyzická osoba podnikající dle živnostenského zákona',
|
||||
'105': 'Fyzická osoba podnikající dle jiných zákonů',
|
||||
'111': 'Veřejná obchodní společnost',
|
||||
'112': 'Společnost s ručením omezeným',
|
||||
'113': 'Komanditní společnost',
|
||||
'121': 'Akciová společnost',
|
||||
'141': 'Obecně prospěšná společnost',
|
||||
'145': 'Společenství vlastníků jednotek',
|
||||
'161': 'Ústav',
|
||||
'205': 'Družstvo',
|
||||
'301': 'Státní podnik',
|
||||
'331': 'Příspěvková organizace',
|
||||
'421': 'Odštěpný závod zahraniční právnické osoby',
|
||||
'424': 'Zahraniční fyzická osoba',
|
||||
'601': 'Vysoká škola',
|
||||
'641': 'Školská právnická osoba',
|
||||
'661': 'Veřejná výzkumná instituce',
|
||||
'701': 'Spolek',
|
||||
'706': 'Pobočný spolek',
|
||||
'801': 'Obec',
|
||||
'804': 'Kraj',
|
||||
'941': 'Evropská společnost',
|
||||
};
|
||||
|
||||
/** IC ma 8 cislic; kratsi se doplnuje nulami zleva, jak to dela i ARES. */
|
||||
export function normalizeIco(value: string): string | null {
|
||||
const digits = value.replace(/\s+/g, '');
|
||||
if (!/^\d{1,8}$/.test(digits)) return null;
|
||||
return digits.padStart(8, '0');
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : '';
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function list(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.map(record).filter((v): v is Record<string, unknown> => v !== null) : [];
|
||||
}
|
||||
|
||||
async function call(path: string, init?: RequestInit): Promise<unknown> {
|
||||
const url = `${config.aresBaseUrl}${path}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
headers: { accept: 'application/json', ...(init?.headers ?? {}) },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
const info = describeFetchError(err, 'ARES');
|
||||
console.error(`[ares] ${info.detail}`);
|
||||
throw new AresError(info.message, 0);
|
||||
}
|
||||
|
||||
const body = await readJsonLimited(response, BODY_LIMIT);
|
||||
if (body.tooLarge) throw new AresError(tooLargeMessage(BODY_LIMIT), response.status);
|
||||
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
// ARES vraci v tele `popis`, ktery rika vic nez samotny kod.
|
||||
const reason = text(record(body.json)?.popis) || text(record(body.json)?.subKod);
|
||||
throw new AresError(
|
||||
`ARES vrátil HTTP ${response.status}${reason ? `: ${reason}` : ''}.`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (body.json === undefined) throw new AresError('ARES vrátil odpověď, která není JSON.', response.status);
|
||||
return body.json;
|
||||
}
|
||||
|
||||
function toCompany(subject: Record<string, unknown>): AresCompany | null {
|
||||
const ico = normalizeIco(text(subject.ico));
|
||||
const name = text(subject.obchodniJmeno);
|
||||
if (ico === null || name === '') return null;
|
||||
const legalFormCode = text(subject.pravniForma);
|
||||
const seat = record(subject.sidlo);
|
||||
return {
|
||||
ico,
|
||||
name,
|
||||
dic: text(subject.dic) || null,
|
||||
address: text(seat?.textovaAdresa),
|
||||
legalFormCode,
|
||||
legalForm: LEGAL_FORMS[legalFormCode] ?? legalFormCode,
|
||||
};
|
||||
}
|
||||
|
||||
/** Firma podle IC. `null` = ARES ji nezna. */
|
||||
export async function lookupCompany(ico: string): Promise<AresCompany | null> {
|
||||
const normalized = normalizeIco(ico);
|
||||
if (normalized === null) throw new AresError('IČ musí být 1 až 8 číslic.', 400);
|
||||
const json = await call(`/ekonomicke-subjekty/${normalized}`);
|
||||
const subject = record(json);
|
||||
return subject ? toCompany(subject) : null;
|
||||
}
|
||||
|
||||
/** Firmy podle casti nazvu, nejvys `limit`. */
|
||||
export async function searchCompanies(name: string, limit = 10): Promise<AresCompany[]> {
|
||||
const query = name.trim();
|
||||
if (query.length < 2) throw new AresError('Název pro hledání je moc krátký.', 400);
|
||||
const json = await call('/ekonomicke-subjekty/vyhledat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ obchodniJmeno: query, start: 0, pocet: Math.min(Math.max(limit, 1), 50) }),
|
||||
});
|
||||
return list(record(json)?.ekonomickeSubjekty)
|
||||
.map(toCompany)
|
||||
.filter((c): c is AresCompany => c !== null);
|
||||
}
|
||||
|
||||
/** "NOVAK" -> "Novak", "DE LA CRUZ-NOVA" -> "De La Cruz-Nova". */
|
||||
function properCase(value: string): string {
|
||||
return value
|
||||
.toLocaleLowerCase('cs-CZ')
|
||||
.replace(/(^|[\s\-'])(\p{L})/gu, (_, sep: string, ch: string) => sep + ch.toLocaleUpperCase('cs-CZ'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Osoby, ktere za firmu dnes jednaji: clenove statutarnich organu a prokura.
|
||||
*
|
||||
* Verejny rejstrik vraci celou historii vcetne vymazanych clenu, proto se
|
||||
* bere jen zaznam bez `datumVymazu`. Tataz osoba muze sedet ve dvou organech,
|
||||
* slucuje se podle jmena a data narozeni.
|
||||
*/
|
||||
export async function listCompanyPersons(ico: string): Promise<AresPerson[]> {
|
||||
const normalized = normalizeIco(ico);
|
||||
if (normalized === null) throw new AresError('IČ musí být 1 až 8 číslic.', 400);
|
||||
const json = await call(`/ekonomicke-subjekty-vr/${normalized}`);
|
||||
const records = list(record(json)?.zaznamy);
|
||||
// Firma muze mit vic zaznamu (napr. po presunu k jinemu soudu), primarni je ten platny.
|
||||
const primary = records.find((r) => r.primarniZaznam === true) ?? records[0];
|
||||
if (!primary) return [];
|
||||
|
||||
const found = new Map<string, AresPerson>();
|
||||
for (const organ of list(primary.statutarniOrgany)) {
|
||||
if (organ.datumVymazu) continue;
|
||||
const organName = text(organ.nazevOrganu);
|
||||
for (const member of list(organ.clenoveOrganu)) {
|
||||
if (member.datumVymazu) continue;
|
||||
const person = record(member.fyzickaOsoba);
|
||||
if (!person) continue;
|
||||
const firstName = properCase(text(person.jmeno));
|
||||
const lastName = properCase(text(person.prijmeni));
|
||||
if (firstName === '' && lastName === '') continue;
|
||||
|
||||
const membership = record(member.clenstvi);
|
||||
const role = text(record(membership?.funkce)?.nazev) || organName || 'člen orgánu';
|
||||
const key = `${firstName}|${lastName}|${text(person.datumNarozeni)}`;
|
||||
const existing = found.get(key);
|
||||
if (existing) {
|
||||
if (!existing.roles.includes(role)) existing.roles.push(role);
|
||||
continue;
|
||||
}
|
||||
found.set(key, {
|
||||
name: [text(person.titulPred), firstName, lastName].filter(Boolean).join(' '),
|
||||
firstName,
|
||||
lastName,
|
||||
roles: [role],
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...found.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nahradni e-mail pro osobu z ARES: registr e-maily nevede, ale ucet bez
|
||||
* e-mailu neexistuje. Tvar IC-poradi je dohodnuty a portal ho pozna, aby
|
||||
* mohl upozornit, ze se ma nahradit skutecnym.
|
||||
*/
|
||||
export const PLACEHOLDER_DOMAIN = 'placeholder.cz';
|
||||
|
||||
export function placeholderEmail(ico: string, index: number): string {
|
||||
return `${ico}-${index}@${PLACEHOLDER_DOMAIN}`;
|
||||
}
|
||||
|
||||
export function isPlaceholderEmail(email: string): boolean {
|
||||
return email.toLowerCase().endsWith(`@${PLACEHOLDER_DOMAIN}`);
|
||||
}
|
||||
@@ -188,6 +188,13 @@ export const config = {
|
||||
* Jen pro lokalni vyvoj, v nasazeni musi zustat vypnute.
|
||||
*/
|
||||
allowPrivateTargets: process.env.ALLOW_PRIVATE_TARGETS === 'true',
|
||||
/**
|
||||
* Registr ARES pro zalozeni firmy podle IC nebo nazvu. Verejne API bez
|
||||
* klice; promenna je tu jen pro testovaci prostredi nebo zrcadlo.
|
||||
*/
|
||||
aresBaseUrl: (process.env.ARES_BASE_URL ?? 'https://ares.gov.cz/ekonomicke-subjekty-v-be/rest')
|
||||
.trim()
|
||||
.replace(/\/+$/, ''),
|
||||
/**
|
||||
* Zpracovava tenhle proces frontu behu?
|
||||
*
|
||||
|
||||
+22
-40
@@ -16,12 +16,15 @@
|
||||
*/
|
||||
|
||||
import { findPersonByEmail, groupMembers, groupsOfPerson, listGroups } from './people.js';
|
||||
import { hasPermission, permissionsOf } from './permissions.js';
|
||||
import { hasPermission, permissionsOf, rolesFor } from './permissions.js';
|
||||
import { listActiveTenants, findTenant, type Tenant } from './tenants.js';
|
||||
import { navFor, type NavItem } from './tenantFeatures.js';
|
||||
import type { User } from '../types.js';
|
||||
import { navFor } from './tenantFeatures.js';
|
||||
import { rolesIn, type User } from '../types.js';
|
||||
|
||||
export type TicketScope = 'all' | 'tenant' | 'mine';
|
||||
import type { Access, TicketScope } from '../shared/access.js';
|
||||
|
||||
/** Tvar prav je sdileny s webem, viz src/shared/access.ts. */
|
||||
export type { Access, TicketScope };
|
||||
|
||||
/**
|
||||
* Strop viditelnosti: co uzivatel **vubec smi** videt.
|
||||
@@ -85,39 +88,6 @@ export function seesWholeTenant(user: User, tenantId: string | null): boolean {
|
||||
return visibilityFor(user, tenantId).kind === 'all';
|
||||
}
|
||||
|
||||
export interface Access {
|
||||
/** Pohledy, ktere smi uzivatel pouzit. Klient podle toho kresli prepinac. */
|
||||
scopes: TicketScope[];
|
||||
/** Firmy, mezi kterymi muze prepinat. */
|
||||
tenants: Array<{ id: string; name: string }>;
|
||||
/** Vychozi firma, kdyz zadnou nezvoli. null = zadna, nema kam. */
|
||||
defaultTenantId: string | null;
|
||||
/** Smi prehazovat tickety mezi lidmi, ne jen brat na sebe. */
|
||||
canAssignOthers: boolean;
|
||||
/** ID resitele odpovidajiciho uzivateli, nebo null. */
|
||||
personId: string | null;
|
||||
/** Efektivni prava. Klient podle nich kresli tlacitka. */
|
||||
permissions: string[];
|
||||
/** Zalozky, ktere ma videt. Prunik toho, co firma ma, a toho, na co ma pravo. */
|
||||
nav: NavItem[];
|
||||
/** true = vidi napric firmami a smi platformni nastaveni. */
|
||||
platformAdmin: boolean;
|
||||
/**
|
||||
* Vidi i cizi tickety, ne jen svoje a frontu?
|
||||
*
|
||||
* Podle toho se nabizi zalozka "Vsechny tickety". Kdo vidi jen svoje,
|
||||
* nema co prepinat - zalozka by mu ukazala totez co ta prvni.
|
||||
*/
|
||||
seesOthers: boolean;
|
||||
/**
|
||||
* Sekce, na ktere clovek vidi. Filtr v zalozce "Vsechny tickety".
|
||||
*
|
||||
* Kdo vidi celou firmu, dostane vsechny. Vedouci jen ty, ktere vede -
|
||||
* nabidnout mu sekci, ze ktere stejne nic neuvidi, je jen matouci.
|
||||
*/
|
||||
visibleGroups: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Co uzivatel smi.
|
||||
*
|
||||
@@ -154,6 +124,14 @@ export function accessFor(user: User, tenantId?: string | null): Access {
|
||||
? groups
|
||||
: groups.filter((group) => visibility.groupIds.includes(group.id));
|
||||
|
||||
// Odkaz v clenstvi smi byt ID role i klic systemove role, viz `permissionsOf`.
|
||||
const roles = activeTenant ? rolesFor(activeTenant) : [];
|
||||
const roleNames = activeTenant
|
||||
? rolesIn(user, activeTenant)
|
||||
.map((ref) => roles.find((role) => role.id === ref || role.key === ref)?.name)
|
||||
.filter((name): name is string => name !== undefined)
|
||||
: [];
|
||||
|
||||
return {
|
||||
scopes,
|
||||
tenants: tenants.map((tenant) => ({ id: tenant.id, name: tenant.name })),
|
||||
@@ -164,6 +142,7 @@ export function accessFor(user: User, tenantId?: string | null): Access {
|
||||
// Prava se pocitaji za **vybranou firmu**. Kdo je spravce v jedne firme
|
||||
// a resitel v druhe, uvidi po prepnuti jen to, co smi tam.
|
||||
permissions: [...permissionsOf(user, activeTenant)].sort(),
|
||||
roleNames,
|
||||
nav: navFor(user, activeTenant),
|
||||
platformAdmin: user.platformAdmin,
|
||||
seesOthers: visibility.kind === 'all' || visibility.groupIds.length > 0,
|
||||
@@ -178,8 +157,9 @@ export function accessFor(user: User, tenantId?: string | null): Access {
|
||||
* Vraci `null`, kdyz uzivatel do zadane firmy nepatri nebo zadnou nema -
|
||||
* volajici pak nema pravo na nic, coz je spravna odpoved.
|
||||
*/
|
||||
export function actingTenant(user: User, requested?: string): string | null {
|
||||
const access = accessFor(user, requested);
|
||||
export function actingTenant(user: User, requested?: string, precomputed?: Access): string | null {
|
||||
// Prava uz za request spocital middleware, druhy vypocet by byl stejny.
|
||||
const access = precomputed ?? accessFor(user, requested);
|
||||
const tenantId = requested ?? access.defaultTenantId;
|
||||
if (!tenantId) return null;
|
||||
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
|
||||
@@ -231,8 +211,10 @@ export interface ScopeDenied {
|
||||
export function resolveScope(
|
||||
user: User,
|
||||
requested: { scope?: string; tenantId?: string },
|
||||
precomputed?: Access,
|
||||
): ResolvedScope | ScopeDenied {
|
||||
const access = accessFor(user, requested.tenantId);
|
||||
// Prava uz za request spocital middleware, druhy vypocet by byl stejny.
|
||||
const access = precomputed ?? accessFor(user, requested.tenantId);
|
||||
|
||||
const scope = (requested.scope ?? (access.scopes.includes('tenant') ? 'tenant' : 'mine')) as
|
||||
| TicketScope
|
||||
|
||||
+24
-5
@@ -68,28 +68,47 @@ export function recordAudit(input: AuditInput): void {
|
||||
|
||||
void auditStore
|
||||
.create(entry)
|
||||
.then(() => trim())
|
||||
.then(() => trimIfDue())
|
||||
.catch((err: unknown) => {
|
||||
console.error('[audit] zaznam se nepodarilo ulozit:', err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uklid je drahy - cte cely audit. Proto ne po kazdem zapisu, ale az po
|
||||
* `TRIM_EVERY` zapisech, nebo nejdriv po `TRIM_INTERVAL_MS` od minuleho.
|
||||
* Par zaznamu nad limit mezitim nikomu nevadi.
|
||||
*/
|
||||
const TRIM_EVERY = 50;
|
||||
const TRIM_INTERVAL_MS = 60_000;
|
||||
|
||||
let writesSinceTrim = 0;
|
||||
let lastTrimAt = 0;
|
||||
let trimming = false;
|
||||
|
||||
function trimIfDue(): Promise<void> {
|
||||
writesSinceTrim += 1;
|
||||
const due = writesSinceTrim >= TRIM_EVERY || Date.now() - lastTrimAt >= TRIM_INTERVAL_MS;
|
||||
if (!due) return Promise.resolve();
|
||||
return trim();
|
||||
}
|
||||
|
||||
/** Odmaze nejstarsi zaznamy nad limit. Bezi nejvyse jednou naraz. */
|
||||
async function trim(): Promise<void> {
|
||||
if (trimming) return;
|
||||
trimming = true;
|
||||
writesSinceTrim = 0;
|
||||
lastTrimAt = Date.now();
|
||||
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`);
|
||||
// Bez filtru na firmu: maze se to, co se prave nacetlo pres listAll.
|
||||
// Driv se mazalo s prazdnym seznamem firem a firemni zaznamy zustavaly.
|
||||
const removed = await auditStore.removeMany(excess.map((entry) => entry.id));
|
||||
console.info(`[audit] odmazano ${removed} z ${excess.length} nejstarsich zaznamu`);
|
||||
} catch (err) {
|
||||
console.error('[audit] uklid selhal:', err);
|
||||
} finally {
|
||||
|
||||
+94
-274
@@ -1,154 +1,54 @@
|
||||
/**
|
||||
* Uloziste automatizaci vcetne jejich stromu akci (flow).
|
||||
*
|
||||
* POZOR: data jsou v pameti procesu - restart API je vrati na vychozi sadu.
|
||||
* To je vedome zjednoduseni prototypu, nahrada za databazi je popsana
|
||||
* v docs/04-backend-api.md, sekce "Kam dal".
|
||||
* Automatizace se drzi v pameti kvuli synchronnimu cteni a po kazde zmene se
|
||||
* cela zapise do uloziste (`withMirror`). Kam - databaze, soubor, nebo nikam -
|
||||
* rozhoduje `data/store/index.ts`; viz documentation/14-databaze.md.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { config } from '../config.js';
|
||||
import { publish } from '../events/bus.js';
|
||||
import { defineStore } from './store/index.js';
|
||||
import { timingSafeEqualString } from '../lib/secure.js';
|
||||
import { defineStore, highestNumber, minutesAgo, writableOrWarn } from './store/index.js';
|
||||
import { withMirror } from './store/mirror.js';
|
||||
import {
|
||||
isUnary,
|
||||
type ConditionMatch,
|
||||
type ConditionOperator,
|
||||
type ConditionRule,
|
||||
type FieldType,
|
||||
} from './conditions.js';
|
||||
import { actionInputsFor, findService } from './services.js';
|
||||
import { collectScopes, duplicateNames, scopeFor } from './flowScope.js';
|
||||
import { referencedFields, rootOf } from './templates.js';
|
||||
import { describeModel, rootsOf, type ModelNode } from './model.js';
|
||||
import { describeModel, rootsOf } from './model.js';
|
||||
|
||||
export type AutomationKind = 'workflow' | 'voicebot' | 'integrace' | 'report';
|
||||
|
||||
/** Jeden vstupni parametr, ktery spoustec preda dal do stromu. */
|
||||
export interface TriggerField {
|
||||
id: string;
|
||||
/** Klic v prichozich datech - napr. "orderTotal". Musi byt unikatni. */
|
||||
name: string;
|
||||
type: FieldType;
|
||||
required: boolean;
|
||||
/**
|
||||
* Kde ta hodnota v tele je, kdyz to neni primo `name`.
|
||||
*
|
||||
* Odesilatele posilaji ruzne tvary: jeden `{"a":"aaa"}`, druhy cely model
|
||||
* s vnorenymi objekty a poli. Bez cesty by slo napojit jen ploche telo
|
||||
* a slozitejsi odesilatel by se musel prizpusobovat nam, coz nejde.
|
||||
*
|
||||
* Priklady: `customer.id`, `errors.0.message`, `data.items`.
|
||||
*/
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface FlowTrigger {
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
/** Deklarovane vstupni parametry. Podminky se odkazuji na jejich `id`. */
|
||||
fields: TriggerField[];
|
||||
/**
|
||||
* Ukazka toho, co odesilatel opravdu posila.
|
||||
*
|
||||
* Vlepi se cele telo z realneho volani. Z nej se odvodi model, tedy seznam
|
||||
* cest i s typy, ktery se v builderu ukaze jako strom a klikanim se z nej
|
||||
* vklada odkaz do sablony.
|
||||
*
|
||||
* Proc ukazka a ne rucne psany seznam poli: model objednavky ze Shoptetu ma
|
||||
* desitky cest a seznam polozek. Vypsat ho po jednom nikdo nebude a pole
|
||||
* do plocheho seznamu nepatri vubec.
|
||||
*/
|
||||
sample?: unknown;
|
||||
/**
|
||||
* Jak casto se ma sluzba obvolavat, kdyz nam sama nezavola (v sekundach).
|
||||
* Plati jen u spoustecu, ktere se musi ptat. Minimum je 10 s.
|
||||
*/
|
||||
intervalSec?: number;
|
||||
/**
|
||||
* Neodhadnutelny token v adrese webhooku. Generuje VZDY server,
|
||||
* klient ho nesmi urcovat ani menit.
|
||||
*/
|
||||
webhookToken?: string;
|
||||
}
|
||||
import type {
|
||||
Automation,
|
||||
AutomationDetail,
|
||||
AutomationFlow,
|
||||
AutomationKind,
|
||||
FlowStep,
|
||||
FlowTrigger,
|
||||
TriggerField,
|
||||
WebhookCall,
|
||||
WebhookCallField,
|
||||
} from '../shared/automations.js';
|
||||
|
||||
/**
|
||||
* Krok stromu. `action` je jeden ukon nad sluzbou, `condition` rozdeluje
|
||||
* beh na dve vetve podle hodnoty vstupniho parametru - proto je to strom.
|
||||
* Tvar automatizace a stromu je sdileny s webem, viz src/shared/automations.ts.
|
||||
* Tady zustava uloziste a prace se stromem.
|
||||
*/
|
||||
export type FlowStep =
|
||||
| {
|
||||
id: string;
|
||||
kind: 'action';
|
||||
/** Ktera sluzba a jeji operace. Sluzba je to, co umime. */
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
/**
|
||||
* Ktery konektor firmy se pouzije, tedy pod jakymi udaji se to zavola.
|
||||
*
|
||||
* `null` znamena vychozi konektor firmy pro tuhle sluzbu. Diky tomu je
|
||||
* vzorovy strom prenositelny mezi firmami - kazde se dosadi jeji vlastni
|
||||
* napojeni. U obecnych sluzeb (webhook, pauza, transformace) je vzdy null,
|
||||
* ty konektor nepotrebuji.
|
||||
*/
|
||||
connectorId?: string | null;
|
||||
/**
|
||||
* Nastaveni akce: klic je `OperationField.id` z katalogu, hodnota je
|
||||
* sablona s odkazy na parametry spoustece (`{{subject}}`).
|
||||
* Chybi u akci, ktere zatim nemaji `inputs`.
|
||||
*/
|
||||
inputs?: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: 'condition';
|
||||
/**
|
||||
* Otazky, ktere se ptaji naraz. Cte se **vzdycky pres `rulesOf`**.
|
||||
*
|
||||
* Driv byla podminka prave jedna otazka a slozitejsi vetveni se skladalo
|
||||
* z vnorenych podminek: "vysledek dorazil" a v nem "vysledek je X" byly
|
||||
* dve urovne stromu misto jedne vety.
|
||||
*/
|
||||
rules?: ConditionRule[];
|
||||
/** `all` = a zaroven, `any` = nebo. Chybi u jedne otazky, tam nema co spojovat. */
|
||||
match?: ConditionMatch;
|
||||
/**
|
||||
* Stara podoba: jedna otazka primo na kroku.
|
||||
*
|
||||
* Zaznamy ulozene driv ji maji misto `rules`. Prevadi ji `rulesOf`,
|
||||
* zapisuje se uz vzdycky `rules`.
|
||||
*/
|
||||
fieldId?: string;
|
||||
operator?: ConditionOperator;
|
||||
value?: string;
|
||||
yes: FlowStep[];
|
||||
no: FlowStep[];
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
/**
|
||||
* Podstrom, ktery se vykona **nad kazdou polozkou seznamu**.
|
||||
*
|
||||
* Bez tohohle nejde rict "kazdou polozku objednavky zaloz v CRM". Jedno
|
||||
* volani se seznamem uvnitr se da poskladat transformaci, ale kdyz ma
|
||||
* kazda polozka vyvolat vlastni akci a vratit vlastni ID, je potreba
|
||||
* smycka.
|
||||
*
|
||||
* Uvnitr je k dispozici `item` (cela polozka) a `index`. Po skonceni
|
||||
* `krok.results`, coz je seznam vysledku za kazdou polozku - prave to se
|
||||
* pak posila dal jako radky objednavky.
|
||||
*/
|
||||
kind: 'foreach';
|
||||
/** Cesta k seznamu v datech, napr. `data.order.items`. */
|
||||
path: string;
|
||||
steps: FlowStep[];
|
||||
};
|
||||
|
||||
export interface AutomationFlow {
|
||||
trigger: FlowTrigger | null;
|
||||
steps: FlowStep[];
|
||||
}
|
||||
export type {
|
||||
Automation,
|
||||
AutomationDetail,
|
||||
AutomationFlow,
|
||||
AutomationKind,
|
||||
FlowStep,
|
||||
FlowTrigger,
|
||||
TriggerField,
|
||||
WebhookCall,
|
||||
WebhookCallField,
|
||||
};
|
||||
|
||||
/**
|
||||
* Otazky podminky, at uz je krok v jakekoliv podobe.
|
||||
@@ -173,85 +73,6 @@ export function generateWebhookToken(): string {
|
||||
return randomBytes(24).toString('base64url');
|
||||
}
|
||||
|
||||
/** Polozka v seznamu automatizaci - bez celeho stromu. */
|
||||
export interface Automation {
|
||||
id: string;
|
||||
/** Firma, ktere automatizace patri. Hranice viditelnosti. */
|
||||
tenantId: string;
|
||||
name: string;
|
||||
kind: AutomationKind;
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Behy za **dnesni den**. Po pulnoci zacina od nuly.
|
||||
*
|
||||
* Driv to byl citac od zalozeni automatizace, jen se jmenoval "dnes".
|
||||
* U automatizace, ktera bezi tri mesice, to rikalo cislo, ktere s dneskem
|
||||
* nemelo nic spolecneho.
|
||||
*/
|
||||
runsToday: number;
|
||||
/** Behy za vcerejsi den, at je s cim srovnat. */
|
||||
runsYesterday: number;
|
||||
/** Behy od zalozeni. Puvodni citac se neztratil, jen se spravne jmenuje. */
|
||||
runsTotal: number;
|
||||
/** Uspesnost dnesnich behu. Kdyz dnes zadny nebyl, posledniho dne, kdy byly. */
|
||||
successRate: number;
|
||||
avgDurationMs: number;
|
||||
lastRunAt: string;
|
||||
/** Pocet vsech kroku vcetne vnorenych vetvi. */
|
||||
stepCount: number;
|
||||
/** false = automatizace jeste nema spoustec, je to koncept. */
|
||||
configured: boolean;
|
||||
/**
|
||||
* Co chybi k tomu, aby se dala zapnout. Prazdne = je hotova.
|
||||
* Zobrazuje se uzivateli, nesmi zmizet tise.
|
||||
*/
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
/** Jak dopadl jeden deklarovany parametr v jednom volani. */
|
||||
export interface WebhookCallField {
|
||||
name: string;
|
||||
/** Cesta v tele. Shodna se jmenem, kdyz se nezadala vlastni. */
|
||||
path: string;
|
||||
required: boolean;
|
||||
/** Deklarovany typ z kontraktu. */
|
||||
type: string;
|
||||
/** Co na te ceste opravdu bylo: text, cislo, objekt, seznam, nepreslo. */
|
||||
got: string;
|
||||
/** Kratky nahled prijate hodnoty. */
|
||||
preview: string;
|
||||
/** false = tenhle parametr je duvod, proc se volani odmitlo. */
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jedno volani webhooku, jak dopadlo.
|
||||
*
|
||||
* Odmitnute volani do ted skoncilo jako radek v logu kontejneru, kam se nikdo
|
||||
* nedostane. Odesilatel dostal 400 a vedel o tom, ale ten, kdo kontrakt napsal,
|
||||
* se nedozvedel nic - automatizace svitila zelene a jen do ni nic nechodilo.
|
||||
*
|
||||
* Drzi se **cele prijate telo i rozpad po parametrech**. Duvod odmitnuti sam
|
||||
* nestaci: "parametr data ma mit typ text" nerekne, co tedy prislo, a bez toho
|
||||
* zbyva hadat. Tickety si cela prijata tela u udalosti drzi uz davno, takze to
|
||||
* neni novy druh dat, jen se konecne daji videt i u volani, ktera neprosla.
|
||||
*/
|
||||
export interface WebhookCall {
|
||||
at: string;
|
||||
/** true = prevzato do fronty, false = odmitnuto pro neplatna data. */
|
||||
ok: boolean;
|
||||
/** Duvody odmitnuti. Prazdne u prevzatych volani. */
|
||||
problems: string[];
|
||||
/** Beh, ktery z volani vznikl. null u odmitnutych a u duplicit. */
|
||||
runId: string | null;
|
||||
/** Kazdy deklarovany parametr a jak dopadl. */
|
||||
fields: WebhookCallField[];
|
||||
/** Cele prijate telo jako JSON. Delsi nez `MAX_BODY` se usekne. */
|
||||
body: string;
|
||||
/** true = telo bylo delsi a je useknute. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** Kolik znaku tela se u volani drzi. Vic uz je v pameti zbytecne. */
|
||||
const MAX_BODY = 8_000;
|
||||
|
||||
@@ -286,27 +107,6 @@ export function recentWebhookCalls(automationId: string): WebhookCall[] {
|
||||
return calls.get(automationId) ?? [];
|
||||
}
|
||||
|
||||
export interface AutomationDetail extends Automation {
|
||||
flow: AutomationFlow;
|
||||
/**
|
||||
* Poslednich `MAX_CALLS` volani webhooku, nejnovejsi prvni.
|
||||
*
|
||||
* Drzi se v pameti, restart je zahodi. Je to diagnostika posledni hodiny,
|
||||
* ne historie - na tu jsou behy.
|
||||
*/
|
||||
recentCalls: WebhookCall[];
|
||||
/**
|
||||
* Model prichozich dat odvozeny z ukazky u spoustece.
|
||||
*
|
||||
* Pocita se pri cteni, neuklada se - je to pohled na `trigger.sample`.
|
||||
* Kdyby se ukladal, sel by rozejit s ukazkou a nikdo by nepoznal, ktery
|
||||
* z nich plati.
|
||||
*/
|
||||
model: ModelNode[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface StoredAutomation {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
@@ -327,6 +127,16 @@ interface StoredAutomation {
|
||||
avgDurationMs: number;
|
||||
lastRunAt: string;
|
||||
flow: AutomationFlow;
|
||||
/**
|
||||
* Nedodelky a pocet kroku **spocitane pri ulozeni**, ne pri kazdem cteni.
|
||||
*
|
||||
* Seznam automatizaci se cte pri kazdem nacteni prehledu i pri kazde zmene
|
||||
* ticketu (spoustece), a prochazet kvuli tomu pokazde cely strom vsech
|
||||
* automatizaci bylo znat. Nepovinne, protoze starsi zaznamy je nemaji -
|
||||
* dopocitaji se pri startu.
|
||||
*/
|
||||
issues?: string[];
|
||||
stepCount?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -378,10 +188,6 @@ function statsOf(stored: StoredAutomation): {
|
||||
};
|
||||
}
|
||||
|
||||
function minutesAgo(minutes: number): string {
|
||||
return new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
}
|
||||
|
||||
/** Rekurzivne secte kroky vcetne obou vetvi podminek. */
|
||||
export function countSteps(steps: FlowStep[]): number {
|
||||
return steps.reduce((sum, step) => {
|
||||
@@ -597,6 +403,15 @@ function save(automation: StoredAutomation): void {
|
||||
mirror.save(automation);
|
||||
}
|
||||
|
||||
/** Doplni to, co se ze stromu dopocitava a uklada s nim (nedodelky, pocet kroku). */
|
||||
function withDerived(automation: StoredAutomation): StoredAutomation {
|
||||
return {
|
||||
...automation,
|
||||
stepCount: countSteps(automation.flow.steps),
|
||||
issues: collectFlowIssues(automation.flow),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nacte automatizace z uloziste. Vola se pri startu, viz data/bootstrap.ts.
|
||||
*
|
||||
@@ -607,14 +422,16 @@ 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);
|
||||
/*
|
||||
* Nedodelky se pri startu prepocitaji u vsech, ne jen u zaznamu bez nich:
|
||||
* zavisi na katalogu sluzeb a ten se mezi nasazenimi meni (nove povinne
|
||||
* pole kroku). Jednou pri startu je to levne, pri kazdem cteni ne.
|
||||
*/
|
||||
for (const row of rows) store.set(row.id, withDerived(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;
|
||||
}
|
||||
idCounter = Math.max(idCounter, highestNumber(store.keys(), 'AUT'));
|
||||
}
|
||||
|
||||
function nextId(): string {
|
||||
@@ -628,15 +445,18 @@ function seed(
|
||||
},
|
||||
) {
|
||||
const id = nextId();
|
||||
store.set(id, {
|
||||
// Ukazkova data patri Automii, kdyz neni receno jinak.
|
||||
tenantId: 'tnt_automia',
|
||||
...automation,
|
||||
store.set(
|
||||
id,
|
||||
kind: deriveKind(automation.flow),
|
||||
createdAt: minutesAgo(60 * 24 * 90),
|
||||
updatedAt: minutesAgo(60 * 12),
|
||||
});
|
||||
withDerived({
|
||||
// Ukazkova data patri Automii, kdyz neni receno jinak.
|
||||
tenantId: 'tnt_automia',
|
||||
...automation,
|
||||
id,
|
||||
kind: deriveKind(automation.flow),
|
||||
createdAt: minutesAgo(60 * 24 * 90),
|
||||
updatedAt: minutesAgo(60 * 12),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1102,15 +922,17 @@ function seedDemoAutomations(): void {
|
||||
/**
|
||||
* Automatizace, ktere na instanci opravdu bezi.
|
||||
*
|
||||
* Dokud data neprezivaji redeploy, je tohle jedine misto, kde nastaveni
|
||||
* prezije nasazeni. Az bude Postgres, patri to zpatky do dat a odsud pryc.
|
||||
* Je to **vychozi sada pro prazdne uloziste**, ne zdroj pravdy: pouzije se
|
||||
* jen pri prvnim startu (nebo po ztrate dat, napr. redeploy bez databaze
|
||||
* a bez svazku). Kdyz uloziste uz neco ma, tenhle kod se nepouzije a strom
|
||||
* na instanci muze byt jiny. Zdrojem pravdy je uloziste, viz
|
||||
* documentation/14-databaze.md.
|
||||
*
|
||||
* Token webhooku se bere z `WEBHOOK_TOKEN_TEST`, aby se adresa po nasazeni
|
||||
* Token webhooku se bere z `WEBHOOK_TOKEN_TEST`, aby se adresa po ztrate dat
|
||||
* nemenila a odesilatel ji nemusel prepisovat.
|
||||
*
|
||||
* **Opsano z bezici instance, ne vymysleno.** Kdyz se strom na instanci zmeni,
|
||||
* patri ta zmena sem, jinak ji dalsi nasazeni zahodi. Naposledy srovnano
|
||||
* 2026-09-02.
|
||||
* Opsano z bezici instance 2026-09-02. Kdyz se strom na instanci zmeni a ma
|
||||
* prezit i ztratu dat, patri ta zmena sem.
|
||||
*/
|
||||
function seedRealAutomations(): void {
|
||||
seed({
|
||||
@@ -1254,9 +1076,11 @@ function toSummary(stored: StoredAutomation): Automation {
|
||||
* je "dnes" nula, dokud opravdu neco nebezi.
|
||||
*/
|
||||
...statsOf(stored),
|
||||
stepCount: countSteps(stored.flow.steps),
|
||||
// Spocitane pri ulozeni nebo pri startu, viz StoredAutomation. Zaloha
|
||||
// pro pripad, ze by zaznam prisel jinudy nez pres `withDerived`.
|
||||
stepCount: stored.stepCount ?? countSteps(stored.flow.steps),
|
||||
configured: stored.flow.trigger !== null,
|
||||
issues: collectFlowIssues(stored.flow),
|
||||
issues: stored.issues ?? collectFlowIssues(stored.flow),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1295,7 +1119,7 @@ export function getAutomation(id: string, tenantIds: string[]): AutomationDetail
|
||||
export function createAutomation(name: string, tenantId: string): AutomationDetail {
|
||||
const id = nextId();
|
||||
const now = new Date().toISOString();
|
||||
const stored: StoredAutomation = {
|
||||
const stored = withDerived({
|
||||
id,
|
||||
tenantId,
|
||||
name,
|
||||
@@ -1308,22 +1132,16 @@ export function createAutomation(name: string, tenantId: string): AutomationDeta
|
||||
flow: { trigger: null, steps: [] },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
save(stored);
|
||||
console.info(`[automations] vytvorena automatizace ${id} "${name}"`);
|
||||
publish('automation.created', `Vytvořena automatizace ${id}: ${name}`, { automationId: id });
|
||||
publish('automation.created', `Vytvořena automatizace ${id}: ${name}`, { automationId: id }, tenantId);
|
||||
return toDetail(stored);
|
||||
}
|
||||
|
||||
/** Automatizace z povolenych firem. Cizi se chova jako neexistujici. */
|
||||
function findWritable(id: string, tenantIds: string[]): StoredAutomation | undefined {
|
||||
const stored = store.get(id);
|
||||
if (!stored) return undefined;
|
||||
if (!tenantIds.includes(stored.tenantId)) {
|
||||
console.warn(`[automations] pokus o zapis do ${id} mimo povolene firmy`);
|
||||
return undefined;
|
||||
}
|
||||
return stored;
|
||||
return writableOrWarn(store.get(id), id, tenantIds, 'automations');
|
||||
}
|
||||
|
||||
export function updateAutomation(
|
||||
@@ -1338,18 +1156,18 @@ export function updateAutomation(
|
||||
}
|
||||
|
||||
const flow = withWebhookToken(patch.flow ?? stored.flow, stored.flow, id);
|
||||
const updated: StoredAutomation = {
|
||||
const updated = withDerived({
|
||||
...stored,
|
||||
name: patch.name ?? stored.name,
|
||||
enabled: patch.enabled ?? stored.enabled,
|
||||
flow,
|
||||
kind: deriveKind(flow),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
// Nedokoncenou automatizaci nepustime do provozu - "aktivni" by nic nedelala
|
||||
// nebo by delala neco jineho, nez uzivatel ceka.
|
||||
const issues = collectFlowIssues(updated.flow);
|
||||
const issues = updated.issues ?? [];
|
||||
if (updated.enabled && issues.length > 0) {
|
||||
console.warn(`[automations] ${id}: zapnuti odmitnuto - ${issues.join(' ')}`);
|
||||
updated.enabled = false;
|
||||
@@ -1357,12 +1175,12 @@ export function updateAutomation(
|
||||
|
||||
save(updated);
|
||||
console.info(
|
||||
`[automations] ulozena automatizace ${id} (kroku: ${countSteps(updated.flow.steps)}, aktivni: ${updated.enabled}, nedodelku: ${issues.length})`,
|
||||
`[automations] ulozena automatizace ${id} (kroku: ${updated.stepCount}, aktivni: ${updated.enabled}, nedodelku: ${issues.length})`,
|
||||
);
|
||||
publish('automation.updated', `Automatizace ${id} uložena: ${updated.name}`, {
|
||||
automationId: id,
|
||||
enabled: updated.enabled,
|
||||
});
|
||||
}, updated.tenantId);
|
||||
return toDetail(updated);
|
||||
}
|
||||
|
||||
@@ -1414,14 +1232,15 @@ export function regenerateWebhookToken(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const updated: StoredAutomation = {
|
||||
// Token je soucast nedodelku ("webhook nema adresu"), proto se dopocitava znovu.
|
||||
const updated = withDerived({
|
||||
...stored,
|
||||
flow: {
|
||||
...stored.flow,
|
||||
trigger: { ...stored.flow.trigger, webhookToken: generateWebhookToken() },
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
save(updated);
|
||||
console.info(`[automations] ${id}: token webhooku pregenerovan, stara adresa neplati`);
|
||||
return toDetail(updated);
|
||||
@@ -1430,7 +1249,7 @@ export function regenerateWebhookToken(
|
||||
/** Najde automatizaci podle tokenu v adrese webhooku. */
|
||||
export function findByWebhookToken(token: string): AutomationDetail | undefined {
|
||||
for (const stored of store.values()) {
|
||||
if (stored.flow.trigger?.webhookToken === token) return toDetail(stored);
|
||||
if (stored.flow.trigger?.webhookToken && timingSafeEqualString(stored.flow.trigger.webhookToken, token)) return toDetail(stored);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1482,6 +1301,7 @@ export function recordRun(id: string, ok = true, tenantIds?: string[]): Automati
|
||||
? `Automatizace ${id} proběhla: ${updated.name}`
|
||||
: `Automatizace ${id} skončila chybou: ${updated.name}`,
|
||||
{ automationId: id, ok },
|
||||
updated.tenantId,
|
||||
);
|
||||
return toDetail(updated);
|
||||
}
|
||||
@@ -1497,7 +1317,7 @@ export function deleteAutomation(id: string, tenantIds: string[]): boolean {
|
||||
console.info(`[automations] smazana automatizace ${id}`);
|
||||
publish('automation.deleted', `Automatizace ${id} smazána: ${name ?? ''}`, {
|
||||
automationId: id,
|
||||
});
|
||||
}, stored?.tenantId ?? null);
|
||||
}
|
||||
return existed;
|
||||
}
|
||||
|
||||
+34
-33
@@ -9,23 +9,16 @@
|
||||
* co se na ne pta.
|
||||
*/
|
||||
|
||||
import { refreshActions, actionPermissions, actionStore, seedActions } from './ticketActions.js';
|
||||
import { refreshCustomWidgets, customWidgetStore, seedCustomWidgets } from './customWidgets.js';
|
||||
import { actionPermissions, actionStore, seedActions } from './ticketActions.js';
|
||||
import { customWidgetStore, seedCustomWidgets } from './customWidgets.js';
|
||||
import { auditStore } from './audit.js';
|
||||
import { notificationStore, refreshNotifications } from './notifications.js';
|
||||
import { inviteStore, refreshInvites } from './invites.js';
|
||||
import { tenantScriptStore, refreshTenantScripts } from './tenantScripts.js';
|
||||
import { notificationStore } from './notifications.js';
|
||||
import { inviteStore } from './invites.js';
|
||||
import { tenantScriptStore } from './tenantScripts.js';
|
||||
import { onTicket } from './ticketHooks.js';
|
||||
import { onTicketEvent } from '../runtime/triggers.js';
|
||||
import { initQueue } from '../runtime/queue.js';
|
||||
import {
|
||||
groupStore,
|
||||
personStore,
|
||||
refreshGroups,
|
||||
refreshPeople,
|
||||
seedGroups,
|
||||
seedPeople,
|
||||
} from './people.js';
|
||||
import { groupStore, personStore, seedGroups, seedPeople } from './people.js';
|
||||
import {
|
||||
refreshRoles,
|
||||
roleStore,
|
||||
@@ -34,11 +27,12 @@ import {
|
||||
systemRoles,
|
||||
invalidatePermissions,
|
||||
} from './permissions.js';
|
||||
import { refreshAllCaches, refreshCache } from './store/cached.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 { featuresStore, seedFeatures } from './tenantFeatures.js';
|
||||
import { seedTenants, tenantStore } from './tenants.js';
|
||||
import { seedTicketTypes, ticketTypeStore } from './ticketTypes.js';
|
||||
import { seedUsers, userStore } from './users.js';
|
||||
import { refreshMcpTools } from './mcpTools.js';
|
||||
import { initTickets } from './ticketStore.js';
|
||||
import { initAutomations } from './automationStore.js';
|
||||
@@ -84,25 +78,32 @@ const runtimeData: Array<{ name: string; init: () => Promise<void> }> = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Obnovi kopie v pameti u entit, ktere se ctou pri kazdem requestu.
|
||||
* Vola se pri startu a po kazdem zapisu, ktery je muze zmenit.
|
||||
* Obnovi kopie v pameti u vsech entit, ktere se ctou pri kazdem requestu.
|
||||
* Vola se pri startu. Po zapisu staci `refreshEntity` jen pro to, co se zmenilo.
|
||||
*/
|
||||
export async function refreshCaches(): Promise<void> {
|
||||
await Promise.all([
|
||||
refreshTenants(),
|
||||
refreshUsers(),
|
||||
refreshRoles(),
|
||||
refreshPeople(),
|
||||
refreshGroups(),
|
||||
refreshFeatures(),
|
||||
refreshTicketTypes(),
|
||||
refreshActions(),
|
||||
refreshCustomWidgets(),
|
||||
refreshNotifications(),
|
||||
refreshInvites(),
|
||||
refreshTenantScripts(),
|
||||
]);
|
||||
// Role maji vlastni kopii mimo `withCache`, proto zvlast.
|
||||
await Promise.all([refreshAllCaches(), refreshRoles()]);
|
||||
afterRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obnovi kopii jednoho druhu entity (`store.kind`, napr. `person`).
|
||||
*
|
||||
* Zapis jedne firmy nema znamenat cteni vsech dvanacti tabulek. Neznamy druh
|
||||
* vraci false a volajici si ma zvolit plnou obnovu.
|
||||
*/
|
||||
export async function refreshEntity(kind: string): Promise<boolean> {
|
||||
if (kind === roleStore.kind) {
|
||||
await refreshRoles();
|
||||
} else if (!(await refreshCache(kind))) {
|
||||
return false;
|
||||
}
|
||||
afterRefresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
function afterRefresh(): void {
|
||||
// Prava k akcim vznikaji z definic akci, takze se registruji az po jejich nacteni.
|
||||
setDynamicPermissions(actionPermissions());
|
||||
invalidatePermissions();
|
||||
|
||||
+10
-45
@@ -1,30 +1,19 @@
|
||||
/**
|
||||
* Typy vstupnich parametru a operatory podminek.
|
||||
*
|
||||
* POZOR: stejne tabulky ma i frontend v apps/web/src/lib/flow.ts.
|
||||
* Pri zmene je nutne upravit obe strany (viz docs/08-automatizace-builder.md).
|
||||
* POZOR: stejnou tabulku `operatorsByType` ma i web ve `web/src/lib/flow.ts`.
|
||||
* Pri zmene je nutne upravit obe strany (viz documentation/05-dashboard-a-builder.md).
|
||||
*/
|
||||
|
||||
/**
|
||||
* `object` a `list` jsou celé struktury, ne jednotlive hodnoty. Vznikly kvuli
|
||||
* transformacim: krok muze predat dal cely objekt objednavky, ne jen jeho pole.
|
||||
* Do sablony se nedosazuji, predavaji se jen jako celek dalsimu kroku.
|
||||
*/
|
||||
export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object' | 'list';
|
||||
import type {
|
||||
ConditionMatch,
|
||||
ConditionOperator,
|
||||
ConditionRule,
|
||||
FieldType,
|
||||
} from '../shared/conditions.js';
|
||||
|
||||
export type ConditionOperator =
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'contains'
|
||||
| 'startsWith'
|
||||
| 'isEmpty'
|
||||
| 'isNotEmpty'
|
||||
| 'isTrue'
|
||||
| 'isFalse';
|
||||
/** Typy jsou sdilene s webem, viz src/shared/conditions.ts. Tady zustavaji tabulky. */
|
||||
export type { ConditionMatch, ConditionOperator, ConditionRule, FieldType };
|
||||
|
||||
export const fieldTypes: FieldType[] = [
|
||||
'string',
|
||||
@@ -70,30 +59,6 @@ export const operatorsByType: Record<FieldType, ConditionOperator[]> = {
|
||||
list: ['contains', 'isEmpty', 'isNotEmpty'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Jedna otazka v podmince.
|
||||
*
|
||||
* Driv byla podminka **prave jedna otazka** a slozitejsi vetveni se muselo
|
||||
* skladat z vnorenych podminek. U tri hodnot, ktere maji dopadnout stejne, to
|
||||
* znamenalo tri urovne stromu, ve kterych se nikdo nevyzna.
|
||||
*/
|
||||
export interface ConditionRule {
|
||||
/** ID parametru spoustece, nebo `krok.vystup`. */
|
||||
fieldId: string;
|
||||
operator: ConditionOperator;
|
||||
/** Chybi u operatoru, ktere hodnotu nepotrebuji (isEmpty, isTrue apod.). */
|
||||
value?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jak se otazky spoji.
|
||||
*
|
||||
* `all` = a zaroven, `any` = nebo. Zamerne **jedna uroven**, ne strom se
|
||||
* zavorkami: dve treti podminek jsou "vsechny tohle" nebo "cokoliv z tohohle"
|
||||
* a zavorky by v rozhrani znamenaly editor vyrazu, ktery uz nikdo neuklika.
|
||||
*/
|
||||
export type ConditionMatch = 'all' | 'any';
|
||||
|
||||
/** Operatory, ktere nepotrebuji hodnotu k porovnani. */
|
||||
export const unaryOperators: ConditionOperator[] = [
|
||||
'isEmpty',
|
||||
|
||||
+15
-26
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Konektory: vyber uloziste a jedine misto, kde se rozhoduje o rezimu.
|
||||
* Konektory: vyber uloziste podle rezimu, o kterem rozhodl `store/index.ts`.
|
||||
*
|
||||
* Tri rezimy, jedno rozhrani:
|
||||
*
|
||||
@@ -9,9 +9,9 @@
|
||||
* | `file` | neni databaze, ale je datova slozka | restart, ne redeploy |
|
||||
* | `memory` | ani jedno, nebo se nepodarilo zapsat | nic |
|
||||
*
|
||||
* Rozhodnuti je **jen tady**. Nikde jinde se nezjistuje, ktery rezim jede -
|
||||
* kdyby se to rozlezlo po kodu, jedno misto by se zapomnelo a chovalo by se
|
||||
* pak jinak nez zbytek.
|
||||
* Rozhodnuti je **jedno pro vsechna uloziste** (`initStores`). Driv se tady
|
||||
* rozhodovalo podruhe a s jinou podminkou, takze tickety mohly jit do databaze
|
||||
* a konektory do souboru. Tady se uz jen sestavuje stav pro portal a log.
|
||||
*
|
||||
* Databaze potrebuje klic ze `SECRETS_KEY`. Klic vedle dat by u ni nemel smysl:
|
||||
* kdo ma zalohu tabulky, ma i klic ze stejneho stroje.
|
||||
@@ -20,7 +20,7 @@
|
||||
import path from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
import { isDatabaseEnabled } from '../db/pool.js';
|
||||
import { canSealSecrets, initSecrets, secretsKeySource } from '../db/secretBox.js';
|
||||
import { canSealSecrets, secretsKeySource } from '../db/secretBox.js';
|
||||
import { createLocalConnectors } from './connectors/local.js';
|
||||
import { postgresConnectors } from './connectors/postgres.js';
|
||||
import type {
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
UpdateConnectorInput,
|
||||
} from './connectors/types.js';
|
||||
import { fileSnapshot, memorySnapshot } from './snapshot.js';
|
||||
import { initStores } from './store/index.js';
|
||||
import type { McpToolset } from '../mcp/client.js';
|
||||
|
||||
export type {
|
||||
@@ -43,21 +44,10 @@ export type {
|
||||
} from './connectors/types.js';
|
||||
export { CHECK_HISTORY, toPublicConnector, validateConnectorValues } from './connectors/types.js';
|
||||
|
||||
export type StorageMode = 'postgres' | 'file' | 'memory';
|
||||
import type { StorageMode, StorageStatus } from '../shared/connectors.js';
|
||||
|
||||
export interface StorageStatus {
|
||||
mode: StorageMode;
|
||||
/** Kratke vysvetleni pro portal. Vzdy vyplnene, i u databaze. */
|
||||
reason: string;
|
||||
/** true = restart procesu data smaze. */
|
||||
ephemeral: boolean;
|
||||
/** true = redeploy containeru data smaze, i kdyz restart ne. */
|
||||
lostOnRedeploy: boolean;
|
||||
/** Odkud je klic k sifrovani tajnych hodnot. */
|
||||
secrets: 'env' | 'file' | 'none';
|
||||
/** Kde data lezi. null u pameti. */
|
||||
location: string | null;
|
||||
}
|
||||
/** Stav uloziste je sdileny s webem, viz src/shared/connectors.ts. */
|
||||
export type { StorageMode, StorageStatus };
|
||||
|
||||
let repository: ConnectorRepository = createLocalConnectors({
|
||||
store: memorySnapshot(),
|
||||
@@ -85,13 +75,12 @@ export interface InitOptions {
|
||||
/** Vybere uloziste. Vola se jednou pri startu, po migracich. */
|
||||
export async function initConnectorStore(options: InitOptions = {}): Promise<StorageStatus> {
|
||||
const databaseReady = options.databaseReady ?? true;
|
||||
const useDatabase = isDatabaseEnabled() && databaseReady;
|
||||
|
||||
// Klic vedle dat je pripustny jen mimo databazi. U ni ma prijit ze secretu.
|
||||
initSecrets({ allowKeyFile: !useDatabase, dir: config.dataDir });
|
||||
// Rezim je spolecny pro vsechna uloziste. `initStores` zaroven nastavi klic
|
||||
// k sifrovani, proto se na nej pta az po nem.
|
||||
const mode = initStores({ databaseReady });
|
||||
const secrets = secretsKeySource();
|
||||
|
||||
if (useDatabase && canSealSecrets()) {
|
||||
if (mode === 'postgres') {
|
||||
repository = postgresConnectors;
|
||||
flushRepository = null;
|
||||
status = {
|
||||
@@ -111,13 +100,13 @@ export async function initConnectorStore(options: InitOptions = {}): Promise<Sto
|
||||
const reasons: string[] = [];
|
||||
if (isDatabaseEnabled() && !databaseReady) {
|
||||
reasons.push('Databáze je nastavená, ale schéma není použitelné, zkontrolujte log migrací.');
|
||||
} else if (useDatabase && !canSealSecrets()) {
|
||||
} else if (isDatabaseEnabled() && !canSealSecrets()) {
|
||||
reasons.push('Databáze je nastavená, ale chybí SECRETS_KEY. Údaje by se ukládaly čitelně.');
|
||||
} else if (!isDatabaseEnabled()) {
|
||||
reasons.push('Databáze není nastavená (DATABASE_URL).');
|
||||
}
|
||||
|
||||
const useFile = config.dataDir !== '';
|
||||
const useFile = mode === 'file';
|
||||
const file = path.join(config.dataDir, 'connectors.json');
|
||||
|
||||
const local = createLocalConnectors({
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { SnapshotStore } from '../snapshot.js';
|
||||
import type { McpToolset } from '../../mcp/client.js';
|
||||
import {
|
||||
CHECK_HISTORY,
|
||||
mergeValues,
|
||||
nowIso,
|
||||
type Connector,
|
||||
type ConnectorRepository,
|
||||
@@ -28,6 +29,44 @@ import {
|
||||
type UpdateConnectorInput,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Novy konektor bez overeni a bez nastroju. Jedno misto pro zalozeni
|
||||
* z formulare i pro ukazkovy zaznam - dva literaly by se rozesly v tom,
|
||||
* jake vychozi hodnoty ma nove pole.
|
||||
*/
|
||||
function newConnector(input: {
|
||||
tenantId: string;
|
||||
serviceId: string;
|
||||
name: string;
|
||||
baseUrl: string | null;
|
||||
values: Record<string, string>;
|
||||
isDefault: boolean;
|
||||
}): Connector {
|
||||
const timestamp = nowIso();
|
||||
return {
|
||||
id: `con_${randomUUID().slice(0, 8)}`,
|
||||
tenantId: input.tenantId,
|
||||
serviceId: input.serviceId,
|
||||
name: input.name,
|
||||
baseUrl: input.baseUrl,
|
||||
values: { ...input.values },
|
||||
enabled: true,
|
||||
status: 'untested',
|
||||
lastCheckAt: null,
|
||||
lastError: null,
|
||||
checks: [],
|
||||
mcp: null,
|
||||
isDefault: input.isDefault,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stejne poradi jako `ORDER BY name` v Postgresu: vychozi prvni, pak podle nazvu. */
|
||||
function byDefaultThenName(a: Connector, b: Connector): number {
|
||||
return Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name, 'cs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tvar zaznamu v souboru. Proti `Connector` se lisi jen tim, ze `secrets`
|
||||
* je zasifrovana obalka misto ctecich hodnot.
|
||||
@@ -79,7 +118,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
|
||||
}
|
||||
|
||||
return {
|
||||
kind: store.kind === 'file' ? 'memory' : 'memory',
|
||||
kind: store.kind,
|
||||
|
||||
async init() {
|
||||
const stored = await store.load();
|
||||
@@ -117,11 +156,12 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
|
||||
},
|
||||
|
||||
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;
|
||||
// Vychozi vyhrava, jinak prvni podle nazvu. Vypnute konektory se neberou.
|
||||
// Stejne pravidlo jako v Postgresu, aby se rezimy nelisily.
|
||||
const forService = rows
|
||||
.filter((row) => row.tenantId === tenantId && row.serviceId === serviceId && row.enabled)
|
||||
.sort(byDefaultThenName);
|
||||
return forService[0] ? copy(forService[0]) : undefined;
|
||||
},
|
||||
|
||||
async countsByService(tenantIds) {
|
||||
@@ -133,29 +173,19 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
|
||||
},
|
||||
|
||||
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)}`,
|
||||
const connector = newConnector({
|
||||
tenantId: input.tenantId,
|
||||
serviceId: input.serviceId,
|
||||
name: input.name,
|
||||
baseUrl: input.baseUrl ?? null,
|
||||
values: { ...(input.values ?? {}) },
|
||||
enabled: true,
|
||||
status: 'untested',
|
||||
lastCheckAt: null,
|
||||
lastError: null,
|
||||
checks: [],
|
||||
mcp: null,
|
||||
values: input.values ?? {},
|
||||
// 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);
|
||||
@@ -172,10 +202,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
|
||||
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;
|
||||
}
|
||||
row.values = mergeValues(row.values, patch.values);
|
||||
// Zmena udaju znamena, ze predchozi overeni uz nic nerika. Nastroje
|
||||
// se ale nemazou: jina adresa muze vratit jiny seznam, jenze dokud ho
|
||||
// nekdo nenacte, jsou ty stare porad to jedine, co v ulozenych
|
||||
@@ -201,12 +228,13 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
|
||||
|
||||
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.
|
||||
// Kdyz zmizel vychozi, prevezme to prvni zbyly podle nazvu - jinak by kroky
|
||||
// bez vyberu prestaly fungovat, aniz by se cokoliv jineho zmenilo.
|
||||
// Podle nazvu, ne podle poradi vlozeni: tak to dela i Postgres.
|
||||
if (row.isDefault) {
|
||||
const next = rows.find(
|
||||
(item) => item.tenantId === row.tenantId && item.serviceId === row.serviceId,
|
||||
);
|
||||
const next = rows
|
||||
.filter((item) => item.tenantId === row.tenantId && item.serviceId === row.serviceId)
|
||||
.sort(byDefaultThenName)[0];
|
||||
if (next) next.isDefault = true;
|
||||
}
|
||||
|
||||
@@ -231,10 +259,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
|
||||
const row = rows.find((item) => item.id === id);
|
||||
if (!row || !tenantIds.includes(row.tenantId)) return undefined;
|
||||
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === '') delete row.values[key];
|
||||
else row.values[key] = value;
|
||||
}
|
||||
row.values = mergeValues(row.values, values);
|
||||
row.updatedAt = nowIso();
|
||||
persist();
|
||||
return copy(row);
|
||||
@@ -262,24 +287,16 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
|
||||
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,
|
||||
checks: [],
|
||||
mcp: null,
|
||||
isDefault: true,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
rows.push(
|
||||
newConnector({
|
||||
tenantId: 'tnt_automia',
|
||||
serviceId: 'idoklad',
|
||||
name: 'iDoklad Automia',
|
||||
baseUrl: null,
|
||||
values: {},
|
||||
isDefault: true,
|
||||
}),
|
||||
);
|
||||
persist();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { McpToolset } from '../../mcp/client.js';
|
||||
import { query, queryOne, transaction } from '../../db/pool.js';
|
||||
import { openAll, sealAll } from '../../db/secretBox.js';
|
||||
import { CHECK_HISTORY } from './types.js';
|
||||
import { CHECK_HISTORY, mergeValues } from './types.js';
|
||||
import type {
|
||||
Connector,
|
||||
ConnectorCheck,
|
||||
@@ -169,12 +169,7 @@ export const postgresConnectors: ConnectorRepository = {
|
||||
let secrets = row.secrets;
|
||||
let statusReset = false;
|
||||
if (patch.values) {
|
||||
const merged = openAll(row.secrets);
|
||||
for (const [key, value] of Object.entries(patch.values)) {
|
||||
if (value === '') delete merged[key];
|
||||
else merged[key] = value;
|
||||
}
|
||||
secrets = sealAll(merged);
|
||||
secrets = sealAll(mergeValues(openAll(row.secrets), patch.values));
|
||||
statusReset = true;
|
||||
}
|
||||
|
||||
@@ -279,11 +274,7 @@ export const postgresConnectors: ConnectorRepository = {
|
||||
const row = current.rows[0];
|
||||
if (!row) return undefined;
|
||||
|
||||
const merged = openAll(row.secrets);
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === '') delete merged[key];
|
||||
else merged[key] = value;
|
||||
}
|
||||
const merged = mergeValues(openAll(row.secrets), values);
|
||||
|
||||
const updated = await client.query<ConnectorRow>(
|
||||
`UPDATE connectors SET secrets = $2::jsonb, updated_at = now()
|
||||
|
||||
@@ -5,44 +5,25 @@
|
||||
* X-ClientId a X-ClientSecret". Konektor drzi hodnoty. Kazda firma ma svoje.
|
||||
*
|
||||
* Tenhle soubor je jen model a to, co plati v obou ulozistich. Samotna
|
||||
* uloziste jsou vedle: `memory.ts` a `postgres.ts`.
|
||||
* uloziste jsou vedle: `local.ts` (soubor nebo pamet) a `postgres.ts`.
|
||||
*/
|
||||
|
||||
import { findService, type Service, type ServiceCredentialField } from '../services.js';
|
||||
import { fieldsFromSchema, outputsFromTool } from '../../mcp/schema.js';
|
||||
import type { McpToolset } from '../../mcp/client.js';
|
||||
|
||||
import type {
|
||||
Connector as PublicConnector,
|
||||
ConnectorCheck,
|
||||
ConnectorStatus,
|
||||
Toolset as PublicToolset,
|
||||
} from '../../shared/connectors.js';
|
||||
|
||||
/**
|
||||
* Jeden zaznam o overeni konektoru.
|
||||
*
|
||||
* Drzi se **cele telo odpovedi**, ne jen kod. Prave tam sluzba pise, co ji
|
||||
* vadilo, a bez toho se neda rozlisit spatny udaj od zakazane IP adresy.
|
||||
* Text je zredigovany uz pri vzniku, takze pristupovy udaj v nem nikdy neni.
|
||||
* Verejny tvar konektoru je sdileny s webem, viz src/shared/connectors.ts.
|
||||
* Tady zustava ulozeny tvar s hodnotami a prevod na verejny.
|
||||
*/
|
||||
export interface ConnectorCheck {
|
||||
/** ISO cas overeni. */
|
||||
at: string;
|
||||
ok: boolean;
|
||||
/** Co se overovalo: pristupove udaje, nebo jen dostupnost sluzby. */
|
||||
checked: string;
|
||||
/** HTTP kod od sluzby. null, kdyz se k volani vubec nedoslo. */
|
||||
status: number | null;
|
||||
message: string;
|
||||
/** Cele telo odpovedi sluzby. */
|
||||
detail: string | null;
|
||||
/** Cela adresa vcetne serveru, bez query. null, kdyz se k volani nedoslo. */
|
||||
request: { method: string; path: string; url: string } | null;
|
||||
/**
|
||||
* Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal - aplikace, nebo
|
||||
* proxy pred ni. U kodu bez tela je to jedina stopa, ktera zbyde.
|
||||
*/
|
||||
responseHeaders: Record<string, string> | null;
|
||||
/**
|
||||
* Odchozi IP adresa portalu ve chvili volani. Vyplnuje se jen u odmitnuteho
|
||||
* pristupu - jinde nema co rict a nestoji za volani ven.
|
||||
*/
|
||||
egressIp: string | null;
|
||||
}
|
||||
export type { ConnectorCheck, ConnectorStatus, PublicConnector, PublicToolset };
|
||||
|
||||
/** Kolik zaznamu o overeni se u konektoru drzi. */
|
||||
export const CHECK_HISTORY = 5;
|
||||
@@ -59,7 +40,7 @@ export interface Connector {
|
||||
values: Record<string, string>;
|
||||
enabled: boolean;
|
||||
/** untested = jeste se neoverovalo, ok = posledni overeni proslo. */
|
||||
status: 'untested' | 'ok' | 'error';
|
||||
status: ConnectorStatus;
|
||||
lastCheckAt: string | null;
|
||||
lastError: string | null;
|
||||
/** Poslednich `CHECK_HISTORY` overeni, nejnovejsi prvni. */
|
||||
@@ -80,61 +61,6 @@ export interface Connector {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verze pro klienta. **Tajne hodnoty tady nejsou** a nikdy nesmi byt -
|
||||
* secrets se nevraci z beznych endpointu (AGENTS.md).
|
||||
*/
|
||||
export interface PublicConnector {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
serviceId: string;
|
||||
name: string;
|
||||
baseUrl: string | null;
|
||||
enabled: boolean;
|
||||
status: Connector['status'];
|
||||
lastCheckAt: string | null;
|
||||
lastError: string | null;
|
||||
/**
|
||||
* Kolik zaznamu o overeni je k dispozici. Samotna historie se sem nedava,
|
||||
* v seznamu konektoru by to byly desitky kilobajtu tel odpovedi navic.
|
||||
* Cte se zvlast pres `/connectors/:id/checks`.
|
||||
*/
|
||||
checkCount: number;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** ID poli, ktera jsou vyplnena. Hodnoty se nevraci. */
|
||||
filled: string[];
|
||||
/** ID povinnych poli, ktera jeste chybi. */
|
||||
missing: string[];
|
||||
/** Necitliva nastaveni. Tajna pole tu nejsou vubec. */
|
||||
config: Record<string, string>;
|
||||
/** true = vsechna povinna pole jsou vyplnena, jde volat. */
|
||||
ready: boolean;
|
||||
/**
|
||||
* Nastroje MCP serveru pro portal. Schemata tu **nejsou** - klient je
|
||||
* nepotrebuje, uz prevedena na pole kroku chodi v katalogu sluzeb. Tady jde
|
||||
* jen o to ukazat u konektoru, co se nacetlo.
|
||||
*/
|
||||
mcp: PublicToolset | null;
|
||||
}
|
||||
|
||||
/** Nastroje jednoho serveru tak, jak je vidi portal. */
|
||||
export interface PublicToolset {
|
||||
at: string;
|
||||
server: string;
|
||||
protocolVersion: string;
|
||||
tools: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
/** Nazvy parametru, ktere nastroj prijima. Povinne jsou prvni. */
|
||||
inputs: string[];
|
||||
/** Nazvy hodnot, ktere vraci. Vzdy aspon text a priznak chyby. */
|
||||
outputs: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CreateConnectorInput {
|
||||
tenantId: string;
|
||||
serviceId: string;
|
||||
@@ -163,7 +89,7 @@ export interface ConnectorIssue {
|
||||
|
||||
/** Rozhrani uloziste. Dve implementace, vyber je na jednom miste. */
|
||||
export interface ConnectorRepository {
|
||||
readonly kind: 'memory' | 'postgres';
|
||||
readonly kind: 'memory' | 'file' | 'postgres';
|
||||
list(tenantIds: string[], options?: { serviceId?: string }): Promise<Connector[]>;
|
||||
get(id: string, tenantIds: string[]): Promise<Connector | undefined>;
|
||||
defaultFor(tenantId: string, serviceId: string): Promise<Connector | undefined>;
|
||||
@@ -205,8 +131,25 @@ export interface ConnectorRepository {
|
||||
|
||||
// ------------------------------------------------------------------- pomocne
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
export { nowIso } from '../store/types.js';
|
||||
|
||||
/**
|
||||
* Slouceni hodnot konektoru s patchem.
|
||||
*
|
||||
* Prazdny retezec hodnotu **smaze**, chybejici klic ji nechava. Diky tomu jde
|
||||
* ulozit formular, ktery tajne hodnoty neposila. Jedno misto pro obe uloziste
|
||||
* i pro spravovane hodnoty - ctyri kopie tehoz pravidla by se casem rozesly.
|
||||
*/
|
||||
export function mergeValues(
|
||||
current: Record<string, string>,
|
||||
patch: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const merged = { ...current };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === '') delete merged[key];
|
||||
else merged[key] = value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Povinna pole, ktera nejsou vyplnena. */
|
||||
|
||||
+16
-78
@@ -17,79 +17,17 @@
|
||||
|
||||
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
|
||||
import { withCache } from './store/cached.js';
|
||||
import type { TicketChannel, TicketStatus } from './ticketStore.js';
|
||||
import type {
|
||||
WidgetGroupBy,
|
||||
WidgetPeriod,
|
||||
WidgetRender,
|
||||
WidgetSize,
|
||||
WidgetSource,
|
||||
WidgetTicketFilter,
|
||||
} from '../shared/widgets.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'
|
||||
/** Stav ticketu. Volny retezec, u ticketu z cizich aplikaci jejich vlastni. */
|
||||
| '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;
|
||||
/**
|
||||
* Jen vyrizene, nebo jen nevyrizene. Nevyplnene = oboji.
|
||||
*
|
||||
* Ptat se na to pres stav nejde: stav je volny retezec a vyjmenovat
|
||||
* vsechny podoby slova "hotovo" se neda. Priznak `closed` je od toho.
|
||||
*/
|
||||
closed?: boolean;
|
||||
}
|
||||
|
||||
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[] }
|
||||
/**
|
||||
* Vykon resitelu: kolik kdo odbavil, jak dlouho mu to trva a kolik se mu
|
||||
* toho vratilo. `period` plati jen na vyresene - fronta neni vec obdobi.
|
||||
*/
|
||||
| { kind: 'agentStats'; period?: WidgetPeriod }
|
||||
/**
|
||||
* Data z napojene sluzby. Co umi zjistit konektor, jde vytahnout do dlazdice.
|
||||
*
|
||||
* Vola se **tentyz skript**, ktery pouziva krok automatizace a akce na
|
||||
* ticketu. Widget si nic vlastniho nevymysli, jen vybere z vysledku:
|
||||
* - `path` je cesta ve vystupu skriptu, napr. `total` nebo `items`,
|
||||
* - cislo se ukaze jako cislo, seznam jako radky.
|
||||
*
|
||||
* Vysledek se **cachuje** (`ttlSec`), aby otevreni prehledu neznamenalo
|
||||
* deset volani cizi sluzby. Cizi API ma limity a plati se za ne.
|
||||
*/
|
||||
| {
|
||||
kind: 'connector';
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
/** null = vychozi konektor firmy pro tuhle sluzbu. */
|
||||
connectorId: string | null;
|
||||
inputs: Record<string, string>;
|
||||
/** Cesta ve vystupu skriptu. Prazdne = cely vystup. */
|
||||
path?: string;
|
||||
/** U seznamu: co v radku ukazat jako popisek a co jako hodnotu. */
|
||||
labelPath?: string;
|
||||
valuePath?: string;
|
||||
/** Jak dlouho se vysledek drzi. Vychozi 300 s, nejmene 30. */
|
||||
ttlSec?: number;
|
||||
};
|
||||
|
||||
export type WidgetSize = 'third' | 'half' | 'full';
|
||||
/** Tvar zdroje dat je sdileny s webem, viz src/shared/widgets.ts. Tady je uloziste. */
|
||||
export type { WidgetGroupBy, WidgetPeriod, WidgetRender, WidgetSize, WidgetSource, WidgetTicketFilter };
|
||||
|
||||
export interface CustomWidget extends TenantEntity {
|
||||
tenantId: string;
|
||||
@@ -159,7 +97,9 @@ export function seedCustomWidgets(): CustomWidget[] {
|
||||
render: 'stat',
|
||||
source: {
|
||||
kind: 'ticketCount',
|
||||
filter: { typeIds: ['tt_order'], status: ['new', 'open', 'waiting'] },
|
||||
// Pres priznak vyrizeni, ne pres text stavu: stav je volny retezec
|
||||
// a vyjmenovat vsechny podoby "nevyrizeno" nejde.
|
||||
filter: { typeIds: ['tt_order'], closed: false },
|
||||
},
|
||||
size: 'third',
|
||||
},
|
||||
@@ -171,7 +111,7 @@ export function seedCustomWidgets(): CustomWidget[] {
|
||||
render: 'table',
|
||||
source: {
|
||||
kind: 'ticketCount',
|
||||
filter: { status: ['new', 'open', 'waiting'] },
|
||||
filter: { closed: false },
|
||||
groupBy: 'assignee',
|
||||
},
|
||||
size: 'half',
|
||||
@@ -186,10 +126,8 @@ export async function refreshCustomWidgets(): Promise<void> {
|
||||
/** 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'));
|
||||
.listByTenant(tenantIds, 'name')
|
||||
.filter((widget) => widget.ownerId === null || widget.ownerId === userId);
|
||||
}
|
||||
|
||||
export function findCustomWidget(id: string): CustomWidget | undefined {
|
||||
|
||||
@@ -8,16 +8,13 @@
|
||||
*/
|
||||
|
||||
import type { CatalogWidget } from './widgets.js';
|
||||
import type { WidgetSize } from './widgets.js';
|
||||
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
|
||||
import { withMirror } from './store/mirror.js';
|
||||
|
||||
export interface LayoutItem {
|
||||
/** Instance widgetu. Tentyz widget muze byt na dashboardu vickrat. */
|
||||
id: string;
|
||||
widgetId: string;
|
||||
size: WidgetSize;
|
||||
}
|
||||
import type { LayoutItem } from '../shared/widgets.js';
|
||||
|
||||
/** Polozka rozlozeni je sdilena s webem, viz src/shared/widgets.ts. */
|
||||
export type { LayoutItem };
|
||||
|
||||
/** Kolik widgetu jeste dava smysl. Nad tim je to seznam, ne prehled. */
|
||||
export const MAX_WIDGETS = 12;
|
||||
@@ -41,7 +38,8 @@ const defaultLayout: LayoutItem[] = [
|
||||
* V katalogu zustava, kdo ho chce, prida si ho.
|
||||
*/
|
||||
|
||||
const layouts = new Map<string, LayoutItem[]>();
|
||||
/** Kopie v pameti. Drzi i cas vzniku, aby ho dalsi ulozeni neprepsalo. */
|
||||
const layouts = new Map<string, { items: LayoutItem[]; createdAt: string }>();
|
||||
|
||||
/**
|
||||
* Zaznam v ulozisti. ID je dvojice uzivatel a firma, takze zapis nepotrebuje
|
||||
@@ -62,11 +60,16 @@ function key(userId: string, tenantId: string): string {
|
||||
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);
|
||||
for (const row of rows) {
|
||||
layouts.set(key(row.userId, row.tenantId ?? ''), {
|
||||
items: row.items,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getLayout(userId: string, tenantId: string): LayoutItem[] {
|
||||
return layouts.get(key(userId, tenantId)) ?? defaultLayout.map((item) => ({ ...item }));
|
||||
return layouts.get(key(userId, tenantId))?.items ?? defaultLayout.map((item) => ({ ...item }));
|
||||
}
|
||||
|
||||
/** true = uzivatel si rozlozeni uz upravil, nekouka na vychozi. */
|
||||
@@ -76,10 +79,13 @@ export function hasCustomLayout(userId: string, tenantId: string): boolean {
|
||||
|
||||
export function saveLayout(userId: string, tenantId: string, items: LayoutItem[]): LayoutItem[] {
|
||||
const id = key(userId, tenantId);
|
||||
layouts.set(id, items);
|
||||
|
||||
const timestamp = nowIso();
|
||||
mirror.save({ id, tenantId, userId, items, createdAt: timestamp, updatedAt: timestamp });
|
||||
// Cas vzniku se pri dalsim ulozeni nemeni. Postgres ho stejne drzi puvodni,
|
||||
// soubor prepisoval cely zaznam - ted se chovaji stejne.
|
||||
const createdAt = layouts.get(id)?.createdAt ?? timestamp;
|
||||
layouts.set(id, { items, createdAt });
|
||||
|
||||
mirror.save({ id, tenantId, userId, items, createdAt, updatedAt: timestamp });
|
||||
console.info(`[layout] ${userId}@${tenantId}: ulozeno ${items.length} widgetu`);
|
||||
return items;
|
||||
}
|
||||
|
||||
+3
-43
@@ -14,50 +14,10 @@
|
||||
|
||||
import { config } from '../config.js';
|
||||
|
||||
export interface EgressIp {
|
||||
/** null = nepodarilo se zjistit, nebo je funkce vypnuta. */
|
||||
ip: string | null;
|
||||
/** Odkud to je. Pro uzivatele, at vi, komu ta odpoved patri. */
|
||||
source: string;
|
||||
checkedAt: string;
|
||||
/** Vyplnene jen kdyz `ip` je null. */
|
||||
error?: string;
|
||||
/**
|
||||
* Jak nas vidi **nase vlastni reverse proxy**, kdyz volani vyjde z tohoto
|
||||
* containeru a otoci se pres verejnou domenu zpatky. Tohle je ta adresa,
|
||||
* kterou porovnava seznam povolenych IP u sluzeb za toutez proxy - a byva
|
||||
* jina nez ta verejna, protoze volani na vlastni stroj se hairpinuje
|
||||
* a proxy vidi adresu docker bridge.
|
||||
*
|
||||
* null = nezmereno, chybi `PUBLIC_ORIGIN`, nebo se to nepodarilo.
|
||||
*/
|
||||
viaProxy: ProxyView | null;
|
||||
}
|
||||
import type { EgressIp, ProxyView } from '../shared/connectors.js';
|
||||
|
||||
export interface ProxyView {
|
||||
/**
|
||||
* **Tohle je to cislo.** Adresa po `trust proxy`, tedy z X-Forwarded-For -
|
||||
* proxy tam zapsala, s jakou adresou k ni nase volani doslo.
|
||||
*/
|
||||
ip: string | null;
|
||||
/** Surova hlavicka, at je videt i to, co proxy nepripsala. */
|
||||
forwardedFor: string | null;
|
||||
/**
|
||||
* Adresa druheho konce spojeni. Pozor, to je **sama proxy**, ne volajici -
|
||||
* k nam uz to jde od ni. Je to tu na to, aby bylo poznat, ze se volani
|
||||
* opravdu tocilo pres ni a ne primo.
|
||||
*/
|
||||
remoteAddress: string | null;
|
||||
/**
|
||||
* Rozsah, ktery tuhle adresu pokryje cely. Docker prideluje z bloku,
|
||||
* takze povolit jednu adresu vydrzi do prvniho prekresleni site - patri
|
||||
* tam cely rozsah. null = adresa je verejna, zadny blok se nenabizi.
|
||||
*/
|
||||
suggestedRange: string | null;
|
||||
/** Kam se volalo. Pro uzivatele, at vi, co se vlastne merilo. */
|
||||
url: string;
|
||||
error?: string;
|
||||
}
|
||||
/** Tvar odpovedi je sdileny s webem, viz src/shared/connectors.ts. */
|
||||
export type { EgressIp, ProxyView };
|
||||
|
||||
/**
|
||||
* Rozsah, ktery danou adresu pokryje cely.
|
||||
|
||||
+31
-63
@@ -9,48 +9,13 @@
|
||||
|
||||
import { config } from '../config.js';
|
||||
import { publish } from '../events/bus.js';
|
||||
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
|
||||
import { defineStore, highestNumber, minutesAgo, 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';
|
||||
import type { Incident, IncidentSeverity, IncidentStatus } from '../shared/incidents.js';
|
||||
|
||||
export interface Incident {
|
||||
id: string;
|
||||
/**
|
||||
* Firma, ktere se to tyka. `null` = nas vlastni, platformni incident
|
||||
* (vypadek sluzby, ktera je spolecna vsem).
|
||||
*/
|
||||
tenantId: string | null;
|
||||
/** Srozumitelne pro klienta. Zadne stack trace, zadne ID kroku. */
|
||||
title: string;
|
||||
service: string;
|
||||
severity: IncidentSeverity;
|
||||
status: IncidentStatus;
|
||||
/**
|
||||
* Co s tim ma klient delat, nebo co to pro nej znamena.
|
||||
*
|
||||
* "Objednávka 5001 se nepřenesla do účetnictví, zkusíme to znovu" je
|
||||
* pouzitelne. "TypeError: undefined" neni.
|
||||
*/
|
||||
impact: string;
|
||||
/**
|
||||
* Vsechno, co potrebuje admin: cele hlaseni, ktery beh, ktery krok, co
|
||||
* prislo na vstupu. Zamerne oddelene od `title` - klient tohle videt nema
|
||||
* a admin bez toho nema z ceho vychazet.
|
||||
*/
|
||||
detail: string | null;
|
||||
/** Odkud incident vznikl, napr. `run_ab12` nebo `rucne`. */
|
||||
source: string | null;
|
||||
/** Ticket, u ktereho to prasklo. */
|
||||
ticketId: string | null;
|
||||
startedAt: string;
|
||||
resolvedAt: string | null;
|
||||
}
|
||||
|
||||
function minutesAgo(minutes: number): string {
|
||||
return new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
}
|
||||
/** Tvar incidentu je sdileny s webem, viz src/shared/incidents.ts. */
|
||||
export type { Incident, IncidentSeverity, IncidentStatus };
|
||||
|
||||
/**
|
||||
* Ukazkove incidenty. Se `SEED_DEMO=1` se pouziji, jinak se zacina naprazdno -
|
||||
@@ -134,33 +99,36 @@ export async function initIncidents(): Promise<void> {
|
||||
incidents.push(incident);
|
||||
}
|
||||
|
||||
for (const incident of incidents) {
|
||||
const number = Number(/^INC-(\d+)$/.exec(incident.id)?.[1] ?? 0);
|
||||
if (number > counter) counter = number;
|
||||
}
|
||||
counter = Math.max(counter, highestNumber(incidents.map((incident) => incident.id), 'INC'));
|
||||
}
|
||||
|
||||
/** Vidi firmy z `tenantIds` tenhle incident? Platformni vidi kazdy. */
|
||||
function visibleTo(incident: Incident, tenantIds: string[]): boolean {
|
||||
return incident.tenantId === null || tenantIds.includes(incident.tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incidenty firmy plus platformni.
|
||||
*
|
||||
* Bez omezeni na firmy vraci prazdno, ne vse. Zapomenuty filtr nesmi znamenat
|
||||
* "ukaz cizi incidenty" - je to stejne pravidlo jako u ticketu.
|
||||
* Filtr na firmy je **povinny argument**, ne volitelny. Zapomenuty filtr tak
|
||||
* neznamena "ukaz cizi incidenty", ale nezkompiluje se - stejne pravidlo
|
||||
* jako u ticketu. Prazdny seznam vraci jen platformni.
|
||||
*/
|
||||
export function listIncidents(tenantIds?: string[]): Incident[] {
|
||||
const visible = tenantIds
|
||||
? incidents.filter((item) => item.tenantId === null || tenantIds.includes(item.tenantId))
|
||||
: incidents;
|
||||
|
||||
return [...visible].sort((a, b) => {
|
||||
if (a.status === 'resolved' && b.status !== 'resolved') return 1;
|
||||
if (b.status === 'resolved' && a.status !== 'resolved') return -1;
|
||||
return b.startedAt.localeCompare(a.startedAt);
|
||||
});
|
||||
export function listIncidents(tenantIds: string[]): Incident[] {
|
||||
return incidents
|
||||
.filter((item) => visibleTo(item, tenantIds))
|
||||
.sort((a, b) => {
|
||||
if (a.status === 'resolved' && b.status !== 'resolved') return 1;
|
||||
if (b.status === 'resolved' && a.status !== 'resolved') return -1;
|
||||
return b.startedAt.localeCompare(a.startedAt);
|
||||
});
|
||||
}
|
||||
|
||||
/** Uz je stejny problem otevreny? Aby deset stejnych chyb nedelalo deset incidentu. */
|
||||
export function findOpenIncident(source: string): Incident | undefined {
|
||||
return incidents.find((item) => item.source === source && item.status !== 'resolved');
|
||||
export function findOpenIncident(source: string, tenantIds: string[]): Incident | undefined {
|
||||
return incidents.find(
|
||||
(item) => item.source === source && item.status !== 'resolved' && visibleTo(item, tenantIds),
|
||||
);
|
||||
}
|
||||
|
||||
export function createIncident(input: {
|
||||
@@ -194,7 +162,7 @@ export function createIncident(input: {
|
||||
publish('incident.started', `Nový incident ${incident.id}: ${incident.title}`, {
|
||||
incidentId: incident.id,
|
||||
severity: incident.severity,
|
||||
});
|
||||
}, incident.tenantId);
|
||||
return incident;
|
||||
}
|
||||
|
||||
@@ -212,17 +180,17 @@ export function updateIncidentStatus(id: string, status: IncidentStatus): Incide
|
||||
if (status === 'resolved') {
|
||||
publish('incident.resolved', `Incident ${incident.id} vyřešen: ${incident.title}`, {
|
||||
incidentId: incident.id,
|
||||
});
|
||||
}, incident.tenantId);
|
||||
} else {
|
||||
publish('incident.updated', `Incident ${incident.id}: ${status}`, {
|
||||
incidentId: incident.id,
|
||||
status,
|
||||
});
|
||||
}, incident.tenantId);
|
||||
}
|
||||
return incident;
|
||||
}
|
||||
|
||||
/** Prvni bezici incident. */
|
||||
export function firstActiveIncident(): Incident | undefined {
|
||||
return incidents.find((i) => i.status !== 'resolved');
|
||||
/** Prvni bezici incident viditelny pro dane firmy. */
|
||||
export function firstActiveIncident(tenantIds: string[]): Incident | undefined {
|
||||
return incidents.find((i) => i.status !== 'resolved' && visibleTo(i, tenantIds));
|
||||
}
|
||||
|
||||
+4
-5
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { timingSafeEqualString } from '../lib/secure.js';
|
||||
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
|
||||
import { withCache } from './store/cached.js';
|
||||
|
||||
@@ -87,7 +88,7 @@ export function newInvite(input: CreateInviteInput): Invite {
|
||||
/** Pozvanka podle kodu. Nezkoumá platnost, na to je `inviteProblem`. */
|
||||
export function findByCode(code: string): Invite | undefined {
|
||||
if (code.length < 16) return undefined;
|
||||
return cache.find((invite) => invite.code === code);
|
||||
return cache.find((invite) => timingSafeEqualString(invite.code, code));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,10 +106,8 @@ export function inviteProblem(invite: Invite): string | null {
|
||||
}
|
||||
|
||||
export function listInvites(tenantIds: string[]): Invite[] {
|
||||
return cache
|
||||
.all()
|
||||
.filter((invite) => tenantIds.includes(invite.tenantId))
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
// Nejnovejsi nahore.
|
||||
return cache.listByTenant(tenantIds, (a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export async function markUsed(invite: Invite, userId: string): Promise<void> {
|
||||
|
||||
+3
-2
@@ -9,6 +9,7 @@
|
||||
import { listAutomations } from './automationStore.js';
|
||||
import { listIncidents } from './incidentStore.js';
|
||||
import { listTickets } from './ticketStore.js';
|
||||
import type { DashboardSummary } from '../shared/dashboard.js';
|
||||
|
||||
/** Casova rada pro graf na dashboardu - poslednich 14 dni. */
|
||||
export function getRunsSeries(days = 14) {
|
||||
@@ -24,10 +25,10 @@ export function getRunsSeries(days = 14) {
|
||||
return series;
|
||||
}
|
||||
|
||||
export function getSummary(tenantIds: string[]) {
|
||||
export function getSummary(tenantIds: string[]): DashboardSummary {
|
||||
// Ukazkova cisla nad vlastnimi daty, strop viditelnosti se sem nevztahuje.
|
||||
const tickets = listTickets({ tenantIds, visibility: { kind: 'all' } });
|
||||
const incidents = listIncidents();
|
||||
const incidents = listIncidents(tenantIds);
|
||||
const automations = listAutomations(tenantIds);
|
||||
const series = getRunsSeries();
|
||||
|
||||
|
||||
+4
-9
@@ -18,15 +18,10 @@
|
||||
|
||||
import type { FieldType } from './conditions.js';
|
||||
|
||||
export interface ModelNode {
|
||||
/** Cesta k hodnote, napr. `data.order.items[].name`. */
|
||||
path: string;
|
||||
type: FieldType;
|
||||
/** Ukazkova hodnota, zkracena. Podle ni clovek pozna, co to je. */
|
||||
sample: string | null;
|
||||
/** Kolik polozek melo pole v ukazce. Jen u typu `list`. */
|
||||
count?: number;
|
||||
}
|
||||
import type { ModelNode } from '../shared/automations.js';
|
||||
|
||||
/** Tvar uzlu modelu je sdileny s webem, viz src/shared/automations.ts. */
|
||||
export type { ModelNode };
|
||||
|
||||
/** Kolik urovni zanoreni se prochazi. Hlubsi model uz nikdo necte. */
|
||||
const MAX_DEPTH = 8;
|
||||
|
||||
+21
-17
@@ -86,30 +86,32 @@ export function notify(input: NotifyInput): void {
|
||||
void notificationStore
|
||||
.create(notification)
|
||||
.then(async () => {
|
||||
// Uklid nad kopii pred obnovou: nove upozorneni v ni jeste neni, takze
|
||||
// se pocita s +1. Obnova je pak jedna, ne dve.
|
||||
await trim(user.id, 1);
|
||||
await cache.refresh();
|
||||
// Portal ma cislo u zalozky prekreslit hned, ne az pri obnoveni stranky.
|
||||
publish('notification.created', input.title, {
|
||||
userId: user.id,
|
||||
ticketId: notification.ticketId,
|
||||
});
|
||||
await trim(user.id);
|
||||
}, notification.tenantId);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('[upozorneni] zapis selhal:', err);
|
||||
});
|
||||
}
|
||||
|
||||
async function trim(userId: string): Promise<void> {
|
||||
/** Odmaze nejstarsi nad limit. `extra` = kolik jich pribylo mimo kopii. */
|
||||
async function trim(userId: string, extra: number): Promise<void> {
|
||||
const mine = cache
|
||||
.all()
|
||||
.filter((item) => item.userId === userId)
|
||||
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
|
||||
const excess = mine.slice(0, Math.max(0, mine.length - MAX_PER_USER));
|
||||
for (const item of excess) {
|
||||
await notificationStore.remove(item.id, { tenantIds: [item.tenantId], includeGlobal: true });
|
||||
}
|
||||
if (excess.length > 0) await cache.refresh();
|
||||
const excess = mine.slice(0, Math.max(0, mine.length + extra - MAX_PER_USER));
|
||||
if (excess.length === 0) return;
|
||||
// Maze se, co se nacetlo z vlastni kopie, filtr na firmu tu nema co hlidat.
|
||||
await notificationStore.removeMany(excess.map((item) => item.id));
|
||||
}
|
||||
|
||||
/** Upozorneni uzivatele, nejnovejsi nahore. */
|
||||
@@ -133,13 +135,15 @@ export async function markRead(userId: string, ids?: string[]): Promise<number>
|
||||
.filter((item) => item.userId === userId && item.readAt === null)
|
||||
.filter((item) => !ids || ids.includes(item.id));
|
||||
|
||||
for (const item of mine) {
|
||||
await notificationStore.update(
|
||||
item.id,
|
||||
{ readAt: timestamp },
|
||||
{ tenantIds: [item.tenantId], includeGlobal: true },
|
||||
);
|
||||
}
|
||||
if (mine.length > 0) await cache.refresh();
|
||||
return mine.length;
|
||||
if (mine.length === 0) return 0;
|
||||
|
||||
// Jeden zapis pro vsechna, ne N za sebou. Firmy se vezmou z toho, co se oznacuje.
|
||||
const tenantIds = [...new Set(mine.map((item) => item.tenantId))];
|
||||
const changed = await notificationStore.updateMany(
|
||||
mine.map((item) => item.id),
|
||||
{ readAt: timestamp },
|
||||
{ tenantIds, includeGlobal: true },
|
||||
);
|
||||
await cache.refresh();
|
||||
return changed;
|
||||
}
|
||||
|
||||
+6
-35
@@ -12,30 +12,10 @@
|
||||
|
||||
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
|
||||
import { withCache } from './store/cached.js';
|
||||
import type { Person } from '../shared/people.js';
|
||||
|
||||
export interface Person extends TenantEntity {
|
||||
/** Firma, jejiz je clenem tymu. Hranice viditelnosti, proto nikdy null. */
|
||||
tenantId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
/** Cim se v tymu zabyva. Jen popisek, nic nerozhoduje. */
|
||||
role: string;
|
||||
/** Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez. */
|
||||
capacity: number;
|
||||
/** Vypnuty resitel se nenabizi k prirazeni, ale stare tickety nespadnou. */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* ID, pod kterymi cloveka znaji cizi aplikace.
|
||||
*
|
||||
* Voicebot posle `voicebotId`, telefonni ustredna klapku, chat svoje ID -
|
||||
* a my z toho musime poznat, komu ticket patri. Bez toho by se to muselo
|
||||
* mapovat v kazde automatizaci zvlast a pri zmene cloveka opravovat na
|
||||
* peti mistech.
|
||||
*
|
||||
* Jeden clovek jich muze mit vic, protoze aplikaci je vic.
|
||||
*/
|
||||
externalIds: string[];
|
||||
}
|
||||
/** Tvar resitele je sdileny s webem, viz src/shared/people.ts. */
|
||||
export type { Person };
|
||||
|
||||
export const personStore = defineStore<Person>('person');
|
||||
const cache = withCache(personStore);
|
||||
@@ -63,18 +43,12 @@ export async function refreshPeople(): Promise<void> {
|
||||
|
||||
/** Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse". */
|
||||
export function listPeople(tenantIds: string[]): Person[] {
|
||||
return cache
|
||||
.all()
|
||||
.filter((person) => tenantIds.includes(person.tenantId) && person.enabled)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
return cache.listByTenant(tenantIds, 'name').filter((person) => person.enabled);
|
||||
}
|
||||
|
||||
/** Vcetne vypnutych. Pro spravu tymu. */
|
||||
export function listAllPeople(tenantIds: string[]): Person[] {
|
||||
return cache
|
||||
.all()
|
||||
.filter((person) => tenantIds.includes(person.tenantId))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
return cache.listByTenant(tenantIds, 'name');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,10 +175,7 @@ export async function refreshGroups(): Promise<void> {
|
||||
}
|
||||
|
||||
export function listGroups(tenantIds: string[]): PersonGroup[] {
|
||||
return groupCache
|
||||
.all()
|
||||
.filter((group) => tenantIds.includes(group.tenantId))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
return groupCache.listByTenant(tenantIds, 'name');
|
||||
}
|
||||
|
||||
export function findGroup(id: string): PersonGroup | undefined {
|
||||
|
||||
+4
-21
@@ -12,20 +12,13 @@
|
||||
* Popis modelu je v documentation/16-prava-a-role.md.
|
||||
*/
|
||||
|
||||
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
|
||||
import { defineStore, nowIso } from './store/index.js';
|
||||
import type { User } from '../types.js';
|
||||
|
||||
export type PermissionKey = string;
|
||||
import type { PermissionDefinition, PermissionKey, Role } from '../shared/permissions.js';
|
||||
|
||||
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;
|
||||
}
|
||||
/** Tvar prava a role je sdileny s webem, viz src/shared/permissions.ts. */
|
||||
export type { PermissionDefinition, PermissionKey, Role };
|
||||
|
||||
/**
|
||||
* Katalog prav.
|
||||
@@ -128,16 +121,6 @@ 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. */
|
||||
|
||||
+59
-19
@@ -7,32 +7,72 @@
|
||||
*
|
||||
* Volani se **slucuje**. Deset zapisu za sebou znamena jednu obnovu, jinak by
|
||||
* hromadna uprava znamenala desetkrat precteni vsech roli a lidi.
|
||||
*
|
||||
* Obnovuje se **jen to, co se zmenilo**: zapis do lidi nema cist znovu firmy,
|
||||
* role, typy ticketu a dalsich devet tabulek. Bez udani entity se obnovi vse.
|
||||
*/
|
||||
|
||||
import { refreshCaches } from './bootstrap.js';
|
||||
import { refreshCaches, refreshEntity } from './bootstrap.js';
|
||||
|
||||
/**
|
||||
* Ktera entita se meni pod kterou cestou routeru nastaveni (`/api/settings/...`).
|
||||
* Prvni segment cesty -> `store.kind`. Neznama cesta znamena plnou obnovu,
|
||||
* radeji precist vse nez nechat zastaralou kopii.
|
||||
*/
|
||||
const routeKinds: Record<string, string> = {
|
||||
tenants: 'tenant',
|
||||
users: 'user',
|
||||
roles: 'role',
|
||||
people: 'person',
|
||||
groups: 'personGroup',
|
||||
features: 'tenantFeatures',
|
||||
'ticket-types': 'ticketType',
|
||||
actions: 'ticketAction',
|
||||
widgets: 'customWidget',
|
||||
};
|
||||
|
||||
let pending: Promise<void> | null = null;
|
||||
let again = false;
|
||||
const queuedKinds = new Set<string>();
|
||||
let queuedFull = 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;
|
||||
}
|
||||
/**
|
||||
* Obnovi kopie v pameti po zapisu.
|
||||
*
|
||||
* `route` je cesta v routeru nastaveni, napr. `/people/ppl_1`. Podle ni se
|
||||
* pozna, co se zmenilo. Bez ni se obnovi vsechno.
|
||||
*/
|
||||
export function bootstrapDataRefresh(route?: string): Promise<void> {
|
||||
const kind = route ? routeKinds[route.split('/').filter(Boolean)[0] ?? ''] : undefined;
|
||||
if (kind) queuedKinds.add(kind);
|
||||
else queuedFull = true;
|
||||
|
||||
pending = refreshCaches()
|
||||
.catch((err: unknown) => {
|
||||
console.error('[data] obnova kopii selhala:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
// Uz se obnovuje: pozadavek je ve fronte a smycka ho vezme po dokonceni -
|
||||
// jinak by se zmena z posledniho zapisu neprojevila.
|
||||
if (!pending) {
|
||||
pending = drain().finally(() => {
|
||||
pending = null;
|
||||
if (again) {
|
||||
again = false;
|
||||
void bootstrapDataRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function drain(): Promise<void> {
|
||||
while (queuedFull || queuedKinds.size > 0) {
|
||||
const full = queuedFull;
|
||||
const kinds = [...queuedKinds];
|
||||
queuedFull = false;
|
||||
queuedKinds.clear();
|
||||
|
||||
try {
|
||||
if (full) {
|
||||
await refreshCaches();
|
||||
continue;
|
||||
}
|
||||
for (const kind of kinds) {
|
||||
if (!(await refreshEntity(kind))) await refreshCaches();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[data] obnova kopii selhala:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+58
-259
@@ -18,249 +18,37 @@
|
||||
* Jak pridat sluzbu: documentation/12-sluzby-a-konektory.md
|
||||
*/
|
||||
|
||||
import type { FieldType } from './conditions.js';
|
||||
import type { User } from '../types.js';
|
||||
import { isMcpService, MCP_EASYWEB_SERVICE_ID, MCP_SERVICE_ID } from '../mcp/dialect.js';
|
||||
import { tenantHasService } from './tenantFeatures.js';
|
||||
|
||||
export type ServiceCategory =
|
||||
/** Obecne veci, ktere ma kazdy. Nepotrebuji konektor. */
|
||||
| 'obecne'
|
||||
| 'crm'
|
||||
| 'ekonomika'
|
||||
| 'logistika'
|
||||
| 'komunikace'
|
||||
| 'analytika'
|
||||
| 'ai'
|
||||
/**
|
||||
* Prevody dat mezi kroky. Vlastni kategorie, ne polozka v Obecnem:
|
||||
* je jich hodne a clovek je hleda jako skupinu, ne mezi webhookem a pauzou.
|
||||
*/
|
||||
| 'transformace';
|
||||
import type {
|
||||
OperationField,
|
||||
ProvidedField,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceCategoryEntry,
|
||||
ServiceCredentialField,
|
||||
ServiceOperation,
|
||||
ServiceStatus,
|
||||
ServiceVisibility,
|
||||
} from '../shared/services.js';
|
||||
|
||||
/**
|
||||
* available = sluzbu umime, planned = je na roadmape.
|
||||
*
|
||||
* "Napojeno" tady zamerne NENI. Jestli je sluzba napojena, zavisi na tom,
|
||||
* jestli si firma vytvorila konektor - to je vlastnost firmy, ne sluzby.
|
||||
* Pocita se v `serviceViewsFor`.
|
||||
* Tvar sluzby je sdileny s webem, viz src/shared/services.ts. Tady je katalog
|
||||
* samotny a to, co se z nej pocita.
|
||||
*/
|
||||
export type ServiceStatus = 'available' | 'planned';
|
||||
|
||||
/**
|
||||
* Kdo sluzbu vidi.
|
||||
*
|
||||
* `everyone` vsichni prihlaseni
|
||||
* `restricted` jen uvedene firmy a jmenovite uvedeni lide
|
||||
* `admin` jen spravce platformy
|
||||
*
|
||||
* Spravce platformy vidi vzdy vsechno. Obecne sluzby (`general: true`) taky
|
||||
* vidi vzdy vsichni, viditelnost se u nich neresi.
|
||||
*/
|
||||
export interface ServiceVisibility {
|
||||
mode: 'everyone' | 'restricted' | 'admin';
|
||||
/** Jen u `restricted`: firmy, ktere sluzbu vidi. */
|
||||
tenantIds: string[];
|
||||
/** Jen u `restricted`: konkretni lide bez ohledu na firmu. */
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeden udaj, ktery je potreba vyplnit pri zakladani konektoru.
|
||||
*
|
||||
* `target: 'header'` znamena, ze se hodnota posila jako hlavicka requestu
|
||||
* pojmenovana v `name`. Tim je popsane, co ktera sluzba vyzaduje, a konektor
|
||||
* uz jen doplni hodnoty.
|
||||
*
|
||||
* `secret: true` se **nikdy nevraci z API** a redaguje se v logu.
|
||||
*/
|
||||
export interface ServiceCredentialField {
|
||||
id: string;
|
||||
label: string;
|
||||
target: 'header' | 'config';
|
||||
/** Jmeno hlavicky u `header`, jmeno klice v `ctx.config` u `config`. */
|
||||
name: string;
|
||||
required: boolean;
|
||||
secret: boolean;
|
||||
hint?: string;
|
||||
/**
|
||||
* Text, ktery se pred hodnotu doplni az pri sestaveni hlavicky.
|
||||
*
|
||||
* Kvuli `Authorization: Bearer <klic>`: uzivatel do formulare vlepi klic
|
||||
* tak, jak ho dostal od sluzby, a slovo `Bearer` dopise runtime. Kdyby si ho
|
||||
* mel psat sam, byl by to prvni zdroj chyb, ktery nikdo nevidi - hodnota se
|
||||
* z API nevraci, takze preklep v ni neni videt ani zpetne.
|
||||
*
|
||||
* Redakce v logu se dela na obojí, na hodnotu i na celou hlavicku.
|
||||
*/
|
||||
prefix?: string;
|
||||
/**
|
||||
* true = hodnotu **spravuje portal**, ne uzivatel.
|
||||
*
|
||||
* Ve formulari se nezobrazuje a nikdo ji nevyplnuje. Zapisuje ji az napojeni
|
||||
* samo, kdyz si za behu neco vyrobi - klic zarizeni u MCP EasyWebu. Bez
|
||||
* tohohle priznaku by uzivatel videl pole, do ktereho nema co psat, a mohl
|
||||
* by ho prepsat.
|
||||
*/
|
||||
managed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parametr, ktery spoustec sam preda do stromu. Tvarem odpovida `TriggerField`
|
||||
* v automationStore, ale definuje se tady - katalog je zdroj pravdy o tom,
|
||||
* co ktera sluzba posila.
|
||||
*
|
||||
* ID musi byt stabilni, odkazuji se na nej podminky v ulozenych stromech.
|
||||
* Prejmenovani ID rozbije existujici automatizace.
|
||||
*/
|
||||
export interface ProvidedField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: FieldType;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nastavitelne pole akce. Na rozdil od `fields` to neni napoveda, ale skutecna
|
||||
* konfigurace - hodnota se uklada do kroku stromu (`FlowStep.inputs`).
|
||||
*
|
||||
* Hodnota je sablona. `{{nazev}}` se nahradi parametrem spoustece,
|
||||
* viz `src/data/templates.ts`.
|
||||
*/
|
||||
export interface OperationField {
|
||||
id: string;
|
||||
label: string;
|
||||
/**
|
||||
* Jak se pole vykresli v builderu.
|
||||
*
|
||||
* `text` jeden radek
|
||||
* `longtext` vice radku
|
||||
* `html` vice radku, obsah je HTML - dosazene hodnoty se escapuji
|
||||
* `choice` vyber z `options`
|
||||
* `lookup` ciselnik **a zaroven** volny text: bud se vybere ze seznamu,
|
||||
* nebo se hodnota dosadi z dat sablonou. Pro pole, kde by jinak
|
||||
* clovek musel opisovat ID, ale kde ho krok muze dostat i z toho,
|
||||
* co prislo.
|
||||
* `json` JSON, kontroluje se uz pri ulozeni stromu
|
||||
* `mapping` pravidla transformace, klikatelny editor nad JSONem
|
||||
* `object` odkaz na parametr typu objekt nebo seznam z predchoziho kroku
|
||||
*
|
||||
* Hodnota je vzdy retezec, i u `json` a `mapping`. Diky tomu se nemenil
|
||||
* `FlowStep.inputs` a strukturu si rozparsuje az ten, kdo ji pouziva.
|
||||
*/
|
||||
kind: 'text' | 'longtext' | 'html' | 'choice' | 'lookup' | 'json' | 'mapping' | 'object';
|
||||
required: boolean;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
/**
|
||||
* Odkud se doplni nabidka za behu. Katalog vznika pri importu modulu,
|
||||
* ale resitele a skupiny se nacitaji z uloziste az pozdeji.
|
||||
*/
|
||||
optionsFrom?: 'people' | 'groups' | 'scripts' | 'ticketTypes';
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ServiceOperation {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/**
|
||||
* Popis toho, co bude potreba nastavit - jen napoveda, nikam se neuklada.
|
||||
* Cilovy stav je prevest vsechny akce na `inputs`, zatim je to udelane
|
||||
* u ticketu a kanalu, ktere do nej ustuji.
|
||||
*/
|
||||
fields?: string[];
|
||||
/** Skutecne nastavitelna pole akce. Kdyz chybi, akci nejde konfigurovat. */
|
||||
inputs?: OperationField[];
|
||||
/**
|
||||
* Jen u akci: co krok vrati dalsim krokum. Diky tomu jde po kroku
|
||||
* "dohledej firmu v CRM" postavit podminku na to, jestli se nasla.
|
||||
*
|
||||
* `required: true` znamena, ze sluzba hodnotu vraci vzdy.
|
||||
* ID musi zustat stabilni, odkazuji se na nej podminky v ulozenych stromech.
|
||||
*/
|
||||
outputFields?: ProvidedField[];
|
||||
/**
|
||||
* Jen u triggeru: true = vstupni parametry si definuje uzivatel
|
||||
* (webhook, formular). false/chybi = data urcuje sluzba.
|
||||
*/
|
||||
customPayload?: boolean;
|
||||
/**
|
||||
* Jen u triggeru: parametry, ktere sluzba predava sama. Uzivatel je nemeni,
|
||||
* server je pri ukladani stromu dosadi misto toho, co poslal klient.
|
||||
* Diky nim jde stavet podminky nad daty, ktera si nikdo nevymyslel.
|
||||
*/
|
||||
providedFields?: ProvidedField[];
|
||||
/**
|
||||
* `script` = operaci obsluhuje skript ze slozky skriptu, tedy se opravdu
|
||||
* vykona. Kdyz chybi, je to zatim jen zapis v katalogu.
|
||||
* Doplnuje `src/scripts/registry.ts`, rucne se to nepise.
|
||||
*/
|
||||
implementation?: 'script';
|
||||
/** Ktery skript operaci obsluhuje. Vyplnene spolu s `implementation`. */
|
||||
scriptId?: string;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
category: ServiceCategory;
|
||||
description: string;
|
||||
/** Klic ikony - frontend si ho mapuje na komponentu (lib/serviceIcons.ts). */
|
||||
icon: string;
|
||||
status: ServiceStatus;
|
||||
/**
|
||||
* true = sluzba funguje bez konektoru. Obecne veci, ktere ma kazdy:
|
||||
* webhook, planovac, tickety, transformace dat, HTTP pozadavek, pauza, log.
|
||||
*/
|
||||
general: boolean;
|
||||
/**
|
||||
* ID aplikace v services.csbot.cz/apps. Zaklad adresy se sklada z nej
|
||||
* a ze `SERVICES_BASE_URL`, verejna domena se nikdy nehardcoduje (AGENTS.md).
|
||||
* null = sluzba se nevola pres jednu pevnou adresu.
|
||||
*/
|
||||
appId: string | null;
|
||||
/**
|
||||
* Cim se sluzba vola.
|
||||
*
|
||||
* `http` (vychozi) je zbytek katalogu: skript rekne cestu a runtime doplni
|
||||
* adresu a hlavicky. `smtp` je e-mail - neni to HTTP, takze operaci nevykona
|
||||
* skript, ale vnitrni krok, a overeni konektoru se misto cteciho volani
|
||||
* prihlasi na posmovni server. `mcp` je JSON-RPC nad HTTP, kde se pred
|
||||
* kazdym volanim navazuje sezeni a operace nejsou v katalogu - rekne je
|
||||
* az server.
|
||||
*
|
||||
* Je to priznak sluzby, ne konektoru: jak se sluzba vola, je jeji vlastnost.
|
||||
*/
|
||||
transport?: 'http' | 'smtp' | 'mcp';
|
||||
/**
|
||||
* Absolutni adresa sluzby, ktera **nebezi u nas**. Typicky OpenAI.
|
||||
*
|
||||
* Je to jina vec nez `appId`: tam se adresa sklada ze `SERVICES_BASE_URL`,
|
||||
* protoze aplikace je nase a muze se cela prestehovat. Tohle je cizi domena,
|
||||
* ktera se ridi cizim rozhodnutim, takze patri ke sluzbe.
|
||||
*
|
||||
* Prepsat ji jde promennou `<SLUZBA>_BASE_URL` (napr. `OPENAI_BASE_URL`)
|
||||
* nebo adresou u konektoru - kvuli branam typu Azure OpenAI a kvuli testu
|
||||
* proti napodobenine.
|
||||
*/
|
||||
baseUrl?: string;
|
||||
visibility: ServiceVisibility;
|
||||
/**
|
||||
* Co je potreba vyplnit pri zakladani konektoru.
|
||||
* Hodnoty patri konektoru, ne prostredi - kazda firma ma svoje.
|
||||
* Prazdne pole u obecnych sluzeb a u tech, ktere jeste nemame popsane.
|
||||
*/
|
||||
credentials: ServiceCredentialField[];
|
||||
/**
|
||||
* Levne cteci volani, kterym se overi, ze konektor funguje.
|
||||
* Musi to byt neco, co **nic nemeni** a co vyzaduje autorizaci - jinak by test
|
||||
* prosel i se spatnymi udaji a rekl uzivateli nepravdu.
|
||||
* Kdyz chybi, overi se jen dostupnost sluzby na `/health`.
|
||||
*/
|
||||
verifyPath?: string;
|
||||
triggers: ServiceOperation[];
|
||||
actions: ServiceOperation[];
|
||||
}
|
||||
export type {
|
||||
OperationField,
|
||||
ProvidedField,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceCategoryEntry,
|
||||
ServiceCredentialField,
|
||||
ServiceOperation,
|
||||
ServiceStatus,
|
||||
ServiceVisibility,
|
||||
};
|
||||
|
||||
/**
|
||||
* Nabidka resitelu do vyberu u akci.
|
||||
@@ -281,14 +69,7 @@ const priorityOptions = [
|
||||
{ value: 'critical', label: 'Kritická' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'new', label: 'Nový' },
|
||||
{ value: 'open', label: 'V řešení' },
|
||||
{ value: 'waiting', label: 'Čeká na klienta' },
|
||||
{ value: 'resolved', label: 'Vyřešeno' },
|
||||
];
|
||||
|
||||
export const serviceCategories: Array<{ id: ServiceCategory; label: string }> = [
|
||||
export const serviceCategories: ServiceCategoryEntry[] = [
|
||||
{ id: 'obecne', label: 'Obecné' },
|
||||
{ id: 'crm', label: 'CRM' },
|
||||
{ id: 'ekonomika', label: 'Ekonomika a banky' },
|
||||
@@ -905,21 +686,10 @@ export const services: Service[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'set-status',
|
||||
name: 'Změnit stav',
|
||||
description: 'Posune ticket na nový, v řešení, čeká na klienta nebo vyřešeno.',
|
||||
inputs: [
|
||||
{ id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true },
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Nový stav',
|
||||
kind: 'choice',
|
||||
required: true,
|
||||
options: statusOptions,
|
||||
},
|
||||
],
|
||||
},
|
||||
/*
|
||||
* Druhy `set-status` s pevnym ciselnikem stavu tady byl a katalog ho
|
||||
* ukazoval vedle prvniho. Stav je volny retezec, plati jen ten vyse.
|
||||
*/
|
||||
{
|
||||
id: 'link-customer',
|
||||
name: 'Napojit na zákazníka',
|
||||
@@ -2783,6 +2553,35 @@ export function findService(serviceId: string): Service | undefined {
|
||||
return services.find((service) => service.id === serviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontrola katalogu pri startu: ID operace musi byt v ramci sluzby jedine.
|
||||
*
|
||||
* `findOperation` bere prvni shodu, takze druha operace se stejnym ID by se
|
||||
* v katalogu ukazala, ale nikdy by se nespustila - a nikdo by nepoznal proc.
|
||||
* Neshazuje start (AGENTS.md), ale rekne to nahlas.
|
||||
*/
|
||||
function checkOperationIds(): void {
|
||||
for (const service of services) {
|
||||
for (const [type, pool] of [
|
||||
['spoustec', service.triggers],
|
||||
['akce', service.actions],
|
||||
] as const) {
|
||||
const seen = new Set<string>();
|
||||
for (const operation of pool) {
|
||||
if (seen.has(operation.id)) {
|
||||
console.error(
|
||||
`[sluzby] sluzba ${service.id} ma dvakrat ${type} "${operation.id}", ` +
|
||||
'pouzije se jen prvni definice',
|
||||
);
|
||||
}
|
||||
seen.add(operation.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkOperationIds();
|
||||
|
||||
// -------------------------------------------------------- kdo co vidi
|
||||
|
||||
/**
|
||||
|
||||
+29
-2
@@ -59,6 +59,12 @@ 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();
|
||||
/**
|
||||
* true = soubor existuje, ale nesel precist ani odlozit stranou. Zapisovat
|
||||
* se pak nesmi: prvni ulozeni by ho prepsalo prazdnymi daty a puvodni
|
||||
* obsah by byl pryc. Jede se v pameti a rika se to v logu.
|
||||
*/
|
||||
let locked = false;
|
||||
|
||||
async function writeNow(items: T[]): Promise<void> {
|
||||
const payload: SnapshotFile<T> = {
|
||||
@@ -91,8 +97,15 @@ export function fileSnapshot<T>(file: string): SnapshotStore<T> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(file, 'utf8');
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Chybejici soubor je bezny stav, ne chyba. Prvni spusteni.
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
// Cokoliv jineho (prava, disk) je chyba a soubor se nesmi prepsat.
|
||||
locked = true;
|
||||
console.error(
|
||||
`[snapshot] ${file} nelze precist (${err instanceof Error ? err.message : err}), ` +
|
||||
'zapisy jsou vypnute, aby se soubor neprepsal prazdnymi daty',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -114,12 +127,26 @@ export function fileSnapshot<T>(file: string): SnapshotStore<T> {
|
||||
`[snapshot] ${file} nelze precist (${err instanceof Error ? err.message : err}), ` +
|
||||
`puvodni soubor je v ${broken}`,
|
||||
);
|
||||
await fs.rename(file, broken).catch(() => undefined);
|
||||
try {
|
||||
await fs.rename(file, broken);
|
||||
} catch (renameErr) {
|
||||
// Bez zalohy se zapisovat nesmi, jinak by prvni ulozeni rozbity
|
||||
// soubor prepsalo a nebylo by z ceho data zachranit.
|
||||
locked = true;
|
||||
console.error(
|
||||
`[snapshot] ${file} se nepodarilo odlozit do ${broken} ` +
|
||||
`(${renameErr instanceof Error ? renameErr.message : renameErr}), zapisy jsou vypnute`,
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
save(items) {
|
||||
if (locked) {
|
||||
console.warn(`[snapshot] ${file}: zapis odmitnut, soubor se pri startu nepodarilo precist`);
|
||||
return;
|
||||
}
|
||||
// Kopie, aby pozdejsi zmeny v pameti neovlivnily to, co se prave zapisuje.
|
||||
pending = items.map((item) => ({ ...item }));
|
||||
if (timer) return;
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
|
||||
import type { EntityStore, TenantEntity } from './types.js';
|
||||
|
||||
/** Podle ceho se seznam radi: nazev pole (ceske razeni) nebo vlastni porovnani. */
|
||||
export type SortBy<T> = (keyof T & string) | ((a: T, b: T) => number);
|
||||
|
||||
export interface CachedStore<T extends TenantEntity> {
|
||||
readonly store: EntityStore<T>;
|
||||
/** Nacte kopii. Vola se pri startu a po kazdem zapisu. */
|
||||
@@ -26,16 +29,32 @@ export interface CachedStore<T extends TenantEntity> {
|
||||
all(): T[];
|
||||
find(predicate: (entity: T) => boolean): T | undefined;
|
||||
byId(id: string): T | undefined;
|
||||
/**
|
||||
* Zaznamy vybranych firem, serazene. Platformni (`tenantId: null`) tu nejsou.
|
||||
*
|
||||
* Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse" -
|
||||
* stejne pravidlo jako u ticketu.
|
||||
*/
|
||||
listByTenant(tenantIds: string[], sortBy?: SortBy<T>): T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vsechny kopie podle druhu entity. Diky tomu je obnova po zapisu jeden
|
||||
* radek (`refreshCache(kind)`) a ne seznam dvanacti funkci, ktery se pri
|
||||
* pridani entity zapomene doplnit.
|
||||
*/
|
||||
const caches = new Map<string, CachedStore<TenantEntity>>();
|
||||
|
||||
export function withCache<T extends TenantEntity>(store: EntityStore<T>): CachedStore<T> {
|
||||
let rows: T[] = [];
|
||||
let index = new Map<string, T>();
|
||||
|
||||
return {
|
||||
const cache: CachedStore<T> = {
|
||||
store,
|
||||
|
||||
async refresh() {
|
||||
rows = await store.listAll();
|
||||
index = new Map(rows.map((row) => [row.id, row]));
|
||||
},
|
||||
|
||||
all() {
|
||||
@@ -47,7 +66,34 @@ export function withCache<T extends TenantEntity>(store: EntityStore<T>): Cached
|
||||
},
|
||||
|
||||
byId(id) {
|
||||
return rows.find((row) => row.id === id);
|
||||
return index.get(id);
|
||||
},
|
||||
|
||||
listByTenant(tenantIds, sortBy) {
|
||||
const selected = rows.filter(
|
||||
(row) => row.tenantId !== null && tenantIds.includes(row.tenantId),
|
||||
);
|
||||
if (!sortBy) return selected;
|
||||
if (typeof sortBy === 'function') return selected.sort(sortBy);
|
||||
return selected.sort((a, b) =>
|
||||
String(a[sortBy] ?? '').localeCompare(String(b[sortBy] ?? ''), 'cs'),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
caches.set(store.kind, cache as CachedStore<TenantEntity>);
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** Obnovi kopii jednoho druhu entity. Neznamy druh je chyba volajiciho, ne ticho. */
|
||||
export async function refreshCache(kind: string): Promise<boolean> {
|
||||
const cache = caches.get(kind);
|
||||
if (!cache) return false;
|
||||
await cache.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Obnovi vsechny kopie naraz. Vola se pri startu. */
|
||||
export async function refreshAllCaches(): Promise<void> {
|
||||
await Promise.all([...caches.values()].map((cache) => cache.refresh()));
|
||||
}
|
||||
|
||||
+28
-8
@@ -18,28 +18,46 @@
|
||||
import path from 'node:path';
|
||||
import { config } from '../../config.js';
|
||||
import { isDatabaseEnabled } from '../../db/pool.js';
|
||||
import { canSealSecrets, initSecrets } from '../../db/secretBox.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';
|
||||
export { highestNumber, isVisible, minutesAgo, nowIso, writableOrWarn } from './types.js';
|
||||
|
||||
export type StoresMode = 'postgres' | 'file' | 'memory';
|
||||
|
||||
/** 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;
|
||||
/**
|
||||
* Nastavi rezim. Vola se pri startu po migracich, pred `init` jednotlivych stores.
|
||||
*
|
||||
* **Jedine rozhodnuti pro vsechna uloziste**, konektory vcetne. Databaze se
|
||||
* pouzije jen kdyz je `DATABASE_URL`, migrace prosly a je cim sifrovat
|
||||
* (`SECRETS_KEY`): konektory nesou pristupove udaje a ty do tabulky citelne
|
||||
* nesmi. Driv se to rozhodovalo na dvou mistech a mohlo se stat, ze tickety
|
||||
* sly do databaze a konektory do souboru.
|
||||
*
|
||||
* Opakovane volani rezim nemeni - konektory i ostatni entity se ptaji
|
||||
* kazde zvlast a odpoved musi byt stejna.
|
||||
*/
|
||||
export function initStores(options: { databaseReady: boolean }): StoresMode {
|
||||
if (ready) return storesMode();
|
||||
|
||||
if (useDatabase) return 'postgres';
|
||||
return config.dataDir === '' ? 'memory' : 'file';
|
||||
const databaseUsable = isDatabaseEnabled() && options.databaseReady;
|
||||
// Klic vedle dat je pripustny jen mimo databazi. U ni ma prijit ze secretu.
|
||||
initSecrets({ allowKeyFile: !databaseUsable, dir: config.dataDir });
|
||||
|
||||
useDatabase = databaseUsable && canSealSecrets();
|
||||
ready = true;
|
||||
return storesMode();
|
||||
}
|
||||
|
||||
export function storesMode(): 'postgres' | 'file' | 'memory' {
|
||||
export function storesMode(): StoresMode {
|
||||
if (useDatabase) return 'postgres';
|
||||
return config.dataDir === '' ? 'memory' : 'file';
|
||||
}
|
||||
@@ -78,7 +96,9 @@ export function defineStore<T extends TenantEntity>(kind: string): EntityStore<T
|
||||
create: (entity) => resolve().create(entity),
|
||||
put: (entity) => resolve().put(entity),
|
||||
update: (id, patch, options) => resolve().update(id, patch, options),
|
||||
updateMany: (ids, patch, options) => resolve().updateMany(ids, patch, options),
|
||||
remove: (id, options) => resolve().remove(id, options),
|
||||
removeMany: (ids) => resolve().removeMany(ids),
|
||||
flush: () => resolve().flush(),
|
||||
};
|
||||
|
||||
|
||||
@@ -72,6 +72,11 @@ export function createLocalStore<T extends TenantEntity>(
|
||||
},
|
||||
|
||||
async create(entity) {
|
||||
// Stejne chovani jako primarni klic v Postgresu: druhy zaznam s tymz ID
|
||||
// je chyba, ne tichy duplikat, ktery by `get` uz nikdy nenasel.
|
||||
if (rows.some((item) => item.id === entity.id)) {
|
||||
throw new Error(`[${options.kind}] zaznam ${entity.id} uz existuje`);
|
||||
}
|
||||
rows.push(entity);
|
||||
persist();
|
||||
return copy(entity);
|
||||
@@ -95,6 +100,20 @@ export function createLocalStore<T extends TenantEntity>(
|
||||
return copy(rows[index]);
|
||||
},
|
||||
|
||||
async updateMany(ids, patch, listOptions) {
|
||||
const { id: _id, createdAt: _createdAt, ...rest } = patch as Partial<TenantEntity>;
|
||||
const wanted = new Set(ids);
|
||||
const timestamp = nowIso();
|
||||
let changed = 0;
|
||||
rows = rows.map((row) => {
|
||||
if (!wanted.has(row.id) || !isVisible(row, listOptions)) return row;
|
||||
changed += 1;
|
||||
return { ...row, ...(rest as Partial<T>), updatedAt: timestamp };
|
||||
});
|
||||
if (changed > 0) persist();
|
||||
return changed;
|
||||
},
|
||||
|
||||
async remove(id, listOptions) {
|
||||
const index = rows.findIndex((item) => item.id === id);
|
||||
if (index === -1 || !isVisible(rows[index], listOptions)) return false;
|
||||
@@ -103,6 +122,15 @@ export function createLocalStore<T extends TenantEntity>(
|
||||
return true;
|
||||
},
|
||||
|
||||
async removeMany(ids) {
|
||||
const wanted = new Set(ids);
|
||||
const before = rows.length;
|
||||
rows = rows.filter((row) => !wanted.has(row.id));
|
||||
const removed = before - rows.length;
|
||||
if (removed > 0) persist();
|
||||
return removed;
|
||||
},
|
||||
|
||||
async flush() {
|
||||
await snapshot.flush();
|
||||
},
|
||||
|
||||
+31
-11
@@ -42,6 +42,28 @@ export function withMirror<T extends TenantEntity>(store: EntityStore<T>): Mirro
|
||||
console.error(`[${store.kind}] ${action} se nepodarilo ulozit:`, err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rozepsane zapisy po zaznamech.
|
||||
*
|
||||
* Zapisy **tehoz zaznamu jdou za sebou**, ne soubezne. Dva `put` stejneho
|
||||
* ticketu vystrelene hned po sobe by v databazi mohly dobehnout v opacnem
|
||||
* poradi a vyhral by starsi stav. Ruzne zaznamy se navzajem nebrzdi.
|
||||
*/
|
||||
const chains = new Map<string, Promise<void>>();
|
||||
|
||||
function enqueue(id: string, action: string, work: () => Promise<unknown>): void {
|
||||
const previous = chains.get(id) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.then(work)
|
||||
.then(() => undefined)
|
||||
.catch((err: unknown) => report(action, err));
|
||||
chains.set(id, next);
|
||||
void next.finally(() => {
|
||||
// Retez se uklidi, jen kdyz mezitim nepribyl dalsi zapis.
|
||||
if (chains.get(id) === next) chains.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
store,
|
||||
|
||||
@@ -51,7 +73,7 @@ export function withMirror<T extends TenantEntity>(store: EntityStore<T>): Mirro
|
||||
},
|
||||
|
||||
save(entity) {
|
||||
void store.put(entity).catch((err: unknown) => report(entity.id, err));
|
||||
enqueue(entity.id, entity.id, () => store.put(entity));
|
||||
},
|
||||
|
||||
drop(id) {
|
||||
@@ -60,16 +82,14 @@ export function withMirror<T extends TenantEntity>(store: EntityStore<T>): Mirro
|
||||
* 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));
|
||||
enqueue(id, `smazani ${id}`, async () => {
|
||||
const entity = await store.getRaw(id);
|
||||
if (!entity) return false;
|
||||
return store.remove(id, {
|
||||
tenantIds: entity.tenantId === null ? [] : [entity.tenantId],
|
||||
includeGlobal: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -142,6 +142,23 @@ export function createPostgresStore<T extends TenantEntity>(kind: string): Entit
|
||||
return row ? toEntity<T>(row) : undefined;
|
||||
},
|
||||
|
||||
async updateMany(ids, patch, options) {
|
||||
if (ids.length === 0) return 0;
|
||||
const { id: _id, createdAt: _createdAt, ...rest } = patch as Partial<TenantEntity>;
|
||||
const { sql, params } = scope(options);
|
||||
// Patch se slouci primo v SQL, at je to jeden dotaz a ne cteni a zapis
|
||||
// za kazdy zaznam zvlast. Cas zmeny se pri cteni bere ze sloupce (toEntity).
|
||||
const rows = await query<{ id: string }>(
|
||||
`UPDATE records
|
||||
SET data = data || $${params.length + 2}::jsonb,
|
||||
updated_at = now()
|
||||
WHERE ${sql} AND id = ANY($${params.length + 1})
|
||||
RETURNING id`,
|
||||
[...params, ids, JSON.stringify(rest)],
|
||||
);
|
||||
return rows.length;
|
||||
},
|
||||
|
||||
async remove(id, options) {
|
||||
const current = await this.get(id, options);
|
||||
if (!current) return false;
|
||||
@@ -149,6 +166,15 @@ export function createPostgresStore<T extends TenantEntity>(kind: string): Entit
|
||||
return true;
|
||||
},
|
||||
|
||||
async removeMany(ids) {
|
||||
if (ids.length === 0) return 0;
|
||||
const rows = await query<{ id: string }>(
|
||||
'DELETE FROM records WHERE kind = $1 AND id = ANY($2) RETURNING id',
|
||||
[kind, ids],
|
||||
);
|
||||
return rows.length;
|
||||
},
|
||||
|
||||
async flush() {
|
||||
// Databaze nic neodklada.
|
||||
},
|
||||
|
||||
+56
-15
@@ -17,22 +17,10 @@
|
||||
* neda, viz documentation/14-databaze.md.
|
||||
*/
|
||||
|
||||
/** Kazdy zaznam ma ID a vi, kdy vznikl. */
|
||||
export interface Entity {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
import type { Entity, TenantEntity } from '../../shared/entities.js';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
/** Zakladni tvar zaznamu je sdileny s webem, viz src/shared/entities.ts. */
|
||||
export type { Entity, TenantEntity };
|
||||
|
||||
export interface ListOptions {
|
||||
/**
|
||||
@@ -64,7 +52,21 @@ export interface EntityStore<T extends TenantEntity> {
|
||||
*/
|
||||
put(entity: T): Promise<void>;
|
||||
update(id: string, patch: Partial<T>, options: ListOptions): Promise<T | undefined>;
|
||||
/**
|
||||
* Stejny patch na vic zaznamu naraz. Vraci, kolik se jich zmenilo.
|
||||
*
|
||||
* Jeden zapis misto N: oznaceni dvaceti upozorneni jako prectenych by jinak
|
||||
* znamenalo dvacet dotazu za sebou.
|
||||
*/
|
||||
updateMany(ids: string[], patch: Partial<T>, options: ListOptions): Promise<number>;
|
||||
remove(id: string, options: ListOptions): Promise<boolean>;
|
||||
/**
|
||||
* Smaze zaznamy podle ID **bez kontroly firmy**. Vraci, kolik se smazalo.
|
||||
*
|
||||
* Jen pro uklid (audit, upozorneni), kde volajici maze to, co sam nacetl
|
||||
* pres `listAll`. Pro zapis za uzivatele patri `remove` s filtrem.
|
||||
*/
|
||||
removeMany(ids: string[]): Promise<number>;
|
||||
/** Dokonci rozepsany zapis. Vola se pri ukonceni procesu. */
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
@@ -73,6 +75,45 @@ export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/** Cas pred N minutami. Pouziva jen ukazkova data. */
|
||||
export function minutesAgo(minutes: number): string {
|
||||
return new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nejvyssi cislo v ID tvaru `PREFIX-123`.
|
||||
*
|
||||
* Citace ticketu, automatizaci a incidentu musi po startu pokracovat za tim,
|
||||
* co uz je v ulozisti, jinak by nove zaznamy prepisovaly stare.
|
||||
*/
|
||||
export function highestNumber(ids: Iterable<string>, prefix: string): number {
|
||||
const pattern = new RegExp(`^${prefix}-(\\d+)$`);
|
||||
let highest = 0;
|
||||
for (const id of ids) {
|
||||
const number = Number(pattern.exec(id)?.[1] ?? 0);
|
||||
if (number > highest) highest = number;
|
||||
}
|
||||
return highest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaznam k zapisu jen z povolenych firem. Cizi se chova jako neexistujici
|
||||
* a zaloguje se - pokus o zapis mimo firmu je vec, o ktere chceme vedet.
|
||||
*/
|
||||
export function writableOrWarn<T extends { tenantId: string }>(
|
||||
entity: T | undefined,
|
||||
id: string,
|
||||
tenantIds: string[],
|
||||
label: string,
|
||||
): T | undefined {
|
||||
if (!entity) return undefined;
|
||||
if (!tenantIds.includes(entity.tenantId)) {
|
||||
console.warn(`[${label}] pokus o zapis do ${id} mimo povolene firmy`);
|
||||
return undefined;
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Vidi volajici tenhle zaznam? */
|
||||
export function isVisible(entity: TenantEntity, options: ListOptions): boolean {
|
||||
if (entity.tenantId === null) return options.includeGlobal === true;
|
||||
|
||||
@@ -20,20 +20,10 @@ 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'
|
||||
| 'actions'
|
||||
| 'widgets'
|
||||
| 'services'
|
||||
| 'connectors'
|
||||
| 'scripts'
|
||||
| 'tickets'
|
||||
| 'helpdesk'
|
||||
| 'people'
|
||||
| 'incidents'
|
||||
| 'settings';
|
||||
import type { ModuleKey, NavItem } from '../shared/access.js';
|
||||
|
||||
/** Klic zalozky a polozka navigace jsou sdilene s webem, viz src/shared/access.ts. */
|
||||
export type { ModuleKey, NavItem };
|
||||
|
||||
export interface ModuleDefinition {
|
||||
key: ModuleKey;
|
||||
@@ -161,11 +151,6 @@ export function limitsOf(tenantId: string | null): TenantLimits {
|
||||
return featuresOf(tenantId).limits;
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
key: ModuleKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zalozky, ktere ma uzivatel videt.
|
||||
*
|
||||
|
||||
+15
-10
@@ -52,15 +52,12 @@ export async function refreshTenantScripts(): Promise<void> {
|
||||
export const MAX_CODE_LENGTH = 20_000;
|
||||
|
||||
export function listTenantScripts(tenantIds: string[]): TenantScript[] {
|
||||
return cache
|
||||
.all()
|
||||
.filter((script) => tenantIds.includes(script.tenantId))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
return cache.listByTenant(tenantIds, 'name');
|
||||
}
|
||||
|
||||
/** Skript firmy. Cizi skript se chova jako neexistujici, ne jako zakazany. */
|
||||
export function findTenantScript(id: string, tenantIds: string[]): TenantScript | undefined {
|
||||
const script = cache.all().find((item) => item.id === id);
|
||||
const script = cache.byId(id);
|
||||
if (!script || !tenantIds.includes(script.tenantId)) return undefined;
|
||||
return script;
|
||||
}
|
||||
@@ -93,14 +90,22 @@ export function newTenantScript(input: {
|
||||
* uz probehl. Je to informace do prehledu, ne soucast vysledku.
|
||||
*/
|
||||
export function noteRun(id: string, tenantId: string, ok: boolean): void {
|
||||
const script = cache.all().find((item) => item.id === id);
|
||||
const script = cache.byId(id);
|
||||
if (!script) return;
|
||||
|
||||
script.lastRunAt = nowIso();
|
||||
script.lastRunOk = ok;
|
||||
|
||||
const lastRunAt = nowIso();
|
||||
void tenantScriptStore
|
||||
.update(id, { lastRunAt: script.lastRunAt, lastRunOk: ok }, { tenantIds: [tenantId], includeGlobal: false })
|
||||
.update(id, { lastRunAt, lastRunOk: ok }, { tenantIds: [tenantId], includeGlobal: false })
|
||||
.then((updated) => {
|
||||
// Kopie se meni az po zapisu. Driv se prepsala hned a kdyz zapis tise
|
||||
// neprosel (skript jine firmy), portal ukazoval beh, ktery v ulozisti nebyl.
|
||||
if (!updated) {
|
||||
console.warn(`[skripty] skript ${id} nepatri firme ${tenantId}, beh se nepoznamenal`);
|
||||
return;
|
||||
}
|
||||
script.lastRunAt = updated.lastRunAt;
|
||||
script.lastRunOk = updated.lastRunOk;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn(`[skripty] beh skriptu ${id} se nepodarilo poznamenat:`, err);
|
||||
});
|
||||
|
||||
+6
-27
@@ -14,34 +14,13 @@
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
|
||||
import { timingSafeEqualString } from '../lib/secure.js';
|
||||
import { defineStore, nowIso } from './store/index.js';
|
||||
import { withCache } from './store/cached.js';
|
||||
import type { Tenant } from '../shared/tenants.js';
|
||||
|
||||
export interface Tenant extends TenantEntity {
|
||||
name: string;
|
||||
/** Kratka poznamka pro nas, klient ji nevidi. */
|
||||
note: string;
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Token pro prijem udalosti na `/webhook/ticket/:token`.
|
||||
*
|
||||
* Je na firme, ne na automatizaci: prijem ticketu ma fungovat i bez toho,
|
||||
* aby se kvuli nemu stavel strom. Token urcuje, ktere firme udalost patri,
|
||||
* a tim i to, v jakem rozsahu je externi ID unikatni.
|
||||
*/
|
||||
intakeToken: string;
|
||||
/**
|
||||
* Firma, ktera teto firme resi pozadavky z helpdesku.
|
||||
*
|
||||
* `null` = helpdesk nema komu poslat, takze se pozadavek nezalozi a rekne se
|
||||
* to nahlas. Nastavuje to spravce platformy, ne firma sama: kdo koho obsluhuje
|
||||
* je obchodni vztah, ne volba klienta.
|
||||
*
|
||||
* Pozadavek pak vznikne jako ticket **teto dodavatelske firmy** a zadavatel
|
||||
* zustane v `helpdeskSourceId`.
|
||||
*/
|
||||
helpdeskProviderId: string | null;
|
||||
}
|
||||
/** Tvar firmy je sdileny s webem, viz src/shared/tenants.ts. */
|
||||
export type { Tenant };
|
||||
|
||||
/**
|
||||
* Firma je platformni zaznam: `tenantId` je vzdy null, protoze firmu nevlastni
|
||||
@@ -95,7 +74,7 @@ export function seedTenants(): Tenant[] {
|
||||
*/
|
||||
export function findByIntakeToken(token: string): Tenant | undefined {
|
||||
if (token.length < 16) return undefined;
|
||||
return cache.all().find((tenant) => tenant.intakeToken === token && tenant.enabled);
|
||||
return cache.all().find((tenant) => tenant.enabled && timingSafeEqualString(tenant.intakeToken, token));
|
||||
}
|
||||
|
||||
export async function refreshTenants(): Promise<void> {
|
||||
|
||||
@@ -120,10 +120,10 @@ export async function refreshActions(): Promise<void> {
|
||||
}
|
||||
|
||||
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'));
|
||||
return cache.listByTenant(
|
||||
tenantIds,
|
||||
(a, b) => a.order - b.order || a.label.localeCompare(b.label, 'cs'),
|
||||
);
|
||||
}
|
||||
|
||||
export function findAction(id: string): TicketAction | undefined {
|
||||
|
||||
+283
-427
@@ -23,213 +23,49 @@ import type { Visibility } from './access.js';
|
||||
import { onTicketChanged } from './ticketHooks.js';
|
||||
import { notify } from './notifications.js';
|
||||
import { findPerson, type Person } from './people.js';
|
||||
import { defineStore } from './store/index.js';
|
||||
import { defineStore, highestNumber, minutesAgo, writableOrWarn } from './store/index.js';
|
||||
import { withMirror } from './store/mirror.js';
|
||||
|
||||
import type {
|
||||
AgentStatsRow,
|
||||
Ticket,
|
||||
TicketAssignee,
|
||||
TicketChannel,
|
||||
TicketCustomer,
|
||||
TicketDetail,
|
||||
TicketEvent,
|
||||
TicketPriority,
|
||||
TicketStatus,
|
||||
TicketTraceEntry,
|
||||
TraceKind,
|
||||
TraceStatus,
|
||||
Workload,
|
||||
WorkloadRow,
|
||||
} from '../shared/tickets.js';
|
||||
|
||||
/**
|
||||
* Stav ticketu. **Volny retezec**, ne ciselnik.
|
||||
*
|
||||
* Tickety chodi z cizich aplikaci, ktere maji svoje stavy: voicebot posila
|
||||
* `ringing` a `completed`, e-shop `pripraveno k expedici`. Nutit je do nasi
|
||||
* ctverice znamenalo, ze uzivatel videl "Novy" u ticketu, ktery byl podle
|
||||
* jeho systemu davno hotovy.
|
||||
*
|
||||
* Vychozi ctverice zustava jako **doporuceni**, ne omezeni - kdo si nic
|
||||
* nenastavi, dostane rozumne stavy a nemusi vymyslet vlastni.
|
||||
* Tvar ticketu je sdileny s webem, viz src/shared/tickets.ts. Tady zustava
|
||||
* uloziste, ukazkova data a to, co se z ticketu odvozuje.
|
||||
*/
|
||||
export type TicketStatus = string;
|
||||
export type {
|
||||
AgentStatsRow,
|
||||
Ticket,
|
||||
TicketAssignee,
|
||||
TicketChannel,
|
||||
TicketCustomer,
|
||||
TicketDetail,
|
||||
TicketEvent,
|
||||
TicketPriority,
|
||||
TicketStatus,
|
||||
TicketTraceEntry,
|
||||
TraceKind,
|
||||
TraceStatus,
|
||||
Workload,
|
||||
WorkloadRow,
|
||||
};
|
||||
|
||||
/** Vychozi stavy, kdyz si typ ticketu nenadefinuje vlastni. Jen nabidka. */
|
||||
export const defaultStatuses = ['Nový', 'V řešení', 'Čeká na klienta', 'Vyřešeno'] as const;
|
||||
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
|
||||
|
||||
/** Odkud pozadavek prisel. Podle toho se pozna, co ma smysl hledat v logu. */
|
||||
export type TicketChannel =
|
||||
| 'whatsapp'
|
||||
| 'facebook'
|
||||
| 'instagram'
|
||||
| 'email'
|
||||
| 'voice'
|
||||
| 'form'
|
||||
| 'portal';
|
||||
|
||||
export interface TicketCustomer {
|
||||
/**
|
||||
* ID firmy v CRM. null = zakaznika se nepodarilo dohledat.
|
||||
* Prave na tohle se pta podminka "mame zakaznika?" ve stromu automatizace.
|
||||
*/
|
||||
id: string | null;
|
||||
company: string;
|
||||
/** Konkretni clovek, ktery pozadavek poslal. */
|
||||
contact: string;
|
||||
/** Adresa nebo cislo, odkud to prislo a kam se odpovida. */
|
||||
reply: string;
|
||||
}
|
||||
|
||||
/** Odkaz na resitele v odpovedi API. Uvnitr se drzi jen jeho ID. */
|
||||
export interface TicketAssignee {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type TraceStatus = 'ok' | 'error' | 'skipped' | 'info';
|
||||
export type TraceKind = 'trigger' | 'action' | 'condition' | 'note';
|
||||
|
||||
/**
|
||||
* Jeden radek v logu ticketu. Strom se sklada pres `parentId` -
|
||||
* vetev podminky visi na zaznamu te podminky.
|
||||
*/
|
||||
export interface TicketTraceEntry {
|
||||
id: string;
|
||||
/** null = zaznam v hlavni sekvenci. */
|
||||
parentId: string | null;
|
||||
kind: TraceKind;
|
||||
/** Ktera sluzba to byla. null u poznamek a podminek. */
|
||||
serviceId: string | null;
|
||||
operationId: string | null;
|
||||
label: string;
|
||||
status: TraceStatus;
|
||||
/** Co sluzba vratila. Tohle je duvod, proc log existuje. */
|
||||
response: string | null;
|
||||
durationMs: number | null;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jedna prichozi udalost k ticketu.
|
||||
*
|
||||
* Rozdil proti radku logu: log je nase stopa toho, co se delo uvnitr, udalost
|
||||
* je **fakt zvenku**. Odesilatel posle "objednavka 3 vznikla" a pozdeji
|
||||
* "objednavka 3 vyfakturovana" - obojí patri k jednomu ticketu a obojí musi
|
||||
* zustat v puvodni podobe, vcetne cele prijate zpravy.
|
||||
*/
|
||||
export interface TicketEvent {
|
||||
id: string;
|
||||
/** Typ od odesilatele, napr. `order.created`, `email.sent`. */
|
||||
type: string;
|
||||
/** Kdo to poslal: `webhook`, `portal`, ID automatizace. */
|
||||
source: string;
|
||||
/** Popisek do casove osy. */
|
||||
label: string;
|
||||
/** Cela prijata data. Nic se nezahazuje, at jde dohledat, co presne prislo. */
|
||||
payload: Record<string, unknown>;
|
||||
at: string;
|
||||
/**
|
||||
* Kolikrat prislo totež. Vetsinou 1.
|
||||
*
|
||||
* Odesilatele umi poslat stejnou zpravu i osmdesatkrat za minutu. Zalozit
|
||||
* osmdesat radku znamena, ze v historii nikdo nic nenajde - a pritom se
|
||||
* nestalo nic noveho. Proto se shodna udalost **pocita** u te predchozi.
|
||||
* Zahodit ji nejde: bez pocitadla by nikdo nezjistil, ze odesilatel tluce.
|
||||
*/
|
||||
repeats: number;
|
||||
/** Kdy prisla naposledy. Same jako `at`, dokud se neopakuje. */
|
||||
lastAt: string;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
/** Firma, ktere ticket patri. Hranice viditelnosti, viz data/access.ts. */
|
||||
tenantId: string;
|
||||
/**
|
||||
* Firma, ze ktere pozadavek prisel pres helpdesk. `null` u beznych ticketu.
|
||||
*
|
||||
* Vlastnikem ticketu (`tenantId`) je ta firma, ktera pozadavek **resi** -
|
||||
* u helpdesku se dosadi sama podle toho, koho ma zadavatel nastaveneho jako
|
||||
* dodavatele. Zadavatel neni vlastnik: kdyby byl, videl by ho resitel jen
|
||||
* jako cizi ticket a nemel by ho ve sve fronte.
|
||||
*
|
||||
* Podle tohohle pole vidi zadavatel svoje pozadavky, a **jen je**. Do bezneho
|
||||
* seznamu ticketu se nepromita, ten zustava vlastnikovi.
|
||||
*/
|
||||
helpdeskSourceId: string | null;
|
||||
/**
|
||||
* ID u odesilatele, napr. cislo objednavky.
|
||||
*
|
||||
* **Unikatni v ramci firmy**, ne globalne: dve firmy mohou mit objednavku
|
||||
* cislo 3 a nesmi si o sebe zavadit. Dalsi zprava se stejnym externim ID se
|
||||
* **navesi na existujici ticket** misto zalozeni druheho, viz `intakeEvent`.
|
||||
*/
|
||||
externalId: string | null;
|
||||
/** Odkud externi ID pochazi, napr. `eshop`. Jen informace, do klice nevstupuje. */
|
||||
externalSource: string | null;
|
||||
subject: string;
|
||||
/**
|
||||
* Cely text pozadavku - telo e-mailu, zprava z WhatsApp, prepis hovoru.
|
||||
* Prazdny retezec znamena, ze krok "Zalozit ticket" obsah nenaplnil.
|
||||
*/
|
||||
body: string;
|
||||
/** Odkaz na zdrojovou zpravu u poskytovatele. null u rucne zalozenych. */
|
||||
sourceRef: string | null;
|
||||
channel: TicketChannel;
|
||||
customer: TicketCustomer;
|
||||
status: TicketStatus;
|
||||
priority: TicketPriority;
|
||||
assignee: TicketAssignee | null;
|
||||
/**
|
||||
* Skupina, ktera ma ticket u sebe. Prehazovat praci na jmeno nestaci -
|
||||
* clovek chce rict "tohle je pro ucetni" bez toho, aby resil, kdo z nich
|
||||
* ma dovolenou. Viz data/people.ts, sekce skupiny.
|
||||
*/
|
||||
assigneeGroupId: string | null;
|
||||
/**
|
||||
* Typ ticketu. Za nim stoji vlastni pole, proto se na nej vazou akce.
|
||||
* null = ticket bez typu, jako driv.
|
||||
*/
|
||||
typeId: string | null;
|
||||
/** Hodnoty vlastnich poli typu. Klic je `TicketTypeField.key`. */
|
||||
fields: Record<string, string | number | boolean | null>;
|
||||
/**
|
||||
* Je vyrizeny?
|
||||
*
|
||||
* **Nastavuje se vyslovne**, neodvozuje se ze stavu. Stav je volny retezec
|
||||
* a hadat z nej, jestli `completed` nebo `hotovo` znamena konec, by znamenalo
|
||||
* uhodnout spatne pokazde, kdyz si nekdo pojmenuje stavy po svem.
|
||||
*
|
||||
* Fronta, vytizeni i statistiky se ptaji na tenhle priznak.
|
||||
*/
|
||||
closed: boolean;
|
||||
/**
|
||||
* Volne oznaceni. Na rozdil od typu jich muze byt vic a nestoji za nimi
|
||||
* zadna pole - proto se hodi na filtry a widgety, ne na akce, ktere
|
||||
* potrebuji data.
|
||||
*/
|
||||
tags: string[];
|
||||
/** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */
|
||||
automationId: string | null;
|
||||
/**
|
||||
* Ucet, ktery ticket zalozil rucne. null u ticketu z automatizace.
|
||||
*
|
||||
* Je to **ucet, ne resitel**: v helpdesku podle nej zadavatel pozna svoje
|
||||
* pozadavky, a ten je nikdy nema prirazene - resi je nekdo u dodavatele.
|
||||
*/
|
||||
createdById: string | null;
|
||||
/**
|
||||
* Kdy se ticketu prvne nekdo venoval: prirazeni, komentar, zmena stavu.
|
||||
* Bez toho nejde rict, jak dlouho zakaznik cekal na reakci.
|
||||
*/
|
||||
firstResponseAt: string | null;
|
||||
/** Kdy byl vyresen. Znovuotevreni to vynuluje. */
|
||||
resolvedAt: string | null;
|
||||
/** Kdo ho vyresil. Bez toho nejde rict, kdo kolik odbavil. */
|
||||
resolvedById: string | null;
|
||||
/**
|
||||
* Kolikrat se vratil z vyreseno zpatky do prace.
|
||||
*
|
||||
* Nejlepsi jednoduchy ukazatel toho, ze prace nebyla hotova. Pocet vyresenych
|
||||
* sam o sobe odmenuje toho, kdo tickety zaviral predcasne.
|
||||
*/
|
||||
reopenCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TicketDetail extends Ticket {
|
||||
trace: TicketTraceEntry[];
|
||||
/** Prichozi udalosti, nejstarsi nahore. */
|
||||
events: TicketEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tvar v ulozisti.
|
||||
*
|
||||
@@ -282,16 +118,30 @@ export const channelLabels: Record<TicketChannel, string> = {
|
||||
portal: 'Portál',
|
||||
};
|
||||
|
||||
function minutesAgo(minutes: number): string {
|
||||
return new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- uloziste
|
||||
|
||||
const tickets: StoredTicket[] = [];
|
||||
/**
|
||||
* Indexy nad polem. Ticket se hleda podle ID pri kazdem zapisu do logu
|
||||
* a podle externiho ID pri kazde prichozi udalosti - linearni hledani
|
||||
* v tisicich ticketu by bylo znat. Pole zustava kvuli poradi v seznamu.
|
||||
*/
|
||||
const ticketsById = new Map<string, StoredTicket>();
|
||||
const ticketsByExternal = new Map<string, StoredTicket>();
|
||||
/** Log ticketu drzime zvlast - je to jina zivotnost i jiny objem dat. */
|
||||
const traces = new Map<string, TicketTraceEntry[]>();
|
||||
|
||||
/** Klic externiho ID: unikatni je v ramci firmy, ne globalne. */
|
||||
function externalKey(tenantId: string, externalId: string): string {
|
||||
return `${tenantId}:${externalId}`;
|
||||
}
|
||||
|
||||
/** Zaradi ticket do indexu. Vola se vsude, kde ticket pribyva do pole. */
|
||||
function index(ticket: StoredTicket): void {
|
||||
ticketsById.set(ticket.id, ticket);
|
||||
if (ticket.externalId) ticketsByExternal.set(externalKey(ticket.tenantId, ticket.externalId), ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -306,13 +156,33 @@ const events = new Map<string, TicketEvent[]>();
|
||||
|
||||
const mirror = withMirror(defineStore<PersistedTicket>('ticket'));
|
||||
|
||||
/**
|
||||
* Tickety, ktere cekaji na zapis. Zapisuje se **jednou za tik** smycky:
|
||||
* jedna operace (zmena stavu, radek do logu, udalost na sbernici) volala
|
||||
* `persist` dvakrat az trikrat a pokazde sla do uloziste cela kopie ticketu
|
||||
* vcetne logu a az dvou set udalosti. Ted se zmeny za tik slouci a zapise se
|
||||
* stav, ktery plati na jeho konci. Poradi zapisu tehoz ticketu hlida `withMirror`.
|
||||
*/
|
||||
const pendingPersist = new Set<string>();
|
||||
|
||||
/** Ulozi ticket vcetne logu. Necekana se, chyba se loguje. */
|
||||
function persist(ticket: StoredTicket): void {
|
||||
mirror.save({
|
||||
...ticket,
|
||||
trace: traces.get(ticket.id) ?? [],
|
||||
events: events.get(ticket.id) ?? [],
|
||||
});
|
||||
if (pendingPersist.size === 0) queueMicrotask(flushPersist);
|
||||
pendingPersist.add(ticket.id);
|
||||
}
|
||||
|
||||
function flushPersist(): void {
|
||||
const ids = [...pendingPersist];
|
||||
pendingPersist.clear();
|
||||
for (const id of ids) {
|
||||
const ticket = ticketsById.get(id);
|
||||
if (!ticket) continue;
|
||||
mirror.save({
|
||||
...ticket,
|
||||
trace: traces.get(id) ?? [],
|
||||
events: events.get(id) ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,11 +211,14 @@ export async function initTickets(): Promise<void> {
|
||||
);
|
||||
|
||||
tickets.length = 0;
|
||||
ticketsById.clear();
|
||||
ticketsByExternal.clear();
|
||||
traces.clear();
|
||||
events.clear();
|
||||
for (const row of rows) {
|
||||
const { trace, events: rowEvents, ...stored } = row;
|
||||
tickets.push(stored);
|
||||
index(stored);
|
||||
traces.set(row.id, trace ?? []);
|
||||
/*
|
||||
* Pocitadlo opakovani pribylo pozdeji. Ulozene udalosti ho nemaji, takze
|
||||
@@ -363,10 +236,7 @@ export async function initTickets(): Promise<void> {
|
||||
|
||||
// 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;
|
||||
}
|
||||
ticketCounter = Math.max(ticketCounter, highestNumber(ticketsById.keys(), 'TK'));
|
||||
|
||||
// Log muze byt dlouhy, do citace radku se to nepocita.
|
||||
traceCounter = [...traces.values()].reduce((sum, list) => sum + list.length, traceCounter);
|
||||
@@ -440,7 +310,7 @@ export function appendTrace(ticketId: string, inputs: TraceInput[]): number {
|
||||
flattenTrace(inputs, currentRun()?.traceParent.id ?? null, existing);
|
||||
|
||||
// Log je soucast ulozeneho ticketu, takze zapis do logu je zmena ticketu.
|
||||
const ticket = tickets.find((item) => item.id === ticketId);
|
||||
const ticket = ticketsById.get(ticketId);
|
||||
if (ticket) persist(ticket);
|
||||
|
||||
return existing.length - before;
|
||||
@@ -450,15 +320,17 @@ export function appendTrace(ticketId: string, inputs: TraceInput[]): number {
|
||||
|
||||
function seed(ticket: StoredTicket, trace: TraceInput[]) {
|
||||
tickets.push(ticket);
|
||||
index(ticket);
|
||||
traces.set(ticket.id, flattenTrace(trace, null, []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ukazkove tickety.
|
||||
*
|
||||
* Nasypou se **jen se `SEED_DEMO=1`**. Na instanci, kde uz chodi skutecny
|
||||
* provoz, jsou to cizi zaznamy mezi opravdovymi a po kazdem redeployi
|
||||
* se vraceji.
|
||||
* Nasypou se **jen se `SEED_DEMO=1`**, vsechny vcetne tech u klientskych firem.
|
||||
* Na instanci, kde uz chodi skutecny provoz, jsou to cizi zaznamy mezi
|
||||
* opravdovymi a po kazdem redeployi se vraceji. Stavy jsou z `defaultStatuses`,
|
||||
* tedy ty, se kterymi vznikaji i skutecne tickety.
|
||||
*/
|
||||
function seedDemoTickets(): void {
|
||||
seed(
|
||||
@@ -477,7 +349,7 @@ function seedDemoTickets(): void {
|
||||
contact: 'Petra Klientová',
|
||||
reply: '+420 601 118 224',
|
||||
},
|
||||
status: 'open',
|
||||
status: 'V řešení',
|
||||
priority: 'high',
|
||||
assigneeId: 'ppl_novakova',
|
||||
automationId: 'AUT-02',
|
||||
@@ -591,7 +463,7 @@ function seedDemoTickets(): void {
|
||||
contact: 'Tomáš Beran',
|
||||
reply: 'tomas.beran@nordis.cz',
|
||||
},
|
||||
status: 'waiting',
|
||||
status: 'Čeká na klienta',
|
||||
priority: 'normal',
|
||||
assigneeId: 'ppl_kriz',
|
||||
automationId: 'AUT-03',
|
||||
@@ -672,7 +544,7 @@ function seedDemoTickets(): void {
|
||||
contact: 'Bistro Kolektiv',
|
||||
reply: '+420 774 902 331',
|
||||
},
|
||||
status: 'new',
|
||||
status: 'Nový',
|
||||
priority: 'normal',
|
||||
assigneeId: null,
|
||||
automationId: 'AUT-01',
|
||||
@@ -751,122 +623,123 @@ function seedDemoTickets(): void {
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
seed(
|
||||
{
|
||||
id: 'TK-4817',
|
||||
tenantId: 'tnt_logitrans',
|
||||
subject: 'Rozšíření hlasového scénáře o objednávku svozu',
|
||||
body:
|
||||
'Chtěli bychom, aby si zákazník mohl objednat svoz rovnou po telefonu, ' +
|
||||
'bez přepojení na dispečink. Rozpočet do 40 000. Prosím o odhad.',
|
||||
sourceRef: null,
|
||||
channel: 'form',
|
||||
customer: {
|
||||
id: 'crm_3390',
|
||||
company: 'LogiTrans',
|
||||
contact: 'Jana Sedláčková',
|
||||
reply: 'jana.sedlackova@logitrans.cz',
|
||||
},
|
||||
status: 'V řešení',
|
||||
priority: 'low',
|
||||
assigneeId: 'ppl_kadlec',
|
||||
automationId: null,
|
||||
createdAt: minutesAgo(2_600),
|
||||
updatedAt: minutesAgo(420),
|
||||
},
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
serviceId: 'form',
|
||||
operationId: 'submitted',
|
||||
label: 'Odeslán formulář Požadavek na úpravu',
|
||||
status: 'ok',
|
||||
response: '{ "company": "LogiTrans", "topic": "voicebot", "budget": "do 40 000" }',
|
||||
durationMs: 45,
|
||||
agoMinutes: 2_600,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
response: '{ "ticketId": "TK-4817", "priority": "low" }',
|
||||
durationMs: 71,
|
||||
agoMinutes: 2_600,
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
label: 'Ticket ručně přiřazen na Ondřeje Kadlece.',
|
||||
status: 'info',
|
||||
agoMinutes: 2_580,
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
label: 'Ondřej Kadlec: Odhad odeslán, čeká se na objednávku.',
|
||||
status: 'info',
|
||||
agoMinutes: 420,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
seed(
|
||||
{
|
||||
id: 'TK-4812',
|
||||
tenantId: 'tnt_nordis',
|
||||
subject: 'Duplicitní zápis kontaktů z webového formuláře',
|
||||
body:
|
||||
'Každé odeslání formuláře zakládá nový kontakt, i když stejný e-mail už v CRM je. ' +
|
||||
'Máme tam desítky duplicit.',
|
||||
sourceRef: null,
|
||||
channel: 'portal',
|
||||
customer: {
|
||||
id: 'crm_1042',
|
||||
company: 'Firma s.r.o.',
|
||||
contact: 'Petra Klientová',
|
||||
reply: 'petra.klientova@firma.cz',
|
||||
},
|
||||
status: 'Vyřešeno',
|
||||
closed: true,
|
||||
priority: 'critical',
|
||||
assigneeId: 'ppl_bartos',
|
||||
automationId: null,
|
||||
createdAt: minutesAgo(5_100),
|
||||
updatedAt: minutesAgo(1_500),
|
||||
},
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
serviceId: 'form',
|
||||
operationId: 'submitted',
|
||||
label: 'Nahlášeno z portálu',
|
||||
status: 'ok',
|
||||
response: '{ "reportedBy": "petra.klientova@firma.cz" }',
|
||||
durationMs: 38,
|
||||
agoMinutes: 5_100,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: 'transform',
|
||||
operationId: 'deduplicate',
|
||||
label: 'Kontrola duplicit v CRM',
|
||||
status: 'ok',
|
||||
response: '{ "scanned": 1284, "duplicates": 37, "key": "email" }',
|
||||
durationMs: 6_700,
|
||||
agoMinutes: 4_900,
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
label: 'Lukáš Bartoš: Do formuláře doplněna kontrola podle e-mailu, duplicity sloučeny.',
|
||||
status: 'info',
|
||||
agoMinutes: 1_500,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (config.seedDemo) seedDemoTickets();
|
||||
|
||||
seed(
|
||||
{
|
||||
id: 'TK-4817',
|
||||
tenantId: 'tnt_logitrans',
|
||||
subject: 'Rozšíření hlasového scénáře o objednávku svozu',
|
||||
body:
|
||||
'Chtěli bychom, aby si zákazník mohl objednat svoz rovnou po telefonu, ' +
|
||||
'bez přepojení na dispečink. Rozpočet do 40 000. Prosím o odhad.',
|
||||
sourceRef: null,
|
||||
channel: 'form',
|
||||
customer: {
|
||||
id: 'crm_3390',
|
||||
company: 'LogiTrans',
|
||||
contact: 'Jana Sedláčková',
|
||||
reply: 'jana.sedlackova@logitrans.cz',
|
||||
},
|
||||
status: 'open',
|
||||
priority: 'low',
|
||||
assigneeId: 'ppl_kadlec',
|
||||
automationId: null,
|
||||
createdAt: minutesAgo(2_600),
|
||||
updatedAt: minutesAgo(420),
|
||||
},
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
serviceId: 'form',
|
||||
operationId: 'submitted',
|
||||
label: 'Odeslán formulář Požadavek na úpravu',
|
||||
status: 'ok',
|
||||
response: '{ "company": "LogiTrans", "topic": "voicebot", "budget": "do 40 000" }',
|
||||
durationMs: 45,
|
||||
agoMinutes: 2_600,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
response: '{ "ticketId": "TK-4817", "priority": "low" }',
|
||||
durationMs: 71,
|
||||
agoMinutes: 2_600,
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
label: 'Ticket ručně přiřazen na Ondřeje Kadlece.',
|
||||
status: 'info',
|
||||
agoMinutes: 2_580,
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
label: 'Ondřej Kadlec: Odhad odeslán, čeká se na objednávku.',
|
||||
status: 'info',
|
||||
agoMinutes: 420,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
seed(
|
||||
{
|
||||
id: 'TK-4812',
|
||||
tenantId: 'tnt_nordis',
|
||||
subject: 'Duplicitní zápis kontaktů z webového formuláře',
|
||||
body:
|
||||
'Každé odeslání formuláře zakládá nový kontakt, i když stejný e-mail už v CRM je. ' +
|
||||
'Máme tam desítky duplicit.',
|
||||
sourceRef: null,
|
||||
channel: 'portal',
|
||||
customer: {
|
||||
id: 'crm_1042',
|
||||
company: 'Firma s.r.o.',
|
||||
contact: 'Petra Klientová',
|
||||
reply: 'petra.klientova@firma.cz',
|
||||
},
|
||||
status: 'resolved',
|
||||
priority: 'critical',
|
||||
assigneeId: 'ppl_bartos',
|
||||
automationId: null,
|
||||
createdAt: minutesAgo(5_100),
|
||||
updatedAt: minutesAgo(1_500),
|
||||
},
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
serviceId: 'form',
|
||||
operationId: 'submitted',
|
||||
label: 'Nahlášeno z portálu',
|
||||
status: 'ok',
|
||||
response: '{ "reportedBy": "petra.klientova@firma.cz" }',
|
||||
durationMs: 38,
|
||||
agoMinutes: 5_100,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: 'transform',
|
||||
operationId: 'deduplicate',
|
||||
label: 'Kontrola duplicit v CRM',
|
||||
status: 'ok',
|
||||
response: '{ "scanned": 1284, "duplicates": 37, "key": "email" }',
|
||||
durationMs: 6_700,
|
||||
agoMinutes: 4_900,
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
label: 'Lukáš Bartoš: Do formuláře doplněna kontrola podle e-mailu, duplicity sloučeny.',
|
||||
status: 'info',
|
||||
agoMinutes: 1_500,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------ prevody
|
||||
|
||||
function toTicket(stored: StoredTicket): Ticket {
|
||||
@@ -1024,7 +897,7 @@ export function getTicket(
|
||||
helpdeskSourceIds: string[] = [],
|
||||
visibility: Visibility = { kind: 'all' },
|
||||
): TicketDetail | undefined {
|
||||
const stored = tickets.find((t) => t.id === id);
|
||||
const stored = ticketsById.get(id);
|
||||
if (!stored) return undefined;
|
||||
|
||||
const owns = tenantIds.includes(stored.tenantId);
|
||||
@@ -1064,7 +937,7 @@ export function getTicket(
|
||||
* za firmu ticketu, ne za prave prepnutou.
|
||||
*/
|
||||
export function ticketWithinVisibility(id: string, visibility: Visibility): boolean {
|
||||
const stored = tickets.find((t) => t.id === id);
|
||||
const stored = ticketsById.get(id);
|
||||
return stored ? withinVisibility(stored, visibility) : false;
|
||||
}
|
||||
|
||||
@@ -1076,29 +949,6 @@ export function firstOpenTicket(tenantIds: string[]): Ticket | undefined {
|
||||
return stored ? toTicket(stored) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kdo co ma u sebe. Prehled pres cely tym, ne jen pres prihlaseneho cloveka -
|
||||
* bez nej nejde poznat, ze jeden clovek utahuje polovinu servicedesku.
|
||||
*/
|
||||
export interface WorkloadRow {
|
||||
person: Person;
|
||||
/** Nevyresene tickety. Tohle je to cislo, na ktere se lidi divaji. */
|
||||
open: number;
|
||||
/** Vcetne vyresenych, aby bylo videt, kolik uz ma za sebou. */
|
||||
total: number;
|
||||
critical: number;
|
||||
/** Nejstarsi nevyreseny ticket. null = nic nevisi. */
|
||||
oldestOpenAt: string | null;
|
||||
/** true = ma vic nevyrizenych, nez je jeho kapacita. */
|
||||
overloaded: boolean;
|
||||
}
|
||||
|
||||
export interface Workload {
|
||||
rows: WorkloadRow[];
|
||||
unassigned: number;
|
||||
openTotal: number;
|
||||
}
|
||||
|
||||
export function getWorkload(
|
||||
everyone: Person[],
|
||||
tenantIds: string[],
|
||||
@@ -1108,20 +958,19 @@ export function getWorkload(
|
||||
// a vytizeni tymu tak obchazelo kazde omezeni viditelnosti.
|
||||
const visible = listTickets({ tenantIds, visibility });
|
||||
|
||||
// Jeden pruchod pres tickety, ne jeden filtr za kazdeho cloveka.
|
||||
const byAssignee = groupByAssignee(visible);
|
||||
|
||||
const rows = everyone.map((person) => {
|
||||
const mine = visible.filter((t) => t.assignee?.id === person.id);
|
||||
const mine = byAssignee.get(person.id) ?? [];
|
||||
const open = mine.filter((t) => !t.closed);
|
||||
const oldest = open.reduce<string | null>(
|
||||
(acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc),
|
||||
null,
|
||||
);
|
||||
|
||||
return {
|
||||
person,
|
||||
open: open.length,
|
||||
total: mine.length,
|
||||
critical: open.filter((t) => t.priority === 'critical').length,
|
||||
oldestOpenAt: oldest,
|
||||
oldestOpenAt: oldestCreatedAt(open),
|
||||
overloaded: open.length > person.capacity,
|
||||
};
|
||||
});
|
||||
@@ -1136,6 +985,26 @@ export function getWorkload(
|
||||
};
|
||||
}
|
||||
|
||||
/** Tickety podle resitele. Nezarazene tu nejsou, ty maji vlastni pocitadlo. */
|
||||
function groupByAssignee(list: Ticket[]): Map<string, Ticket[]> {
|
||||
const groups = new Map<string, Ticket[]>();
|
||||
for (const ticket of list) {
|
||||
if (!ticket.assignee) continue;
|
||||
const mine = groups.get(ticket.assignee.id);
|
||||
if (mine) mine.push(ticket);
|
||||
else groups.set(ticket.assignee.id, [ticket]);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Nejstarsi cas vzniku. null = prazdny seznam. */
|
||||
function oldestCreatedAt(list: Ticket[]): string | null {
|
||||
return list.reduce<string | null>(
|
||||
(acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prvni reakce se zapisuje jednou a uz se neprepisuje.
|
||||
*
|
||||
@@ -1148,16 +1017,14 @@ function markResponded(ticket: StoredTicket): void {
|
||||
|
||||
/** Ticket podle naseho ID, jen z povolenych firem. */
|
||||
export function findTicket(id: string, tenantIds: string[]): Ticket | undefined {
|
||||
const stored = tickets.find((ticket) => ticket.id === id);
|
||||
const stored = ticketsById.get(id);
|
||||
if (!stored || !tenantIds.includes(stored.tenantId)) return undefined;
|
||||
return toTicket(stored);
|
||||
}
|
||||
|
||||
/** Ticket firmy podle externiho ID. Klic je dvojice firma a ID, ne ID samotne. */
|
||||
export function findByExternalId(tenantId: string, externalId: string): Ticket | undefined {
|
||||
const stored = tickets.find(
|
||||
(ticket) => ticket.tenantId === tenantId && ticket.externalId === externalId,
|
||||
);
|
||||
const stored = ticketsByExternal.get(externalKey(tenantId, externalId));
|
||||
return stored ? toTicket(stored) : undefined;
|
||||
}
|
||||
|
||||
@@ -1260,9 +1127,7 @@ export function intakeEvent(input: IntakeInput): IntakeResult {
|
||||
};
|
||||
|
||||
const existing = input.externalId
|
||||
? tickets.find(
|
||||
(ticket) => ticket.tenantId === input.tenantId && ticket.externalId === input.externalId,
|
||||
)
|
||||
? ticketsByExternal.get(externalKey(input.tenantId, input.externalId))
|
||||
: undefined;
|
||||
|
||||
if (existing) {
|
||||
@@ -1309,7 +1174,8 @@ export function intakeEvent(input: IntakeInput): IntakeResult {
|
||||
publish('ticket.updated', `Ticket ${existing.id}: ${event.label}`, {
|
||||
ticketId: existing.id,
|
||||
externalId: existing.externalId,
|
||||
});
|
||||
ticket: toTicket(existing),
|
||||
}, existing.tenantId);
|
||||
|
||||
return { ticket: toTicket(existing), created: false, event, repeated: false };
|
||||
}
|
||||
@@ -1357,7 +1223,7 @@ export function intakeEvent(input: IntakeInput): IntakeResult {
|
||||
],
|
||||
});
|
||||
|
||||
const stored = tickets.find((ticket) => ticket.id === created.id);
|
||||
const stored = ticketsById.get(created.id);
|
||||
if (stored) {
|
||||
appendEvent(stored, event);
|
||||
persist(stored);
|
||||
@@ -1501,19 +1367,6 @@ function describePayload(payload: Record<string, unknown>): string | null {
|
||||
|
||||
// ---------------------------------------------------------------- statistiky
|
||||
|
||||
export interface AgentStatsRow {
|
||||
personId: string;
|
||||
name: string;
|
||||
resolved: number;
|
||||
open: number;
|
||||
critical: number;
|
||||
medianResolveMs: number | null;
|
||||
medianResponseMs: number | null;
|
||||
reopened: number;
|
||||
oldestOpenAt: string | null;
|
||||
overloaded: boolean;
|
||||
}
|
||||
|
||||
/** Median, ne prumer: jeden ticket zapomenuty pres dovolenou jinak prebije vsechno. */
|
||||
function median(values: number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
@@ -1539,39 +1392,43 @@ export function getAgentStats(
|
||||
): AgentStatsRow[] {
|
||||
const visible = listTickets({ tenantIds, visibility });
|
||||
|
||||
/**
|
||||
* Cas z nepovinneho pole. `undefined` znamena starsi ticket zalozeny driv,
|
||||
* nez se to zacalo evidovat - takovy se do statistiky nepocita.
|
||||
*/
|
||||
function at(value: string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
// Jeden pruchod: tickety podle resitele a vyresene podle toho, kdo je vyresil.
|
||||
const byAssignee = groupByAssignee(visible);
|
||||
const byResolver = new Map<string, Ticket[]>();
|
||||
for (const ticket of visible) {
|
||||
if (!ticket.resolvedById) continue;
|
||||
const time = at(ticket.resolvedAt);
|
||||
if (time === null || (since !== null && time < since)) continue;
|
||||
const mine = byResolver.get(ticket.resolvedById);
|
||||
if (mine) mine.push(ticket);
|
||||
else byResolver.set(ticket.resolvedById, [ticket]);
|
||||
}
|
||||
|
||||
const rows = everyone.map((person) => {
|
||||
const open = visible.filter((t) => t.assignee?.id === person.id && !t.closed);
|
||||
|
||||
/**
|
||||
* Cas z nepovinneho pole. `undefined` znamena starsi ticket zalozeny driv,
|
||||
* nez se to zacalo evidovat - takovy se do statistiky nepocita.
|
||||
*/
|
||||
function at(value: string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
const resolved = visible.filter((t) => {
|
||||
if (t.resolvedById !== person.id) return false;
|
||||
const time = at(t.resolvedAt);
|
||||
if (time === null) return false;
|
||||
return since === null || time >= since;
|
||||
});
|
||||
const mine = byAssignee.get(person.id) ?? [];
|
||||
const open = mine.filter((t) => !t.closed);
|
||||
const resolved = byResolver.get(person.id) ?? [];
|
||||
|
||||
const resolveTimes = resolved
|
||||
.map((t) => (at(t.resolvedAt) ?? 0) - (at(t.createdAt) ?? 0))
|
||||
.filter((value) => value >= 0);
|
||||
|
||||
const responseTimes = visible
|
||||
.filter((t) => t.assignee?.id === person.id && at(t.firstResponseAt) !== null)
|
||||
const responseTimes = mine
|
||||
.filter((t) => at(t.firstResponseAt) !== null)
|
||||
.map((t) => (at(t.firstResponseAt) ?? 0) - (at(t.createdAt) ?? 0))
|
||||
.filter((value) => value >= 0);
|
||||
|
||||
const oldest = open.reduce<string | null>(
|
||||
(acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc),
|
||||
null,
|
||||
);
|
||||
const oldest = oldestCreatedAt(open);
|
||||
|
||||
return {
|
||||
personId: person.id,
|
||||
@@ -1671,6 +1528,7 @@ export function createTicket(input: CreateTicketInput): Ticket {
|
||||
updatedAt: now,
|
||||
};
|
||||
tickets.unshift(stored);
|
||||
index(stored);
|
||||
traces.set(stored.id, flattenTrace(input.trace ?? [], null, []));
|
||||
events.set(stored.id, []);
|
||||
persist(stored);
|
||||
@@ -1682,19 +1540,13 @@ export function createTicket(input: CreateTicketInput): Ticket {
|
||||
channel: stored.channel,
|
||||
priority: stored.priority,
|
||||
knownCustomer: stored.customer.id !== null,
|
||||
});
|
||||
}, stored.tenantId);
|
||||
return toTicket(stored);
|
||||
}
|
||||
|
||||
/** Ticket z povolenych firem. Cizi se chova jako neexistujici. */
|
||||
function findWritable(id: string, tenantIds: string[]): StoredTicket | undefined {
|
||||
const ticket = tickets.find((t) => t.id === id);
|
||||
if (!ticket) return undefined;
|
||||
if (!tenantIds.includes(ticket.tenantId)) {
|
||||
console.warn(`[tickets] pokus o zapis do ticketu ${id} mimo povolene firmy`);
|
||||
return undefined;
|
||||
}
|
||||
return ticket;
|
||||
return writableOrWarn(ticketsById.get(id), id, tenantIds, 'tickets');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1755,12 +1607,14 @@ export function updateTicketStatus(
|
||||
if (ticket.closed) {
|
||||
publish('ticket.resolved', `Ticket ${ticket.id} vyřešen: ${ticket.subject}`, {
|
||||
ticketId: ticket.id,
|
||||
});
|
||||
ticket: toTicket(ticket),
|
||||
}, ticket.tenantId);
|
||||
} else {
|
||||
publish('ticket.updated', `Ticket ${ticket.id} má nový stav`, {
|
||||
ticketId: ticket.id,
|
||||
status,
|
||||
});
|
||||
ticket: toTicket(ticket),
|
||||
}, ticket.tenantId);
|
||||
}
|
||||
return toTicket(ticket);
|
||||
}
|
||||
@@ -1820,7 +1674,8 @@ export function assignTicket(
|
||||
person
|
||||
? `Ticket ${ticket.id} přiřazen: ${person.name}`
|
||||
: `Ticket ${ticket.id} vrácen do fronty`,
|
||||
{ ticketId: ticket.id, assigneeId: ticket.assigneeId },
|
||||
{ ticketId: ticket.id, assigneeId: ticket.assigneeId, ticket: toTicket(ticket) },
|
||||
ticket.tenantId,
|
||||
);
|
||||
return toTicket(ticket);
|
||||
}
|
||||
@@ -1848,7 +1703,7 @@ export function setTicketType(
|
||||
appendTrace(id, [
|
||||
{ kind: 'note', status: 'info', label: `Typ ticketu nastaven na ${typeId ?? 'bez typu'}` },
|
||||
]);
|
||||
publish('ticket.updated', `Ticket ${ticket.id} má nový typ`, { ticketId: ticket.id });
|
||||
publish('ticket.updated', `Ticket ${ticket.id} má nový typ`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
|
||||
return toTicket(ticket);
|
||||
}
|
||||
|
||||
@@ -1859,7 +1714,7 @@ export function setTicketTags(id: string, tags: string[], tenantIds: string[]):
|
||||
|
||||
ticket.tags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
|
||||
touch(ticket);
|
||||
publish('ticket.updated', `Ticket ${ticket.id} má upravené tagy`, { ticketId: ticket.id });
|
||||
publish('ticket.updated', `Ticket ${ticket.id} má upravené tagy`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
|
||||
return toTicket(ticket);
|
||||
}
|
||||
|
||||
@@ -1890,7 +1745,7 @@ export function assignTicketGroup(
|
||||
label: groupId ? `Přehozeno na skupinu ${groupId}` : 'Odebráno ze skupiny',
|
||||
},
|
||||
]);
|
||||
publish('ticket.assigned', `Ticket ${ticket.id} přehozen na skupinu`, { ticketId: ticket.id });
|
||||
publish('ticket.assigned', `Ticket ${ticket.id} přehozen na skupinu`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
|
||||
return toTicket(ticket);
|
||||
}
|
||||
|
||||
@@ -1924,13 +1779,14 @@ export function claimTicket(
|
||||
publish('ticket.assigned', `${person.name} si vzal ticket ${ticket.id}`, {
|
||||
ticketId: ticket.id,
|
||||
assigneeId: person.id,
|
||||
});
|
||||
ticket: toTicket(ticket),
|
||||
}, ticket.tenantId);
|
||||
return toTicket(ticket);
|
||||
}
|
||||
|
||||
/** Ma ticket uz nekoho? Pro rozhodnuti, jestli jde prevzit. */
|
||||
export function ticketAssignee(id: string, tenantIds: string[]): string | null | undefined {
|
||||
const ticket = tickets.find((item) => item.id === id);
|
||||
const ticket = ticketsById.get(id);
|
||||
if (!ticket || !tenantIds.includes(ticket.tenantId)) return undefined;
|
||||
return ticket.assigneeId;
|
||||
}
|
||||
@@ -1950,7 +1806,7 @@ export function addComment(
|
||||
tenantIds: string[],
|
||||
helpdeskSourceIds: string[] = [],
|
||||
): Ticket | undefined {
|
||||
const found = tickets.find((item) => item.id === id);
|
||||
const found = ticketsById.get(id);
|
||||
const asked =
|
||||
found?.helpdeskSourceId !== null &&
|
||||
found?.helpdeskSourceId !== undefined &&
|
||||
@@ -1966,6 +1822,6 @@ export function addComment(
|
||||
touch(ticket);
|
||||
appendTrace(id, [{ kind: 'note', label: `${author}: ${text}`, status: 'info' }]);
|
||||
|
||||
publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id });
|
||||
publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
|
||||
return toTicket(ticket);
|
||||
}
|
||||
|
||||
+5
-33
@@ -14,39 +14,14 @@
|
||||
* "Typ nebo tag".
|
||||
*/
|
||||
|
||||
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
|
||||
import { defineStore, nowIso } 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;
|
||||
}
|
||||
import type { TicketType, TicketTypeField } from '../shared/tickets.js';
|
||||
|
||||
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 stavy ticketu. Prazdne = vychozi doporucena ctverice.
|
||||
*
|
||||
* Stav je volny retezec, tohle je jen nabidka v rozhrani - ticket muze mit
|
||||
* i stav, ktery poslala cizi aplikace a v seznamu neni.
|
||||
*/
|
||||
statuses: string[];
|
||||
|
||||
fields: TicketTypeField[];
|
||||
}
|
||||
/** Tvar typu ticketu je sdileny s webem, viz src/shared/tickets.ts. */
|
||||
export type { TicketType, TicketTypeField };
|
||||
|
||||
export const ticketTypeStore = defineStore<TicketType>('ticketType');
|
||||
const cache = withCache(ticketTypeStore);
|
||||
@@ -96,10 +71,7 @@ export async function refreshTicketTypes(): Promise<void> {
|
||||
}
|
||||
|
||||
export function listTicketTypes(tenantIds: string[]): TicketType[] {
|
||||
return cache
|
||||
.all()
|
||||
.filter((type) => tenantIds.includes(type.tenantId))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
return cache.listByTenant(tenantIds, 'name');
|
||||
}
|
||||
|
||||
export function findTicketType(id: string): TicketType | undefined {
|
||||
|
||||
+11
-2
@@ -115,8 +115,17 @@ export function findUserById(id: string): User | undefined {
|
||||
return found && found.enabled ? toUser(found) : undefined;
|
||||
}
|
||||
|
||||
export function hashPassword(plain: string): string {
|
||||
return bcrypt.hashSync(plain, 10);
|
||||
/**
|
||||
* Asynchronne zamerne. `hashSync` blokuje event loop na desitky milisekund
|
||||
* a po tu dobu nikdo jiny nedostane odpoved - u hesla se to opakuje pri
|
||||
* kazdem prihlaseni.
|
||||
*/
|
||||
export function hashPassword(plain: string): Promise<string> {
|
||||
return bcrypt.hash(plain, 10);
|
||||
}
|
||||
|
||||
export function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(plain, hash);
|
||||
}
|
||||
|
||||
/** Uzivatele, kteri patri do dane firmy. */
|
||||
|
||||
+10
-36
@@ -6,35 +6,18 @@
|
||||
* i novou komponentu na webu, proto jich je zamerne malo a jsou obecne.
|
||||
*/
|
||||
|
||||
import { listCustomWidgets, sizesFor, type WidgetRender, type WidgetSource } from './customWidgets.js';
|
||||
import { listCustomWidgets, sizesFor, type WidgetSource } from './customWidgets.js';
|
||||
|
||||
export type WidgetSize = 'third' | 'half' | 'full';
|
||||
import type {
|
||||
CatalogWidget,
|
||||
WidgetDefinition,
|
||||
WidgetKind,
|
||||
WidgetMetric,
|
||||
WidgetSize,
|
||||
} from '../shared/widgets.js';
|
||||
|
||||
export type WidgetKind =
|
||||
| 'stat'
|
||||
| 'chart'
|
||||
| 'ticketList'
|
||||
| 'incidentList'
|
||||
| 'workload'
|
||||
/** Vykon resitelu. Data chodi z `/widget-data`, stejne jako u vlastnich. */
|
||||
| 'agents'
|
||||
/** Vlastni widget: kresleni si urcuje jeho `render`. */
|
||||
| WidgetRender;
|
||||
|
||||
export interface WidgetDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: WidgetKind;
|
||||
/** Ktere sirky ma smysl nabizet. Graf v tretine sloupce necteme. */
|
||||
sizes: WidgetSize[];
|
||||
defaultSize: WidgetSize;
|
||||
/**
|
||||
* Jen u `stat`: ktere cislo ze souhrnu ukazat.
|
||||
* Klic odpovida poli v odpovedi /api/dashboard/summary.
|
||||
*/
|
||||
metric?: 'openTickets' | 'activeIncidents' | 'activeAutomations' | 'runsToday' | 'savedHoursMonth' | 'uptime';
|
||||
}
|
||||
/** Tvar widgetu je sdileny s webem, viz src/shared/widgets.ts. Tady je katalog. */
|
||||
export type { CatalogWidget, WidgetDefinition, WidgetKind, WidgetMetric, WidgetSize };
|
||||
|
||||
export const widgets: WidgetDefinition[] = [
|
||||
{
|
||||
@@ -189,15 +172,6 @@ export function findWidget(id: string): WidgetDefinition | undefined {
|
||||
return widgets.find((widget) => widget.id === id);
|
||||
}
|
||||
|
||||
/** Polozka katalogu tak, jak ji vidi klient. Vlastni widget nese i zdroj dat. */
|
||||
export interface CatalogWidget extends WidgetDefinition {
|
||||
/** true = widget si firma nadefinovala sama. */
|
||||
custom: boolean;
|
||||
render?: WidgetRender;
|
||||
source?: WidgetSource;
|
||||
target?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cely katalog: pevne widgety plus vlastni te firmy.
|
||||
*
|
||||
|
||||
+26
-28
@@ -9,31 +9,15 @@ import { randomUUID } from 'node:crypto';
|
||||
* nahradit Redis pub/sub nebo jina sdilena fronta (viz dokumentace).
|
||||
*/
|
||||
|
||||
export type DashboardEventType =
|
||||
| 'ticket.created'
|
||||
| 'ticket.updated'
|
||||
| 'ticket.assigned'
|
||||
| 'ticket.resolved'
|
||||
| 'incident.started'
|
||||
| 'incident.updated'
|
||||
| 'incident.resolved'
|
||||
| 'automation.created'
|
||||
| 'automation.updated'
|
||||
| 'automation.deleted'
|
||||
| 'automation.run'
|
||||
| 'webhook.received'
|
||||
/** Nekomu prislo upozorneni. Portal podle toho prekresli cislo u zalozky. */
|
||||
| 'notification.created';
|
||||
import type {
|
||||
DashboardEvent,
|
||||
DashboardEventType,
|
||||
EntityEventKind,
|
||||
EntityEventType,
|
||||
} from '../shared/events.js';
|
||||
|
||||
export interface DashboardEvent {
|
||||
id: string;
|
||||
type: DashboardEventType;
|
||||
at: string;
|
||||
/** Kratka veta pro uzivatele, zobrazuje se v notifikaci. */
|
||||
message: string;
|
||||
/** Doplnkova data, napr. id ticketu. */
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
/** Typy udalosti jsou sdilene s webem, viz src/shared/events.ts. */
|
||||
export type { DashboardEvent, DashboardEventType, EntityEventKind, EntityEventType };
|
||||
|
||||
const CHANNEL = 'dashboard';
|
||||
const MAX_RECENT = 50;
|
||||
@@ -44,10 +28,15 @@ emitter.setMaxListeners(200);
|
||||
|
||||
const recent: DashboardEvent[] = [];
|
||||
|
||||
/**
|
||||
* `tenantId` je povinny a posledni zamerne: kdyby byl nepovinny, prvni
|
||||
* zapomenute volani by udalost poslalo vsem. `null` jen u platformnich udalosti.
|
||||
*/
|
||||
export function publish(
|
||||
type: DashboardEventType,
|
||||
message: string,
|
||||
payload?: Record<string, unknown>,
|
||||
payload: Record<string, unknown> | undefined,
|
||||
tenantId: string | null,
|
||||
): DashboardEvent {
|
||||
const event: DashboardEvent = {
|
||||
id: randomUUID(),
|
||||
@@ -55,6 +44,7 @@ export function publish(
|
||||
at: new Date().toISOString(),
|
||||
message,
|
||||
payload,
|
||||
tenantId,
|
||||
};
|
||||
|
||||
recent.unshift(event);
|
||||
@@ -73,9 +63,17 @@ export function subscribe(listener: (event: DashboardEvent) => void): () => void
|
||||
};
|
||||
}
|
||||
|
||||
/** Poslednich par udalosti - klient je dostane hned po pripojeni. */
|
||||
export function recentEvents(limit = 10): DashboardEvent[] {
|
||||
return recent.slice(0, limit);
|
||||
/**
|
||||
* Poslednich par udalosti - klient je dostane hned po pripojeni.
|
||||
*
|
||||
* `visible` filtruje pred orezem, jinak by clovek z male firmy dostal prazdno
|
||||
* vzdycky, kdyz posledni udalosti patrily jinym.
|
||||
*/
|
||||
export function recentEvents(
|
||||
limit = 10,
|
||||
visible: (event: DashboardEvent) => boolean = () => true,
|
||||
): DashboardEvent[] {
|
||||
return recent.filter(visible).slice(0, limit);
|
||||
}
|
||||
|
||||
export function listenerCount(): number {
|
||||
|
||||
+58
-11
@@ -1,10 +1,5 @@
|
||||
import cors from 'cors';
|
||||
import express, {
|
||||
Router,
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from 'express';
|
||||
import express, { type NextFunction, type Request, type Response } from 'express';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -20,6 +15,7 @@ import { bootstrapData } from './data/bootstrap.js';
|
||||
import { flushConnectorStore, initConnectorStore, storageStatus } from './data/connectorStore.js';
|
||||
import { flushStores } from './data/store/index.js';
|
||||
import { adminRouter } from './routes/admin.js';
|
||||
import { safeRouter } from './middleware/asyncHandler.js';
|
||||
import { runMigrations } from './db/migrate.js';
|
||||
import { closeDatabase, databaseHealth, isDatabaseEnabled } from './db/pool.js';
|
||||
import { ensureLoaded, scriptsDir } from './scripts/registry.js';
|
||||
@@ -31,8 +27,13 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.join(here, 'public');
|
||||
|
||||
const app = express();
|
||||
// Aplikace bezi za reverse proxy, jinak by req.ip a protokol byly containeru.
|
||||
app.set('trust proxy', true);
|
||||
/*
|
||||
* Aplikace bezi za reverse proxy (Caddy), jinak by req.ip a protokol byly
|
||||
* containeru. Duveruje se **jednomu** skoku, ne vsem: pri `true` by si kazdy
|
||||
* volajici mohl do X-Forwarded-For vepsat cizi adresu a obejit tak limit
|
||||
* poctu pokusu, ktery je na adresu navazany.
|
||||
*/
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
@@ -47,9 +48,30 @@ app.use(
|
||||
);
|
||||
app.use(express.json({ limit: '256kb' }));
|
||||
|
||||
/*
|
||||
* Bezpecnostni hlavicky. Rucne a stridme: zadne CSP, ktere by rozbilo SPA
|
||||
* nebo Swagger UI. Ramovani jen ze stejne domeny, zadne hadani typu obsahu,
|
||||
* referer bez cesty pri odchodu jinam a vypnute senzory, ktere portal nepouziva.
|
||||
*/
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Token v adrese je pristupovy udaj. Do logu jde jen jeho zacatek, aby slo
|
||||
* volani dohledat, ale ne zopakovat.
|
||||
*/
|
||||
function maskSecretsInUrl(url: string): string {
|
||||
return url.replace(/(\/webhook\/(?:ticket\/)?|\/invites\/)([^/?#]{6})[^/?#]*/g, '$1$2...');
|
||||
}
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
// Loguje se jen metoda a cesta, nikdy hlavicky ani telo - obsahuji secrets.
|
||||
console.info(`[req] ${req.method} ${req.originalUrl}`);
|
||||
console.info(`[req] ${req.method} ${maskSecretsInUrl(req.originalUrl)}`);
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -67,8 +89,11 @@ function isOriginAllowed(origin: string): boolean {
|
||||
/**
|
||||
* strict: true je nutne. Bez nej by se cesta /docs shodovala i s /docs/
|
||||
* a presmerovani nize by se zacyklilo.
|
||||
*
|
||||
* `safeRouter`: async handler, ktery spadne, dojde do error handleru nize
|
||||
* misto toho, aby request visel a chyba skoncila jako unhandledRejection.
|
||||
*/
|
||||
const api = Router({ strict: true });
|
||||
const api = safeRouter({ strict: true });
|
||||
|
||||
/**
|
||||
* Liveness. Zamerne **nezavisi na databazi**: kratky vypadek DB by jinak vedl
|
||||
@@ -151,8 +176,12 @@ app.use(api);
|
||||
if (config.rootPath) app.use(config.rootPath, api);
|
||||
|
||||
// Neexistujici API cesta musi vratit JSON, ne HTML aplikace.
|
||||
// Prefix se bere z ROOT_PATH, ne z tvaru `/apps/<id>` napsaneho natvrdo.
|
||||
const apiPathPattern = new RegExp(
|
||||
`^(${config.rootPath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')})?/(api|webhook)/`,
|
||||
);
|
||||
app.use((req, res, next) => {
|
||||
if (/^(\/apps\/[^/]+)?\/(api|webhook)\//.test(req.path)) {
|
||||
if (apiPathPattern.test(req.path)) {
|
||||
console.warn(`[404] ${req.method} ${req.originalUrl}`);
|
||||
return res.status(404).json({ error: 'not_found', message: 'Endpoint neexistuje.' });
|
||||
}
|
||||
@@ -315,6 +344,24 @@ for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Posledni zachytna sit. Bez ni Node odmitnuty promise jen vypise a jede dal
|
||||
* (u starsich verzi), nebo proces shodi bez slova o tom, kde to bylo.
|
||||
*
|
||||
* Odmitnuty promise: zalogovat i se stackem a bezet dal - byva to jedna
|
||||
* rozbita uloha na pozadi, ne stav celeho procesu. Nechycena vyjimka: proces
|
||||
* uz nemusi byt konzistentni, proto se po zapisu do logu ukonci a AppFactory
|
||||
* ho nastartuje znovu.
|
||||
*/
|
||||
process.on('unhandledRejection', (reason: unknown) => {
|
||||
console.error('[fatal] neosetreny odmitnuty promise:', reason instanceof Error ? reason.stack : reason);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err: Error) => {
|
||||
console.error('[fatal] nechycena vyjimka, proces se ukonci:', err.stack ?? err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`[start] Port ${config.port} je obsazeny.`);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Porovnani tajnych retezcu v konstantnim case.
|
||||
*
|
||||
* `a === b` skonci u prvniho jineho znaku, takze se z doby odpovedi da po
|
||||
* kouskach uhadnout token. U webhooku a pozvanek je token jedina ochrana,
|
||||
* proto se porovnava takhle.
|
||||
*/
|
||||
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export function timingSafeEqualString(a: string, b: string): boolean {
|
||||
// Hash srovna delky, jinak by uz rozdilna delka prozradila, ze se netrefil.
|
||||
const left = createHash('sha256').update(a).digest();
|
||||
const right = createHash('sha256').update(b).digest();
|
||||
return timingSafeEqual(left, right);
|
||||
}
|
||||
+3
-8
@@ -17,7 +17,7 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { config } from '../config.js';
|
||||
import type { ResolvedTarget } from '../scripts/connections.js';
|
||||
import { isPrivateHost } from '../scripts/http.js';
|
||||
import { hostProblem } from '../net/guard.js';
|
||||
import { createRedactor, truncate } from '../scripts/util.js';
|
||||
|
||||
export interface SmtpSettings {
|
||||
@@ -81,13 +81,8 @@ export function smtpSettings(target: ResolvedTarget): { settings: SmtpSettings }
|
||||
if (host === '') return { error: 'Konektor nemá vyplněný SMTP server.' };
|
||||
// Stejne pravidlo jako u HTTP: adresu vyplnuje firma, takze tohle je jedina
|
||||
// zabrana proti navazani spojeni dovnitr nasi site.
|
||||
if (!config.allowPrivateTargets && isPrivateHost(host)) {
|
||||
return {
|
||||
error:
|
||||
`Server ${host} míří do vnitřní sítě. ` +
|
||||
'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.',
|
||||
};
|
||||
}
|
||||
const problem = hostProblem(host);
|
||||
if (problem !== null) return { error: problem };
|
||||
if (user === '') return { error: 'Konektor nemá vyplněného uživatele.' };
|
||||
if (password === '') return { error: 'Konektor nemá vyplněné heslo.' };
|
||||
if (from === '') return { error: 'Konektor nemá vyplněnou adresu odesílatele.' };
|
||||
|
||||
+64
-71
@@ -22,10 +22,15 @@
|
||||
*/
|
||||
|
||||
import { config } from '../config.js';
|
||||
import { describeFetchError, readJsonLimited } from '../net/guard.js';
|
||||
import type { ResolvedTarget } from '../scripts/connections.js';
|
||||
import { truncate } from '../scripts/util.js';
|
||||
import { jwtExpiry, pickText, truncate } from '../scripts/util.js';
|
||||
import { dialectFor, normalizeServerUrl } from './dialect.js';
|
||||
import { ensureAccess, forgetSession, type EasyWebCredentials } from './easyweb/session.js';
|
||||
import { AuthFailure } from './errors.js';
|
||||
|
||||
// Trida bydli v `errors.ts` kvuli kruhu s EasyWebem, ale patri k prihlaseni.
|
||||
export { AuthFailure } from './errors.js';
|
||||
|
||||
/**
|
||||
* O kolik driv nez vyprsi se token vymeni.
|
||||
@@ -75,16 +80,14 @@ interface Session {
|
||||
|
||||
const cache = new Map<string, Session>();
|
||||
|
||||
/** Chyba prihlaseni. Nese vetu pro uzivatele, ne stack. */
|
||||
export class AuthFailure extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly detail: string | null = null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AuthFailure';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Rozdelane prihlaseni podle konektoru.
|
||||
*
|
||||
* Dva soubezne behy nad tim samym konektorem nesmi spustit dve prihlaseni:
|
||||
* je to zbytecne a autorizacni servery to pocitaji do limitu. Druhy volajici
|
||||
* pocka na prvni, stejne jako u EasyWebu.
|
||||
*/
|
||||
const inflight = new Map<string, Promise<Session>>();
|
||||
|
||||
interface Credentials {
|
||||
serviceId: string;
|
||||
@@ -157,17 +160,6 @@ function bearer(token: string, method: AuthMethod): Authorization {
|
||||
|
||||
// ------------------------------------------------------- cteni odpovedi
|
||||
|
||||
/** Hodnota z JSONu bez ohledu na velikost pismen. Servery se v tom lisi. */
|
||||
function stringField(source: Record<string, unknown> | null, ...names: string[]): string | null {
|
||||
if (!source) return null;
|
||||
const wanted = names.map((name) => name.toLowerCase());
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (!wanted.includes(key.toLowerCase())) continue;
|
||||
if (typeof value === 'string' && value.trim() !== '') return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kdy token vyprsi.
|
||||
*
|
||||
@@ -180,48 +172,31 @@ function expiryFrom(body: Record<string, unknown>, token: string): number {
|
||||
const seconds = body.expires_in ?? body.expiresIn ?? body.ExpiresIn;
|
||||
if (typeof seconds === 'number' && seconds > 0) return Date.now() + seconds * 1000;
|
||||
|
||||
const stamp = stringField(body, 'expiresAt', 'expiration', 'expires', 'expiresUtc', 'expirationUtc');
|
||||
const stamp = pickText(body, 'expiresAt', 'expiration', 'expires', 'expiresUtc', 'expirationUtc');
|
||||
if (stamp) {
|
||||
const parsed = Date.parse(stamp);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
|
||||
const claim = expiryFromJwt(token);
|
||||
const claim = jwtExpiry(token);
|
||||
if (claim !== null) return claim;
|
||||
|
||||
return Date.now() + DEFAULT_LIFETIME_MS;
|
||||
}
|
||||
|
||||
/** `exp` z prostredni casti JWT. null, kdyz to JWT neni. */
|
||||
function expiryFromJwt(token: string): number | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
try {
|
||||
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const json = Buffer.from(padded, 'base64').toString('utf8');
|
||||
const payload = JSON.parse(json) as { exp?: unknown };
|
||||
return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Telo odpovedi jako objekt. Vyhazuje, kdyz to JSON neni. */
|
||||
async function readJson(response: Response, where: string): Promise<Record<string, unknown>> {
|
||||
const raw = await response.text();
|
||||
if (raw.length > config.scriptMaxResponseBytes) {
|
||||
const body = await readJsonLimited(response, config.scriptMaxResponseBytes);
|
||||
if (body.tooLarge) {
|
||||
throw new AuthFailure(`Odpověď z ${where} je nad povoleným limitem.`);
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed === null || typeof parsed !== 'object') throw new Error('neni objekt');
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
if (body.json === undefined || body.json === null || typeof body.json !== 'object') {
|
||||
throw new AuthFailure(
|
||||
`Odpověď z ${where} není platný JSON. Míří adresa opravdu na přihlášení?`,
|
||||
truncate(raw, config.errorDetailBytes),
|
||||
truncate(body.text, config.errorDetailBytes),
|
||||
);
|
||||
}
|
||||
return body.json as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- OAuth
|
||||
@@ -234,10 +209,11 @@ async function readMetadata(
|
||||
try {
|
||||
const response = await fetch(url, { method: 'GET', signal, headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) return null;
|
||||
const raw = await response.text();
|
||||
if (raw.length > config.scriptMaxResponseBytes) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return parsed !== null && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
|
||||
const body = await readJsonLimited(response, config.scriptMaxResponseBytes);
|
||||
const parsed = body.json;
|
||||
return parsed !== undefined && parsed !== null && typeof parsed === 'object'
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -322,7 +298,7 @@ async function discoverTokenUrl(serverUrl: string, signal: AbortSignal): Promise
|
||||
|
||||
for (const suffix of ['oauth-authorization-server', 'openid-configuration']) {
|
||||
for (const url of wellKnown(issuerUrl, suffix)) {
|
||||
const endpoint = stringField(await readMetadata(url, signal), 'token_endpoint');
|
||||
const endpoint = pickText(await readMetadata(url, signal), 'token_endpoint');
|
||||
if (endpoint) return endpoint;
|
||||
}
|
||||
}
|
||||
@@ -347,9 +323,9 @@ async function tokenRequest(
|
||||
body: new URLSearchParams(body).toString(),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new AuthFailure(
|
||||
`Nepodařilo se spojit s ${tokenUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
const info = describeFetchError(err, tokenUrl);
|
||||
// Nenavazane spojeni pomine, odmitnute udaje ne.
|
||||
throw new AuthFailure(info.message, info.detail, null, !info.timedOut);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -384,7 +360,7 @@ async function oauthLogin(
|
||||
signal,
|
||||
);
|
||||
|
||||
const accessToken = body ? stringField(body, 'access_token', 'accessToken') : null;
|
||||
const accessToken = pickText(body, 'access_token', 'accessToken');
|
||||
if (!body || !accessToken) {
|
||||
throw new AuthFailure(
|
||||
`Server ${tokenUrl} přihlášení aplikace nepřijal. Ověřte ID a tajemství, ` +
|
||||
@@ -395,7 +371,7 @@ async function oauthLogin(
|
||||
return {
|
||||
accessToken,
|
||||
expiresAt: expiryFrom(body, accessToken),
|
||||
refreshToken: stringField(body, 'refresh_token', 'refreshToken'),
|
||||
refreshToken: pickText(body, 'refresh_token', 'refreshToken'),
|
||||
method: 'OAuth jako aplikace',
|
||||
renewUrl: tokenUrl,
|
||||
fingerprint: fingerprintOf(credentials),
|
||||
@@ -422,14 +398,14 @@ async function oauthRenew(
|
||||
signal,
|
||||
);
|
||||
|
||||
const accessToken = body ? stringField(body, 'access_token', 'accessToken') : null;
|
||||
const accessToken = pickText(body, 'access_token', 'accessToken');
|
||||
if (!body || !accessToken) return null;
|
||||
|
||||
return {
|
||||
...session,
|
||||
accessToken,
|
||||
expiresAt: expiryFrom(body, accessToken),
|
||||
refreshToken: stringField(body, 'refresh_token', 'refreshToken') ?? session.refreshToken,
|
||||
refreshToken: pickText(body, 'refresh_token', 'refreshToken') ?? session.refreshToken,
|
||||
fingerprint: fingerprintOf(credentials),
|
||||
};
|
||||
}
|
||||
@@ -488,17 +464,36 @@ export async function authorize(
|
||||
const fingerprint = fingerprintOf(credentials);
|
||||
if (force) cache.delete(key);
|
||||
const cached = cache.get(key);
|
||||
const usable = cached && cached.fingerprint === fingerprint ? cached : null;
|
||||
|
||||
if (cached && cached.fingerprint === fingerprint) {
|
||||
if (Date.now() < cached.expiresAt - EXPIRY_MARGIN_MS) {
|
||||
return bearer(cached.accessToken, cached.method);
|
||||
}
|
||||
// Vyprsel. Obnova je levnejsi nez cele prihlaseni znovu.
|
||||
const renewed = await oauthRenew(cached, credentials, signal);
|
||||
if (renewed) {
|
||||
cache.set(key, renewed);
|
||||
return bearer(renewed.accessToken, renewed.method);
|
||||
}
|
||||
if (usable && Date.now() < usable.expiresAt - EXPIRY_MARGIN_MS) {
|
||||
return bearer(usable.accessToken, usable.method);
|
||||
}
|
||||
|
||||
// Jedno prihlaseni naraz na konektor. Kdo prijde druhy, pocka na prvni.
|
||||
let pending = inflight.get(key);
|
||||
if (!pending) {
|
||||
pending = obtainSession(usable, credentials, signal).finally(() => {
|
||||
if (inflight.get(key) === pending) inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, pending);
|
||||
}
|
||||
|
||||
const session = await pending;
|
||||
cache.set(key, session);
|
||||
return bearer(session.accessToken, session.method);
|
||||
}
|
||||
|
||||
/** Obnovi prosly token, nebo se prihlasi znovu. Vzdy vraci platne sezeni. */
|
||||
async function obtainSession(
|
||||
expired: Session | null,
|
||||
credentials: Credentials,
|
||||
signal: AbortSignal,
|
||||
): Promise<Session> {
|
||||
if (expired) {
|
||||
// Obnova je levnejsi nez cele prihlaseni znovu.
|
||||
const renewed = await oauthRenew(expired, credentials, signal);
|
||||
if (renewed) return renewed;
|
||||
}
|
||||
|
||||
const tokenUrl = credentials.tokenUrl || (await discoverTokenUrl(credentials.serverUrl, signal));
|
||||
@@ -509,9 +504,7 @@ export async function authorize(
|
||||
);
|
||||
}
|
||||
|
||||
const session = await oauthLogin(tokenUrl, credentials, signal);
|
||||
cache.set(key, session);
|
||||
return bearer(session.accessToken, session.method);
|
||||
return oauthLogin(tokenUrl, credentials, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+116
-67
@@ -22,9 +22,15 @@
|
||||
*/
|
||||
|
||||
import { config } from '../config.js';
|
||||
import {
|
||||
assertAllowedUrl,
|
||||
describeFetchError,
|
||||
readBodyLimited,
|
||||
readJsonLimited,
|
||||
tooLargeMessage,
|
||||
} from '../net/guard.js';
|
||||
import { targetSecrets, type ResolvedTarget } from '../scripts/connections.js';
|
||||
import { isPrivateHost } from '../scripts/http.js';
|
||||
import { createRedactor, truncate } from '../scripts/util.js';
|
||||
import { createRedactor, pick, truncate } from '../scripts/util.js';
|
||||
import { AuthFailure, authorize, type AuthMethod } from './auth.js';
|
||||
import { dialectFor, normalizeServerUrl, type McpDialect } from './dialect.js';
|
||||
|
||||
@@ -51,6 +57,15 @@ const SESSION_LOST_CODE = -32008;
|
||||
/** Kod, kterym server rika, ze nastroj se musi spustit jako uloha. */
|
||||
const TASK_REQUIRED_CODE = -32601;
|
||||
|
||||
/**
|
||||
* HTTP kody, po kterych ma smysl zkusit to znovu.
|
||||
*
|
||||
* Server volani odmitl driv, nez nastroj spustil (pretizeni, limit, proxy
|
||||
* bez backendu). Bez 500: to uz mohl nastroj bezet a MCP nema idempotencni
|
||||
* klic, ktery by druhe provedeni zastavil.
|
||||
*/
|
||||
const retryableStatuses = new Set([408, 425, 429, 502, 503, 504]);
|
||||
|
||||
/** Schema podle JSON Schema. Tvar se prochazi az v `schema.ts`. */
|
||||
export type JsonSchema = Record<string, unknown>;
|
||||
|
||||
@@ -106,6 +121,11 @@ export interface McpOutcome<T> {
|
||||
status: number | null;
|
||||
request: { method: string; path: string; url: string } | null;
|
||||
value: T | null;
|
||||
/**
|
||||
* true = k nastroji se nedoslo (spojeni, odmitnuti pred spustenim) a za
|
||||
* chvili to muze projit. Timeout beziciho nastroje ne, viz `McpFailure`.
|
||||
*/
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
/** Chyba uvnitr tohoto souboru. Ven se nedostane, prevede se na `McpOutcome`. */
|
||||
@@ -118,6 +138,17 @@ class McpFailure extends Error {
|
||||
readonly sessionLost = false,
|
||||
/** Kod chyby JSON-RPC, kdyz ji server poslal. */
|
||||
readonly code: number | null = null,
|
||||
/**
|
||||
* true = opakovani ma smysl: spojeni se nenavazalo, nebo server odmitl
|
||||
* driv, nez cokoliv spustil. Timeout uz odeslaneho volani ne - nastroj
|
||||
* mohl bezet a druhy pokus by ho provedl podruhe.
|
||||
*/
|
||||
readonly retryable = false,
|
||||
/**
|
||||
* true = server nezna sezeni, ktere jsme meli za platne (400/404 po
|
||||
* preskocenem handshaku). Staci handshake zopakovat, token plati.
|
||||
*/
|
||||
readonly handshakeLost = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'McpFailure';
|
||||
@@ -141,15 +172,7 @@ function serverUrl(target: ResolvedTarget): URL {
|
||||
} catch {
|
||||
throw new McpFailure(`Adresa ${raw} není platná URL.`);
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw new McpFailure(`Adresa ${url.protocol} není povolená, jen http a https.`);
|
||||
}
|
||||
if (!config.allowPrivateTargets && isPrivateHost(url.hostname)) {
|
||||
throw new McpFailure(
|
||||
`Adresa ${url.hostname} míří do vnitřní sítě. ` +
|
||||
'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.',
|
||||
);
|
||||
}
|
||||
assertAllowedUrl(url, (message) => new McpFailure(message));
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -160,6 +183,8 @@ interface Session {
|
||||
authHeader: string;
|
||||
/** ID sezeni z hlavicky odpovedi. Server ho mit nemusi. */
|
||||
sessionId: string | null;
|
||||
/** true = handshake se preskocil, protoze uz probehl driv s timto tokenem. */
|
||||
reused: boolean;
|
||||
protocolVersion: string;
|
||||
redact: (value: string) => string;
|
||||
/** Jak se portal prihlasil. Jde to do hlasky u konektoru. */
|
||||
@@ -172,13 +197,19 @@ interface Session {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pro ktery token uz probehl handshake.
|
||||
* Pro ktery token uz probehl handshake a jake sezeni pri nem server zalozil.
|
||||
*
|
||||
* Klic je konektor, hodnota je hlavicka `Authorization`. Jina hlavicka znamena
|
||||
* jiny token, tedy jine sezeni, a handshake se musi zopakovat. Servery, ktere
|
||||
* si sezeni drzi u tokenu, jinak odpovi, ze relace neni inicializovana.
|
||||
* Klic je konektor. Jina hlavicka `Authorization` znamena jiny token, tedy
|
||||
* jine sezeni, a handshake se musi zopakovat. Servery, ktere si sezeni drzi
|
||||
* u tokenu, jinak odpovi, ze relace neni inicializovana.
|
||||
*
|
||||
* `sessionId` se drzi u handshaku, ne u jednoho volani: server ho vydá pri
|
||||
* `initialize` a pak ho vyzaduje u kazdeho dalsiho volani. Bez toho by druhe
|
||||
* `tools/call` slo bez hlavicky a spec-compliant server odpovi 400 nebo 404.
|
||||
* Plati i pro server bez prihlaseni - i ten ma sezeni a handshake stoji dve
|
||||
* volani navic pri kazdem kroku.
|
||||
*/
|
||||
const initialized = new Map<string, string>();
|
||||
const initialized = new Map<string, { authHeader: string; sessionId: string | null }>();
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
@@ -303,12 +334,10 @@ async function rpc(
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (err) {
|
||||
const name = err instanceof Error ? err.name : '';
|
||||
if (name === 'AbortError' || name === 'TimeoutError') {
|
||||
throw new McpFailure(`Server ${session.url.host} neodpověděl v limitu.`);
|
||||
}
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new McpFailure(`Nepodařilo se spojit se serverem ${session.url.host}: ${reason}`);
|
||||
const info = describeFetchError(err, session.url.host);
|
||||
// Nenavazane spojeni se smi opakovat. Timeout ne: volani uz odeslo
|
||||
// a nastroj mohl bezet, druhy pokus by ho provedl podruhe.
|
||||
throw new McpFailure(info.message, null, null, false, null, !info.timedOut);
|
||||
}
|
||||
|
||||
// Sezeni zaklada server pri prvnim volani a pak ho vyzaduje u dalsich.
|
||||
@@ -320,8 +349,9 @@ async function rpc(
|
||||
const isStream = response.headers.get('content-type')?.includes('event-stream') ?? false;
|
||||
|
||||
if (!response.ok) {
|
||||
// Telo chyby byva male, u nej se po kouscich cist nemusi.
|
||||
const raw = await response.text().catch(() => '');
|
||||
const raw = await readBodyLimited(response, config.scriptMaxResponseBytes)
|
||||
.then((body) => body.text)
|
||||
.catch(() => '');
|
||||
const detail = session.redact(truncate(raw, config.errorDetailBytes));
|
||||
throw new McpFailure(
|
||||
`${method} vrátilo HTTP ${response.status}.` +
|
||||
@@ -329,6 +359,10 @@ async function rpc(
|
||||
response.status,
|
||||
detail === '' ? null : detail,
|
||||
response.status === 401,
|
||||
null,
|
||||
retryableStatuses.has(response.status),
|
||||
// Preskoceny handshake a server sezeni nezna: 400/404 podle specifikace.
|
||||
session.reused && (response.status === 400 || response.status === 404),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -344,23 +378,20 @@ async function rpc(
|
||||
// Stream skoncil bez odpovedi. Server to obcas udela, neni to chyba.
|
||||
if (!envelope) return null;
|
||||
} else {
|
||||
const raw = await response.text();
|
||||
if (raw.length > config.scriptMaxResponseBytes) {
|
||||
throw new McpFailure(
|
||||
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
|
||||
response.status,
|
||||
);
|
||||
const body = await readJsonLimited(response, config.scriptMaxResponseBytes);
|
||||
if (body.tooLarge) {
|
||||
throw new McpFailure(tooLargeMessage(config.scriptMaxResponseBytes), response.status);
|
||||
}
|
||||
if (raw.trim() === '') return null;
|
||||
try {
|
||||
envelope = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Prazdne telo neni chyba, server to obcas udela.
|
||||
if (body.json === null) return null;
|
||||
if (body.json === undefined || typeof body.json !== 'object') {
|
||||
throw new McpFailure(
|
||||
`${method} nevrátilo platný JSON. Míří adresa opravdu na MCP server?`,
|
||||
response.status,
|
||||
session.redact(truncate(raw, config.errorDetailBytes)),
|
||||
session.redact(truncate(body.text, config.errorDetailBytes)),
|
||||
);
|
||||
}
|
||||
envelope = body.json as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const error = envelope.error as { code?: number; message?: string } | undefined;
|
||||
@@ -394,6 +425,7 @@ async function buildSession(
|
||||
headers: { ...target.headers, ...auth.headers },
|
||||
authHeader: auth.headers.Authorization ?? '',
|
||||
sessionId: null,
|
||||
reused: false,
|
||||
protocolVersion: dialect.protocolVersion,
|
||||
redact: createRedactor([...targetSecrets(target), ...auth.secrets]),
|
||||
authMethod: auth.method,
|
||||
@@ -403,6 +435,10 @@ async function buildSession(
|
||||
};
|
||||
}
|
||||
|
||||
function handshakeKey(session: Session): string {
|
||||
return session.connectorId ?? session.url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handshake, jednou na token.
|
||||
*
|
||||
@@ -413,8 +449,13 @@ async function buildSession(
|
||||
* probehl driv a jmeno tedy znovu neprislo.
|
||||
*/
|
||||
async function ensureInitialized(session: Session, signal: AbortSignal): Promise<string> {
|
||||
const key = session.connectorId ?? session.url.toString();
|
||||
if (session.authHeader !== '' && initialized.get(key) === session.authHeader) return '';
|
||||
const known = initialized.get(handshakeKey(session));
|
||||
if (known && known.authHeader === session.authHeader) {
|
||||
// Sezeni z handshaku plati dal, server ho chce u kazdeho volani.
|
||||
session.sessionId = known.sessionId;
|
||||
session.reused = true;
|
||||
return '';
|
||||
}
|
||||
|
||||
const result = (await rpc(
|
||||
session,
|
||||
@@ -429,16 +470,19 @@ async function ensureInitialized(session: Session, signal: AbortSignal): Promise
|
||||
}
|
||||
|
||||
await rpc(session, 'notifications/initialized', undefined, signal, false);
|
||||
if (session.authHeader !== '') initialized.set(key, session.authHeader);
|
||||
initialized.set(handshakeKey(session), {
|
||||
authHeader: session.authHeader,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
|
||||
const name = result?.serverInfo?.name ?? 'neznámý server';
|
||||
const version = result?.serverInfo?.version;
|
||||
return version ? `${name} ${version}` : name;
|
||||
}
|
||||
|
||||
/** Zapomene handshake. Po odmitnutem tokenu se musi udelat znovu. */
|
||||
/** Zapomene handshake. Po odmitnutem tokenu nebo ztracenem sezeni se musi udelat znovu. */
|
||||
function forgetHandshake(session: Session): void {
|
||||
initialized.delete(session.connectorId ?? session.url.toString());
|
||||
initialized.delete(handshakeKey(session));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ rozbaleni
|
||||
@@ -510,8 +554,10 @@ function toTool(value: unknown): McpTool | null {
|
||||
* Obal, ktery z vyjimky udela vysledek. Ven z tohoto souboru nic nevyhazuje.
|
||||
*
|
||||
* Resi jednu vec navic: **kdyz server odmitne token nebo sezeni, ktere jsme
|
||||
* povazovali za platne, prihlasi se znovu a zkusi to jeste jednou**. Stava se
|
||||
* to, kdyz nekdo na druhe strane odebral opravneni driv, nez token vyprsel.
|
||||
* povazovali za platne, zopakuje handshake a zkusi to jeste jednou**. Odmitnuty
|
||||
* token (401, -32008) se pri tom vymeni za novy - stava se to, kdyz nekdo na
|
||||
* druhe strane odebral opravneni driv, nez token vyprsel. Ztracene sezeni
|
||||
* (400/404 po preskocenem handshaku) staci navazat znovu s tymz tokenem.
|
||||
* Podruhe uz se to neopakuje - to uz nejsou udaje, ktere by sedely.
|
||||
*/
|
||||
async function attempt<T>(
|
||||
@@ -539,10 +585,13 @@ async function attempt<T>(
|
||||
timer = setTimeout(() => controller.abort(), ms);
|
||||
};
|
||||
const signal = controller.signal;
|
||||
// Posledni sestavene sezeni. Jeho redakce zna i token, ne jen udaje konektoru.
|
||||
let session: Session | null = null;
|
||||
|
||||
try {
|
||||
let force = false;
|
||||
for (let round = 0; ; round += 1) {
|
||||
const session = await buildSession(target, signal, round > 0, extend);
|
||||
session = await buildSession(target, signal, force, extend);
|
||||
try {
|
||||
const value = await run(session, signal);
|
||||
return {
|
||||
@@ -552,11 +601,15 @@ async function attempt<T>(
|
||||
status: 200,
|
||||
request,
|
||||
value,
|
||||
retryable: false,
|
||||
};
|
||||
} catch (err) {
|
||||
const lost = err instanceof McpFailure && err.sessionLost;
|
||||
if (lost && round === 0 && session.authMethod !== 'bez přihlášení') {
|
||||
const failure = err instanceof McpFailure ? err : null;
|
||||
const stale = failure !== null && (failure.sessionLost || failure.handshakeLost);
|
||||
if (stale && round === 0) {
|
||||
forgetHandshake(session);
|
||||
// Odmitnuty token se vymeni. Bez prihlaseni neni co menit, staci handshake.
|
||||
force = failure.sessionLost && session.authMethod !== 'bez přihlášení';
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
@@ -565,16 +618,19 @@ async function attempt<T>(
|
||||
} catch (err) {
|
||||
const failure = err instanceof McpFailure ? err : null;
|
||||
const auth = err instanceof AuthFailure ? err : null;
|
||||
const redact = createRedactor(targetSecrets(target));
|
||||
const redact = session?.redact ?? createRedactor(targetSecrets(target));
|
||||
const reported = failure ?? auth;
|
||||
const message = reported ? reported.message : err instanceof Error ? err.message : String(err);
|
||||
const detail = reported?.detail ?? null;
|
||||
return {
|
||||
ok: false,
|
||||
message: redact(message),
|
||||
detail: failure?.detail ?? auth?.detail ?? null,
|
||||
status: failure?.status ?? null,
|
||||
// Vzdy pres redakci: i chyba prihlaseni nese telo odpovedi serveru.
|
||||
detail: detail === null ? null : redact(detail),
|
||||
status: failure?.status ?? auth?.status ?? null,
|
||||
request,
|
||||
value: null,
|
||||
retryable: reported?.retryable ?? false,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
@@ -663,27 +719,20 @@ const TASK_WAIT_MS = 600_000;
|
||||
/** Jak casto se server ptame na stav ulohy, kdyz svuj interval nerekl. */
|
||||
const TASK_POLL_MS = 2_000;
|
||||
|
||||
/** Hodnota z odpovedi .NETu. Klice pise velkymi i malymi pismeny. */
|
||||
function pick(source: unknown, ...names: string[]): unknown {
|
||||
if (source === null || typeof source !== 'object') return undefined;
|
||||
const wanted = names.map((name) => name.toLowerCase());
|
||||
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
|
||||
if (wanted.includes(key.toLowerCase())) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Cekani, ktere skonci driv, kdyz se volani zrusi. */
|
||||
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
// Posluchac se musi odebrat i kdyz cekani dobehlo samo. Uloha se
|
||||
// dotazuje stokrat a kazde kolo by jinak nechalo jeden posluchac viset.
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+25
-41
@@ -25,7 +25,9 @@
|
||||
*/
|
||||
|
||||
import { config } from '../../config.js';
|
||||
import { truncate } from '../../scripts/util.js';
|
||||
import { describeFetchError, readBodyLimited } from '../../net/guard.js';
|
||||
import { jwtExpiry, pickText, truncate } from '../../scripts/util.js';
|
||||
import { AuthFailure } from '../errors.js';
|
||||
import {
|
||||
KEY_REGISTRATION_CONTEXT,
|
||||
POP_CONTEXT,
|
||||
@@ -48,14 +50,20 @@ const RENEW_MARGIN_MS = 90_000;
|
||||
/** Jak dlouho token plati, kdyz to z nej nejde vycíst. Odhad smerem dolu. */
|
||||
const FALLBACK_LIFETIME_MS = 300_000;
|
||||
|
||||
/** Chyba prihlaseni. Vetu cte uzivatel, proto zadny stack. */
|
||||
export class EasyWebAuthError extends Error {
|
||||
/**
|
||||
* Chyba prihlaseni. Vetu cte uzivatel, proto zadny stack.
|
||||
*
|
||||
* Dedi z `AuthFailure`, aby ji klient MCP poznal jako chybu prihlaseni
|
||||
* a vzal si z ni `detail` i `status` - driv se oboji cestou ztratilo.
|
||||
*/
|
||||
export class EasyWebAuthError extends AuthFailure {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number | null = null,
|
||||
readonly detail: string | null = null,
|
||||
status: number | null = null,
|
||||
detail: string | null = null,
|
||||
retryable = false,
|
||||
) {
|
||||
super(message);
|
||||
super(message, detail, status, retryable);
|
||||
this.name = 'EasyWebAuthError';
|
||||
}
|
||||
}
|
||||
@@ -101,16 +109,6 @@ function entryFor(connectorId: string): Entry {
|
||||
|
||||
// ------------------------------------------------------------------ volani
|
||||
|
||||
/** Odpoved serveru jako objekt. Klice cte necitlive, .NET je pise velkymi. */
|
||||
function fieldOf(body: Record<string, unknown>, ...names: string[]): string | null {
|
||||
const wanted = names.map((name) => name.toLowerCase());
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (!wanted.includes(key.toLowerCase())) continue;
|
||||
if (typeof value === 'string' && value.trim() !== '') return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kdy vyprsi pristupovy token.
|
||||
*
|
||||
@@ -119,18 +117,8 @@ function fieldOf(body: Record<string, unknown>, ...names: string[]): string | nu
|
||||
* navic u kazdeho kroku.
|
||||
*/
|
||||
function accessExpiry(token: string): number {
|
||||
const parts = token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as {
|
||||
exp?: unknown;
|
||||
};
|
||||
if (typeof payload.exp === 'number') return payload.exp * 1000;
|
||||
} catch {
|
||||
// Neni to JWT. Plati odhad nize.
|
||||
}
|
||||
}
|
||||
return Date.now() + FALLBACK_LIFETIME_MS;
|
||||
// Neni to JWT: plati odhad smerem dolu.
|
||||
return jwtExpiry(token) ?? Date.now() + FALLBACK_LIFETIME_MS;
|
||||
}
|
||||
|
||||
function endpoint(serverUrl: string, path: string): string {
|
||||
@@ -159,16 +147,12 @@ async function post(
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (err) {
|
||||
const name = err instanceof Error ? err.name : '';
|
||||
if (name === 'AbortError' || name === 'TimeoutError') {
|
||||
throw new EasyWebAuthError(`Server ${url} neodpověděl v limitu.`);
|
||||
}
|
||||
throw new EasyWebAuthError(
|
||||
`Nepodařilo se spojit s ${url}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
const info = describeFetchError(err, url);
|
||||
// Nenavazane spojeni pomine, odmitnute udaje ne.
|
||||
throw new EasyWebAuthError(info.message, null, info.detail, !info.timedOut);
|
||||
}
|
||||
|
||||
const raw = await response.text();
|
||||
const raw = (await readBodyLimited(response, config.scriptMaxResponseBytes)).text;
|
||||
const detail = truncate(raw, config.errorDetailBytes);
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
@@ -190,7 +174,7 @@ function tokensFrom(
|
||||
previous: Tokens | null,
|
||||
where: string,
|
||||
): Tokens {
|
||||
const accessToken = body ? fieldOf(body, 'AccessToken') : null;
|
||||
const accessToken = body ? pickText(body, 'AccessToken') : null;
|
||||
if (!accessToken) {
|
||||
throw new EasyWebAuthError(`Odpověď z ${where} neobsahuje přístupový token.`);
|
||||
}
|
||||
@@ -203,10 +187,10 @@ function tokensFrom(
|
||||
return {
|
||||
accessToken,
|
||||
accessExpiresAt: accessExpiry(accessToken),
|
||||
refreshToken: (body ? fieldOf(body, 'RefreshToken') : null) ?? previous?.refreshToken ?? '',
|
||||
refreshNonce: (body ? fieldOf(body, 'RefreshTokenNonce') : null) ?? previous?.refreshNonce ?? '',
|
||||
deviceToken: (body ? fieldOf(body, 'DeviceToken') : null) ?? previous?.deviceToken ?? '',
|
||||
deviceNonce: (body ? fieldOf(body, 'DeviceTokenNonce') : null) ?? previous?.deviceNonce ?? '',
|
||||
refreshToken: (body ? pickText(body, 'RefreshToken') : null) ?? previous?.refreshToken ?? '',
|
||||
refreshNonce: (body ? pickText(body, 'RefreshTokenNonce') : null) ?? previous?.refreshNonce ?? '',
|
||||
deviceToken: (body ? pickText(body, 'DeviceToken') : null) ?? previous?.deviceToken ?? '',
|
||||
deviceNonce: (body ? pickText(body, 'DeviceTokenNonce') : null) ?? previous?.deviceNonce ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Chyba prihlaseni k MCP serveru.
|
||||
*
|
||||
* Ve vlastnim souboru, protoze ji dedi i chyba EasyWebu v `easyweb/session.ts`
|
||||
* a ten soubor importuje `auth.ts`. Kdyby trida bydlela v `auth.ts`, vznikl by
|
||||
* kruh, ve kterem se trida pouzije driv, nez existuje.
|
||||
*
|
||||
* Nese vetu pro uzivatele, ne stack. `detail` je cela odpoved serveru
|
||||
* a redakci tajemstvi dostane az v klientovi, kde jsou tajemstvi znama.
|
||||
*/
|
||||
export class AuthFailure extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly detail: string | null = null,
|
||||
/** HTTP kod, kdyz se k odpovedi serveru vubec doslo. */
|
||||
readonly status: number | null = null,
|
||||
/** true = spojeni se nenavazalo, za chvili to muze projit. Odmitnute udaje ne. */
|
||||
readonly retryable = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AuthFailure';
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -21,6 +21,7 @@
|
||||
import type { ProvidedField, OperationField } from '../data/services.js';
|
||||
import type { FieldType } from '../data/conditions.js';
|
||||
import type { JsonSchema, McpTool } from './client.js';
|
||||
import { parseBool } from '../scripts/util.js';
|
||||
|
||||
/**
|
||||
* Vystupy, ktere ma **kazdy** nastroj bez ohledu na schema.
|
||||
@@ -247,10 +248,7 @@ export interface ArgumentsResult {
|
||||
|
||||
/** Zaskrtnuto, nebo ne? Ve strome se vsechno predava jako text. */
|
||||
function toBoolean(value: string): boolean | null {
|
||||
const text = value.trim().toLowerCase();
|
||||
if (['true', '1', 'ano', 'yes'].includes(text)) return true;
|
||||
if (['false', '0', 'ne', 'no'].includes(text)) return false;
|
||||
return null;
|
||||
return parseBool(value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Async handlery v Expressu 4.
|
||||
*
|
||||
* Express 4 odmitnuty promise z handleru nechyti: request zustane viset bez
|
||||
* odpovedi a chyba skonci jako unhandledRejection. Tenhle soubor to resi na
|
||||
* jednom miste, aby se na `.catch(next)` nemuselo myslet u kazde routy.
|
||||
*/
|
||||
|
||||
import { Router, type NextFunction, type Request, type RequestHandler, type Response, type RouterOptions } from 'express';
|
||||
|
||||
type AnyHandler = (req: Request, res: Response, next: NextFunction) => unknown;
|
||||
|
||||
/** Obali handler tak, aby odmitnuty promise dosel do centralniho error handleru. */
|
||||
export function wrap(fn: AnyHandler): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'all'] as const;
|
||||
|
||||
/** Error handler ma ctyri parametry, ten se obalovat nesmi - zmenil by se mu tvar. */
|
||||
function wrapArgument(value: unknown): unknown {
|
||||
if (typeof value === 'function' && value.length < 4) return wrap(value as AnyHandler);
|
||||
if (Array.isArray(value)) return value.map(wrapArgument);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Router, ktery kazdy handler obali sam.
|
||||
*
|
||||
* Misto `router.get(path, wrap(async ...))` u kazde routy staci jednou
|
||||
* `safeRouter()` misto `Router()`. Cesta a middleware se predavaji beze zmeny.
|
||||
*/
|
||||
export function safeRouter(options?: RouterOptions): Router {
|
||||
const router = Router(options);
|
||||
const target = router as unknown as Record<string, (...args: unknown[]) => unknown>;
|
||||
|
||||
for (const method of METHODS) {
|
||||
const original = target[method];
|
||||
target[method] = (...args: unknown[]) => original.apply(router, args.map(wrapArgument));
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Omezeni poctu pokusu z jedne adresy.
|
||||
*
|
||||
* Bez toho jde prihlaseni, prijeti pozvanky nebo kontaktni formular zkouset
|
||||
* donekonecna. Zamerne v pameti procesu a bez knihovny: klic je adresa a routa,
|
||||
* okno je klouzave a mapa ma strop, aby ji nikdo nenafoukl.
|
||||
*/
|
||||
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
export interface RateLimitOptions {
|
||||
/** Nazev do klice a do logu, napr. `login`. */
|
||||
name: string;
|
||||
/** Delka okna v milisekundach. */
|
||||
windowMs: number;
|
||||
/** Kolik pokusu se v okne pusti. */
|
||||
max: number;
|
||||
}
|
||||
|
||||
/** Kolik klicu se drzi nejvys. Pri prekroceni se nejdriv uklidi stare. */
|
||||
const MAX_KEYS = 10_000;
|
||||
|
||||
export function rateLimit(options: RateLimitOptions): RequestHandler {
|
||||
const hits = new Map<string, number[]>();
|
||||
let lastCleanup = Date.now();
|
||||
|
||||
function cleanup(now: number): void {
|
||||
for (const [key, stamps] of hits) {
|
||||
const fresh = stamps.filter((time) => now - time < options.windowMs);
|
||||
if (fresh.length === 0) hits.delete(key);
|
||||
else hits.set(key, fresh);
|
||||
}
|
||||
lastCleanup = now;
|
||||
}
|
||||
|
||||
return (req, res, next) => {
|
||||
const now = Date.now();
|
||||
// Uklid jednou za okno, ne pri kazdem requestu - mapa je mala, ale ne nulova.
|
||||
if (now - lastCleanup > options.windowMs || hits.size > MAX_KEYS) cleanup(now);
|
||||
|
||||
const key = `${options.name}:${req.ip ?? 'unknown'}`;
|
||||
const stamps = (hits.get(key) ?? []).filter((time) => now - time < options.windowMs);
|
||||
|
||||
if (stamps.length >= options.max) {
|
||||
const retryAfterSec = Math.ceil((stamps[0] + options.windowMs - now) / 1000);
|
||||
console.warn(`[limit] ${options.name}: ${req.ip} prekrocil ${options.max} pokusu`);
|
||||
res.setHeader('Retry-After', String(Math.max(retryAfterSec, 1)));
|
||||
return res.status(429).json({
|
||||
error: 'too_many_requests',
|
||||
message: 'Příliš mnoho pokusů. Zkuste to prosím za chvíli.',
|
||||
});
|
||||
}
|
||||
|
||||
stamps.push(now);
|
||||
hits.set(key, stamps);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Firma a prava za jeden request.
|
||||
*
|
||||
* `accessFor` se driv pocitalo nekolikrat za request (pohled, firma, prava,
|
||||
* resitel...). Tady se spocita jednou do `req.access` a vsechno ostatni
|
||||
* z toho cte. Zaroven je tu jedno misto pro "firma z query plus kontrola
|
||||
* clenstvi", ktere bylo rozepsane v peti souborech a v sesti dalsich chybelo.
|
||||
*/
|
||||
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import {
|
||||
accessFor,
|
||||
isDenied,
|
||||
resolveScope,
|
||||
type Access,
|
||||
type ResolvedScope,
|
||||
} from '../data/access.js';
|
||||
|
||||
/** Firma z query, kdyz ji klient poslal. */
|
||||
export function requestedTenantId(req: Request): string | undefined {
|
||||
return typeof req.query.tenantId === 'string' && req.query.tenantId !== ''
|
||||
? req.query.tenantId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prava za request. Kdyz middleware nebezel, spocita se a ulozi ted -
|
||||
* routa tak funguje i mimo dashboard, jen bez usetreneho vypoctu.
|
||||
*/
|
||||
export function accessOf(req: Request): Access {
|
||||
if (!req.access) req.access = accessFor(req.user!, requestedTenantId(req));
|
||||
return req.access;
|
||||
}
|
||||
|
||||
/** Spocita prava jednou za request. Musi bezet az po `requireAuth`. */
|
||||
export function attachAccess(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.user) {
|
||||
console.error('[access] attachAccess pouzito bez requireAuth');
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
accessOf(req);
|
||||
return next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Firma, za kterou request jedna, bez odpovedi.
|
||||
*
|
||||
* Vraci `null`, kdyz uzivatel do zadane firmy nepatri nebo zadnou nema.
|
||||
* Pro routy, ktere si chteji rict, co se stalo, samy.
|
||||
*/
|
||||
export function requestTenant(req: Request): string | null {
|
||||
const access = accessOf(req);
|
||||
const tenantId = requestedTenantId(req) ?? access.defaultTenantId;
|
||||
if (!tenantId) return null;
|
||||
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
|
||||
console.warn(`[access] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
|
||||
return null;
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Firma z query s kontrolou clenstvi. Pri odepreni rovnou odpovi a vrati null,
|
||||
* takze volajici jen zkontroluje `if (!tenantId) return;`.
|
||||
*
|
||||
* Cizi firma je 404, ne 403: z odpovedi nema jit poznat, ze firma existuje.
|
||||
*/
|
||||
export function tenantOrDeny(req: Request, res: Response): string | null {
|
||||
const access = accessOf(req);
|
||||
const tenantId = requestedTenantId(req) ?? access.defaultTenantId;
|
||||
|
||||
if (!tenantId) {
|
||||
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
|
||||
return null;
|
||||
}
|
||||
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
|
||||
console.warn(`[access] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
|
||||
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
|
||||
return null;
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Totez, ale bez firmy se nezlobi.
|
||||
*
|
||||
* Katalog sluzeb nebo skriptu dava smysl i uctu bez clenstvi, jen bez
|
||||
* firemnich udaju. Cizi firma je porad 404. Vraci `null`, kdyz uz odpovedel.
|
||||
*/
|
||||
export function optionalTenantOrDeny(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): { tenantId: string | null } | null {
|
||||
const access = accessOf(req);
|
||||
const tenantId = requestedTenantId(req) ?? access.defaultTenantId;
|
||||
if (!tenantId) return { tenantId: null };
|
||||
|
||||
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
|
||||
console.warn(`[access] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
|
||||
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
|
||||
return null;
|
||||
}
|
||||
return { tenantId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevede query (`scope`, `tenantId`) na povolene firmy a strop viditelnosti.
|
||||
* Pri odepreni rovnou odpovi a vrati null.
|
||||
*/
|
||||
export function scopeOrDeny(req: Request, res: Response): ResolvedScope | null {
|
||||
const resolved = resolveScope(
|
||||
req.user!,
|
||||
{
|
||||
scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
|
||||
tenantId: requestedTenantId(req),
|
||||
},
|
||||
accessOf(req),
|
||||
);
|
||||
|
||||
if (isDenied(resolved)) {
|
||||
res.status(resolved.status).json({ error: resolved.error, message: resolved.message });
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Jednotny tvar chyby validace.
|
||||
*
|
||||
* Driv se to psalo u kazde routy zvlast a tvar se rozesel: nekde `issues[].path`,
|
||||
* nekde `issues[].field`, nekde nic. Klient cte `field` (viz web/src/lib/api.ts),
|
||||
* proto je tady jen ten.
|
||||
*/
|
||||
|
||||
import type { Response } from 'express';
|
||||
import type { ZodError } from 'zod';
|
||||
|
||||
/**
|
||||
* Odpovi 400 s prvni hlaskou a seznamem vsech problemu.
|
||||
*
|
||||
* `message` prebije hlasku ze schematu. Hodi se tam, kde zod vraci anglicky
|
||||
* text ("String must contain...") a routa ma vlastni, srozumitelnou vetu.
|
||||
*/
|
||||
export function validationError(res: Response, error: ZodError, message?: string): Response {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: message ?? error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
issues: error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Spolecna pravidla pro volani ven.
|
||||
*
|
||||
* Adresu vyplnuje firma (napojeni, MCP server, SMTP), takze tohle je jedina
|
||||
* zabrana proti navazani spojeni dovnitr nasi site. Pravidlo ma byt jedno:
|
||||
* driv bylo trikrat (HTTP klient, MCP klient, SMTP) a casem by se rozesla.
|
||||
*
|
||||
* Totez plati pro popis chyby spojeni a pro cteni tela s limitem - kazdy
|
||||
* klient to resil po svem a jeden z nich cetl cele telo do pameti, nez zjistil,
|
||||
* ze je moc velke.
|
||||
*/
|
||||
|
||||
import { config } from '../config.js';
|
||||
|
||||
const privateHostPattern =
|
||||
/^(localhost|127\.|0\.0\.0\.0$|10\.|192\.168\.|169\.254\.|::1$|\[::1\]$|172\.(1[6-9]|2\d|3[01])\.)/i;
|
||||
|
||||
/** Miri tohle jmeno do vnitrni site? */
|
||||
export function isPrivateHost(hostname: string): boolean {
|
||||
return privateHostPattern.test(hostname);
|
||||
}
|
||||
|
||||
/** Duvod, proc se na tento server nesmi. `null` = smi. */
|
||||
export function hostProblem(hostname: string): string | null {
|
||||
if (!config.allowPrivateTargets && isPrivateHost(hostname)) {
|
||||
return (
|
||||
`Adresa ${hostname} míří do vnitřní sítě. ` +
|
||||
'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.'
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Duvod, proc se na tuto adresu nesmi: jen http(s) a ne do vnitrni site. */
|
||||
export function urlProblem(url: URL): string | null {
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return `Adresa ${url.protocol} není povolená, jen http a https.`;
|
||||
}
|
||||
return hostProblem(url.hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overi adresu a pri problemu vyhodi chybu, kterou si volajici sam vyrobi.
|
||||
* Kazdy klient ma vlastni typ chyby (ScriptError, McpFailure), proto se
|
||||
* nevyhazuje primo.
|
||||
*/
|
||||
export function assertAllowedUrl(url: URL, fail: (message: string) => Error): void {
|
||||
const problem = urlProblem(url);
|
||||
if (problem !== null) throw fail(problem);
|
||||
}
|
||||
|
||||
export interface FetchErrorInfo {
|
||||
/** true = vyprsel limit (AbortError), ne chyba site. */
|
||||
timedOut: boolean;
|
||||
/** Kod od Node (`ECONNREFUSED`), prazdny kdyz zadny neni. */
|
||||
code: string;
|
||||
/** Hlaska pro uzivatele. */
|
||||
message: string;
|
||||
/** Puvodni chyba vcetne priciny, do `detail`. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popis chyby, kterou vyhodil `fetch`.
|
||||
*
|
||||
* `where` je server nebo adresa do hlasky. `cause` nese u fetch to podstatne,
|
||||
* proto se pripojuje - bez nej je v hlasce jen "fetch failed".
|
||||
*/
|
||||
export function describeFetchError(err: unknown, where: string): FetchErrorInfo {
|
||||
const code =
|
||||
err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).code) : '';
|
||||
const name = err instanceof Error ? err.name : '';
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
const cause = err instanceof Error && err.cause instanceof Error ? `\nPříčina: ${err.cause.message}` : '';
|
||||
const timedOut = name === 'AbortError' || name === 'TimeoutError';
|
||||
|
||||
return {
|
||||
timedOut,
|
||||
code,
|
||||
message: timedOut
|
||||
? `Server ${where} neodpověděl v limitu.`
|
||||
: `Nepodařilo se spojit s ${where}: ${reason}`,
|
||||
detail: `${name}: ${reason}${cause}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Hlaska pro telo nad limitem. Jedna, aby byla vsude stejna. */
|
||||
export function tooLargeMessage(limit: number): string {
|
||||
return `Odpověď je větší než povolený limit ${limit} bajtů.`;
|
||||
}
|
||||
|
||||
export interface LimitedBody {
|
||||
text: string;
|
||||
/** true = telo presahlo limit. Cteni se zastavilo, `text` je neuplny. */
|
||||
tooLarge: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precte telo odpovedi, ale nejvys `limit` bajtu.
|
||||
*
|
||||
* Cte se po kouscich a pri prekroceni se spojeni zavre. Precist cele telo
|
||||
* a az pak merit by znamenalo, ze limit chrani jen log, ne pamet procesu.
|
||||
*/
|
||||
export async function readBodyLimited(response: Response, limit: number): Promise<LimitedBody> {
|
||||
const declared = Number(response.headers.get('content-length') ?? 0);
|
||||
if (declared > limit) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
return { text: '', tooLarge: true };
|
||||
}
|
||||
|
||||
const body = response.body;
|
||||
if (!body) return { text: '', tooLarge: false };
|
||||
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = '';
|
||||
let size = 0;
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > limit) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
return { text, tooLarge: true };
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
return { text, tooLarge: false };
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export type LimitedJson =
|
||||
| { tooLarge: true; text: string; json: undefined }
|
||||
/** `json` je `undefined`, kdyz telo neni platny JSON. Prazdne telo je `null`. */
|
||||
| { tooLarge: false; text: string; json: unknown };
|
||||
|
||||
/** Telo jako JSON s limitem. Nevyhazuje, volajici si vybere vlastni chybu. */
|
||||
export async function readJsonLimited(response: Response, limit: number): Promise<LimitedJson> {
|
||||
const body = await readBodyLimited(response, limit);
|
||||
if (body.tooLarge) return { tooLarge: true, text: body.text, json: undefined };
|
||||
if (body.text.trim() === '') return { tooLarge: false, text: body.text, json: null };
|
||||
try {
|
||||
return { tooLarge: false, text: body.text, json: JSON.parse(body.text) as unknown };
|
||||
} catch {
|
||||
return { tooLarge: false, text: body.text, json: undefined };
|
||||
}
|
||||
}
|
||||
+753
-16
File diff suppressed because it is too large
Load Diff
+30
-14
@@ -10,17 +10,39 @@
|
||||
* a smaze neco cizim jmenem.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../config.js';
|
||||
import { listAudit, recordAudit } from '../data/audit.js';
|
||||
import { hasPermission } from '../data/permissions.js';
|
||||
import { listAllUsers, findUserById } from '../data/users.js';
|
||||
import { safeRouter } from '../middleware/asyncHandler.js';
|
||||
import { requireAuth, requirePlatformAdmin } from '../middleware/auth.js';
|
||||
import { attachAccess } from '../middleware/tenant.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
import { readScope } from './crud.js';
|
||||
|
||||
export const adminRouter = Router();
|
||||
adminRouter.use(requireAuth);
|
||||
export const adminRouter = safeRouter();
|
||||
adminRouter.use(requireAuth, attachAccess);
|
||||
|
||||
/**
|
||||
* Platformni pravo z katalogu (`impersonate`, `audit.view`).
|
||||
*
|
||||
* Spravce platformy ma vsechna prava, takze je to dnes totez co
|
||||
* `requirePlatformAdmin`. Kontroluje se presto: pravo je v katalogu a bez
|
||||
* teto kontroly by tam bylo jen na okrasu. Az se platformni prava zacnou
|
||||
* delit mezi vic lidi, tohle je misto, kde se to projevi.
|
||||
*/
|
||||
function requirePlatformPermission(permission: string) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!hasPermission(req.user!, permission, null)) {
|
||||
console.warn(`[admin] ${req.user!.email}: chybi pravo ${permission}`);
|
||||
return res.status(403).json({ error: 'forbidden', message: 'Nedostatečná oprávnění.' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
/** Kratka platnost je zamer. Impersonace neni rezim, ve kterem se pracuje. */
|
||||
const IMPERSONATION_MINUTES = 30;
|
||||
@@ -35,11 +57,9 @@ const startSchema = z.object({
|
||||
reason: z.string().trim().max(300).optional(),
|
||||
});
|
||||
|
||||
adminRouter.post('/impersonate', requirePlatformAdmin, (req, res) => {
|
||||
adminRouter.post('/impersonate', requirePlatformAdmin, requirePlatformPermission('impersonate'), (req, res) => {
|
||||
const parsed = startSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Vyberte uživatele.' });
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error, 'Vyberte uživatele.');
|
||||
|
||||
const target = findUserById(parsed.data.userId);
|
||||
if (!target) {
|
||||
@@ -114,7 +134,7 @@ adminRouter.post('/impersonate/stop', (req, res) => {
|
||||
});
|
||||
|
||||
/** Koho lze prepnout. Spravci platformy se nenabizeji. */
|
||||
adminRouter.get('/impersonate/candidates', requirePlatformAdmin, (_req, res) => {
|
||||
adminRouter.get('/impersonate/candidates', requirePlatformAdmin, requirePlatformPermission('impersonate'), (_req, res) => {
|
||||
res.json({
|
||||
items: listAllUsers()
|
||||
.filter((user) => !user.platformAdmin && user.enabled)
|
||||
@@ -124,12 +144,8 @@ adminRouter.get('/impersonate/candidates', requirePlatformAdmin, (_req, res) =>
|
||||
|
||||
// ------------------------------------------------------------------- audit
|
||||
|
||||
adminRouter.get('/audit', (req, res) => {
|
||||
// Audit vidi jen kdo ma pravo. Je to zaznam o lidech, ne provozni log.
|
||||
if (!req.user!.platformAdmin) {
|
||||
return res.status(403).json({ error: 'forbidden', message: 'Audit vidí správce platformy.' });
|
||||
}
|
||||
|
||||
// Audit vidi jen kdo ma pravo. Je to zaznam o lidech, ne provozni log.
|
||||
adminRouter.get('/audit', requirePlatformAdmin, requirePlatformPermission('audit.view'), (req, res) => {
|
||||
const limit = Number(req.query.limit ?? 200);
|
||||
return void listAudit({
|
||||
...readScope(req),
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Zalozeni firmy z registru ARES.
|
||||
*
|
||||
* Spravce platformy napise IC nebo nazev, portal dotahne udaje firmy a osoby,
|
||||
* ktere za ni jednaji, a spravce si vybere, komu zalozit ucet. ARES e-maily
|
||||
* nevede, takze osoba bez zadaneho e-mailu dostane nahradni ve tvaru
|
||||
* `IC-poradi@placeholder.cz`, ktery se ma v Nastaveni nahradit skutecnym.
|
||||
*
|
||||
* Jen spravce platformy: firmy zaklada jen on (rozhodnuti v 07-firmy-a-prava).
|
||||
* Router je namontovany pod /settings, takze `requireAuth` uz probehl.
|
||||
*/
|
||||
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
AresError,
|
||||
listCompanyPersons,
|
||||
lookupCompany,
|
||||
normalizeIco,
|
||||
placeholderEmail,
|
||||
searchCompanies,
|
||||
} from '../ares/client.js';
|
||||
import { recordAudit } from '../data/audit.js';
|
||||
import { nowIso } from '../data/store/index.js';
|
||||
import { generateIntakeToken, listTenants, tenantStore, type Tenant } from '../data/tenants.js';
|
||||
import { hashPassword, listAllUsers, userStore, type StoredUser } from '../data/users.js';
|
||||
import { safeRouter } from '../middleware/asyncHandler.js';
|
||||
import { requirePlatformAdmin } from '../middleware/auth.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
|
||||
export const aresRouter = safeRouter();
|
||||
aresRouter.use(requirePlatformAdmin);
|
||||
|
||||
/** Chyba ARES neni chyba naseho API: 400 je vstup, 404 neznama firma, jinak 502. */
|
||||
function aresStatus(err: AresError): number {
|
||||
if (err.status === 400 || err.status === 404) return err.status;
|
||||
return 502;
|
||||
}
|
||||
|
||||
/** Firmy podle IC (presne) nebo casti nazvu. */
|
||||
aresRouter.get('/companies', async (req, res) => {
|
||||
const query = String(req.query.query ?? '').trim();
|
||||
if (query === '') {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Zadejte IČ nebo název.' });
|
||||
}
|
||||
try {
|
||||
const ico = normalizeIco(query);
|
||||
const companies = ico !== null ? [await lookupCompany(ico)].filter((c) => c !== null) : await searchCompanies(query, 10);
|
||||
const known = new Map(listTenants().filter((t) => t.ico).map((t) => [t.ico!, t.id]));
|
||||
return res.json({
|
||||
// `existingTenantId` rika, ze firma uz v portalu je - druhe zalozeni nema smysl.
|
||||
companies: companies.map((c) => ({ ...c, existingTenantId: known.get(c.ico) ?? null })),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AresError) return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message });
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
/** Osoby, ktere za firmu dnes jednaji, kazda s navrzenym nahradnim e-mailem. */
|
||||
aresRouter.get('/companies/:ico/persons', async (req, res) => {
|
||||
const ico = normalizeIco(req.params.ico);
|
||||
if (ico === null) return res.status(400).json({ error: 'validation_error', message: 'IČ má 1 až 8 číslic.' });
|
||||
try {
|
||||
const persons = await listCompanyPersons(ico);
|
||||
return res.json({
|
||||
persons: persons.map((person, index) => ({ ...person, email: placeholderEmail(ico, index + 1) })),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AresError) return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message });
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
ico: z.string().trim().regex(/^\d{1,8}$/, 'IČ má 1 až 8 číslic.'),
|
||||
/** Nazev jde prepsat, ARES vraci i tvary jako "FIRMA, s.r.o." velkymi pismeny. */
|
||||
name: z.string().trim().min(2).max(80).optional(),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
persons: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(2, 'Jméno je moc krátké.').max(80),
|
||||
/** Prazdne = nahradni e-mail podle poradi. */
|
||||
email: z.string().trim().email('Zadejte platný e-mail.').optional().or(z.literal('')),
|
||||
}),
|
||||
)
|
||||
.max(50)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
/**
|
||||
* Zalozi firmu a ucty vybranych osob, vsechny jako spravce firmy.
|
||||
*
|
||||
* Udaje firmy se berou znovu z ARES, ne z klienta: co se ulozi k firme, ma
|
||||
* odpovidat registru, ne tomu, co prislo v tele. Heslo je nahodne a nikam se
|
||||
* neposila; clovek si ho nastavi pres zmenu hesla v Nastaveni nebo pozvanku.
|
||||
*/
|
||||
aresRouter.post('/tenants', async (req, res) => {
|
||||
const parsed = createSchema.safeParse(req.body);
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
const ico = normalizeIco(parsed.data.ico)!;
|
||||
|
||||
let company;
|
||||
try {
|
||||
company = await lookupCompany(ico);
|
||||
} catch (err) {
|
||||
if (err instanceof AresError) return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message });
|
||||
throw err;
|
||||
}
|
||||
if (company === null) {
|
||||
return res.status(404).json({ error: 'not_found', message: `ARES nezná firmu s IČ ${ico}.` });
|
||||
}
|
||||
|
||||
const tenants = listTenants();
|
||||
const name = parsed.data.name ?? company.name;
|
||||
if (tenants.some((t) => t.ico === ico)) {
|
||||
return res.status(409).json({ error: 'conflict', message: `Firma s IČ ${ico} už existuje.` });
|
||||
}
|
||||
if (tenants.some((t) => t.name.toLowerCase() === name.toLowerCase())) {
|
||||
return res.status(409).json({ error: 'conflict', message: `Firma ${name} už existuje.` });
|
||||
}
|
||||
|
||||
// E-maily se kontroluji pred prvnim zapisem: firma bez lidi by jinak vznikla
|
||||
// a druhy pokus by skoncil na "uz existuje".
|
||||
const taken = new Set(listAllUsers().map((u) => u.email.toLowerCase()));
|
||||
const emails = parsed.data.persons.map((person, index) =>
|
||||
(person.email && person.email !== '' ? person.email : placeholderEmail(ico, index + 1)).toLowerCase(),
|
||||
);
|
||||
for (const email of emails) {
|
||||
if (taken.has(email)) {
|
||||
return res.status(409).json({ error: 'conflict', message: `E-mail ${email} už někdo má.` });
|
||||
}
|
||||
taken.delete(email);
|
||||
}
|
||||
if (new Set(emails).size !== emails.length) {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Dva lidé mají stejný e-mail.' });
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
const tenant: Tenant = {
|
||||
id: `tnt_${randomUUID().slice(0, 8)}`,
|
||||
tenantId: null,
|
||||
name,
|
||||
note: parsed.data.note ?? '',
|
||||
enabled: true,
|
||||
intakeToken: generateIntakeToken(),
|
||||
helpdeskProviderId: null,
|
||||
ico: company.ico,
|
||||
dic: company.dic,
|
||||
address: company.address || null,
|
||||
legalForm: company.legalForm || null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
const created = await tenantStore.create(tenant);
|
||||
|
||||
const users: StoredUser[] = [];
|
||||
for (const [index, person] of parsed.data.persons.entries()) {
|
||||
const user: StoredUser = {
|
||||
id: `usr_${randomUUID().slice(0, 8)}`,
|
||||
tenantId: null,
|
||||
email: emails[index],
|
||||
name: person.name,
|
||||
passwordHash: await hashPassword(randomBytes(18).toString('base64url')),
|
||||
platformAdmin: false,
|
||||
memberships: [{ tenantId: created.id, roleIds: ['role_admin'] }],
|
||||
enabled: true,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
users.push(await userStore.create(user));
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
userId: req.user!.id,
|
||||
userEmail: req.user!.email,
|
||||
tenantId: created.id,
|
||||
action: 'tenant.create.ares',
|
||||
target: created.id,
|
||||
detail: { ico, users: users.map((u) => u.email) },
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
tenant: created,
|
||||
users: users.map(({ passwordHash: _hash, ...rest }) => rest),
|
||||
});
|
||||
});
|
||||
+14
-12
@@ -1,27 +1,29 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Router } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../config.js';
|
||||
import { findUserByEmail } from '../data/users.js';
|
||||
import { findUserByEmail, verifyPassword } from '../data/users.js';
|
||||
import { safeRouter } from '../middleware/asyncHandler.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { rateLimit } from '../middleware/rateLimit.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
import { toPublicUser, type JwtPayload } from '../types.js';
|
||||
|
||||
export const authRouter = Router();
|
||||
export const authRouter = safeRouter();
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Zadejte platný e-mail.'),
|
||||
password: z.string().min(1, 'Zadejte heslo.'),
|
||||
});
|
||||
|
||||
authRouter.post('/login', async (req, res) => {
|
||||
/**
|
||||
* Dvacet pokusu za ctvrt hodiny z jedne adresy. Dost na preklepy cele
|
||||
* kancelare za jednou NAT adresou, malo na hadani hesla.
|
||||
*/
|
||||
const loginLimiter = rateLimit({ name: 'login', windowMs: 15 * 60_000, max: 20 });
|
||||
|
||||
authRouter.post('/login', loginLimiter, async (req, res) => {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const { email, password } = parsed.data;
|
||||
const user = findUserByEmail(email);
|
||||
@@ -35,7 +37,7 @@ authRouter.post('/login', async (req, res) => {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const passwordOk = await bcrypt.compare(password, user.passwordHash);
|
||||
const passwordOk = await verifyPassword(password, user.passwordHash);
|
||||
if (!passwordOk) {
|
||||
console.info(`[auth] neuspesne prihlaseni - spatne heslo: ${user.email}`);
|
||||
return invalid();
|
||||
|
||||
+57
-39
@@ -15,9 +15,9 @@
|
||||
* udaje do prohlizece.
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { type Request, type Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { accessFor } from '../data/access.js';
|
||||
import { hasPermission } from '../data/permissions.js';
|
||||
import {
|
||||
CHECK_HISTORY,
|
||||
connectorCountsByService,
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from '../data/services.js';
|
||||
import { config } from '../config.js';
|
||||
import { egressIp } from '../data/egressIp.js';
|
||||
import { publish } from '../events/bus.js';
|
||||
import { smtpSettings, smtpTargetUrl, verifySmtp } from '../mail/smtp.js';
|
||||
import { listTools } from '../mcp/client.js';
|
||||
import { forgetMcpTools, rememberMcpTools } from '../data/mcpTools.js';
|
||||
@@ -50,25 +51,25 @@ import { resolveTarget, serviceBaseUrl, targetSecrets } from '../scripts/connect
|
||||
import { createHttp } from '../scripts/http.js';
|
||||
import { ScriptError } from '../scripts/types.js';
|
||||
import { createRedactor, describe, truncate } from '../scripts/util.js';
|
||||
import { safeRouter } from '../middleware/asyncHandler.js';
|
||||
import { optionalTenantOrDeny, tenantOrDeny } from '../middleware/tenant.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
|
||||
export const connectorsRouter = Router();
|
||||
export const connectorsRouter = safeRouter();
|
||||
|
||||
/**
|
||||
* Firma, do ktere se zapisuje. Konektor je vzdy jedne firmy, i kdyz uzivatel
|
||||
* kouka na pohled "vse" - jinak by nebylo kam ho zaloz1t.
|
||||
* Firma, do ktere se zapisuje, plus pravo konektory spravovat.
|
||||
*
|
||||
* Konektor je vzdy jedne firmy, i kdyz uzivatel kouka na pohled "vse". Cteni
|
||||
* staci clenstvi, zapis a overeni (to vola cizi sluzbu pod udaji firmy) chce
|
||||
* `connector.manage` - driv stacilo clenstvi i na to.
|
||||
*/
|
||||
function tenantOrDeny(req: Request, res: Response): string | null {
|
||||
const access = accessFor(req.user!);
|
||||
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
|
||||
const tenantId = requested ?? access.defaultTenantId;
|
||||
|
||||
if (!tenantId) {
|
||||
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
|
||||
return null;
|
||||
}
|
||||
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
|
||||
console.warn(`[connectors] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
|
||||
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
|
||||
function managedTenantOrDeny(req: Request, res: Response): string | null {
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return null;
|
||||
if (!hasPermission(req.user!, 'connector.manage', tenantId)) {
|
||||
console.warn(`[connectors] ${req.user!.email}: chybi pravo connector.manage ve firme ${tenantId}`);
|
||||
res.status(403).json({ error: 'forbidden', message: 'Konektory spravuje ten, kdo na to má právo.' });
|
||||
return null;
|
||||
}
|
||||
return tenantId;
|
||||
@@ -100,9 +101,10 @@ function serviceOrDeny(
|
||||
* ale te firmy, proto se to pocita tady a ne v katalogu.
|
||||
*/
|
||||
connectorsRouter.get('/services', async (req, res) => {
|
||||
const access = accessFor(req.user!);
|
||||
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
|
||||
const tenantId = requested ?? access.defaultTenantId;
|
||||
// Cizi firma je 404. Bez firmy jen katalog bez poctu konektoru.
|
||||
const resolved = optionalTenantOrDeny(req, res);
|
||||
if (!resolved) return;
|
||||
const { tenantId } = resolved;
|
||||
|
||||
const visible = visibleServices(req.user!, tenantId);
|
||||
// Firma se predava kvuli MCP: nastroje jsou vlastnost jejiho napojeni.
|
||||
@@ -183,16 +185,11 @@ const createSchema = z.object({
|
||||
});
|
||||
|
||||
connectorsRouter.post('/', async (req, res) => {
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
const tenantId = managedTenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const parsed = createSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const service = serviceOrDeny(req, res, parsed.data.serviceId, tenantId);
|
||||
if (!service) return;
|
||||
@@ -217,6 +214,13 @@ connectorsRouter.post('/', async (req, res) => {
|
||||
values: parsed.data.values,
|
||||
});
|
||||
|
||||
// Portal si konektory drzi v pameti, bez udalosti by novy videl az po obnoveni.
|
||||
publish(
|
||||
'connector.created',
|
||||
`Nový konektor ${connector.name}`,
|
||||
{ id: connector.id, connector: toPublicConnector(connector) },
|
||||
connector.tenantId,
|
||||
);
|
||||
return res.status(201).json(toPublicConnector(connector));
|
||||
});
|
||||
|
||||
@@ -230,7 +234,7 @@ const updateSchema = z.object({
|
||||
});
|
||||
|
||||
connectorsRouter.patch('/:id', async (req, res) => {
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
const tenantId = managedTenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const existing = await getConnector(req.params.id, [tenantId]);
|
||||
@@ -242,12 +246,7 @@ connectorsRouter.patch('/:id', async (req, res) => {
|
||||
if (!service) return;
|
||||
|
||||
const parsed = updateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
if (parsed.data.values) {
|
||||
const issues = validateConnectorValues(service, parsed.data.values);
|
||||
@@ -263,11 +262,17 @@ connectorsRouter.patch('/:id', async (req, res) => {
|
||||
// Nazev konektoru je v nazvu kazdeho jeho nastroje ve vyberu kroku. Bez
|
||||
// tohohle by tam po prejmenovani zustal stary az do restartu.
|
||||
if (isMcpService(updated.serviceId)) rememberMcpTools(updated);
|
||||
publish(
|
||||
'connector.updated',
|
||||
`Konektor ${updated.name} upraven`,
|
||||
{ id: updated.id, connector: toPublicConnector(updated) },
|
||||
updated.tenantId,
|
||||
);
|
||||
return res.json(toPublicConnector(updated));
|
||||
});
|
||||
|
||||
connectorsRouter.delete('/:id', async (req, res) => {
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
const tenantId = managedTenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
if (!(await deleteConnector(req.params.id, [tenantId]))) {
|
||||
@@ -276,6 +281,7 @@ connectorsRouter.delete('/:id', async (req, res) => {
|
||||
// Smazanym konektorem zmizi i jeho nastroje z katalogu, jinak by v builderu
|
||||
// zustaly kroky, ktere uz nemaji kam volat.
|
||||
forgetMcpTools(req.params.id);
|
||||
publish('connector.deleted', `Konektor ${req.params.id} smazán`, { id: req.params.id }, tenantId);
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
@@ -321,6 +327,10 @@ async function loadMcpTools(connectorId: string, tenantId: string) {
|
||||
}
|
||||
|
||||
const outcome = await listTools(target);
|
||||
// Telo chybove odpovedi serveru nesmi nest token. Hlasku uz klient MCP
|
||||
// rediguje, telo ne vzdy - proto jeste jednou tady, pred zapisem i odpovedi.
|
||||
const redact = createRedactor(targetSecrets(target));
|
||||
const detail = outcome.detail ? redact(outcome.detail) : outcome.detail;
|
||||
|
||||
await setConnectorStatus(
|
||||
connector.id,
|
||||
@@ -332,7 +342,7 @@ async function loadMcpTools(connectorId: string, tenantId: string) {
|
||||
checked,
|
||||
status: outcome.status,
|
||||
message: outcome.message,
|
||||
detail: outcome.detail,
|
||||
detail,
|
||||
request: outcome.request,
|
||||
responseHeaders: null,
|
||||
// Server si zaklada firma, seznamy povolenych IP na nem nemame v ruce.
|
||||
@@ -348,7 +358,7 @@ async function loadMcpTools(connectorId: string, tenantId: string) {
|
||||
checked,
|
||||
message: outcome.message,
|
||||
...(outcome.status !== null ? { status: outcome.status } : {}),
|
||||
...(outcome.detail ? { detail: outcome.detail } : {}),
|
||||
...(detail ? { detail } : {}),
|
||||
request: outcome.request,
|
||||
tools: [],
|
||||
};
|
||||
@@ -359,6 +369,14 @@ async function loadMcpTools(connectorId: string, tenantId: string) {
|
||||
|
||||
const publicView = saved ? toPublicConnector(saved) : null;
|
||||
console.info(`[mcp] ${connector.id}: ${outcome.message}`);
|
||||
if (saved) {
|
||||
publish(
|
||||
'connector.updated',
|
||||
`Konektor ${saved.name} má nové nástroje`,
|
||||
{ id: saved.id, connector: publicView },
|
||||
saved.tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -386,7 +404,7 @@ async function loadMcpTools(connectorId: string, tenantId: string) {
|
||||
* na spravny stroj. Hadat to podle toho, kde je nasazeny portal, nejde.
|
||||
*/
|
||||
connectorsRouter.post('/:id/test', async (req, res) => {
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
const tenantId = managedTenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const connector = await getConnector(req.params.id, [tenantId]);
|
||||
@@ -598,7 +616,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
|
||||
* da se spustit kdykoliv.
|
||||
*/
|
||||
connectorsRouter.post('/:id/mcp/tools', async (req, res) => {
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
const tenantId = managedTenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const connector = await getConnector(req.params.id, [tenantId]);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { rateLimit } from '../middleware/rateLimit.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
|
||||
export const contactRouter = Router();
|
||||
|
||||
/** Verejny formular bez prihlaseni. Pet poptavek za hodinu z jedne adresy staci. */
|
||||
const contactLimiter = rateLimit({ name: 'contact', windowMs: 60 * 60_000, max: 5 });
|
||||
|
||||
const contactSchema = z.object({
|
||||
name: z.string().min(2, 'Zadejte jméno.'),
|
||||
email: z.string().email('Zadejte platný e-mail.'),
|
||||
@@ -16,15 +21,9 @@ const contactSchema = z.object({
|
||||
* PROTOTYP: zpravu jen zalogujeme. Realne odeslani (SMTP / ticket system)
|
||||
* pribude pozdeji - viz docs/04-backend-api.md.
|
||||
*/
|
||||
contactRouter.post('/', (req, res) => {
|
||||
contactRouter.post('/', contactLimiter, (req, res) => {
|
||||
const parsed = contactSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const data = parsed.data;
|
||||
console.info(
|
||||
|
||||
+110
-67
@@ -21,43 +21,14 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { type Request, type Response, type Router } from 'express';
|
||||
import type { z } from 'zod';
|
||||
import { accessFor, actingTenant } from '../data/access.js';
|
||||
import { hasPermission } from '../data/permissions.js';
|
||||
import { publish as publishEvent, type EntityEventKind } from '../events/bus.js';
|
||||
import { nowIso, type EntityStore, type ListOptions, type TenantEntity } from '../data/store/index.js';
|
||||
|
||||
/** Firma, ve ktere se prave pracuje. Zapis je vzdy do jedne. */
|
||||
export function currentTenant(req: Request, res: Response): string | null {
|
||||
const access = accessFor(req.user!);
|
||||
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
|
||||
const tenantId = requested ?? access.defaultTenantId;
|
||||
|
||||
if (!tenantId) {
|
||||
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
|
||||
return null;
|
||||
}
|
||||
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
|
||||
console.warn(`[crud] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
|
||||
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
|
||||
return null;
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/** Rozsah pro cteni: firmy uzivatele plus volitelne platformni zaznamy. */
|
||||
/**
|
||||
* Firma, za kterou request jedna. Podle ni se pocitaji prava.
|
||||
*
|
||||
* Na rozdil od `currentTenant` neodpovida - vraci jen hodnotu, protoze
|
||||
* kontrola prava chce sama rict, co se stalo.
|
||||
*/
|
||||
export function requestTenant(req: Request): string | null {
|
||||
return actingTenant(
|
||||
req.user!,
|
||||
typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
|
||||
);
|
||||
}
|
||||
import { safeRouter } from '../middleware/asyncHandler.js';
|
||||
import { requestTenant, tenantOrDeny } from '../middleware/tenant.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
|
||||
/**
|
||||
* Ze ktere firmy smi request cist.
|
||||
@@ -88,20 +59,95 @@ export interface CrudOptions<T extends TenantEntity, C, U> {
|
||||
updateSchema: z.ZodType<U, z.ZodTypeDef, unknown>;
|
||||
/** Pravo potrebne k zapisu. Cteni staci prihlaseni. */
|
||||
writePermission: string;
|
||||
/** Sestavi novy zaznam z overeneho vstupu. */
|
||||
build: (input: C, tenantId: string) => Omit<T, keyof TenantEntity> & Partial<TenantEntity>;
|
||||
/** Sestavi novy zaznam z overeneho vstupu. Smi byt asynchronni (hash hesla). */
|
||||
build: (
|
||||
input: C,
|
||||
tenantId: string,
|
||||
) => BuiltEntity<T> | Promise<BuiltEntity<T>>;
|
||||
/** Vlastni kontrola nad ulozenym zaznamem. Vraci popisy problemu. */
|
||||
validate?: (entity: T, all: T[]) => string[];
|
||||
/** Uprava pred odeslanim klientovi, napr. schovani citlivych poli. */
|
||||
toPublic?: (entity: T) => unknown;
|
||||
/**
|
||||
* Druh udalosti pro zivy stream (`person.created` apod.). Bez nej se zmena
|
||||
* neohlasi a portal ji uvidi az po novem nacteni. Payload je verejna podoba
|
||||
* zaznamu (`toPublic`), takze tudy neutece nic, co neutece ani z GET.
|
||||
*/
|
||||
event?: EntityEventKind;
|
||||
/** true = zaznamy vidi jen spravce platformy. */
|
||||
platformOnly?: boolean;
|
||||
/**
|
||||
* Zaznamy bez firmy (`tenantId: null`), ktere se presto spravuji po firmach.
|
||||
*
|
||||
* Uzivatel neni majetkem firmy, muze byt ve vic firmach naraz. Fabrika ho
|
||||
* proto nesmi brat jako systemovy zaznam (to by ho smel menit jen spravce
|
||||
* platformy), ale pta se tady: patri do firmy, ve ktere se prave pracuje,
|
||||
* a smi tenhle vstup projit?
|
||||
*/
|
||||
scopeBy?: {
|
||||
/** Patri zaznam do firmy? Cizi se chova jako neexistujici. */
|
||||
belongsTo: (entity: T, tenantId: string) => boolean;
|
||||
/**
|
||||
* Overi a pripadne upravi vstup za firmu. `input` je `undefined` u mazani.
|
||||
* Vraci upraveny vstup, nebo hlasku a stav, se kterym se odmitne.
|
||||
*/
|
||||
prepare: (
|
||||
req: Request,
|
||||
tenantId: string,
|
||||
input: C | U | undefined,
|
||||
existing?: T,
|
||||
) => { ok: true; input: C | U | undefined } | { ok: false; status: number; message: string };
|
||||
};
|
||||
}
|
||||
|
||||
type BuiltEntity<T extends TenantEntity> = Omit<T, keyof TenantEntity> & Partial<TenantEntity>;
|
||||
|
||||
export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T, C, U>): Router {
|
||||
const router = Router();
|
||||
const router = safeRouter();
|
||||
const publish = options.toPublic ?? ((entity: T) => entity);
|
||||
|
||||
/** Ohlasi zmenu do streamu. Firma zaznamu, u platformnich (null) ji dostanou vsichni. */
|
||||
const announce = (verb: 'created' | 'updated' | 'deleted', entity: T) => {
|
||||
if (!options.event) return;
|
||||
const label = (entity as { name?: unknown }).name;
|
||||
const message = `${options.event} ${verb}: ${typeof label === 'string' ? label : entity.id}`;
|
||||
const payload = verb === 'deleted' ? { id: entity.id } : { id: entity.id, [options.event]: publish(entity) };
|
||||
publishEvent(`${options.event}.${verb}`, message, payload, entity.tenantId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Zaznam v dosahu firmy. Platformni admin vidi vse, ostatni jen to, co do
|
||||
* jejich firmy patri - u obycejnych entit to zaridi uloziste, u zaznamu
|
||||
* bez firmy (`scopeBy`) se to musi rict tady.
|
||||
*/
|
||||
function reachable(req: Request, entity: T): boolean {
|
||||
if (!options.scopeBy || req.user!.platformAdmin) return true;
|
||||
const tenantId = requestTenant(req);
|
||||
return tenantId !== null && options.scopeBy.belongsTo(entity, tenantId);
|
||||
}
|
||||
|
||||
/** Vstup po kontrole za firmu. Pri odmitnuti odpovi a vrati `null`. */
|
||||
function prepared(
|
||||
req: Request,
|
||||
res: Response,
|
||||
input: C | U | undefined,
|
||||
existing?: T,
|
||||
): { input: C | U | undefined } | null {
|
||||
if (!options.scopeBy) return { input };
|
||||
const tenantId = requestTenant(req);
|
||||
if (!tenantId) {
|
||||
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
|
||||
return null;
|
||||
}
|
||||
const outcome = options.scopeBy.prepare(req, tenantId, input, existing);
|
||||
if (!outcome.ok) {
|
||||
console.warn(`[crud] ${req.user!.email}: ${options.store.kind} odmitnuto - ${outcome.message}`);
|
||||
res.status(outcome.status).json({ error: 'forbidden', message: outcome.message });
|
||||
return null;
|
||||
}
|
||||
return { input: outcome.input };
|
||||
}
|
||||
|
||||
/** Smi uzivatel zapisovat? Jedno misto, aby se to nekontrolovalo jen nekde. */
|
||||
function canWrite(req: Request, res: Response, entity?: T): boolean {
|
||||
if (options.platformOnly && !req.user!.platformAdmin) {
|
||||
@@ -109,7 +155,8 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
|
||||
return false;
|
||||
}
|
||||
// Platformni zaznam smi menit jen spravce platformy, i kdyz pravo jinak ma.
|
||||
if (entity && entity.tenantId === null && !req.user!.platformAdmin) {
|
||||
// Zaznamy bez firmy spravovane po firmach (`scopeBy`) systemove nejsou.
|
||||
if (entity && entity.tenantId === null && !options.scopeBy && !req.user!.platformAdmin) {
|
||||
res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: 'Tenhle záznam je systémový a mění ho jen správce platformy.',
|
||||
@@ -134,13 +181,13 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
|
||||
if (options.platformOnly && !req.user!.platformAdmin) {
|
||||
return res.status(403).json({ error: 'forbidden', message: 'Jen pro správce platformy.' });
|
||||
}
|
||||
const items = await options.store.list(readScope(req));
|
||||
const items = (await options.store.list(readScope(req))).filter((item) => reachable(req, item));
|
||||
return res.json({ items: items.map(publish) });
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const entity = await options.store.get(req.params.id, readScope(req));
|
||||
if (!entity) {
|
||||
if (!entity || !reachable(req, entity)) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
|
||||
}
|
||||
return res.json(publish(entity));
|
||||
@@ -149,26 +196,23 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
|
||||
router.post('/', async (req, res) => {
|
||||
if (!canWrite(req, res)) return;
|
||||
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const parsed = options.createSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
issues: parsed.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const checked = prepared(req, res, parsed.data);
|
||||
if (!checked) return;
|
||||
|
||||
const timestamp = nowIso();
|
||||
const built = await options.build(checked.input as C, tenantId);
|
||||
const entity = {
|
||||
...options.build(parsed.data, tenantId),
|
||||
...built,
|
||||
id: `${options.idPrefix}_${randomUUID().slice(0, 8)}`,
|
||||
tenantId,
|
||||
// Kdo si firmu urcil sam (`tenantId: null` u uzivatele a firmy), tomu
|
||||
// se neprepisuje. Driv se prepsala vzdy a "bez firmy" nikdy nenastalo.
|
||||
tenantId: built.tenantId === undefined ? tenantId : built.tenantId,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
} as T;
|
||||
@@ -181,26 +225,26 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
|
||||
}
|
||||
|
||||
const created = await options.store.create(entity);
|
||||
announce('created', created);
|
||||
return res.status(201).json(publish(created));
|
||||
});
|
||||
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const existing = await options.store.get(req.params.id, readScope(req));
|
||||
if (!existing) {
|
||||
if (!existing || !reachable(req, existing)) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
|
||||
}
|
||||
if (!canWrite(req, res, existing)) return;
|
||||
|
||||
const parsed = options.updateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const checked = prepared(req, res, parsed.data, existing);
|
||||
if (!checked) return;
|
||||
const patch = checked.input as Partial<T>;
|
||||
|
||||
if (options.validate) {
|
||||
const merged = { ...existing, ...(parsed.data as Partial<T>) } as T;
|
||||
const merged = { ...existing, ...patch } as T;
|
||||
const others = (await options.store.list(readScope(req))).filter(
|
||||
(item) => item.id !== existing.id,
|
||||
);
|
||||
@@ -210,25 +254,24 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await options.store.update(
|
||||
req.params.id,
|
||||
parsed.data as Partial<T>,
|
||||
readScope(req),
|
||||
);
|
||||
const updated = await options.store.update(req.params.id, patch, readScope(req));
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
|
||||
}
|
||||
announce('updated', updated);
|
||||
return res.json(publish(updated));
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const existing = await options.store.get(req.params.id, readScope(req));
|
||||
if (!existing) {
|
||||
if (!existing || !reachable(req, existing)) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
|
||||
}
|
||||
if (!canWrite(req, res, existing)) return;
|
||||
if (!prepared(req, res, undefined, existing)) return;
|
||||
|
||||
const removed = await options.store.remove(req.params.id, readScope(req));
|
||||
if (removed) announce('deleted', existing);
|
||||
return removed
|
||||
? res.status(204).end()
|
||||
: res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
|
||||
|
||||
+162
-319
@@ -1,13 +1,8 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { type Request, type Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { publicBaseUrl } from '../config.js';
|
||||
import {
|
||||
accessFor,
|
||||
isDenied,
|
||||
resolveScope,
|
||||
visibilityFor,
|
||||
type ResolvedScope,
|
||||
} from '../data/access.js';
|
||||
import { type ResolvedScope } from '../data/access.js';
|
||||
import {
|
||||
createAutomation,
|
||||
deleteAutomation,
|
||||
@@ -46,13 +41,7 @@ import { collectScopes } from '../data/flowScope.js';
|
||||
import { widgetCatalog } from '../data/widgets.js';
|
||||
import { createIncident, listIncidents } from '../data/incidentStore.js';
|
||||
import { getSummary } from '../data/mock.js';
|
||||
import {
|
||||
findPersonByEmail,
|
||||
groupsOfPerson,
|
||||
isMember,
|
||||
listGroups,
|
||||
listPeople,
|
||||
} from '../data/people.js';
|
||||
import { isMember, listGroups, listPeople } from '../data/people.js';
|
||||
import { hasPermission } from '../data/permissions.js';
|
||||
import { listNotifications, markRead, unreadCount } from '../data/notifications.js';
|
||||
import { inviteRouter } from './invites.js';
|
||||
@@ -61,56 +50,61 @@ import { recordAudit } from '../data/audit.js';
|
||||
import { findTenant, generateIntakeToken, refreshTenants, tenantStore } from '../data/tenants.js';
|
||||
import { listTicketTypes } from '../data/ticketTypes.js';
|
||||
import {
|
||||
addComment,
|
||||
assignTicket,
|
||||
claimTicket,
|
||||
createTicket,
|
||||
ticketAssignee,
|
||||
getAgentStats,
|
||||
getTicket,
|
||||
ticketWithinVisibility,
|
||||
getWorkload,
|
||||
listTickets,
|
||||
updateTicketStatus,
|
||||
type TicketChannel,
|
||||
type TicketFilter,
|
||||
} from '../data/ticketStore.js';
|
||||
import { safeRouter } from '../middleware/asyncHandler.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import {
|
||||
accessOf,
|
||||
attachAccess,
|
||||
optionalTenantOrDeny,
|
||||
scopeOrDeny,
|
||||
tenantOrDeny,
|
||||
} from '../middleware/tenant.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
import { validateRules } from '../scripts/mapping.js';
|
||||
import { connectorsRouter } from './connectors.js';
|
||||
import { helpdeskRouter } from './helpdesk.js';
|
||||
import { settingsRouter } from './settings.js';
|
||||
import { ticketActionsRouter } from './ticketActions.js';
|
||||
import { ticketActionsRouter, visibleTicketOrDeny } from './ticketActions.js';
|
||||
import { widgetDataRouter } from './widgetData.js';
|
||||
import { scriptsRouter } from './scripts.js';
|
||||
import { tenantScriptRouter } from './tenantScripts.js';
|
||||
import { streamRouter } from './stream.js';
|
||||
|
||||
export const dashboardRouter = Router();
|
||||
export const dashboardRouter = safeRouter();
|
||||
|
||||
// Cely dashboard je jen pro prihlasene.
|
||||
dashboardRouter.use(requireAuth);
|
||||
// Cely dashboard je jen pro prihlasene. Prava se spocitaji jednou za request.
|
||||
dashboardRouter.use(requireAuth, attachAccess);
|
||||
|
||||
/**
|
||||
* Prevede query na povolene firmy. Pri odepreni rovnou odpovi a vrati null,
|
||||
* takze volajici jen zkontroluje `if (!scope) return;`.
|
||||
* Strankovani ze query. `limit` bez hodnoty = vsechno, jako driv.
|
||||
*
|
||||
* Odpoved zustava seznam, jen se k ni prida hlavicka `X-Total-Count`
|
||||
* s poctem pred orezem - klient, ktery strankovani nezna, nic nepozna.
|
||||
*/
|
||||
function scopeOrDeny(req: Request, res: Response): ResolvedScope | null {
|
||||
const resolved = resolveScope(req.user!, {
|
||||
scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
|
||||
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
|
||||
});
|
||||
function pageFrom(query: Record<string, unknown>, maxLimit = 500): { limit: number | null; offset: number } {
|
||||
const limit = Number(query.limit);
|
||||
const offset = Number(query.offset);
|
||||
return {
|
||||
limit: Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLimit) : null,
|
||||
offset: Number.isInteger(offset) && offset > 0 ? offset : 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (isDenied(resolved)) {
|
||||
res.status(resolved.status).json({ error: resolved.error, message: resolved.message });
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
function paginate<T>(items: T[], page: { limit: number | null; offset: number }): T[] {
|
||||
if (page.limit === null && page.offset === 0) return items;
|
||||
return items.slice(page.offset, page.limit === null ? undefined : page.offset + page.limit);
|
||||
}
|
||||
|
||||
/** Co uzivatel smi, aby klient nemusel hadat, ktere prepinace kreslit. */
|
||||
dashboardRouter.get('/access', (req, res) => {
|
||||
res.json(accessFor(req.user!));
|
||||
res.json(accessOf(req));
|
||||
});
|
||||
|
||||
dashboardRouter.get('/summary', (req, res) => {
|
||||
@@ -125,8 +119,10 @@ dashboardRouter.get('/summary', (req, res) => {
|
||||
* Portal to musi umet rict nahlas. Bez toho se clovek divi, kam se podely
|
||||
* jeho konektory, a hleda chybu v aplikaci - presne to se stalo.
|
||||
*/
|
||||
dashboardRouter.get('/storage', (_req, res) => {
|
||||
res.json(storageStatus());
|
||||
dashboardRouter.get('/storage', (req, res) => {
|
||||
// Cesta na disku serveru je nase provozni informace, ne zakaznikova.
|
||||
const { location, ...status } = storageStatus();
|
||||
res.json(req.user!.platformAdmin ? { ...status, location } : { ...status, location: null });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -182,9 +178,29 @@ const FIELD_LIMIT = 4_000;
|
||||
*/
|
||||
const DEDUPE_MS = 600_000;
|
||||
|
||||
/** Kolik otisku se drzi. Pri prekroceni se uklidi prosle, pak nejstarsi. */
|
||||
const RECENT_LIMIT = 1_000;
|
||||
|
||||
/** Otisk padu a kdy naposled zalozil incident. */
|
||||
const recent = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Uklid mapy otisku. Klic je z hlasky od klienta, takze bez stropu by ji
|
||||
* kdokoliv prihlaseny mohl nafouknout do nekonecna.
|
||||
*/
|
||||
function forgetOldCrashes(now: number): void {
|
||||
if (recent.size < RECENT_LIMIT) return;
|
||||
for (const [key, at] of recent) {
|
||||
if (now - at >= DEDUPE_MS) recent.delete(key);
|
||||
}
|
||||
// Same cerstve? Mapa drzi poradi vkladani, nejstarsi je prvni.
|
||||
while (recent.size >= RECENT_LIMIT) {
|
||||
const oldest = recent.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
recent.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
const crashSchema = z.object({
|
||||
message: z.string().trim().min(1).max(FIELD_LIMIT),
|
||||
stack: z.string().max(FIELD_LIMIT).optional(),
|
||||
@@ -196,17 +212,12 @@ const crashSchema = z.object({
|
||||
});
|
||||
|
||||
dashboardRouter.post('/client-crash', (req, res) => {
|
||||
const access = accessFor(req.user!);
|
||||
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
|
||||
const tenantId = requested ?? access.defaultTenantId;
|
||||
// Incident se zaklada firme, do ktere clovek patri. Cizi firma je 404.
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const parsed = crashSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const crash = parsed.data;
|
||||
const where = crash.path ?? 'neznámá stránka';
|
||||
@@ -214,13 +225,19 @@ dashboardRouter.post('/client-crash', (req, res) => {
|
||||
/*
|
||||
* Otisk je hlaska a misto, ne cas. Tatáz chyba na tomtez miste je porad
|
||||
* jeden problem, i kdyz na ni clovek narazi desetkrat za sebou.
|
||||
*
|
||||
* Hash, ne surovy text: klic v mape by jinak nesl az 4 kB od klienta.
|
||||
* Zacatek hlasky staci, konec byva promenlivy (ID, cisla radku).
|
||||
*/
|
||||
const fingerprint = `${crash.message}|${where}`;
|
||||
const fingerprint = createHash('sha1')
|
||||
.update(`${tenantId}|${crash.message.slice(0, 500)}|${where.slice(0, 500)}`)
|
||||
.digest('hex');
|
||||
const last = recent.get(fingerprint) ?? 0;
|
||||
const now = Date.now();
|
||||
if (now - last < DEDUPE_MS) {
|
||||
return res.status(202).json({ created: false, reason: 'stejný pád už je hlášený' });
|
||||
}
|
||||
forgetOldCrashes(now);
|
||||
recent.set(fingerprint, now);
|
||||
|
||||
const detail = [
|
||||
@@ -264,37 +281,20 @@ dashboardRouter.get('/widgets', (req, res) => {
|
||||
* vsech firem uzivatele, sla by polozit dlazdice Automie na dashboard
|
||||
* Nordisu - v nabidce by byla, ale data by k ni nikdy neprisla.
|
||||
*/
|
||||
const tenantId = layoutTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
return res.json({
|
||||
items: widgetCatalog([tenantId], req.user!.id, accessFor(req.user!, tenantId).personId !== null),
|
||||
items: widgetCatalog([tenantId], req.user!.id, accessOf(req).personId !== null),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
/*
|
||||
* Rozlozeni je vzdy za konkretni firmu, i kdyz uzivatel kouka na pohled "vse".
|
||||
* Jinak by clovek ve dvou firmach nemel kam ulozit dve ruzna nastaveni.
|
||||
*/
|
||||
function layoutTenant(req: Request, res: Response): string | null {
|
||||
const access = accessFor(req.user!);
|
||||
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
|
||||
const tenantId = requested ?? access.defaultTenantId;
|
||||
|
||||
if (!tenantId) {
|
||||
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
|
||||
return null;
|
||||
}
|
||||
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
|
||||
console.warn(`[layout] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
|
||||
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
|
||||
return null;
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
dashboardRouter.get('/layout', (req, res) => {
|
||||
const tenantId = layoutTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
return res.json({
|
||||
@@ -315,20 +315,15 @@ const layoutSchema = z.object({
|
||||
});
|
||||
|
||||
dashboardRouter.put('/layout', (req, res) => {
|
||||
const tenantId = layoutTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const parsed = layoutSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatné rozložení.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error, 'Neplatné rozložení.');
|
||||
|
||||
const problems = validateLayout(
|
||||
parsed.data.items,
|
||||
widgetCatalog([tenantId], req.user!.id, accessFor(req.user!, tenantId).personId !== null),
|
||||
widgetCatalog([tenantId], req.user!.id, accessOf(req).personId !== null),
|
||||
);
|
||||
if (problems.length > 0) {
|
||||
console.warn(`[layout] ${req.user!.email}: neplatne rozlozeni - ${problems.join(' ')}`);
|
||||
@@ -341,7 +336,7 @@ dashboardRouter.put('/layout', (req, res) => {
|
||||
|
||||
/** Vraceni na vychozi. Zamerne DELETE - je to smazani ulozeneho nastaveni. */
|
||||
dashboardRouter.delete('/layout', (req, res) => {
|
||||
const tenantId = layoutTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
return res.json({
|
||||
@@ -367,17 +362,8 @@ dashboardRouter.get('/people/:id', (req, res) => {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Řešitel neexistuje.' });
|
||||
}
|
||||
|
||||
const tickets = listTickets({
|
||||
tenantIds: scope.tenantIds,
|
||||
visibility: scope.visibility,
|
||||
assignee: person.id,
|
||||
});
|
||||
const resolvedRecently = listTickets({
|
||||
tenantIds: scope.tenantIds,
|
||||
visibility: scope.visibility,
|
||||
}).filter(
|
||||
(ticket) => ticket.resolvedById === person.id,
|
||||
);
|
||||
// Jeden pruchod seznamem: "ma u sebe" i "vyresil" se pozna z tehoz ticketu.
|
||||
const tickets = listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility });
|
||||
|
||||
// Obdobi drzime stejne jako u widgetu vykonu, aby cisla sedela na obou mistech.
|
||||
const since = Date.now() - 30 * 86_400_000;
|
||||
@@ -390,10 +376,10 @@ dashboardRouter.get('/people/:id', (req, res) => {
|
||||
.filter((group) => isMember(group, person.id))
|
||||
.map((group) => ({ id: group.id, name: group.name })),
|
||||
/** Co ma prave ted u sebe. */
|
||||
open: tickets.filter((ticket) => !ticket.closed),
|
||||
open: tickets.filter((ticket) => !ticket.closed && ticket.assignee?.id === person.id),
|
||||
/** Co za posledni dobu vyresil, nejnovejsi nahore. */
|
||||
resolved: resolvedRecently
|
||||
.filter((ticket) => ticket.closed)
|
||||
resolved: tickets
|
||||
.filter((ticket) => ticket.closed && ticket.resolvedById === person.id)
|
||||
.sort((a, b) => (b.resolvedAt ?? '').localeCompare(a.resolvedAt ?? ''))
|
||||
.slice(0, 20),
|
||||
});
|
||||
@@ -518,7 +504,7 @@ dashboardRouter.post('/notifications/read', (req, res) => {
|
||||
|
||||
/** Kolik nevyrizenych ma prihlaseny u sebe. */
|
||||
function myOpenTickets(req: Request): number {
|
||||
const access = accessFor(req.user!);
|
||||
const access = accessOf(req);
|
||||
if (!access.personId) return 0;
|
||||
|
||||
// Vlastni tickety jsou ve stropu vzdycky, ale posila se vyslovne - filtr
|
||||
@@ -542,9 +528,17 @@ dashboardRouter.get('/runs', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
const stats = queueStats(scope.tenantIds);
|
||||
const page = pageFrom(req.query as Record<string, unknown>);
|
||||
// Bez `limit` poslednich 50, jako driv. Celkovy pocet je soucet stavu fronty.
|
||||
const runs = page.limit === null && page.offset === 0
|
||||
? recentRuns(scope.tenantIds)
|
||||
: recentRuns(scope.tenantIds, page.offset + (page.limit ?? 50)).slice(page.offset);
|
||||
res.setHeader('X-Total-Count', String(stats.pending + stats.running + stats.failed + stats.done));
|
||||
|
||||
return res.json({
|
||||
stats: queueStats(scope.tenantIds),
|
||||
items: recentRuns(scope.tenantIds).map((run) => ({
|
||||
stats,
|
||||
items: runs.map((run) => ({
|
||||
id: run.id,
|
||||
automationId: run.automationId,
|
||||
trigger: run.trigger,
|
||||
@@ -573,11 +567,10 @@ 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),
|
||||
groups: listGroups(scope.tenantIds).map((group) => ({ id: group.id, name: group.name })),
|
||||
meId: me?.id ?? null,
|
||||
meId: accessOf(req).personId,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -645,31 +638,32 @@ dashboardRouter.get('/tickets', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
const me = findPersonByEmail(req.user!.email);
|
||||
const filter = ticketFilterFrom(req.query as Record<string, unknown>, scope);
|
||||
|
||||
const items = listTickets(filter);
|
||||
|
||||
/*
|
||||
* Stavy, ktere firma opravdu pouziva. Stav je volny retezec, takze pevny
|
||||
* seznam v rozhrani by u ticketu z cizich aplikaci nikdy nesedel - filtr
|
||||
* musi nabidnout to, co v datech je.
|
||||
*
|
||||
* Bere se z celeho rozsahu, ne z vyfiltrovaneho vysledku: jinak by po
|
||||
* kliknuti na jeden stav zmizely ostatni a nesel by prepnout.
|
||||
* kliknuti na jeden stav zmizely ostatni a nesel by prepnout. Kdyz zadny
|
||||
* filtr neni, je cely rozsah zaroven vysledek a seznam se cte jen jednou.
|
||||
*/
|
||||
const statuses = [
|
||||
...new Set(
|
||||
listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }).map(
|
||||
(ticket) => ticket.status,
|
||||
),
|
||||
),
|
||||
].sort((a, b) => a.localeCompare(b, 'cs'));
|
||||
const all = listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility });
|
||||
const filtered =
|
||||
Object.keys(filter).length === 2 ? all : listTickets(filter);
|
||||
const statuses = [...new Set(all.map((ticket) => ticket.status))].sort((a, b) =>
|
||||
a.localeCompare(b, 'cs'),
|
||||
);
|
||||
|
||||
const page = pageFrom(req.query as Record<string, unknown>);
|
||||
res.setHeader('X-Total-Count', String(filtered.length));
|
||||
|
||||
return res.json({
|
||||
items,
|
||||
items: paginate(filtered, page),
|
||||
total: filtered.length,
|
||||
statuses,
|
||||
meId: me?.id ?? null,
|
||||
meId: accessOf(req).personId,
|
||||
scope: scope.scope,
|
||||
tenantId: scope.tenantId,
|
||||
});
|
||||
@@ -715,16 +709,7 @@ dashboardRouter.post('/tickets', (req, res) => {
|
||||
}
|
||||
|
||||
const parsed = createTicketSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Ticket se nepodařilo založit.',
|
||||
issues: parsed.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
// Zaklada se vzdy do firmy, ve ktere clovek prave je. Vybirat ji ve formulari
|
||||
// by znamenalo, ze se ticket omylem zalozi jinam.
|
||||
@@ -778,79 +763,10 @@ dashboardRouter.post('/tickets', (req, res) => {
|
||||
return res.status(201).json(ticket);
|
||||
});
|
||||
|
||||
/**
|
||||
* Prevzeti ticketu.
|
||||
*
|
||||
* Clovek si vezme praci sam, misto aby cekal, az mu ji nekdo prideli. Tohle je
|
||||
* bezny zpusob prace v tymu: ticket lezi ve fronte skupiny a kdo ma cas, si ho
|
||||
* vezme.
|
||||
*
|
||||
* Smi to u ticketu **ze sve skupiny** nebo u takoveho, ktery nema nikoho.
|
||||
* Vzit nekomu rozdelanou praci nejde - to uz je prehozeni a na to je pravo
|
||||
* `ticket.assign.others`.
|
||||
/*
|
||||
* Prevzeti, prirazeni, stav a komentar jsou vestavene akce na ticketu,
|
||||
* viz `routes/ticketActions.ts` - kazda ma sve pravo a projde auditem.
|
||||
*/
|
||||
dashboardRouter.post('/tickets/:id/claim', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
const access = accessFor(req.user!);
|
||||
if (!access.personId) {
|
||||
return res.status(400).json({
|
||||
error: 'not_a_person',
|
||||
message: 'Nejste vedený jako řešitel, takže si ticket nemůžete převzít.',
|
||||
});
|
||||
}
|
||||
|
||||
const tenantIds = writableTenants(req);
|
||||
const current = ticketAssignee(req.params.id, tenantIds);
|
||||
if (current === undefined) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
|
||||
const ticket = getTicket(req.params.id, tenantIds);
|
||||
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
|
||||
// Prevzit jde jen to, na co clovek vidi.
|
||||
if (!ticketWithinVisibility(ticket.id, visibilityFor(req.user!, ticket.tenantId))) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
|
||||
if (current !== null && current !== access.personId) {
|
||||
// Uz to nekdo resi. Prehodit praci nekomu jinemu je jine rozhodnuti.
|
||||
// Pravo se pta za firmu ticketu, ne za tu prave prepnutou.
|
||||
if (!hasPermission(req.user!, 'ticket.assign.others', ticket.tenantId)) {
|
||||
return res.status(409).json({
|
||||
error: 'already_assigned',
|
||||
message: 'Ticket už někdo řeší. Přehodit ho může jen ten, kdo na to má právo.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mine = groupsOfPerson(access.personId, tenantIds).map((group) => group.id);
|
||||
const inMyGroup = ticket.assigneeGroupId !== null && mine.includes(ticket.assigneeGroupId);
|
||||
|
||||
if (!inMyGroup && current !== null && current !== access.personId) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: 'Tenhle ticket není ve vaší skupině.',
|
||||
});
|
||||
}
|
||||
|
||||
const updated = claimTicket(req.params.id, access.personId, tenantIds);
|
||||
if (!updated) {
|
||||
return res.status(400).json({ error: 'failed', message: 'Převzetí se nepodařilo.' });
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
userId: req.user!.id,
|
||||
userEmail: req.user!.email,
|
||||
tenantId: updated.tenantId,
|
||||
action: 'ticket.claim',
|
||||
target: updated.id,
|
||||
});
|
||||
|
||||
return res.json(updated);
|
||||
});
|
||||
|
||||
/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */
|
||||
/**
|
||||
@@ -886,116 +802,18 @@ dashboardRouter.get('/tickets/workload', (req, res) => {
|
||||
});
|
||||
|
||||
dashboardRouter.get('/tickets/:id', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
// Detail hledame pres vsechny firmy uzivatele, ne jen pres tu prave zvolenou.
|
||||
// Jinak by odkaz z pohledu "vse" na ticket jine firmy vratil 404.
|
||||
const reachable = accessFor(req.user!).tenants.map((tenant) => tenant.id);
|
||||
const ticket = getTicket(req.params.id, reachable);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
|
||||
// Strop se pocita za firmu ticketu, ne za prave prepnutou. Bez teto kontroly
|
||||
// by seznam ticket schoval, ale adresa detailu by ho vydala.
|
||||
if (!ticketWithinVisibility(ticket.id, visibilityFor(req.user!, ticket.tenantId))) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
// Detail se hleda pres vsechny firmy uzivatele a strop se pocita za firmu
|
||||
// ticketu - stejne jako u akci, viz `visibleTicketOrDeny`.
|
||||
const ticket = visibleTicketOrDeny(req, res);
|
||||
if (!ticket) return;
|
||||
return res.json(ticket);
|
||||
});
|
||||
|
||||
/** Firmy, na ktere ma uzivatel dosah pri zapisu. */
|
||||
function writableTenants(req: Request): string[] {
|
||||
return accessFor(req.user!).tenants.map((tenant) => tenant.id);
|
||||
return accessOf(req).tenants.map((tenant) => tenant.id);
|
||||
}
|
||||
|
||||
const assignSchema = z.object({
|
||||
/** null = vratit do fronty. */
|
||||
assigneeId: z.string().min(1).nullable(),
|
||||
});
|
||||
|
||||
dashboardRouter.post('/tickets/:id/assign', (req, res) => {
|
||||
const parsed = assignSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Chybí ID řešitele, nebo poslete null pro vrácení do fronty.',
|
||||
});
|
||||
}
|
||||
|
||||
// Prehazovat praci mezi lidmi smi jen admin. Agent si smi vzit ticket na sebe.
|
||||
const access = accessFor(req.user!);
|
||||
if (!access.canAssignOthers && parsed.data.assigneeId !== access.personId) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: 'Přiřazovat ostatním může jen správce firmy. Ticket si můžete vzít na sebe.',
|
||||
});
|
||||
}
|
||||
|
||||
const ticket = assignTicket(req.params.id, parsed.data.assigneeId, writableTenants(req));
|
||||
if (!ticket) {
|
||||
return res.status(404).json({
|
||||
error: 'not_found',
|
||||
message: 'Ticket nebo řešitel neexistuje, nebo je řešitel z jiné firmy.',
|
||||
});
|
||||
}
|
||||
return res.json(ticket);
|
||||
});
|
||||
|
||||
const statusSchema = z.object({
|
||||
/*
|
||||
* Stav je volny retezec, ne ciselnik. Tickety chodi z cizich aplikaci, ktere
|
||||
* maji svoje stavy, a nutit je do nasi ctverice znamenalo, ze uzivatel videl
|
||||
* nesmysl. Omezena je jen delka, aby se do pole nedal cely dokument.
|
||||
*/
|
||||
status: z.string().trim().min(1).max(60),
|
||||
/**
|
||||
* Je vyrizeny? Kdyz se neposle, priznak zustava - zmena textu stavu sama
|
||||
* o sobe neznamena, ze je hotovo.
|
||||
*/
|
||||
closed: z.boolean().optional(),
|
||||
});
|
||||
|
||||
dashboardRouter.post('/tickets/:id/status', (req, res) => {
|
||||
const parsed = statusSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Neplatný stav ticketu.' });
|
||||
}
|
||||
|
||||
const ticket = updateTicketStatus(
|
||||
req.params.id,
|
||||
parsed.data.status,
|
||||
writableTenants(req),
|
||||
parsed.data.closed,
|
||||
);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
return res.json(ticket);
|
||||
});
|
||||
|
||||
const commentSchema = z.object({
|
||||
text: z.string().trim().min(2, 'Komentář musí mít alespoň 2 znaky.'),
|
||||
});
|
||||
|
||||
dashboardRouter.post('/tickets/:id/comment', (req, res) => {
|
||||
const parsed = commentSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný komentář.',
|
||||
});
|
||||
}
|
||||
|
||||
const author = req.user?.name ?? 'Portál';
|
||||
const ticket = addComment(req.params.id, author, parsed.data.text, writableTenants(req));
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
return res.json(ticket);
|
||||
});
|
||||
|
||||
// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo.
|
||||
dashboardRouter.use('/stream', streamRouter);
|
||||
|
||||
@@ -1035,9 +853,10 @@ dashboardRouter.use('/helpdesk', helpdeskRouter);
|
||||
* neni vubec, ne se stavem "nemate pravo".
|
||||
*/
|
||||
dashboardRouter.get('/services', async (req, res) => {
|
||||
const access = accessFor(req.user!);
|
||||
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
|
||||
const tenantId = requested ?? access.defaultTenantId;
|
||||
// Cizi firma je 404. Bez firmy se vrati jen katalog bez firemnich nabidek.
|
||||
const resolved = optionalTenantOrDeny(req, res);
|
||||
if (!resolved) return;
|
||||
const { tenantId } = resolved;
|
||||
|
||||
const visible = new Set(visibleServices(req.user!, tenantId).map((service) => service.id));
|
||||
const tenantIds = tenantId ? [tenantId] : [];
|
||||
@@ -1368,14 +1187,33 @@ dashboardRouter.get('/automations/:id', (req, res) => {
|
||||
return res.json(automation);
|
||||
});
|
||||
|
||||
/**
|
||||
* Smi volajici menit automatizace teto firmy? Pri odepreni odpovi.
|
||||
*
|
||||
* Driv stacilo clenstvi: kdokoliv z firmy mohl prepsat strom, ktery posila
|
||||
* data ven. Pravo je `automation.edit` a pta se za firmu automatizace.
|
||||
*/
|
||||
function mayEditAutomations(req: Request, res: Response, tenantId: string): boolean {
|
||||
if (hasPermission(req.user!, 'automation.edit', tenantId)) return true;
|
||||
console.warn(`[automations] ${req.user!.email}: chybi pravo automation.edit ve firme ${tenantId}`);
|
||||
res.status(403).json({ error: 'forbidden', message: 'Automatizace upravuje ten, kdo na to má právo.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Automatizace v dosahu uzivatele, nebo 404. Kdo ji smi menit, se pta zvlast. */
|
||||
function editableAutomationOrDeny(req: Request, res: Response) {
|
||||
const automation = getAutomation(req.params.id, writableTenants(req));
|
||||
if (!automation) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
return null;
|
||||
}
|
||||
if (!mayEditAutomations(req, res, automation.tenantId)) return null;
|
||||
return automation;
|
||||
}
|
||||
|
||||
dashboardRouter.post('/automations', (req, res) => {
|
||||
const parsed = createSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
// Zakladat se musi do konkretni firmy, pohled "vse" na to nestaci.
|
||||
const scope = scopeOrDeny(req, res);
|
||||
@@ -1386,6 +1224,7 @@ dashboardRouter.post('/automations', (req, res) => {
|
||||
message: 'Vyberte firmu, do které se má automatizace založit.',
|
||||
});
|
||||
}
|
||||
if (!mayEditAutomations(req, res, scope.tenantId)) return;
|
||||
|
||||
const automation = createAutomation(parsed.data.name, scope.tenantId);
|
||||
return res.status(201).json(automation);
|
||||
@@ -1393,32 +1232,30 @@ dashboardRouter.post('/automations', (req, res) => {
|
||||
|
||||
dashboardRouter.put('/automations/:id', async (req, res) => {
|
||||
const parsed = updateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const existing = editableAutomationOrDeny(req, res);
|
||||
if (!existing) return;
|
||||
|
||||
const flow = parsed.data.flow ? normalizeTriggerFields(parsed.data.flow) : undefined;
|
||||
|
||||
let connectorIssues: string[] = [];
|
||||
|
||||
if (flow) {
|
||||
const { problems, issues } = await validateFlowReferences(flow, writableTenants(req));
|
||||
// Konektory se hledaji jen ve firme automatizace, cizi je rozbity strom.
|
||||
const { problems, issues } = await validateFlowReferences(flow, [existing.tenantId]);
|
||||
if (problems.length > 0) {
|
||||
console.warn(`[automations] ${req.params.id}: neplatny strom - ${problems.join(' ')}`);
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: problems[0],
|
||||
issues: problems.map((message) => ({ path: 'flow', message })),
|
||||
issues: problems.map((message) => ({ field: 'flow', message })),
|
||||
});
|
||||
}
|
||||
connectorIssues = issues;
|
||||
}
|
||||
|
||||
const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, writableTenants(req));
|
||||
const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, [existing.tenantId]);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
@@ -1429,7 +1266,10 @@ dashboardRouter.put('/automations/:id', async (req, res) => {
|
||||
|
||||
/** Nova adresa webhooku. Stara okamzite prestane fungovat - zamer, ne chyba. */
|
||||
dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => {
|
||||
const updated = regenerateWebhookToken(req.params.id, writableTenants(req));
|
||||
const existing = editableAutomationOrDeny(req, res);
|
||||
if (!existing) return;
|
||||
|
||||
const updated = regenerateWebhookToken(req.params.id, [existing.tenantId]);
|
||||
if (!updated) {
|
||||
return res.status(404).json({
|
||||
error: 'not_found',
|
||||
@@ -1440,7 +1280,10 @@ dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => {
|
||||
});
|
||||
|
||||
dashboardRouter.delete('/automations/:id', (req, res) => {
|
||||
if (!deleteAutomation(req.params.id, writableTenants(req))) {
|
||||
const existing = editableAutomationOrDeny(req, res);
|
||||
if (!existing) return;
|
||||
|
||||
if (!deleteAutomation(req.params.id, [existing.tenantId])) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
return res.status(204).end();
|
||||
|
||||
+9
-19
@@ -23,16 +23,19 @@
|
||||
* klienta.
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { type Request, type Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { actingTenant, seesWholeTenant } from '../data/access.js';
|
||||
import { seesWholeTenant } from '../data/access.js';
|
||||
import { recordAudit } from '../data/audit.js';
|
||||
import { hasPermission } from '../data/permissions.js';
|
||||
import { findPersonByEmail } from '../data/people.js';
|
||||
import { findTenant } from '../data/tenants.js';
|
||||
import { addComment, createTicket, getTicket, listTickets } from '../data/ticketStore.js';
|
||||
import { safeRouter } from '../middleware/asyncHandler.js';
|
||||
import { requestTenant } from '../middleware/tenant.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
|
||||
export const helpdeskRouter = Router();
|
||||
export const helpdeskRouter = safeRouter();
|
||||
|
||||
/**
|
||||
* Firma, za kterou clovek prave jedna, plus kontrola prava.
|
||||
@@ -47,10 +50,7 @@ function asking(req: Request, res: Response, permission: string): string | null
|
||||
* bez ni neni ceho se ptat - a kdyz clovek patri do vic firem, rozhoduje
|
||||
* ta, kterou ma prepnutou.
|
||||
*/
|
||||
const tenantId = actingTenant(
|
||||
req.user!,
|
||||
typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
|
||||
);
|
||||
const tenantId = requestTenant(req);
|
||||
|
||||
if (!tenantId) {
|
||||
res.status(403).json({
|
||||
@@ -150,12 +150,7 @@ helpdeskRouter.post('/', (req, res) => {
|
||||
if (!provider) return;
|
||||
|
||||
const parsed = createSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Požadavek se nepodařilo poslat.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const tenant = findTenant(tenantId);
|
||||
const person = findPersonByEmail(req.user!.email);
|
||||
@@ -227,12 +222,7 @@ helpdeskRouter.post('/:id/comment', (req, res) => {
|
||||
if (!tenantId) return;
|
||||
|
||||
const parsed = commentSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Komentář se nepodařilo uložit.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const person = findPersonByEmail(req.user!.email);
|
||||
const author = person?.name ?? req.user!.email;
|
||||
|
||||
+51
-20
@@ -11,7 +11,7 @@
|
||||
* ochrana a odkaz se snadno dostane, kam nema.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { recordAudit } from '../data/audit.js';
|
||||
import { publicBaseUrl } from '../config.js';
|
||||
@@ -24,26 +24,36 @@ import {
|
||||
newInvite,
|
||||
refreshInvites,
|
||||
} from '../data/invites.js';
|
||||
import { rolesFor, type Role } from '../data/permissions.js';
|
||||
import { hasPermission, rolesFor, type Role } from '../data/permissions.js';
|
||||
import { personStore, refreshPeople, listPeople } from '../data/people.js';
|
||||
import { findTenant } from '../data/tenants.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import {
|
||||
findUserByEmail,
|
||||
hashPassword,
|
||||
listAllUsers,
|
||||
refreshUsers,
|
||||
userStore,
|
||||
verifyPassword,
|
||||
} from '../data/users.js';
|
||||
import { nowIso } from '../data/store/index.js';
|
||||
import { safeRouter } from '../middleware/asyncHandler.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { currentTenant } from './crud.js';
|
||||
import { rateLimit } from '../middleware/rateLimit.js';
|
||||
import { tenantOrDeny } from '../middleware/tenant.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
|
||||
/** Verejne cesty. Bez prihlaseni, autorizuje kod v adrese. */
|
||||
export const publicInviteRouter = Router();
|
||||
export const publicInviteRouter = safeRouter();
|
||||
|
||||
/** Cesty za prihlasenim: sprava pozvanek. */
|
||||
export const inviteRouter = Router();
|
||||
export const inviteRouter = safeRouter();
|
||||
|
||||
/**
|
||||
* Prijeti pozvanky s existujicim uctem overuje heslo, takze je to druhe misto
|
||||
* vedle prihlaseni, kde jde heslo zkouset. Prisny limit: pet pokusu za ctvrt
|
||||
* hodiny z jedne adresy. Kdo pozvanku prijima, potrebuje jeden.
|
||||
*/
|
||||
const acceptLimiter = rateLimit({ name: 'invite-accept', windowMs: 15 * 60_000, max: 5 });
|
||||
|
||||
// ------------------------------------------------------------------ verejne
|
||||
|
||||
@@ -88,7 +98,7 @@ const acceptSchema = z.object({
|
||||
*
|
||||
* Heslo se **nikdy neposila** - clovek si ho nastavi sam prave tady.
|
||||
*/
|
||||
publicInviteRouter.post('/:code/accept', (req, res) => {
|
||||
publicInviteRouter.post('/:code/accept', acceptLimiter, (req, res) => {
|
||||
const invite = findByCode(req.params.code);
|
||||
if (!invite) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Pozvánka neexistuje.' });
|
||||
@@ -98,12 +108,7 @@ publicInviteRouter.post('/:code/accept', (req, res) => {
|
||||
if (problem) return res.status(409).json({ error: 'invalid_invite', message: problem });
|
||||
|
||||
const parsed = acceptSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const email = parsed.data.email.toLowerCase();
|
||||
|
||||
@@ -131,7 +136,7 @@ publicInviteRouter.post('/:code/accept', (req, res) => {
|
||||
*/
|
||||
const stored = listAllUsers().find((item) => item.id === existing.id);
|
||||
const passwordOk =
|
||||
stored !== undefined && (await bcrypt.compare(parsed.data.password, stored.passwordHash));
|
||||
stored !== undefined && (await verifyPassword(parsed.data.password, stored.passwordHash));
|
||||
|
||||
if (!passwordOk) {
|
||||
return res.status(401).json({
|
||||
@@ -163,7 +168,8 @@ publicInviteRouter.post('/:code/accept', (req, res) => {
|
||||
tenantId: null,
|
||||
email,
|
||||
name: parsed.data.name,
|
||||
passwordHash: hashPassword(parsed.data.password),
|
||||
passwordHash: await hashPassword(parsed.data.password),
|
||||
// Pozvanka nikdy nedela spravce platformy, at ji vytvoril kdokoliv.
|
||||
platformAdmin: false,
|
||||
enabled: true,
|
||||
memberships: [{ tenantId: invite.tenantId, roleIds: invite.roleIds }],
|
||||
@@ -229,8 +235,25 @@ inviteRouter.use(requireAuth);
|
||||
/** Kdo smi zvat. Zvat lidi do firmy je totez co spravovat uzivatele. */
|
||||
const PERMISSION = 'user.manage';
|
||||
|
||||
/**
|
||||
* Firma plus pravo zvat do ni. Pri odepreni odpovi a vrati null.
|
||||
*
|
||||
* Driv se pravo nekontrolovalo vubec: kdokoliv z firmy mohl vytvorit pozvanku
|
||||
* s roli spravce a pozvat sam sebe podruhe.
|
||||
*/
|
||||
function managedTenant(req: Request, res: Response): string | null {
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return null;
|
||||
if (!hasPermission(req.user!, PERMISSION, tenantId)) {
|
||||
console.warn(`[pozvanky] ${req.user!.email}: chybi pravo ${PERMISSION} ve firme ${tenantId}`);
|
||||
res.status(403).json({ error: 'forbidden', message: 'Pozvánky spravuje ten, kdo spravuje uživatele.' });
|
||||
return null;
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
inviteRouter.get('/', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = managedTenant(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const base = `${publicBaseUrl()}/pozvanka/`;
|
||||
@@ -257,14 +280,22 @@ const createSchema = z.object({
|
||||
});
|
||||
|
||||
inviteRouter.post('/', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = managedTenant(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const parsed = createSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
// Jen role teto firmy nebo systemove. Cizi role by se stejne nepouzila,
|
||||
// ale pozvanka s ni by vypadala, ze nekomu neco dava.
|
||||
const known = rolesFor(tenantId);
|
||||
const unknown = parsed.data.roleIds.filter(
|
||||
(ref) => !known.some((role) => role.id === ref || role.key === ref),
|
||||
);
|
||||
if (unknown.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
|
||||
message: `Role ${unknown.join(', ')} v této firmě neexistuje.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -304,7 +335,7 @@ inviteRouter.post('/', (req, res) => {
|
||||
|
||||
/** Zruseni pozvanky. Odkaz okamzite prestane platit. */
|
||||
inviteRouter.delete('/:id', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = managedTenant(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
return void inviteStore
|
||||
|
||||
+21
-30
@@ -7,12 +7,13 @@
|
||||
* zakladat tickety (viz documentation/09-navrh-rozsireni.md, bod 9).
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { accessFor } from '../data/access.js';
|
||||
import { defaultConnectorFor, getConnector, toPublicConnector } from '../data/connectorStore.js';
|
||||
import { findService } from '../data/services.js';
|
||||
import { safeRouter } from '../middleware/asyncHandler.js';
|
||||
import { requirePlatformAdmin } from '../middleware/auth.js';
|
||||
import { optionalTenantOrDeny, tenantOrDeny } from '../middleware/tenant.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
import { operationIdOf, serviceIdOf } from '../scripts/manifest.js';
|
||||
import {
|
||||
ensureLoaded,
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
} from '../scripts/registry.js';
|
||||
import { runScript } from '../scripts/runner.js';
|
||||
|
||||
export const scriptsRouter = Router();
|
||||
export const scriptsRouter = safeRouter();
|
||||
|
||||
/** Manifest plus to, co si klient nema dopocitavat sam. */
|
||||
async function scriptSummaries() {
|
||||
@@ -43,11 +44,12 @@ async function scriptSummaries() {
|
||||
}
|
||||
|
||||
scriptsRouter.get('/', async (req, res) => {
|
||||
// Cizi firma je 404. Bez firmy jen seznam skriptu bez konektoru.
|
||||
const resolved = optionalTenantOrDeny(req, res);
|
||||
if (!resolved) return;
|
||||
const { tenantId } = resolved;
|
||||
|
||||
const [items, problems] = await Promise.all([scriptSummaries(), scriptProblems()]);
|
||||
const access = accessFor(req.user!);
|
||||
const tenantId =
|
||||
(typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ??
|
||||
access.defaultTenantId;
|
||||
|
||||
// Ke kazde sluzbe vychozi konektor firmy, aby portal poznal, jestli je cim volat.
|
||||
const pairs = await Promise.all(
|
||||
@@ -63,8 +65,11 @@ scriptsRouter.get('/', async (req, res) => {
|
||||
problems,
|
||||
connectors,
|
||||
tenantId: tenantId ?? null,
|
||||
/** Kam se soubory ukladaji. Kdo ma na server pristup, upravi je i rucne. */
|
||||
directory: scriptsDir(),
|
||||
/**
|
||||
* Kam se soubory ukladaji. Kdo ma na server pristup, upravi je i rucne.
|
||||
* Cesta na disku je nase provozni informace, zakaznik ji nevidi.
|
||||
*/
|
||||
directory: req.user!.platformAdmin ? scriptsDir() : null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,10 +93,9 @@ scriptsRouter.get('/:id', async (req, res) => {
|
||||
// Manifest muze chybet, kdyz je soubor rozbity. Kod se vrati vzdy, aby slo opravit.
|
||||
const problems = await scriptProblems();
|
||||
const serviceId = serviceIdOf(id);
|
||||
const access = accessFor(req.user!);
|
||||
const tenantId =
|
||||
(typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ??
|
||||
access.defaultTenantId;
|
||||
const resolved = optionalTenantOrDeny(req, res);
|
||||
if (!resolved) return;
|
||||
const { tenantId } = resolved;
|
||||
const connector = tenantId ? await defaultConnectorFor(tenantId, serviceId) : undefined;
|
||||
|
||||
return res.json({
|
||||
@@ -121,12 +125,7 @@ scriptsRouter.put('/:id', requirePlatformAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
const parsed = saveSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const result = await saveSource(id, parsed.data.code);
|
||||
if (!result.ok) {
|
||||
@@ -162,19 +161,11 @@ scriptsRouter.post('/:id/test', requirePlatformAdmin, async (req, res) => {
|
||||
|
||||
const parsed = testSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Vstupy musí být objekt s hodnotami parametrů.',
|
||||
});
|
||||
return validationError(res, parsed.error, 'Vstupy musí být objekt s hodnotami parametrů.');
|
||||
}
|
||||
|
||||
const access = accessFor(req.user!);
|
||||
const tenantId =
|
||||
(typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ??
|
||||
access.defaultTenantId;
|
||||
if (!tenantId) {
|
||||
return res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
|
||||
}
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const serviceId = serviceIdOf(id);
|
||||
const service = findService(serviceId);
|
||||
|
||||
+176
-42
@@ -10,7 +10,7 @@
|
||||
* uzivatel ulozil roli a prava by se zmenila az po restartu.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { Request } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { recordAudit } from '../data/audit.js';
|
||||
import { bootstrapDataRefresh } from '../data/refresh.js';
|
||||
@@ -54,18 +54,24 @@ import {
|
||||
validateAction,
|
||||
type TicketAction,
|
||||
} from '../data/ticketActions.js';
|
||||
import { hashPassword, listAllUsers, userStore, type StoredUser } from '../data/users.js';
|
||||
import { hashPassword, listAllUsers, userStore, usersOfTenant, type StoredUser } from '../data/users.js';
|
||||
import { hasPermission } from '../data/permissions.js';
|
||||
import { publish } from '../events/bus.js';
|
||||
import { safeRouter } from '../middleware/asyncHandler.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { crudRouter, currentTenant, readScope } from './crud.js';
|
||||
import { requestTenant, tenantOrDeny } from '../middleware/tenant.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
import { aresRouter } from './ares.js';
|
||||
import { crudRouter, readScope } from './crud.js';
|
||||
|
||||
export const settingsRouter = Router();
|
||||
export const settingsRouter = safeRouter();
|
||||
settingsRouter.use(requireAuth);
|
||||
|
||||
/** Zapis do jakekoliv entity muze zmenit prava nebo nabidky, proto obnova. */
|
||||
settingsRouter.use((req, res, next) => {
|
||||
if (req.method === 'GET') return next();
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode < 400) void bootstrapDataRefresh();
|
||||
if (res.statusCode < 400) void bootstrapDataRefresh(req.path);
|
||||
});
|
||||
return next();
|
||||
});
|
||||
@@ -88,16 +94,34 @@ settingsRouter.get('/catalog', (req, res) => {
|
||||
|
||||
// -------------------------------------------------------------------- firmy
|
||||
|
||||
/** Udaje z ARES jsou nepovinne, rucne zalozena firma je mit nemusi. */
|
||||
const tenantRegistryFields = {
|
||||
ico: z.string().trim().regex(/^\d{8}$/, 'IČ má 8 číslic.').nullable().optional(),
|
||||
dic: z.string().trim().max(20).nullable().optional(),
|
||||
address: z.string().trim().max(300).nullable().optional(),
|
||||
legalForm: z.string().trim().max(120).nullable().optional(),
|
||||
};
|
||||
|
||||
const tenantCreate = z.object({
|
||||
name: z.string().trim().min(2, 'Název firmy je moc krátký.').max(80),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
...tenantRegistryFields,
|
||||
});
|
||||
|
||||
/** Dve firmy se stejnym IC jsou jedna firma zalozena dvakrat. */
|
||||
function duplicateIco(tenant: Tenant, all: Tenant[]): string[] {
|
||||
if (!tenant.ico) return [];
|
||||
return all.some((other) => other.ico === tenant.ico)
|
||||
? [`Firma s IČ ${tenant.ico} už existuje.`]
|
||||
: [];
|
||||
}
|
||||
|
||||
settingsRouter.use(
|
||||
'/tenants',
|
||||
crudRouter<Tenant, z.infer<typeof tenantCreate>, Partial<Tenant>>({
|
||||
store: tenantStore,
|
||||
idPrefix: 'tnt',
|
||||
event: 'tenant',
|
||||
createSchema: tenantCreate,
|
||||
updateSchema: z.object({
|
||||
name: z.string().trim().min(2).max(80).optional(),
|
||||
@@ -105,6 +129,7 @@ settingsRouter.use(
|
||||
enabled: z.boolean().optional(),
|
||||
/** Kdo teto firme resi helpdesk. null = nikdo, pozadavek nepujde poslat. */
|
||||
helpdeskProviderId: z.string().trim().min(1).nullable().optional(),
|
||||
...tenantRegistryFields,
|
||||
}),
|
||||
writePermission: 'tenant.manage',
|
||||
platformOnly: true,
|
||||
@@ -119,14 +144,23 @@ settingsRouter.use(
|
||||
intakeToken: generateIntakeToken(),
|
||||
// Dodavatele helpdesku doplni spravce az pri nastaveni vztahu.
|
||||
helpdeskProviderId: null,
|
||||
ico: input.ico ?? null,
|
||||
dic: input.dic ?? null,
|
||||
address: input.address ?? null,
|
||||
legalForm: input.legalForm ?? null,
|
||||
}),
|
||||
validate: (tenant, all) =>
|
||||
all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase())
|
||||
validate: (tenant, all) => [
|
||||
...(all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase())
|
||||
? [`Firma ${tenant.name} už existuje.`]
|
||||
: [],
|
||||
: []),
|
||||
...duplicateIco(tenant, all),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Zalozeni firmy z registru ARES vcetne lidi, kteri za ni jednaji.
|
||||
settingsRouter.use('/ares', aresRouter);
|
||||
|
||||
// ---------------------------------------------------------------- uzivatele
|
||||
|
||||
const membershipSchema = z.object({
|
||||
@@ -142,31 +176,102 @@ const userCreate = z.object({
|
||||
memberships: z.array(membershipSchema).default([]),
|
||||
});
|
||||
|
||||
const userUpdate = z.object({
|
||||
name: z.string().trim().min(2).max(80).optional(),
|
||||
email: z.string().trim().email().optional(),
|
||||
platformAdmin: z.boolean().optional(),
|
||||
memberships: z.array(membershipSchema).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type UserInput = z.infer<typeof userCreate> | z.infer<typeof userUpdate>;
|
||||
|
||||
/** Heslo se z API nikdy nevraci, ani jako hash. */
|
||||
function publicUser(user: StoredUser) {
|
||||
const { passwordHash: _passwordHash, ...rest } = user;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/** Je uzivatel clenem firmy? Spravce firmy vidi jen sve lidi. */
|
||||
function memberOf(user: StoredUser, tenantId: string): boolean {
|
||||
return user.memberships.some((membership) => membership.tenantId === tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Co smi se zaznamem uzivatele spravce firmy.
|
||||
*
|
||||
* Spravce platformy muze vsechno. Spravce firmy (pravo `user.manage`) smi
|
||||
* zakladat a menit jen lidi sve firmy, a to jen v ni: clenstvi v jinych
|
||||
* firmach se mu nechavaji, jak jsou, priznak spravce platformy nenastavi
|
||||
* a na spravce platformy vubec nesahne. Bez toho by si kazdy spravce firmy
|
||||
* mohl pridat clenstvi kamkoliv a udelat ze sebe spravce platformy.
|
||||
*/
|
||||
function prepareUserInput(
|
||||
req: Request,
|
||||
tenantId: string,
|
||||
input: UserInput | undefined,
|
||||
existing?: StoredUser,
|
||||
): { ok: true; input: UserInput | undefined } | { ok: false; status: number; message: string } {
|
||||
if (req.user!.platformAdmin) return { ok: true, input };
|
||||
|
||||
if (existing?.platformAdmin) {
|
||||
return { ok: false, status: 403, message: 'Správce platformy upravuje jen správce platformy.' };
|
||||
}
|
||||
|
||||
// Mazani: clovek z vic firem se nemaze, jen se mu vezme clenstvi tady.
|
||||
if (input === undefined) {
|
||||
const elsewhere = (existing?.memberships ?? []).some((m) => m.tenantId !== tenantId);
|
||||
if (elsewhere) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
message: 'Uživatel patří i do jiné firmy. Odeberte mu členství ve vaší, nemažte ho.',
|
||||
};
|
||||
}
|
||||
return { ok: true, input };
|
||||
}
|
||||
|
||||
if (input.platformAdmin !== undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
message: 'Příznak správce platformy nastavuje jen správce platformy.',
|
||||
};
|
||||
}
|
||||
|
||||
if (input.memberships !== undefined) {
|
||||
if (input.memberships.some((membership) => membership.tenantId !== tenantId)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
message: 'Členství můžete nastavit jen ve firmě, ve které právě jste.',
|
||||
};
|
||||
}
|
||||
if (!existing && input.memberships.length === 0) {
|
||||
return { ok: false, status: 403, message: 'Nový uživatel musí mít členství ve vaší firmě.' };
|
||||
}
|
||||
// Clenstvi jinde zustavaji, ta spravce firmy nevidi a nesmi je smazat.
|
||||
const others = (existing?.memberships ?? []).filter((m) => m.tenantId !== tenantId);
|
||||
return { ok: true, input: { ...input, memberships: [...others, ...input.memberships] } };
|
||||
}
|
||||
|
||||
return { ok: true, input };
|
||||
}
|
||||
|
||||
settingsRouter.use(
|
||||
'/users',
|
||||
crudRouter<StoredUser, z.infer<typeof userCreate>, Partial<StoredUser>>({
|
||||
crudRouter<StoredUser, z.infer<typeof userCreate>, z.infer<typeof userUpdate>>({
|
||||
store: userStore,
|
||||
idPrefix: 'usr',
|
||||
event: 'user',
|
||||
createSchema: userCreate,
|
||||
updateSchema: z.object({
|
||||
name: z.string().trim().min(2).max(80).optional(),
|
||||
email: z.string().trim().email().optional(),
|
||||
password: z.string().min(8).optional(),
|
||||
platformAdmin: z.boolean().optional(),
|
||||
memberships: z.array(membershipSchema).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}),
|
||||
updateSchema: userUpdate,
|
||||
writePermission: 'user.manage',
|
||||
build: (input) => ({
|
||||
scopeBy: { belongsTo: memberOf, prepare: prepareUserInput },
|
||||
build: async (input) => ({
|
||||
email: input.email.toLowerCase(),
|
||||
name: input.name,
|
||||
passwordHash: hashPassword(input.password),
|
||||
passwordHash: await hashPassword(input.password),
|
||||
platformAdmin: input.platformAdmin ?? false,
|
||||
memberships: input.memberships,
|
||||
enabled: true,
|
||||
@@ -182,6 +287,15 @@ settingsRouter.use(
|
||||
for (const membership of user.memberships) {
|
||||
if (!listTenants().some((tenant) => tenant.id === membership.tenantId)) {
|
||||
problems.push(`Firma ${membership.tenantId} neexistuje.`);
|
||||
continue;
|
||||
}
|
||||
// Role musi byt te firmy nebo systemova. Cizi role by se stejne
|
||||
// nepouzila (viz permissionsOf), ale ulozit ji je matouci.
|
||||
const known = rolesFor(membership.tenantId);
|
||||
for (const ref of membership.roleIds) {
|
||||
if (!known.some((role) => role.id === ref || role.key === ref)) {
|
||||
problems.push(`Role ${ref} v této firmě neexistuje.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
@@ -196,22 +310,29 @@ settingsRouter.use(
|
||||
*/
|
||||
settingsRouter.patch('/users/:id/password', async (req, res) => {
|
||||
const parsed = z.object({ password: z.string().min(8) }).safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Heslo musí mít aspoň 8 znaků.' });
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error, 'Heslo musí mít aspoň 8 znaků.');
|
||||
|
||||
const target = await userStore.get(req.params.id, readScope(req));
|
||||
if (!target) return res.status(404).json({ error: 'not_found', message: 'Uživatel neexistuje.' });
|
||||
|
||||
// Svoje heslo si smi zmenit kazdy, cizi jen kdo spravuje uzivatele.
|
||||
/*
|
||||
* Svoje heslo si smi zmenit kazdy. Cizi spravce platformy, nebo spravce
|
||||
* firmy svym lidem - ne ale spravci platformy, ten je mimo jeho dosah.
|
||||
*/
|
||||
const own = target.id === req.user!.id;
|
||||
if (!own && !req.user!.platformAdmin) {
|
||||
const tenantId = requestTenant(req);
|
||||
const managesTarget =
|
||||
tenantId !== null &&
|
||||
!target.platformAdmin &&
|
||||
memberOf(target, tenantId) &&
|
||||
hasPermission(req.user!, 'user.manage', tenantId);
|
||||
if (!own && !req.user!.platformAdmin && !managesTarget) {
|
||||
return res.status(403).json({ error: 'forbidden', message: 'Cizí heslo měnit nemůžete.' });
|
||||
}
|
||||
|
||||
await userStore.update(
|
||||
req.params.id,
|
||||
{ passwordHash: hashPassword(parsed.data.password) } as Partial<StoredUser>,
|
||||
{ passwordHash: await hashPassword(parsed.data.password) } as Partial<StoredUser>,
|
||||
readScope(req),
|
||||
);
|
||||
recordAudit({
|
||||
@@ -224,12 +345,22 @@ settingsRouter.patch('/users/:id/password', async (req, res) => {
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
/** Uzivatele vcetne vypnutych. Seznam pro spravu, ne pro nabidky. */
|
||||
/**
|
||||
* Uzivatele vcetne vypnutych. Seznam pro spravu, ne pro nabidky.
|
||||
*
|
||||
* Spravce platformy vidi vsechny, spravce firmy jen lidi sve firmy.
|
||||
*/
|
||||
settingsRouter.get('/users-overview', (req, res) => {
|
||||
if (!req.user!.platformAdmin) {
|
||||
return res.status(403).json({ error: 'forbidden', message: 'Jen pro správce platformy.' });
|
||||
if (req.user!.platformAdmin) {
|
||||
return res.json({ items: listAllUsers().map(publicUser) });
|
||||
}
|
||||
return res.json({ items: listAllUsers().map(publicUser) });
|
||||
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
if (!hasPermission(req.user!, 'user.manage', tenantId)) {
|
||||
return res.status(403).json({ error: 'forbidden', message: 'Uživatele vidí ten, kdo je spravuje.' });
|
||||
}
|
||||
return res.json({ items: usersOfTenant(tenantId).map(publicUser) });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- role
|
||||
@@ -245,6 +376,7 @@ settingsRouter.use(
|
||||
crudRouter<Role, z.infer<typeof roleCreate>, Partial<Role>>({
|
||||
store: roleStore,
|
||||
idPrefix: 'role',
|
||||
event: 'role',
|
||||
createSchema: roleCreate,
|
||||
updateSchema: z.object({
|
||||
name: z.string().trim().min(2).max(60).optional(),
|
||||
@@ -270,7 +402,7 @@ settingsRouter.use(
|
||||
|
||||
/** Role dostupne firme vcetne systemovych. Pro nabidku u clenstvi. */
|
||||
settingsRouter.get('/roles-available', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
return res.json({ items: rolesFor(tenantId) });
|
||||
});
|
||||
@@ -291,6 +423,7 @@ settingsRouter.use(
|
||||
crudRouter<Person, z.infer<typeof personCreate>, Partial<Person>>({
|
||||
store: personStore,
|
||||
idPrefix: 'ppl',
|
||||
event: 'person',
|
||||
createSchema: personCreate,
|
||||
updateSchema: z.object({
|
||||
name: z.string().trim().min(2).max(80).optional(),
|
||||
@@ -318,7 +451,7 @@ settingsRouter.use(
|
||||
|
||||
/** Vcetne vypnutych. Pro spravu tymu. */
|
||||
settingsRouter.get('/people-overview', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
return res.json({ items: listAllPeople([tenantId]) });
|
||||
});
|
||||
@@ -347,6 +480,7 @@ settingsRouter.use(
|
||||
crudRouter({
|
||||
store: groupStore,
|
||||
idPrefix: 'grp',
|
||||
event: 'group',
|
||||
createSchema: groupCreate,
|
||||
updateSchema: z.object({
|
||||
name: z.string().trim().min(2).max(60).optional(),
|
||||
@@ -377,7 +511,7 @@ const featuresUpdate = z.object({
|
||||
|
||||
/** Nastaveni firmy. GET vraci i vychozi, kdyz firma vlastni jeste nema. */
|
||||
settingsRouter.get('/features', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
return res.json({ tenantId, features: featuresOf(tenantId) });
|
||||
});
|
||||
@@ -386,16 +520,11 @@ settingsRouter.put('/features', async (req, res) => {
|
||||
if (!req.user!.platformAdmin) {
|
||||
return res.status(403).json({ error: 'forbidden', message: 'Záložky nastavuje správce platformy.' });
|
||||
}
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const parsed = featuresUpdate.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const known = new Set(moduleCatalog.map((module) => module.key));
|
||||
const unknown = (parsed.data.modules ?? []).filter((key) => !known.has(key as never));
|
||||
@@ -441,6 +570,8 @@ settingsRouter.put('/features', async (req, res) => {
|
||||
detail: { modules },
|
||||
});
|
||||
|
||||
// Zalozky a limity meni navigaci portalu; ten si je drzi s pravy v pameti.
|
||||
publish('feature.updated', 'Záložky firmy upraveny', { id: tenantId, feature: saved }, tenantId);
|
||||
return res.json({ tenantId, features: saved });
|
||||
});
|
||||
|
||||
@@ -488,6 +619,7 @@ settingsRouter.use(
|
||||
crudRouter<TicketType, z.infer<typeof typeCreate>, Partial<TicketType>>({
|
||||
store: ticketTypeStore,
|
||||
idPrefix: 'tt',
|
||||
event: 'ticketType',
|
||||
createSchema: typeCreate,
|
||||
// Transformace dogeneruje ID poli. Bez ni by pole prislo bez `id`
|
||||
// a podminky v akcich by se nemely na co odkazat.
|
||||
@@ -589,6 +721,7 @@ settingsRouter.use(
|
||||
crudRouter<TicketAction, z.infer<typeof actionCreate>, Partial<TicketAction>>({
|
||||
store: actionStore,
|
||||
idPrefix: 'tka',
|
||||
event: 'action',
|
||||
createSchema: actionCreate,
|
||||
updateSchema: z.object({
|
||||
label: z.string().trim().min(2).max(60).optional(),
|
||||
@@ -622,7 +755,7 @@ settingsRouter.use(
|
||||
|
||||
/** Akce firmy vcetne vypnutych. Pro spravu. */
|
||||
settingsRouter.get('/actions-overview', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
return res.json({ items: listActions([tenantId]) });
|
||||
});
|
||||
@@ -635,7 +768,7 @@ settingsRouter.get('/actions-overview', (req, res) => {
|
||||
* server, aby si klient nedelal druhy seznam, ktery se casem rozejde.
|
||||
*/
|
||||
settingsRouter.get('/actions/:id/scope', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const action = listActions([tenantId]).find((item) => item.id === req.params.id);
|
||||
@@ -732,6 +865,7 @@ settingsRouter.use(
|
||||
crudRouter<CustomWidget, z.infer<typeof widgetCreate>, Partial<CustomWidget>>({
|
||||
store: customWidgetStore,
|
||||
idPrefix: 'cw',
|
||||
event: 'widget',
|
||||
createSchema: widgetCreate,
|
||||
updateSchema: z.object({
|
||||
name: z.string().trim().min(2).max(60).optional(),
|
||||
@@ -758,7 +892,7 @@ settingsRouter.use(
|
||||
|
||||
/** Widgety firmy plus osobni prihlaseneho. */
|
||||
settingsRouter.get('/widgets-overview', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
return res.json({ items: listCustomWidgets([tenantId], req.user!.id) });
|
||||
});
|
||||
|
||||
+19
-2
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { listenerCount, recentEvents, subscribe, type DashboardEvent } from '../events/bus.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { accessOf } from '../middleware/tenant.js';
|
||||
|
||||
export const streamRouter = Router();
|
||||
|
||||
@@ -26,10 +27,26 @@ streamRouter.get('/', requireAuth, (req, res) => {
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
const email = req.user?.email ?? 'neznamy';
|
||||
const user = req.user!;
|
||||
const email = user.email;
|
||||
console.info(`[stream] pripojen ${email} (celkem posluchacu: ${listenerCount() + 1})`);
|
||||
|
||||
/*
|
||||
* Co tenhle clovek smi videt. Firmy se berou jednou pri pripojeni: kdo
|
||||
* clenstvi ztrati, dostava udalosti do dalsiho pripojeni - token mu stejne
|
||||
* vyprsi driv, nez by to vadilo.
|
||||
*/
|
||||
const allowed = new Set(accessOf(req).tenants.map((tenant) => tenant.id));
|
||||
const visible = (event: DashboardEvent): boolean => {
|
||||
// Upozorneni patri jednomu cloveku, ne cele firme.
|
||||
const forUser = event.payload?.userId;
|
||||
if (typeof forUser === 'string' && forUser !== user.id) return false;
|
||||
if (event.tenantId === null || user.platformAdmin) return true;
|
||||
return allowed.has(event.tenantId);
|
||||
};
|
||||
|
||||
const send = (event: DashboardEvent) => {
|
||||
if (!visible(event)) return;
|
||||
res.write(`event: ${event.type}\n`);
|
||||
res.write(`id: ${event.id}\n`);
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
@@ -39,7 +56,7 @@ streamRouter.get('/', requireAuth, (req, res) => {
|
||||
res.write(`event: connected\ndata: ${JSON.stringify({ at: new Date().toISOString() })}\n\n`);
|
||||
|
||||
// Kratka historie, aby klient nepresel o to, co se stalo tesne pred pripojenim.
|
||||
for (const event of recentEvents(5).reverse()) send(event);
|
||||
for (const event of recentEvents(5, visible).reverse()) send(event);
|
||||
|
||||
const unsubscribe = subscribe(send);
|
||||
|
||||
|
||||
+19
-32
@@ -10,7 +10,7 @@
|
||||
* co aplikace ve firme umi - ne bezna prace s tickety.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { recordAudit } from '../data/audit.js';
|
||||
import {
|
||||
@@ -23,11 +23,13 @@ import {
|
||||
SCRIPT_TEMPLATE,
|
||||
} from '../data/tenantScripts.js';
|
||||
import { hasPermission } from '../data/permissions.js';
|
||||
import { safeRouter } from '../middleware/asyncHandler.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { tenantOrDeny } from '../middleware/tenant.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
import { runSandbox } from '../runtime/sandbox.js';
|
||||
import { currentTenant, requestTenant } from './crud.js';
|
||||
|
||||
export const tenantScriptRouter = Router();
|
||||
export const tenantScriptRouter = safeRouter();
|
||||
|
||||
tenantScriptRouter.use(requireAuth);
|
||||
|
||||
@@ -35,9 +37,9 @@ tenantScriptRouter.use(requireAuth);
|
||||
const PERMISSION = 'action.manage';
|
||||
|
||||
/** Vrati false a odpovi, kdyz volajici na zmenu nema pravo. */
|
||||
function mayWrite(req: Parameters<typeof requireAuth>[0], res: Parameters<typeof requireAuth>[1]): boolean {
|
||||
function mayWrite(req: Request, res: Response, tenantId: string): boolean {
|
||||
// Pravo za firmu, kterou ma clovek prepnutou. Skript patri jedne firme.
|
||||
if (hasPermission(req.user!, PERMISSION, requestTenant(req))) return true;
|
||||
if (hasPermission(req.user!, PERMISSION, tenantId)) return true;
|
||||
console.warn(`[skripty] ${req.user!.email}: chybi pravo ${PERMISSION}`);
|
||||
res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' });
|
||||
return false;
|
||||
@@ -54,7 +56,7 @@ const bodySchema = z.object({
|
||||
});
|
||||
|
||||
tenantScriptRouter.get('/', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
return res.json({
|
||||
@@ -66,17 +68,12 @@ tenantScriptRouter.get('/', (req, res) => {
|
||||
});
|
||||
|
||||
tenantScriptRouter.post('/', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
if (!mayWrite(req, res)) return;
|
||||
if (!mayWrite(req, res, tenantId)) return;
|
||||
|
||||
const parsed = bodySchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const script = newTenantScript({ tenantId, ...parsed.data });
|
||||
|
||||
@@ -103,21 +100,16 @@ tenantScriptRouter.post('/', (req, res) => {
|
||||
});
|
||||
|
||||
tenantScriptRouter.put('/:id', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
if (!mayWrite(req, res)) return;
|
||||
if (!mayWrite(req, res, tenantId)) return;
|
||||
|
||||
if (!findTenantScript(req.params.id, [tenantId])) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Skript neexistuje.' });
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
return void tenantScriptStore
|
||||
.update(req.params.id, parsed.data, { tenantIds: [tenantId], includeGlobal: false })
|
||||
@@ -145,9 +137,9 @@ tenantScriptRouter.put('/:id', (req, res) => {
|
||||
});
|
||||
|
||||
tenantScriptRouter.delete('/:id', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
if (!mayWrite(req, res)) return;
|
||||
if (!mayWrite(req, res, tenantId)) return;
|
||||
|
||||
return void tenantScriptStore
|
||||
.remove(req.params.id, { tenantIds: [tenantId], includeGlobal: false })
|
||||
@@ -187,17 +179,12 @@ const testSchema = z.object({
|
||||
* az z padleho behu o hodinu pozdeji.
|
||||
*/
|
||||
tenantScriptRouter.post('/test', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
if (!mayWrite(req, res)) return;
|
||||
if (!mayWrite(req, res, tenantId)) return;
|
||||
|
||||
const parsed = testSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
|
||||
});
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error);
|
||||
|
||||
const result = runSandbox(parsed.data.code, parsed.data.input ?? {});
|
||||
|
||||
|
||||
+191
-48
@@ -10,22 +10,31 @@
|
||||
* zmackl" by znamenala dve casove osy a hledani ve dvou mistech.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { isDenied, resolveScope } from '../data/access.js';
|
||||
import { visibilityFor } from '../data/access.js';
|
||||
import { recordAudit } from '../data/audit.js';
|
||||
import { defaultConnectorFor, getConnector } from '../data/connectorStore.js';
|
||||
import { findGroup } from '../data/people.js';
|
||||
import { findGroup, groupsOfPerson } from '../data/people.js';
|
||||
import { hasPermission } from '../data/permissions.js';
|
||||
import { renderTemplate } from '../data/templates.js';
|
||||
import {
|
||||
addComment,
|
||||
appendTrace,
|
||||
assignTicket,
|
||||
assignTicketGroup,
|
||||
claimTicket,
|
||||
getTicket,
|
||||
setTicketTags,
|
||||
setTicketType,
|
||||
ticketWithinVisibility,
|
||||
updateTicketStatus,
|
||||
type Ticket,
|
||||
type TicketDetail,
|
||||
} from '../data/ticketStore.js';
|
||||
import { safeRouter } from '../middleware/asyncHandler.js';
|
||||
import { accessOf } from '../middleware/tenant.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
import {
|
||||
actionPermission,
|
||||
actionsForTicket,
|
||||
@@ -39,7 +48,25 @@ import { runScript } from '../scripts/runner.js';
|
||||
import { scriptIdFor } from '../scripts/lookup.js';
|
||||
import { runFlow } from '../runtime/executor.js';
|
||||
|
||||
export const ticketActionsRouter = Router();
|
||||
export const ticketActionsRouter = safeRouter();
|
||||
|
||||
/**
|
||||
* Ticket, na ktery volajici vidi. Cizi nebo neviditelny je 404, ne 403.
|
||||
*
|
||||
* Hleda se pres **vsechny firmy uzivatele**, ne jen prepnutou - odkaz
|
||||
* z pohledu "vse" vede i na ticket jine firmy. Strop viditelnosti se pak
|
||||
* pocita za firmu ticketu. Stejne pravidlo jako u detailu, proto jedno misto:
|
||||
* kdyby si to kazda akce resila sama, jednou nekde strop chybi.
|
||||
*/
|
||||
export function visibleTicketOrDeny(req: Request, res: Response): TicketDetail | null {
|
||||
const reachable = accessOf(req).tenants.map((tenant) => tenant.id);
|
||||
const ticket = getTicket(req.params.id, reachable);
|
||||
if (!ticket || !ticketWithinVisibility(ticket.id, visibilityFor(req.user!, ticket.tenantId))) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
return null;
|
||||
}
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hodnoty, proti kterym se vyhodnocuji podminky a sablony.
|
||||
@@ -103,16 +130,8 @@ function publicAction(action: TicketAction) {
|
||||
|
||||
/** Akce, ktere na ticket sedi. Uz vyfiltrovane podle typu, tagu, podminek a prav. */
|
||||
ticketActionsRouter.get('/:id/actions', async (req, res) => {
|
||||
const scope = resolveScope(req.user!, {
|
||||
scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
|
||||
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
|
||||
});
|
||||
if (isDenied(scope)) {
|
||||
return res.status(scope.status).json({ error: scope.error, message: scope.message });
|
||||
}
|
||||
|
||||
const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility);
|
||||
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
const ticket = visibleTicketOrDeny(req, res);
|
||||
if (!ticket) return;
|
||||
|
||||
const actions = actionsForTicket(
|
||||
[ticket.tenantId],
|
||||
@@ -136,15 +155,8 @@ const runSchema = z.object({
|
||||
* otevrenou starou stranku, nebo tlacitko vubec nezobrazit a poslat request sam.
|
||||
*/
|
||||
ticketActionsRouter.post('/:id/actions/:actionId', async (req, res) => {
|
||||
const scope = resolveScope(req.user!, {
|
||||
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
|
||||
});
|
||||
if (isDenied(scope)) {
|
||||
return res.status(scope.status).json({ error: scope.error, message: scope.message });
|
||||
}
|
||||
|
||||
const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility);
|
||||
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
const ticket = visibleTicketOrDeny(req, res);
|
||||
if (!ticket) return;
|
||||
|
||||
const action = findAction(req.params.actionId);
|
||||
if (!action || action.tenantId !== ticket.tenantId || !action.enabled) {
|
||||
@@ -173,9 +185,7 @@ ticketActionsRouter.post('/:id/actions/:actionId', async (req, res) => {
|
||||
}
|
||||
|
||||
const parsed = runSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Neplatný formulář.' });
|
||||
}
|
||||
if (!parsed.success) return validationError(res, parsed.error, 'Neplatný formulář.');
|
||||
|
||||
for (const field of action.form) {
|
||||
if (field.required && (parsed.data.form[field.id] ?? '').trim() === '') {
|
||||
@@ -340,32 +350,63 @@ async function runAction(
|
||||
|
||||
// ------------------------------------------------- vestavene akce na ticketu
|
||||
|
||||
/**
|
||||
* Odmitnuti z `apply`, kdyz duvod neni "ticket neexistuje". Ma vlastni tvar,
|
||||
* aby se nepletl s ticketem - ten ma taky pole `status`.
|
||||
*/
|
||||
interface ActionRefusal {
|
||||
refused: true;
|
||||
status: number;
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function refuse(status: number, error: string, message: string): ActionRefusal {
|
||||
return { refused: true, status, error, message };
|
||||
}
|
||||
|
||||
function isRefusal(value: unknown): value is ActionRefusal {
|
||||
return typeof value === 'object' && value !== null && (value as ActionRefusal).refused === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vestavene akce jsou ve stejnem rezimu jako vlastni: kazda ma svoje pravo
|
||||
* a projde auditem. Diky tomu jde vestavenou akci nekomu vypnout bez zmeny kodu.
|
||||
*
|
||||
* Fabrika, ne tri skoro stejne handlery. Kazdy by jinak resil znovu firmu,
|
||||
* pravo, 404, audit a udalost.
|
||||
* Fabrika, ne sedm skoro stejnych handleru. Kazdy by jinak resil znovu firmu,
|
||||
* strop viditelnosti, pravo, 404, audit a udalost - a prirazeni, stav
|
||||
* a komentar to driv opravdu resily kazdy jinak a pravo nekontrolovaly vubec.
|
||||
*/
|
||||
function builtinAction<T>(options: {
|
||||
path: string;
|
||||
permission: string;
|
||||
/** Pravo, nebo funkce, kdyz zalezi na vstupu (vzit si vs. prehodit). */
|
||||
permission: string | ((input: T, req: Request) => string);
|
||||
/** Hlaska pri odepreni. Bez ni obecna. */
|
||||
deniedMessage?: string;
|
||||
auditAction: string;
|
||||
schema: z.ZodType<T, z.ZodTypeDef, unknown>;
|
||||
apply: (ticket: Ticket, input: T, tenantIds: string[]) => Ticket | undefined;
|
||||
/** Hlaska pri neplatnem vstupu. Bez ni ta ze schematu. */
|
||||
invalidMessage?: string;
|
||||
apply: (
|
||||
ticket: TicketDetail,
|
||||
input: T,
|
||||
tenantIds: string[],
|
||||
req: Request,
|
||||
) => Ticket | undefined | ActionRefusal;
|
||||
}): void {
|
||||
ticketActionsRouter.post(`/:id/${options.path}`, (req, res) => {
|
||||
const scope = resolveScope(req.user!, {
|
||||
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
|
||||
});
|
||||
if (isDenied(scope)) {
|
||||
return res.status(scope.status).json({ error: scope.error, message: scope.message });
|
||||
}
|
||||
const ticket = visibleTicketOrDeny(req, res);
|
||||
if (!ticket) return;
|
||||
|
||||
const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility);
|
||||
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
const parsed = options.schema.safeParse(req.body);
|
||||
if (!parsed.success) return validationError(res, parsed.error, options.invalidMessage);
|
||||
|
||||
if (!hasPermission(req.user!, options.permission, ticket.tenantId)) {
|
||||
// Pravo za firmu ticketu, ne za prave prepnutou.
|
||||
const permission =
|
||||
typeof options.permission === 'string'
|
||||
? options.permission
|
||||
: options.permission(parsed.data, req);
|
||||
if (!hasPermission(req.user!, permission, ticket.tenantId)) {
|
||||
recordAudit({
|
||||
userId: req.user!.id,
|
||||
userEmail: req.user!.email,
|
||||
@@ -375,18 +416,16 @@ function builtinAction<T>(options: {
|
||||
target: ticket.id,
|
||||
result: 'denied',
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' });
|
||||
}
|
||||
|
||||
const parsed = options.schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: options.deniedMessage ?? 'K této změně nemáte oprávnění.',
|
||||
});
|
||||
}
|
||||
|
||||
const updated = options.apply(ticket, parsed.data, scope.tenantIds);
|
||||
const updated = options.apply(ticket, parsed.data, [ticket.tenantId], req);
|
||||
if (isRefusal(updated)) {
|
||||
return res.status(updated.status).json({ error: updated.error, message: updated.message });
|
||||
}
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
@@ -447,3 +486,107 @@ builtinAction({
|
||||
return assignTicketGroup(ticket.id, input.groupId, tenantIds);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Prirazeni resitele. `null` = vratit do fronty.
|
||||
*
|
||||
* Vzit si ticket na sebe je jine pravo nez prehodit ho nekomu jinemu
|
||||
* (nebo vratit do fronty). Ktere se pta, rozhoduje vstup.
|
||||
*/
|
||||
builtinAction({
|
||||
path: 'assign',
|
||||
permission: (input: { assigneeId: string | null }, req) =>
|
||||
input.assigneeId !== null && input.assigneeId === accessOf(req).personId
|
||||
? 'ticket.assign.self'
|
||||
: 'ticket.assign.others',
|
||||
deniedMessage: 'Přiřazovat ostatním může jen ten, kdo na to má právo. Ticket si můžete vzít na sebe.',
|
||||
auditAction: 'ticket.assign',
|
||||
schema: z.object({
|
||||
/** null = vratit do fronty. */
|
||||
assigneeId: z.string().min(1).nullable(),
|
||||
}),
|
||||
invalidMessage: 'Chybí ID řešitele, nebo pošlete null pro vrácení do fronty.',
|
||||
apply: (ticket, input, tenantIds) =>
|
||||
assignTicket(ticket.id, input.assigneeId, tenantIds) ??
|
||||
refuse(404, 'not_found', 'Ticket nebo řešitel neexistuje, nebo je řešitel z jiné firmy.'),
|
||||
});
|
||||
|
||||
builtinAction({
|
||||
path: 'status',
|
||||
permission: 'ticket.status.change',
|
||||
auditAction: 'ticket.status',
|
||||
schema: z.object({
|
||||
/*
|
||||
* Stav je volny retezec, ne ciselnik. Tickety chodi z cizich aplikaci, ktere
|
||||
* maji svoje stavy, a nutit je do nasi ctverice znamenalo, ze uzivatel videl
|
||||
* nesmysl. Omezena je jen delka, aby se do pole nedal cely dokument.
|
||||
*/
|
||||
status: z.string().trim().min(1).max(60),
|
||||
/**
|
||||
* Je vyrizeny? Kdyz se neposle, priznak zustava - zmena textu stavu sama
|
||||
* o sobe neznamena, ze je hotovo.
|
||||
*/
|
||||
closed: z.boolean().optional(),
|
||||
}),
|
||||
invalidMessage: 'Neplatný stav ticketu.',
|
||||
apply: (ticket, input, tenantIds) =>
|
||||
updateTicketStatus(ticket.id, input.status, tenantIds, input.closed),
|
||||
});
|
||||
|
||||
builtinAction({
|
||||
path: 'comment',
|
||||
permission: 'ticket.comment',
|
||||
auditAction: 'ticket.comment',
|
||||
schema: z.object({
|
||||
text: z.string().trim().min(2, 'Komentář musí mít alespoň 2 znaky.'),
|
||||
}),
|
||||
apply: (ticket, input, tenantIds, req) =>
|
||||
addComment(ticket.id, req.user?.name ?? 'Portál', input.text, tenantIds),
|
||||
});
|
||||
|
||||
/**
|
||||
* Prevzeti ticketu.
|
||||
*
|
||||
* Clovek si vezme praci sam, misto aby cekal, az mu ji nekdo prideli. Tohle je
|
||||
* bezny zpusob prace v tymu: ticket lezi ve fronte skupiny a kdo ma cas, si ho
|
||||
* vezme.
|
||||
*
|
||||
* Smi to u ticketu **ze sve skupiny** nebo u takoveho, ktery nema nikoho.
|
||||
* Vzit nekomu rozdelanou praci nejde - to uz je prehozeni a na to je pravo
|
||||
* `ticket.assign.others`.
|
||||
*/
|
||||
builtinAction({
|
||||
path: 'claim',
|
||||
permission: 'ticket.assign.self',
|
||||
auditAction: 'ticket.claim',
|
||||
// Prevzeti nema telo. Cokoliv prijde, se ignoruje.
|
||||
schema: z.unknown(),
|
||||
apply: (ticket, _input, tenantIds, req) => {
|
||||
const personId = accessOf(req).personId;
|
||||
if (!personId) {
|
||||
return refuse(400, 'not_a_person', 'Nejste vedený jako řešitel, takže si ticket nemůžete převzít.');
|
||||
}
|
||||
|
||||
const current = ticket.assignee?.id ?? null;
|
||||
if (current !== null && current !== personId) {
|
||||
// Uz to nekdo resi. Prehodit praci nekomu jinemu je jine rozhodnuti.
|
||||
// Pravo se pta za firmu ticketu, ne za tu prave prepnutou.
|
||||
if (!hasPermission(req.user!, 'ticket.assign.others', ticket.tenantId)) {
|
||||
return refuse(
|
||||
409,
|
||||
'already_assigned',
|
||||
'Ticket už někdo řeší. Přehodit ho může jen ten, kdo na to má právo.',
|
||||
);
|
||||
}
|
||||
|
||||
const mine = groupsOfPerson(personId, tenantIds).map((group) => group.id);
|
||||
const inMyGroup = ticket.assigneeGroupId !== null && mine.includes(ticket.assigneeGroupId);
|
||||
if (!inMyGroup) return refuse(403, 'forbidden', 'Tenhle ticket není ve vaší skupině.');
|
||||
}
|
||||
|
||||
return (
|
||||
claimTicket(ticket.id, personId, tenantIds) ??
|
||||
refuse(400, 'failed', 'Převzetí se nepodařilo.')
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ webhookRouter.post('/:token', (req, res) => {
|
||||
publish('webhook.received', `Webhook přijal data pro ${automation.id}`, {
|
||||
automationId: automation.id,
|
||||
fields: Object.keys(values),
|
||||
});
|
||||
}, automation.tenantId);
|
||||
|
||||
return void enqueue({
|
||||
tenantId: automation.tenantId,
|
||||
|
||||
+49
-85
@@ -12,9 +12,9 @@
|
||||
* nejde poslat nic, co by server polozilo.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { isDenied, resolveScope, type ResolvedScope } from '../data/access.js';
|
||||
import type { WidgetResult, WidgetValue } from '../shared/widgets.js';
|
||||
import { type ResolvedScope } from '../data/access.js';
|
||||
import {
|
||||
findCustomWidget,
|
||||
type CustomWidget,
|
||||
@@ -29,27 +29,45 @@ import { runScript } from '../scripts/runner.js';
|
||||
import { scriptIdFor } from '../scripts/lookup.js';
|
||||
import {
|
||||
channelLabels,
|
||||
defaultStatuses,
|
||||
getAgentStats,
|
||||
getWorkload,
|
||||
listTickets,
|
||||
type AgentStatsRow,
|
||||
type Ticket,
|
||||
} from '../data/ticketStore.js';
|
||||
import { findTicketType, listTicketTypes } from '../data/ticketTypes.js';
|
||||
import { builtinSources } from '../data/widgets.js';
|
||||
import { safeRouter } from '../middleware/asyncHandler.js';
|
||||
import { scopeOrDeny } from '../middleware/tenant.js';
|
||||
import { validationError } from '../middleware/validation.js';
|
||||
|
||||
export const widgetDataRouter = Router();
|
||||
export const widgetDataRouter = safeRouter();
|
||||
|
||||
/**
|
||||
* Tickety v dosahu, nactene **jednou za request**.
|
||||
*
|
||||
* Prehled ma az 24 dlazdic a skoro kazda filtruje tentyz seznam. Driv si ho
|
||||
* kazda cetla znovu, coz bylo 24 pruchodu ulozistem kvuli jednomu prekresleni.
|
||||
*/
|
||||
function ticketSourceFor(scope: ResolvedScope): () => Ticket[] {
|
||||
let loaded: Ticket[] | null = null;
|
||||
return () => {
|
||||
if (loaded === null) {
|
||||
loaded = listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility });
|
||||
}
|
||||
return loaded;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Popisky stavu. `ticketStore` je neexportuje, a duplikovat je sem je mensi
|
||||
* zlo nez rozsirovat verejne rozhrani uloziste kvuli jednomu widgetu.
|
||||
*/
|
||||
const statusLabels: Record<string, string> = {
|
||||
new: 'Nový',
|
||||
open: 'V řešení',
|
||||
waiting: 'Čeká na klienta',
|
||||
resolved: 'Vyřešeno',
|
||||
};
|
||||
// Stav je volny retezec a tickety vznikaji s hodnotami z `defaultStatuses`,
|
||||
// takze nabidka ve widgetu je tataz - jiny seznam by nikdy nic nenapocital.
|
||||
const statusLabels: Record<string, string> = Object.fromEntries(
|
||||
defaultStatuses.map((status) => [status, status]),
|
||||
);
|
||||
|
||||
const requestSchema = z.object({
|
||||
widgetIds: z.array(z.string().min(1)).max(24),
|
||||
@@ -62,55 +80,6 @@ const requestSchema = z.object({
|
||||
force: z.boolean().default(false),
|
||||
});
|
||||
|
||||
/**
|
||||
* Jedna hodnota, nebo seskupeny vysledek. Klient podle `render` vi, co s tim.
|
||||
*
|
||||
* U cisel a radku chodi `href`: kam se proklikne ten, kdo chce videt, co je
|
||||
* za tim. Sklada ho **server**, protoze on jediny zna filtr widgetu - klient
|
||||
* by si musel odvozovat, co ktery zdroj znamena, a jednou by se to rozeslo.
|
||||
*/
|
||||
type WidgetValue =
|
||||
| { kind: 'number'; value: number; href?: string }
|
||||
| {
|
||||
kind: 'groups';
|
||||
rows: Array<{ key: string; label: string; value: number; href?: string }>;
|
||||
}
|
||||
| { kind: 'series'; points: Array<{ date: string; value: number }>; href?: string }
|
||||
| {
|
||||
kind: 'tickets';
|
||||
items: Array<{ id: string; subject: string; status: string; assignee: string | null }>;
|
||||
href?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'workload';
|
||||
rows: Array<{ personId: string; name: string; open: number; overCapacity: boolean; href: string }>;
|
||||
href?: string;
|
||||
}
|
||||
| { kind: 'agents'; rows: AgentStatsRow[] }
|
||||
/** Vysledek z konektoru. `stale` rika, ze je to z cache, ne cerstve zavolane. */
|
||||
| {
|
||||
kind: 'external';
|
||||
value: number | null;
|
||||
text: string | null;
|
||||
rows: Array<{ key: string; label: string; value: number }>;
|
||||
at: string;
|
||||
stale: boolean;
|
||||
/**
|
||||
* Kdy se data zase natáhnou. Klient podle toho pozna, jestli ma smysl
|
||||
* cekat, nebo si vyzadat obnovu rucne.
|
||||
*/
|
||||
nextAt: string;
|
||||
/** Jak dlouho se drzi, v sekundach. Nastavuje se u widgetu. */
|
||||
ttlSec: number;
|
||||
};
|
||||
|
||||
interface WidgetResult {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
value?: WidgetValue;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Od kdy se pocita. `all` znamena bez omezeni. */
|
||||
function since(period: WidgetTicketFilter['period']): number | null {
|
||||
const now = Date.now();
|
||||
@@ -391,7 +360,11 @@ function ticketsHref(
|
||||
return query ? `/dashboard/tickety?${query}` : '/dashboard/tickety';
|
||||
}
|
||||
|
||||
function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue {
|
||||
function computeSource(
|
||||
source: WidgetSource,
|
||||
scope: ResolvedScope,
|
||||
allTickets: () => Ticket[],
|
||||
): WidgetValue {
|
||||
if (source.kind === 'agentStats') {
|
||||
return {
|
||||
kind: 'agents',
|
||||
@@ -426,10 +399,7 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
|
||||
throw new Error('Zdroj Konektor se počítá zvlášť.');
|
||||
}
|
||||
|
||||
const tickets = listTickets({
|
||||
tenantIds: scope.tenantIds,
|
||||
visibility: scope.visibility,
|
||||
}).filter((ticket) => matches(ticket, source.filter, scope.personId));
|
||||
const tickets = allTickets().filter((ticket) => matches(ticket, source.filter, scope.personId));
|
||||
|
||||
if (source.kind === 'ticketCount') {
|
||||
if (!source.groupBy) {
|
||||
@@ -488,34 +458,32 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
|
||||
}
|
||||
|
||||
/** Zdroj vlastniho widgetu. Vestavene maji svuj v `builtinSources`. */
|
||||
function computeWidget(widget: CustomWidget, scope: ResolvedScope): WidgetValue {
|
||||
return computeSource(widget.source, scope);
|
||||
function computeWidget(
|
||||
widget: CustomWidget,
|
||||
scope: ResolvedScope,
|
||||
allTickets: () => Ticket[],
|
||||
): WidgetValue {
|
||||
return computeSource(widget.source, scope, allTickets);
|
||||
}
|
||||
|
||||
widgetDataRouter.post('/', async (req, res) => {
|
||||
const scope = resolveScope(req.user!, {
|
||||
scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
|
||||
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
|
||||
});
|
||||
if (isDenied(scope)) {
|
||||
return res.status(scope.status).json({ error: scope.error, message: scope.message });
|
||||
}
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
const parsed = requestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Pošlete seznam ID widgetů, nejvýš 24.',
|
||||
});
|
||||
return validationError(res, parsed.error, 'Pošlete seznam ID widgetů, nejvýš 24.');
|
||||
}
|
||||
|
||||
const allTickets = ticketSourceFor(scope);
|
||||
|
||||
const results: WidgetResult[] = await Promise.all(
|
||||
parsed.data.widgetIds.map(async (id): Promise<WidgetResult> => {
|
||||
// Vestaveny widget s vypoctem. Nema definici v ulozisti, jen zdroj dat.
|
||||
const builtin = builtinSources[id];
|
||||
if (builtin) {
|
||||
try {
|
||||
return { id, ok: true, value: computeSource(builtin, scope) };
|
||||
return { id, ok: true, value: computeSource(builtin, scope, allTickets) };
|
||||
} catch (err) {
|
||||
console.error(`[widgets] ${id} selhal:`, err);
|
||||
return { id, ok: false, error: message(err) };
|
||||
@@ -535,7 +503,7 @@ widgetDataRouter.post('/', async (req, res) => {
|
||||
const value =
|
||||
widget.source.kind === 'connector'
|
||||
? await computeConnector(widget.id, widget.source, scope, parsed.data.force)
|
||||
: computeWidget(widget, scope);
|
||||
: computeWidget(widget, scope, allTickets);
|
||||
return { id, ok: true, value };
|
||||
} catch (err) {
|
||||
// Jeden rozbity zdroj nesmi zhasnout cely prehled.
|
||||
@@ -555,12 +523,8 @@ function message(err: unknown): string {
|
||||
|
||||
/** Co lze do filtru vybrat. Klient si nesklada vlastni seznam. */
|
||||
widgetDataRouter.get('/options', (req, res) => {
|
||||
const scope = resolveScope(req.user!, {
|
||||
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
|
||||
});
|
||||
if (isDenied(scope)) {
|
||||
return res.status(scope.status).json({ error: scope.error, message: scope.message });
|
||||
}
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
return res.json({
|
||||
types: listTicketTypes(scope.tenantIds).map((type) => ({ id: type.id, name: type.name })),
|
||||
|
||||
+23
-12
@@ -14,7 +14,7 @@ import { ALL_PAGES_INPUT, findMcpTool, MAX_TOOL_PAGES, parseOperationId } from '
|
||||
import { isMcpService } from '../mcp/dialect.js';
|
||||
import { callTool, type McpCallResult } from '../mcp/client.js';
|
||||
import { argumentsFrom, cursorFieldOf, nextCursorFrom, rowsFrom } from '../mcp/schema.js';
|
||||
import { truncate } from '../scripts/util.js';
|
||||
import { DETAIL_BYTES, parseBool, truncate } from '../scripts/util.js';
|
||||
import { createIncident } from '../data/incidentStore.js';
|
||||
import { sendMail } from '../mail/smtp.js';
|
||||
import { resolveTarget } from '../scripts/connections.js';
|
||||
@@ -125,11 +125,8 @@ const channels: TicketChannel[] = [
|
||||
* ktery meni stav, mimochodem otevrel vyrizeny ticket.
|
||||
*/
|
||||
function boolInput(value: string | undefined): boolean | undefined {
|
||||
const text = value?.trim().toLowerCase();
|
||||
if (!text) return undefined;
|
||||
if (['true', '1', 'ano', 'yes'].includes(text)) return true;
|
||||
if (['false', '0', 'ne', 'no'].includes(text)) return false;
|
||||
return undefined;
|
||||
if (!value?.trim()) return undefined;
|
||||
return parseBool(value) ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -604,11 +601,13 @@ const handlers: Record<string, Handler> = {
|
||||
* Incident je neco jineho nez ticket: ticket je pozadavek jednoho zakaznika,
|
||||
* incident je "nefunguje to a tyka se to vic lidi". Proto vlastni krok.
|
||||
*/
|
||||
'incident/create': (inputs) => {
|
||||
'incident/create': (inputs, context) => {
|
||||
const title = inputs.title?.trim();
|
||||
if (!title) return missing('název');
|
||||
|
||||
const incident = createIncident({
|
||||
// Bez firmy by byl incident globalni a videly by ho vsechny firmy.
|
||||
tenantId: context.tenantId,
|
||||
title,
|
||||
service: inputs.service?.trim() || 'Neurčeno',
|
||||
severity: (['sev1', 'sev2', 'sev3'] as const).includes(inputs.severity as 'sev1')
|
||||
@@ -741,9 +740,11 @@ function safeJson(text: string): Record<string, unknown> {
|
||||
* operace (`tool:<konektor>:<nastroj>`) - jinak by musel existovat kus kodu na
|
||||
* kazdy nastroj, ktery si firma zalozi, a to je presne to, co MCP resi.
|
||||
*
|
||||
* Krok se **neopakuje**. MCP nema idempotencni klic, takze druhy pokus po
|
||||
* timeoutu by nastroj provedl podruhe - a jestli to znamena druhou objednavku,
|
||||
* vi jen server, ktery neni nas.
|
||||
* Krok se opakuje jen kdyz se **k nastroji vubec nedoslo**: spojeni se
|
||||
* nenavazalo, nebo server volani odmitl (503, 429) driv, nez nastroj spustil.
|
||||
* Timeout uz beziciho nastroje se neopakuje: MCP nema idempotencni klic, takze
|
||||
* druhy pokus by nastroj provedl podruhe - a jestli to znamena druhou
|
||||
* objednavku, vi jen server, ktery neni nas. Rozhoduje o tom `McpFailure.retryable`.
|
||||
*/
|
||||
async function runMcpTool(
|
||||
inputs: Record<string, string>,
|
||||
@@ -830,7 +831,15 @@ async function runMcpTool(
|
||||
|
||||
const outcome = await callTool(target, parsed.toolName, pageArgs);
|
||||
if (!outcome.ok || !outcome.value) {
|
||||
return { ok: false, summary: outcome.message, detail: outcome.detail, outputs: {} };
|
||||
return {
|
||||
ok: false,
|
||||
summary: outcome.message,
|
||||
detail: outcome.detail,
|
||||
// Opakuje se jen to, co klient oznacil: nenavazane spojeni a odmitnuti
|
||||
// pred spustenim nastroje. Timeout uz beziciho nastroje ne, viz vyse.
|
||||
retryable: outcome.retryable,
|
||||
outputs: {},
|
||||
};
|
||||
}
|
||||
|
||||
last = outcome.value;
|
||||
@@ -867,7 +876,9 @@ async function runMcpTool(
|
||||
return {
|
||||
ok: !last.isError,
|
||||
summary: truncated ? `${summary}, strop stránek vyčerpán` : summary,
|
||||
detail: text === '' ? null : truncate(text, 600),
|
||||
// Stejny strop jako u ostatnich kroku. Sest set znaku bylo malo: prave
|
||||
// v textu odpovedi nastroj pise, proc se mu neco nepovedlo.
|
||||
detail: text === '' ? null : truncate(text, DETAIL_BYTES),
|
||||
/*
|
||||
* Strukturovana odpoved se rozbaluje do vystupu, aby na ni sla postavit
|
||||
* podminka bez psani cesty. Spolecne hodnoty se pisou az po ni: `text`
|
||||
|
||||
+127
-37
@@ -23,13 +23,19 @@
|
||||
|
||||
import { defaultConnectorFor, getConnector, type Connector } from '../data/connectorStore.js';
|
||||
import { matchOf, rulesOf, type FlowStep } from '../data/automationStore.js';
|
||||
import { isUnary, type ConditionOperator, type ConditionRule } from '../data/conditions.js';
|
||||
import {
|
||||
isUnary,
|
||||
type ConditionOperator,
|
||||
type ConditionRule,
|
||||
type FieldType,
|
||||
} from '../data/conditions.js';
|
||||
import { escapeHtml, renderTemplate } from '../data/templates.js';
|
||||
import { actionInputsFor, type OperationField } from '../data/services.js';
|
||||
import { getPath } from '../scripts/mapping.js';
|
||||
import { appendTrace, type TraceInput } from '../data/ticketStore.js';
|
||||
import { scriptIdFor } from '../scripts/lookup.js';
|
||||
import { runScript } from '../scripts/runner.js';
|
||||
import { DETAIL_BYTES, truncate } from '../scripts/util.js';
|
||||
import { findBuiltinStep } from './builtinSteps.js';
|
||||
|
||||
/**
|
||||
@@ -65,8 +71,22 @@ export interface RunOptions {
|
||||
* teto tabulky se podminka nema o co oprit a tise vychazi jako prazdna.
|
||||
*/
|
||||
fieldNames?: Record<string, string>;
|
||||
/**
|
||||
* Typy parametru podle jejich ID. Podminka nad datem se musi porovnavat
|
||||
* jako cas, ne jako cislo - ISO retezec by pres `Number()` vysel jako NaN.
|
||||
*/
|
||||
fieldTypes?: Record<string, FieldType>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pravidlo opakovani, stejne ve vsech vrstvach (skript, vnitrni krok, MCP,
|
||||
* fronta): **opakuje se jen to, co muze pominout samo** - nenavazane spojeni,
|
||||
* timeout, 5xx a 429 od cizi sluzby, nedostupne uloziste. Vsechno ostatni
|
||||
* (401, 403, 404, spatny vstup, chybejici skript, `ctx.fail`, chyba v kodu)
|
||||
* konci hned a zaklada incident. Krok, ktery nerekne nic, se **neopakuje**:
|
||||
* vychozi je "ne", protoze marne opakovani petkrat za hodinu jen zopakuje
|
||||
* tutez hlasku a pri tom muze cizi sluzbu zavolat podruhe.
|
||||
*/
|
||||
export interface StepResult {
|
||||
stepId: string;
|
||||
kind: 'action' | 'condition' | 'foreach';
|
||||
@@ -74,12 +94,7 @@ export interface StepResult {
|
||||
ok: boolean;
|
||||
/** true = do logu ticketu se nezapisuje. Nic se nestalo, viz StepOutcome. */
|
||||
quiet?: boolean;
|
||||
/**
|
||||
* false = opakovat nema smysl.
|
||||
*
|
||||
* Chybejici skript nebo neznama skupina za minutu existovat nezacne.
|
||||
* Opakovat se ma jen to, co muze pominout: nedostupna sluzba, timeout.
|
||||
*/
|
||||
/** true = docasna prekazka, opakovani ma smysl. Chybi = ne. Viz pravidlo vyse. */
|
||||
retryable?: boolean;
|
||||
/** U podminky, kterou vetvi se slo. */
|
||||
branch?: 'yes' | 'no';
|
||||
@@ -102,7 +117,7 @@ export interface RunResult {
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/** Vychozi strop kroku. Vic uz neni strom, ale program. */
|
||||
/** Vychozi strop kroku **ve strome**. Vic uz neni strom, ale program. */
|
||||
const MAX_STEPS = 50;
|
||||
|
||||
/**
|
||||
@@ -114,6 +129,26 @@ const MAX_STEPS = 50;
|
||||
*/
|
||||
const MAX_LOOP_ITEMS = 200;
|
||||
|
||||
/**
|
||||
* Kolik kroku se v jednom behu nejvys **vykona**, vcetne pruchodu smyckami.
|
||||
*
|
||||
* Jiny strop nez `MAX_STEPS`: strom o dvou krocich ve smycce nad sto polozkami
|
||||
* je maly strom, ale dve ste vykonanych kroku. Driv se pocitalo obojí jednou
|
||||
* hodnotou a smycka spadla na 26. polozce, i kdyz slibovala 200.
|
||||
*/
|
||||
const MAX_ACTIONS = 1_000;
|
||||
|
||||
/** Kolik kroku strom ma, vcetne vetvi podminek a tel smycek. */
|
||||
function countSteps(list: FlowStep[]): number {
|
||||
let total = 0;
|
||||
for (const step of list) {
|
||||
total += 1;
|
||||
if (step.kind === 'condition') total += countSteps(step.yes) + countSteps(step.no);
|
||||
else if (step.kind === 'foreach') total += countSteps(step.steps);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vykona strom.
|
||||
*
|
||||
@@ -135,10 +170,25 @@ export async function runFlow(
|
||||
|
||||
let error: string | null = null;
|
||||
|
||||
// Velikost stromu se meri predem a staticky. Smycka ji nemeni.
|
||||
const size = countSteps(steps);
|
||||
if (size > limit) {
|
||||
return {
|
||||
ok: false,
|
||||
retryable: false,
|
||||
steps: [],
|
||||
context: working,
|
||||
error: `Strom má ${size} kroků, strop je ${limit}. Běh se nespustil.`,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function walk(list: FlowStep[]): Promise<boolean> {
|
||||
for (const step of list) {
|
||||
if (results.length >= limit) {
|
||||
error = `Strom má víc než ${limit} kroků, běh se zastavil.`;
|
||||
if (results.length >= MAX_ACTIONS) {
|
||||
error =
|
||||
`Běh vykonal ${MAX_ACTIONS} kroků včetně průchodů smyčkami a zastavil se. ` +
|
||||
'Tolik práce v jednom běhu nikdo nečeká, rozdělte data nebo strom.';
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -298,9 +348,8 @@ export async function runFlow(
|
||||
|
||||
return {
|
||||
ok: error === null,
|
||||
// Kdyz krok nerekl jinak, opakovat se smi: vypadek cizi sluzby je
|
||||
// nejcastejsi duvod chyby a ten pomine.
|
||||
retryable: failed?.retryable !== false,
|
||||
// Opakuje se jen to, co krok vyslovne oznacil. Viz pravidlo u StepResult.
|
||||
retryable: failed?.retryable === true,
|
||||
steps: results,
|
||||
context: working,
|
||||
error,
|
||||
@@ -348,14 +397,7 @@ async function runAction(
|
||||
options.ticketId = outcome.ticketId;
|
||||
}
|
||||
|
||||
context[step.id] = outcome.outputs;
|
||||
for (const [key, value] of Object.entries(outcome.outputs)) {
|
||||
context[`${step.id}.${key}`] = value;
|
||||
// Vystup je i pod holym jmenem, aby se v sablone dalo psat
|
||||
// `{{ticketId}}` misto `{{krok1.ticketId}}`. Pozdejsi krok prepise
|
||||
// starsi, coz je to, co clovek ceka.
|
||||
context[key] = value;
|
||||
}
|
||||
publishOutputs(context, step.id, outcome.outputs);
|
||||
|
||||
return {
|
||||
stepId: step.id,
|
||||
@@ -383,6 +425,8 @@ async function runAction(
|
||||
ok: false,
|
||||
summary: 'krok skončil chybou',
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
// Vyjimka z vnitrniho kroku je chyba v kodu nebo v datech, ne vypadek.
|
||||
retryable: false,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
@@ -419,6 +463,8 @@ async function runAction(
|
||||
ok: false,
|
||||
summary: 'napojení se nepodařilo načíst',
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
// Vypadek uloziste pomine, na rozdil od spatne nastaveneho kroku.
|
||||
retryable: true,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
@@ -438,15 +484,14 @@ async function runAction(
|
||||
ok: false,
|
||||
summary: result.error?.message ?? 'krok selhal',
|
||||
detail: result.error?.detail ?? null,
|
||||
// Runner uz rozhodl: timeout a vypadek ano, 401/404/validace/ctx.fail ne.
|
||||
// Driv se priznak cestou ztratil a kazda chyba se opakovala petkrat.
|
||||
retryable: result.error?.retryable === true,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Vystupy pod jmenem kroku, aby si dva stejne kroky neprepsaly hodnoty.
|
||||
context[step.id] = result.outputs;
|
||||
for (const [key, value] of Object.entries(result.outputs)) {
|
||||
context[`${step.id}.${key}`] = value;
|
||||
}
|
||||
publishOutputs(context, step.id, result.outputs);
|
||||
|
||||
return {
|
||||
stepId: step.id,
|
||||
@@ -459,6 +504,23 @@ async function runAction(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapise vystupy kroku do kontextu. Stejne pro vnitrni kroky i skripty.
|
||||
*
|
||||
* Pod jmenem kroku (`krok1.ticketId`), aby si dva stejne kroky neprepsaly
|
||||
* hodnoty. Pod holym jmenem (`{{ticketId}}`) jen kdyz tam jeste nic neni:
|
||||
* driv vnitrni krok prepisoval i data spoustece, takze nastroj MCP, ktery
|
||||
* vratil `status` nebo `subject`, tise zmenil to, s cim beh zacal. Existujici
|
||||
* stromy pisou `{{ticketId}}` za krokem zalozeni ticketu a to dal funguje.
|
||||
*/
|
||||
function publishOutputs(context: RunContext, stepId: string, outputs: Record<string, unknown>): void {
|
||||
context[stepId] = outputs;
|
||||
for (const [key, value] of Object.entries(outputs)) {
|
||||
context[`${stepId}.${key}`] = value;
|
||||
if (context[key] === undefined) context[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Dosadi `{{jmeno}}` z kontextu do vsech poli kroku. */
|
||||
function fillTemplates(
|
||||
inputs: Record<string, string>,
|
||||
@@ -570,7 +632,7 @@ function conditionDetail(
|
||||
function matchRule(rule: ConditionRule, context: RunContext, options: RunOptions): boolean {
|
||||
const raw = conditionValue(rule.fieldId, context, options);
|
||||
const value = raw === undefined || raw === null ? '' : stringify(raw);
|
||||
return compare(rule.operator, value, rule.value ?? '');
|
||||
return compare(rule.operator, value, rule.value ?? '', options.fieldTypes?.[rule.fieldId] === 'date');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -604,9 +666,38 @@ function nameOf(fieldId: string): string {
|
||||
return dot === -1 ? fieldId : fieldId.slice(dot + 1);
|
||||
}
|
||||
|
||||
function compare(operator: ConditionOperator, value: string, expected: string): boolean {
|
||||
const number = Number(value);
|
||||
const expectedNumber = Number(expected);
|
||||
/** Vypada to jako datum (`2026-08-20`, s casem nebo bez)? */
|
||||
function isoLike(value: string): boolean {
|
||||
return /^\d{4}-\d{2}-\d{2}/.test(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Obe strany jako cisla k porovnani, nebo `null`, kdyz to nejde.
|
||||
*
|
||||
* Datum se porovnava jako cas: `Number('2026-08-20')` je NaN a podminka
|
||||
* "vetsi nez" nad datem by tise nikdy neplatila. Plati to pro parametr typu
|
||||
* datum i pro hodnoty, ktere jako datum vypadaji - cislo se hleda jen tam,
|
||||
* kde to cislo opravdu je.
|
||||
*/
|
||||
function ordered(value: string, expected: string, dateTyped: boolean): [number, number] | null {
|
||||
if (!dateTyped) {
|
||||
const numbers: [number, number] = [Number(value), Number(expected)];
|
||||
if (numbers.every(Number.isFinite)) return numbers;
|
||||
}
|
||||
if (dateTyped || (isoLike(value) && isoLike(expected))) {
|
||||
const dates: [number, number] = [Date.parse(value), Date.parse(expected)];
|
||||
if (dates.every(Number.isFinite)) return dates;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function compare(
|
||||
operator: ConditionOperator,
|
||||
value: string,
|
||||
expected: string,
|
||||
dateTyped = false,
|
||||
): boolean {
|
||||
const pair = ordered(value, expected, dateTyped);
|
||||
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
@@ -618,13 +709,13 @@ function compare(operator: ConditionOperator, value: string, expected: string):
|
||||
case 'startsWith':
|
||||
return value.toLowerCase().startsWith(expected.toLowerCase());
|
||||
case 'gt':
|
||||
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number > expectedNumber;
|
||||
return pair !== null && pair[0] > pair[1];
|
||||
case 'gte':
|
||||
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number >= expectedNumber;
|
||||
return pair !== null && pair[0] >= pair[1];
|
||||
case 'lt':
|
||||
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number < expectedNumber;
|
||||
return pair !== null && pair[0] < pair[1];
|
||||
case 'lte':
|
||||
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number <= expectedNumber;
|
||||
return pair !== null && pair[0] <= pair[1];
|
||||
case 'isEmpty':
|
||||
return value.trim() === '';
|
||||
case 'isNotEmpty':
|
||||
@@ -659,12 +750,11 @@ function describeCondition(
|
||||
return `Podmínka: ${parts.join(spojka)}`;
|
||||
}
|
||||
|
||||
/** Co krok vratil, do logu. Delsi vystup se orizne, cely je ve vysledku. */
|
||||
/** Co krok vratil, do logu. Stejny strop jako u chyb, cely vystup je v kontextu. */
|
||||
function describe(outputs: Record<string, unknown>): string | null {
|
||||
const keys = Object.keys(outputs);
|
||||
if (keys.length === 0) return null;
|
||||
const text = JSON.stringify(outputs);
|
||||
return text.length > 4_000 ? `${text.slice(0, 4_000)}...` : text;
|
||||
return truncate(JSON.stringify(outputs), DETAIL_BYTES);
|
||||
}
|
||||
|
||||
function toTrace(step: StepResult): TraceInput {
|
||||
|
||||
+25
-5
@@ -35,7 +35,9 @@ export type TriggerKind =
|
||||
/** Ticket se zmenil. */
|
||||
| 'ticket.updated'
|
||||
/** Rucni spusteni z portalu. */
|
||||
| 'manual';
|
||||
| 'manual'
|
||||
/** Planovac: je cas se zeptat sluzby, ktera webhooky nema. */
|
||||
| 'poll';
|
||||
|
||||
export interface QueueItem extends TenantEntity {
|
||||
tenantId: string;
|
||||
@@ -72,8 +74,15 @@ export const queueStore = defineStore<QueueItem>('runQueue');
|
||||
const BACKOFF_MS = [30_000, 2 * 60_000, 10 * 60_000, 60 * 60_000];
|
||||
export const MAX_ATTEMPTS = BACKOFF_MS.length + 1;
|
||||
|
||||
/** Po jake dobe se bezici beh povazuje za zaseknuty a vrati se do fronty. */
|
||||
const STUCK_AFTER_MS = 10 * 60_000;
|
||||
/**
|
||||
* Po jake dobe se bezici beh povazuje za zaseknuty a vrati se do fronty.
|
||||
*
|
||||
* Meri se od posledniho tlukotu (`touchClaim`), ne od vzeti z fronty. Worker
|
||||
* tluce kazdou minutu, takze tohle plati jen pro beh, jehoz proces spadl.
|
||||
* Zamerne vyrazne vic nez nejdelsi legitimni cekani (uloha MCP, 10 minut) -
|
||||
* driv bylo obojí deset minut a bezici uloha se rozjela podruhe.
|
||||
*/
|
||||
const STUCK_AFTER_MS = 30 * 60_000;
|
||||
|
||||
/** Kopie fronty v pameti. Cte se pri kazdem kole workeru, meni se zridka. */
|
||||
let items: QueueItem[] = [];
|
||||
@@ -163,12 +172,16 @@ export async function enqueue(input: EnqueueInput): Promise<QueueItem | null> {
|
||||
* LOCKED`), jinak si dva workeri vezmou tentyz beh. Tady je to v pameti
|
||||
* jednoho procesu, coz pro jednu instanci staci a je to videt na jednom miste.
|
||||
*/
|
||||
export async function claimBatch(limit: number): Promise<QueueItem[]> {
|
||||
export async function claimBatch(
|
||||
limit: number,
|
||||
active: ReadonlySet<string> = new Set(),
|
||||
): Promise<QueueItem[]> {
|
||||
const now = Date.now();
|
||||
|
||||
// Zaseknuty beh se vraci do fronty, aby se neztratil kvuli spadlemu workeru.
|
||||
// Beh, ktery tenhle proces prave vykonava, zaseknuty neni, at trva jakkoliv.
|
||||
for (const item of items) {
|
||||
if (item.status !== 'running' || !item.claimedAt) continue;
|
||||
if (item.status !== 'running' || !item.claimedAt || active.has(item.id)) continue;
|
||||
if (now - new Date(item.claimedAt).getTime() < STUCK_AFTER_MS) continue;
|
||||
console.warn(`[fronta] ${item.id} visel v behu moc dlouho, vraci se do fronty`);
|
||||
item.status = 'pending';
|
||||
@@ -199,6 +212,13 @@ export async function claimBatch(limit: number): Promise<QueueItem[]> {
|
||||
return claimed;
|
||||
}
|
||||
|
||||
/** Tlukot beziciho behu. Posune cas, od ktereho se meri zaseknuti. */
|
||||
export async function touchClaim(item: QueueItem): Promise<void> {
|
||||
if (item.status !== 'running') return;
|
||||
item.claimedAt = nowIso();
|
||||
await save(item);
|
||||
}
|
||||
|
||||
export async function markDone(item: QueueItem): Promise<void> {
|
||||
item.status = 'done';
|
||||
item.finishedAt = nowIso();
|
||||
|
||||
+79
-47
@@ -18,7 +18,10 @@
|
||||
* pravy, ne do teto funkce.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createContext, Script } from 'node:vm';
|
||||
import { getPath } from '../scripts/mapping.js';
|
||||
import { parseNumber } from '../scripts/util.js';
|
||||
|
||||
/** Kolik ms smi skript bezet. Delsi vypocet blokuje workera ostatnim firmam. */
|
||||
const TIMEOUT_MS = 2_000;
|
||||
@@ -26,6 +29,14 @@ const TIMEOUT_MS = 2_000;
|
||||
/** Strop na velikost vysledku. Vetsi objekt uz nikdo dal nezpracuje. */
|
||||
const MAX_RESULT_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Kolik zkompilovanych skriptu se drzi v pameti.
|
||||
*
|
||||
* Kompilace stoji vic nez samotny beh kratkeho prevodu a tentyz skript bezi
|
||||
* pri kazde udalosti znovu. Strop, aby firma s tisicem verzi neprotahla pamet.
|
||||
*/
|
||||
const MAX_CACHED_SCRIPTS = 100;
|
||||
|
||||
export interface SandboxResult {
|
||||
ok: boolean;
|
||||
/** Vysledek skriptu. Vzdy objekt, i kdyz skript vrati neco jineho. */
|
||||
@@ -40,29 +51,65 @@ export interface SandboxResult {
|
||||
*
|
||||
* Zamerne jich je malo. Kazda dalsi je vec, kterou musi ctenar skriptu znat -
|
||||
* a `input.data.order.items.map(...)` umi kazdy, kdo umi JS.
|
||||
*
|
||||
* Sestavuji se **uvnitr kontextu skriptu** ze zdrojoveho textu, ne predanim
|
||||
* hotovych funkci. Funkce hostitele by skriptu dala jeho `Function`, a pres
|
||||
* `util.num.constructor('return process')()` by se dostal ven. Zdrojovy text
|
||||
* je tentyz kod jako `getPath` a `parseNumber` jinde v systemu, takze cesty
|
||||
* i ceska carka funguji stejne jako v transformaci dat.
|
||||
*/
|
||||
const util = {
|
||||
/** Hodnota na ceste, `undefined` kdyz tam nic neni. `get(o, 'a.b.0.c')`. */
|
||||
get(source: unknown, path: string): unknown {
|
||||
let current: unknown = source;
|
||||
for (const part of path.split('.')) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
},
|
||||
/** Cislo z cehokoliv. Ceska desetinna carka je bezna, nema smysl na ni padat. */
|
||||
num(value: unknown): number | null {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||
const parsed = Number(String(value ?? '').trim().replace(',', '.'));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
},
|
||||
/** Datum jako `2026-08-20`. Prazdne, kdyz to datum neni. */
|
||||
day(value: unknown): string {
|
||||
const UTIL_SOURCE = `(function () {
|
||||
'use strict';
|
||||
const get = (${getPath.toString()});
|
||||
const num = (${parseNumber.toString()});
|
||||
/* Datum jako 2026-08-20. Prazdne, kdyz to datum neni. */
|
||||
const day = (value) => {
|
||||
const parsed = Date.parse(String(value ?? ''));
|
||||
return Number.isNaN(parsed) ? '' : new Date(parsed).toISOString().slice(0, 10);
|
||||
},
|
||||
};
|
||||
};
|
||||
return Object.freeze({ get, num, day });
|
||||
})()`;
|
||||
|
||||
const utilScript = new Script(UTIL_SOURCE, { filename: 'pomucky.js' });
|
||||
|
||||
/** Zkompilovane skripty podle otisku kodu. Mapa drzi poradi, takze slouzi jako LRU. */
|
||||
const compiled = new Map<string, Script>();
|
||||
|
||||
function compile(code: string): Script {
|
||||
const key = createHash('sha256').update(code).digest('base64url');
|
||||
const cached = compiled.get(key);
|
||||
if (cached) {
|
||||
// Posledni pouzity jde na konec, aby se pri uklidu zahodil ten nejstarsi.
|
||||
compiled.delete(key);
|
||||
compiled.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
/*
|
||||
* Kod se zabali do funkce, ktera se **rovnou zavola**. Dve veci najednou:
|
||||
* - v kodu jde psat `return`, coz je to, co clovek ceka,
|
||||
* - beh je uvnitr `runInContext`, takze na nej plati casovy limit.
|
||||
*
|
||||
* Zavolat funkci az potom zvenku by limit obeslo: `runInContext` by jen
|
||||
* vyrobil funkci a `while (true) {}` uvnitr ni by zablokovalo workera
|
||||
* navzdy. Presne to se pri prvni zkousce stalo.
|
||||
*
|
||||
* Vstup prichazi jako JSON text a vysledek odchazi jako JSON text. Objekt
|
||||
* hostitele by skriptu dal jeho prototyp a s nim cestu ven; text nic
|
||||
* takoveho nenese.
|
||||
*/
|
||||
const wrapped =
|
||||
`JSON.stringify((function (input, util) {\n'use strict';\n${code}\n})` +
|
||||
'(JSON.parse(__input), __util));';
|
||||
const script = new Script(wrapped, { filename: 'skript-firmy.js' });
|
||||
|
||||
compiled.set(key, script);
|
||||
if (compiled.size > MAX_CACHED_SCRIPTS) {
|
||||
const oldest = compiled.keys().next().value;
|
||||
if (oldest !== undefined) compiled.delete(oldest);
|
||||
}
|
||||
return script;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spusti telo skriptu nad vstupem.
|
||||
@@ -74,28 +121,17 @@ const util = {
|
||||
export function runSandbox(code: string, input: unknown): SandboxResult {
|
||||
const startedAt = Date.now();
|
||||
|
||||
/*
|
||||
* Kod se zabali do funkce, ktera se **rovnou zavola**. Dve veci najednou:
|
||||
* - v kodu jde psat `return`, coz je to, co clovek ceka,
|
||||
* - beh je uvnitr `runInContext`, takze na nej plati casovy limit.
|
||||
*
|
||||
* Zavolat funkci az potom zvenku by limit obeslo: `runInContext` by jen
|
||||
* vyrobil funkci a `while (true) {}` uvnitr ni by zablokovalo workera
|
||||
* navzdy. Presne to se pri prvni zkousce stalo.
|
||||
*/
|
||||
const wrapped = `(function (input, util) {\n'use strict';\n${code}\n})(__input, __util);`;
|
||||
|
||||
try {
|
||||
const script = new Script(wrapped, { filename: 'skript-firmy.js' });
|
||||
const script = compile(code);
|
||||
|
||||
/*
|
||||
* Kontext nese jen vstup a pomucky. Skript nevidi ani `console`, ani
|
||||
* globalni objekty hostitele, takze nema kudy sahnout ven.
|
||||
* Kontext nese jen vstup jako text a pomucky sestavene uvnitr nej. Nic
|
||||
* z hostitele - zadny objekt ani funkce, jejichz prototyp by vedl ven.
|
||||
*/
|
||||
const context = createContext(
|
||||
{
|
||||
__input: structuredClone(input),
|
||||
__util: util,
|
||||
__input: JSON.stringify(input ?? null),
|
||||
__util: undefined,
|
||||
/*
|
||||
* `console` v novem kontextu existuje samo od sebe a psalo by do logu
|
||||
* serveru. Prevod dat nema co vypisovat - kdyz je potreba videt, co se
|
||||
@@ -105,10 +141,11 @@ export function runSandbox(code: string, input: unknown): SandboxResult {
|
||||
},
|
||||
{ codeGeneration: { strings: false, wasm: false } },
|
||||
);
|
||||
context.__util = utilScript.runInContext(context, { timeout: TIMEOUT_MS }) as unknown;
|
||||
|
||||
const value = script.runInContext(context, { timeout: TIMEOUT_MS }) as unknown;
|
||||
const json = script.runInContext(context, { timeout: TIMEOUT_MS }) as unknown;
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
if (typeof json !== 'string' || json === 'null') {
|
||||
return {
|
||||
ok: false,
|
||||
value: null,
|
||||
@@ -117,24 +154,19 @@ export function runSandbox(code: string, input: unknown): SandboxResult {
|
||||
};
|
||||
}
|
||||
|
||||
const size = JSON.stringify(value)?.length ?? 0;
|
||||
if (size > MAX_RESULT_BYTES) {
|
||||
if (json.length > MAX_RESULT_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
value: null,
|
||||
error: `Výsledek má ${Math.round(size / 1024)} kB, vejde se ${MAX_RESULT_BYTES / 1024} kB.`,
|
||||
error: `Výsledek má ${Math.round(json.length / 1024)} kB, vejde se ${MAX_RESULT_BYTES / 1024} kB.`,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Vysledek vznikl uvnitr kontextu. Prevedeme ho na prosta data, aby se dal
|
||||
* ulozit i porovnat - objekt z ciziho kontextu neprojde kontrolou
|
||||
* `instanceof` a dal by se choval nepredvidatelne.
|
||||
*/
|
||||
// Vysledek prisel jako text, takze uz je to prosta data bez ciziho prototypu.
|
||||
return {
|
||||
ok: true,
|
||||
value: JSON.parse(JSON.stringify(value)) as unknown,
|
||||
value: JSON.parse(json) as unknown,
|
||||
error: null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
|
||||
@@ -218,7 +218,9 @@ export async function planPolled(): Promise<number> {
|
||||
const item = await enqueue({
|
||||
tenantId: tenant.id,
|
||||
automationId: automation.id,
|
||||
trigger: 'manual',
|
||||
// Vlastni druh, ne `manual`: v prehledu behu ma byt videt, ze to
|
||||
// spustil planovac, ne clovek tlacitkem.
|
||||
trigger: 'poll',
|
||||
payload: { plannedAt: new Date(now).toISOString() },
|
||||
/*
|
||||
* Klic drzi jeden cekajici dotaz na automatizaci. Kdyz predchozi jeste
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user