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:
JiriUhlir
2026-09-09 10:26:07 +02:00
co-authored by Claude Fable 5.1
parent 0c405ea55a
commit 104ae36783
215 changed files with 13226 additions and 8320 deletions
+4 -2
View File
@@ -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.
+52 -7
View File
@@ -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.
+32 -19
View File
@@ -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)
+54 -15
View File
@@ -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
View File
@@ -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.
+55 -8
View File
@@ -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
+56 -5
View File
@@ -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
+16 -3
View File
@@ -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 |
+46 -4
View File
@@ -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 |
+47 -15
View File
@@ -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 |
+34 -6
View File
@@ -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. |
+38 -3
View File
@@ -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
+69 -8
View File
@@ -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
View File
@@ -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` |
+44 -4
View File
@@ -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
+31 -16
View File
@@ -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.
+246
View File
@@ -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
View File
@@ -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
+3 -17
View File
@@ -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 {
+2 -9
View File
@@ -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);
+5 -9
View File
@@ -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,
+2 -6
View File
@@ -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,
};
+3 -9
View File
@@ -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.');
+3 -8
View File
@@ -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', {
+2 -5
View File
@@ -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))}`);
+1 -5
View File
@@ -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) {
+247
View File
@@ -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}`);
}
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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({
+67 -50
View File
@@ -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();
}
}
+3 -12
View File
@@ -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()
+32 -89
View File
@@ -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
View File
@@ -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 {
+19 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+48 -2
View File
@@ -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
View File
@@ -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(),
};
+28
View File
@@ -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
View File
@@ -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,
});
});
},
};
}
+26
View File
@@ -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
View File
@@ -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;
+4 -19
View File
@@ -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
View File
@@ -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
View File
@@ -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> {
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.`);
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 ?? '',
};
}
+23
View File
@@ -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
View File
@@ -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);
}
/**
+45
View File
@@ -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;
}
+58
View File
@@ -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();
};
}
+125
View File
@@ -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;
}
+27
View File
@@ -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,
})),
});
}
+152
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+30 -14
View File
@@ -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),
+188
View File
@@ -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
View File
@@ -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
View File
@@ -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]);
+7 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.')
);
},
});
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
+3 -1
View File
@@ -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