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
+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