diff --git a/Dockerfile b/Dockerfile
index 1927cb9..be610af 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,4 +14,7 @@ EXPOSE 3000
COPY package*.json ./
RUN npm install --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.
+COPY --from=build /app/scripts ./scripts
CMD ["npm", "start"]
diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md
index 8d8c8e5..d38a0a6 100644
--- a/documentation/01-prehled-a-stav.md
+++ b/documentation/01-prehled-a-stav.md
@@ -30,6 +30,7 @@ React aplikaci ze slozky `dist/public`.
| Kanaly WhatsApp, FB, Instagram | hotovo | vcetne vzorovych automatizaci na prijem |
| Firmy a prava | hotovo | tri pohledy, uzivatel muze byt ve vic firmach |
| Nastavitelny dashboard | hotovo | widgety, sirky a poradi, ulozene za uzivatele a firmu |
+| Skripty konektoru | hotovo | manifest, kontrola parametru, hot reload, iDoklad |
| Sprava clenstvi z portalu | chybi | memberships jdou zmenit jen v kodu |
| Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu |
| Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime |
@@ -63,3 +64,12 @@ 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.
+
+Zbytek navrhu je ve dvou souborech, oba jsou navrh k rozhodnuti, ne popis stavu:
+[09-navrh-rozsireni.md](09-navrh-rozsireni.md) pro datove modely a prava,
+[10-runtime-a-kapacita.md](10-runtime-a-kapacita.md) pro frontu, beh kroku
+a rozpocet na 150 klientu.
diff --git a/documentation/04-api.md b/documentation/04-api.md
index 2cb1f73..c2c7709 100644
--- a/documentation/04-api.md
+++ b/documentation/04-api.md
@@ -37,6 +37,11 @@ Vyzaduji `Authorization: Bearer `:
| POST | `/api/dashboard/tickets/:id/comment` |
| GET | `/api/dashboard/incidents` |
| GET | `/api/dashboard/connectors` |
+| GET | `/api/dashboard/scripts` |
+| GET | `/api/dashboard/scripts/:id` |
+| PUT | `/api/dashboard/scripts/:id` |
+| POST | `/api/dashboard/scripts/:id/test` |
+| POST | `/api/dashboard/scripts/reload` |
| GET | `/api/dashboard/stream` |
| GET | `/api/dashboard/automations` |
| POST | `/api/dashboard/automations` |
@@ -166,6 +171,25 @@ ticket zustane bez zakaznika i bez resitele a v logu je videt proc.
Nevyplnena pole server doplni ukazkovou hodnotou. U akci s "resolved" se bez
zadaneho id pouzije prvni nevyrizeny zaznam.
+## Skripty konektoru
+
+Popis modelu je v [11-skripty-konektoru.md](11-skripty-konektoru.md), tady jen API.
+
+Cteni smi kazdy prihlaseny, protoze builder potrebuje vedet, co skript umi.
+Uprava, zkusebni spusteni a vynucene nacteni smi **jen spravce platformy** -
+uprava skriptu meni chovani vseho, co ho pouziva.
+
+`GET /api/dashboard/scripts` vraci vedle manifestu i `problems` s rozbitymi
+skripty a `connections` se stavem napojeni. **Hodnoty pristupovych udaju se
+nevraci nikdy**, jen jmena chybejicich environment variables.
+
+`PUT /api/dashboard/scripts/:id` kod nejdriv nacte a overi a az pak prepise
+soubor. Rozbita uprava vraci 400 s `issues` a puvodni skript dal funguje.
+
+`POST /api/dashboard/scripts/:id/test` **vola opravdovou sluzbu**. Chyba skriptu
+neni chyba API, vraci se 200 a popis v `error` vcetne toho, jestli ma smysl
+zkusit to znovu.
+
## Pri pridani endpointu
Soucasne aktualizovat `src/openapi.ts` a tenhle soubor. Swagger musi odpovidat
diff --git a/documentation/09-navrh-rozsireni.md b/documentation/09-navrh-rozsireni.md
new file mode 100644
index 0000000..0af4621
--- /dev/null
+++ b/documentation/09-navrh-rozsireni.md
@@ -0,0 +1,1142 @@
+# 09 - Navrh rozsireni
+
+Navrh, ne popis stavu. Nic z toho zatim neni naprogramovane. Kdyz se cast udela,
+prepise se do prislusneho souboru dokumentace a odsud zmizi.
+
+Vychazi z toho, co uz v aplikaci je. Popis stavajiciho stavu je
+v [01-prehled-a-stav.md](01-prehled-a-stav.md).
+
+## Jedno rozhodnuti nad vsim ostatnim
+
+**Nezakladat druhy system akci.** Vsechno, co ma tlacitko na ticketu udelat,
+uz v katalogu konektoru existuje jako akce: zaloz doklad, posli e-mail, zapis
+do skladu, prirad cloveka. Ma to `inputs`, sablony `{{parametr}}`, deklarovane
+`outputFields` a validaci.
+
+Akce ma proto **vlastni definici, ale stoji na tom samem zakladu**: tentyz katalog
+konektoru, tentyz model kroku, tentyz builder, tataz validace, tentyz log.
+
+Kdyby se pro tlacitka postavil samostatny mechanismus, existovaly by dva zpusoby,
+jak zavolat Fakturoid, dva zpusoby, jak dosadit sablonu, a dva logy. Jeden by
+se casem opravoval a druhy ne.
+
+Stejne pravidlo plati na widgety (katalog je zdroj pravdy) a na prava
+(`access.ts` je jedine misto).
+
+### Dve oddelene veci, ne jedna spolecna
+
+Automatizacni stromy a akce jsou **dve samostatne veci** a nemaji spolecnou
+vrstvu mezi sebou:
+
+| Vec | Spousti | Kde je definovana |
+| -------------------- | --------------------------- | ------------------------ |
+| Automatizacni strom | udalost, spoustec | seznam automatizaci |
+| Akce | clovek kliknutim na ticketu | seznam akci za firmu |
+
+**Akce se vaze na typ nebo tag ticketu.** Priklad: "Odeslat do iDokladu" pro
+objednavku. Na ticketu se pak vykresli CTA vsech akci, ktere na jeho typ nebo tag
+sedi a na ktere ma uzivatel pravo.
+
+**Automatizacni strom si to dela sam.** Nevola akci a nesdili s ni nic. Kdyz ma
+po prijeti objednavky vzniknout doklad v iDokladu, ma strom ten krok v sobe.
+
+Akce si svoje telo drzi sama. Muze to byt jeden krok konektoru, **vlastni strom**,
+nebo **vlastni skript** - podrobne nize u definice akce. Podstatne je, ze strom
+akce je jeji vlastni, ne odkaz nekam jinam.
+
+Spolecne je pod obojim jen dvoje, a v tom je ta uspora: **katalog konektoru**
+a **model kroku**. Operace "vytvorit doklad" je napsana jednou a obe strany na ni
+odkazuji. Strom akce pouziva tentyz `FlowStep`, tentyz builder a tutez validaci
+jako automatizace. Duplikuje se tedy nastaveni, ne logika, a nevznika druhy
+builder, ktery by zaostaval.
+
+Prvni verze tohoto navrhu mezi to vkladala treti entitu, sdileny postup volany
+z obou stran. Bylo to zbytecne: prinesla by verzovani napric, hloubku volani
+a jeden seznam navic, a to vsechno kvuli tomu, aby se nedvakrat vyplnilo mapovani
+poli.
+
+Zbyva z toho jedna vedoma cena, kterou je potreba znat: **kdyz se ma tataz vec
+dit automaticky i rucne, nastavuje se na dvou mistech.** Zmena v iDokladu
+znamena upravit strom automatizace a upravit akci. Kdyby to nekdy zacalo bolet,
+nejlevnejsi zaplata je akce, kterou umi zavolat i krok stromu - ale az kdyz to
+bude potreba, ne dopredu.
+
+## 1 a 2 - Typy ticketu a akce nad nimi
+
+Body 1 a 2 jsou jedna vec. Bez typu ticketu neni na cem tlacitka rozlisovat,
+bez vlastnich poli neni s cim pracovat: akce "Potvrdit objednavku" potrebuje
+cislo objednavky, ne jen predmet a text.
+
+### Typ ticketu
+
+Novy soubor `src/data/ticketTypes.ts`. Typ je nastavitelny za firmu, ne pevny
+v kodu.
+
+```ts
+interface TicketTypeField {
+ id: string; // 'fld_order_no', stabilni - odkazuji se na nej podminky
+ key: string; // 'orderNumber', pouziva se v sablonach: {{orderNumber}}
+ label: string; // 'Cislo objednavky', to co vidi uzivatel
+ type: FieldType; // uz existuje v conditions.ts
+ required: boolean;
+ options?: Array<{ value: string; label: string }>;
+}
+
+interface TicketType {
+ id: string;
+ tenantId: string;
+ key: string; // 'order', 'complaint', 'request'
+ name: string; // 'Objednavka'
+ icon: string; // klic do connectorIcons.ts
+ statuses: string[]; // vlastni workflow, prazdne = vychozi ctverice
+ fields: TicketTypeField[];
+}
+```
+
+`Ticket` dostane dve pole:
+
+```ts
+typeId: string | null; // null = ticket bez typu, jako dnes
+fields: Record;
+```
+
+Dve `id` u pole nejsou zbytecna. Je to totez rozdeleni, ktere uz v projektu je
+a je vysvetlene v [06-tickety.md](06-tickety.md): **podminky odkazuji na `id`**,
+aby prejmenovani nic nerozbilo, **sablony odkazuji na `key`**, aby si to clovek
+precetl.
+
+### Typ nebo tag
+
+V zadani je otazka, jestli je "objednavka" tag. Odpoved je, ze jsou potreba
+**oba a jsou to jine veci**:
+
+| Vec | Kolik na ticket | K cemu |
+| ---- | --------------- | --------------------------------------------- |
+| Typ | prave jeden | vlastni pole a vlastni workflow stavu |
+| Tag | libovolne mnoho | volne oznaceni, filtry a widgety |
+
+**Akce se muze vazat na oboji**, ale nasledek se lisi:
+
+- **Na typ.** Za typem stoji pole, takze akce vi, ze existuje `orderNumber`,
+ a muze ho dosadit do sablony. Kdyz akce potrebuje vlastni pole, musi byt
+ na typu.
+- **Na tag.** Za tagem nestoji zadna pole, takze akce umi pracovat jen
+ s vestavenymi polemi ticketu a s tim, na co se dopta formular. Zaroven tag muze
+ kdokoliv pridat i odebrat, takze tlacitko se muze objevit u ticketu, ktery
+ objednavka neni. U akce, ktera nekam neco odesle, je proto vhodne pridat
+ `visibleWhen` nebo potvrzeni.
+
+Tagy jsou hlavne spravna vec pro widgety a filtry, tam je volnost prinos.
+`Ticket` proto dostane i `tags: string[]`.
+
+### Definice akce
+
+`src/data/ticketActions.ts`.
+
+```ts
+interface TicketAction {
+ id: string;
+ tenantId: string;
+ /** Na co se vaze. Aspon jedno z obojiho, jinak by se nabizela vsude. */
+ ticketTypeIds: string[]; // prazdne = na typu nezalezi
+ tags: string[]; // prazdne = na tagu nezalezi
+ label: string; // 'Potvrdit objednavku'
+ icon: string;
+ style: 'primary' | 'default' | 'danger';
+ order: number;
+ /** Kdy se tlacitko vubec ukaze. Vsechny podminky musi platit. */
+ visibleWhen: ActionCondition[];
+ /** Text potvrzovaciho dialogu, null = spusti se hned. */
+ confirm: string | null;
+ /** Na co se doptat pred spustenim. Stejny typ jako inputs u akce konektoru. */
+ form: OperationField[];
+ /** Co akce dela. Tri moznosti, viz nize. */
+ body: ActionBody;
+ /** Verze definice. Uprava zaklada novou, bezici behy si drzi svou. */
+ version: number;
+ /** Pravo, ktere musi uzivatel mit. Vznika spolu s akci. Viz bod 5. */
+ permission: string; // 'action:tka_confirm_order'
+ enabled: boolean;
+}
+
+type ActionBody =
+ /** Jeden krok konektoru. Nejcastejsi pripad, nastavi se bez builderu. */
+ | { kind: 'operation'; connectorId: string; operationId: string;
+ connectionId: string | null; inputs: Record }
+ /** Vlastni strom. Tentyz model i tentyz builder jako u automatizaci. */
+ | { kind: 'tree'; steps: FlowStep[] }
+ /** Vlastni skript. Podminky a limity jsou v bodu 9. */
+ | { kind: 'script'; scriptId: string; scriptVersion: number;
+ inputs: Record };
+```
+
+`visibleWhen` je nad `ticket.*` a `ticket.fields.*`, tedy nad uz existujicim
+modelem podminek. Typicky "stav je novy" nebo "objednavka jeste neni potvrzena".
+Tlacitko, ktere se ukaze vzdy a pak vyhodi chybu, je horsi nez tlacitko, ktere
+se neukaze.
+
+### Tri druhy tela akce
+
+Akce neni jen jeden krok. `ActionBody` je proto union a **kazdy druh je jina
+uroven slozitosti pro tehoz cloveka**:
+
+| Druh | Kdy | UI |
+| ----------- | ---------------------------------------------- | --------------- |
+| `operation` | odesli tenhle doklad tam | formular |
+| `tree` | zkus to, a kdyz se to nepovede, dej to Karlovi | builder |
+| `script` | poskladej text v nasem formatu | editor skriptu |
+
+`tree` pouziva **tentyz model kroku, tentyz builder a tutez validaci** jako
+automatizace. Neni to druhy strom, je to ten samy strom na jinem miste. Kdyby to
+byl jiny model, mel by builder dve verze a jedna by zaostavala.
+
+`operation` je jen strom s jednim krokem, ale vyplati se ho mit zvlast: nejcasteji
+chce clovek presne tohle a nema chodit do builderu kvuli jednomu volani. Prevod
+z `operation` na `tree` musi jit jednim kliknutim, protoze potreba vetveni prijde
+az pri druhem selhani.
+
+`script` je pro pripad, kdy jde o prevod dat, ne o volani sluzby. Plati pro nej
+vsechna omezeni z bodu 9: nema sit, nema tajemstvi, vystupy deklaruje dopredu.
+
+### Odkud akce vidi data ticketu
+
+Akce spustena z ticketu dostane vstupy ve tri skupinach. Pro builder je to totez
+jako `providedFields` u spoustece, takze se menit nemusi:
+
+| Zdroj | Priklad ID | Priklad v sablone |
+| ---------------------- | ----------------------- | --------------------- |
+| vestavena pole ticketu | `ticket.subject` | `{{subject}}` |
+| vlastni pole typu | `ticket.fld_order_no` | `{{orderNumber}}` |
+| doptavaci formular | `form.reason` | `{{reason}}` |
+
+Nabidka vstupu se pocita **za typ ticketu**, ne staticky z katalogu. To je jediny
+novy pripad: katalog dnes vraci pevny seznam.
+
+**Akce navazana na tag ma jen prvni a treti skupinu.** Za tagem nestoji zadna
+pole, takze sablona nema odkud vzit cislo objednavky. Je to podstatny prakticky
+rozdil: akce na tagu umi pracovat s predmetem, telem, zakaznikem a s tim, na co
+se dopta formular. Akce na typu umi navic vlastni pole. Kdyz akce potrebuje
+`orderNumber`, musi byt na typu.
+
+### Jak to vypada na ticketu
+
+`GET /api/dashboard/tickets/:id` bude vracet navic:
+
+```json
+"actions": [
+ { "id": "tka_confirm_order", "label": "Potvrdit objednavku",
+ "icon": "check-circle", "style": "primary",
+ "confirm": "Objednavka se posle do skladu. Pokracovat?",
+ "form": [{ "id": "note", "label": "Poznamka", "kind": "text", "required": false }] }
+]
+```
+
+Seznam **uz je filtrovany serverem** podle `visibleWhen`, typu ticketu a prav
+uzivatele. Klient jen kresli tlacitka. Je to totez pravidlo jako u `access` -
+klient si nic nedovozuje, jinak se to pocita na dvou mistech a jednou se rozejde.
+
+Spusteni:
+
+```
+POST /api/dashboard/tickets/:id/actions/:actionId
+body: { "form": { "note": "potvrzeno telefonicky" } }
+odpoved: 202 { "runId": "run_..." }
+```
+
+| Situace | Odpoved |
+| --------------------------------------- | ------- |
+| akce neexistuje nebo neni pro tento typ | 404 |
+| uzivatel na ni nema pravo | 403 |
+| `visibleWhen` neplati | 409 |
+| chybi povinne pole formulare | 400 |
+| automatizace je pozastavena | 409 |
+
+409 u `visibleWhen` je zamer. Znamena "v tomhle stavu to nedava smysl", presne
+jako u ostatnich 409 v API. Uzivatel mel otevrenou starou stranku.
+
+### Audit je soucast, ne pristavek
+
+Kazde stisknuti hned zapise zaznam do logu ticketu:
+
+```
+kind: 'action', status: 'info'
+label: 'Rucne spustil Karel Vomacka: Potvrdit objednavku'
+```
+
+Log ticketu uz je strom a lide do nej chodi. Zvlastni evidence "kdo co zmackl"
+by znamenala dve casove osy a hledani ve dvou mistech. Vysledek behu se pak
+zavesi pod tento zaznam jako jeho deti.
+
+### Da se dodat pred runtimem
+
+Rucni akce nemusi cekat na frontu z posledni sekce. Kratky strom lze na zacatku
+vykonat **synchronne v requestu**, s limitem kroku a timeoutem. Podminka je
+napsat vykonavani jako `executeStep(step, context)`, kterou pozdeji zavola
+i worker. Ne jako kod uvnitr route handleru.
+
+Kdyz se to napise takhle, je rucni akce prvni uzivatel vykonavace a runtime
+pak nepridava novy kod, jen frontu nad nim.
+
+## 3 - Rucni prehozeni a vestavene akce
+
+Cast uz existuje: `POST /tickets/:id/assign`, `POST /tickets/:id/status`,
+prava hlida `canAssignOthers`, v detailu ticketu jsou dva selecty.
+
+Navrh je **postavit vestavene akce do stejneho seznamu jako vlastni**. Ne jako
+zvlastni kus UI vedle nich.
+
+| ID | Co dela | Pravo |
+| ---------------------- | ------------------------------ | ------------------------- |
+| `builtin.assign.self` | vzit ticket na sebe | `ticket.assign.self` |
+| `builtin.assign.other` | prehodit na kolegu | `ticket.assign.others` |
+| `builtin.assign.group` | prehodit na skupinu | `ticket.assign.group` |
+| `builtin.status` | zmenit stav | `ticket.status.change` |
+| `builtin.priority` | zmenit prioritu | `ticket.priority.change` |
+| `builtin.type` | zmenit typ ticketu | `ticket.type.change` |
+| `builtin.reopen` | otevrit vyreseny | `ticket.reopen` |
+
+Dva prinosy: pravo se resi jednim mechanismem pro vestavene i vlastni akce,
+a admin muze vestavenou akci pro nekoho vypnout, aniz by se menil kod.
+
+### Prehozeni na skupinu, ne jen na cloveka
+
+Bod 5 mluvi o ucetni a skladnikovi. To znamena, ze prehazovani na konkretni
+jmeno nestaci - clovek chce rict "tohle je pro ucetni" bez toho, aby resil,
+kdo z nich ma dovolenou.
+
+```ts
+interface TicketGroup { id: string; tenantId: string; name: string; personIds: string[]; }
+```
+
+`Ticket` dostane `assigneeGroupId: string | null` vedle `assigneeId`. Fronta bez
+resitele se tim rozpadne na fronty skupin a prehled vytizeni dostane radek za
+skupinu. Je to vic prace nez zbytek bodu 3, takze druha vlna.
+
+### Co jeste chybi k prehazovani
+
+- **Duvod prehozeni.** Nepovinne pole, zapise se jako komentar do logu. Bez nej
+ se z logu neda poznat, proc ticket obesel tri lidi.
+- **Hromadne prehozeni ze seznamu.** Zaskrtavatka v seznamu ticketu, jedna akce
+ nad vyberem. Server to musi resit jako N samostatnych zapisu s dilcim
+ vysledkem, ne jako vse nebo nic - jeden ticket bez prava nesmi zabit zbytek.
+- **Notifikace.** Dnes se prirazeni objevi jen v SSE toastu, kdyz je clovek
+ prihlaseny. Prehozeni na nekoho, kdo se prave nekouka, se ztrati.
+
+## 5 - Kazdy spousti jen svuj seznam akci
+
+`TenantRole = 'admin' | 'agent'` na ucetni, skladnika a vedouciho nestaci.
+Pridavat dalsi hodnoty do unionu je slepa ulicka - kazdy klient chce jine.
+
+**Role a prava se stanou daty.**
+
+```ts
+type PermissionKey = string; // 'ticket.assign.others', 'action:tka_confirm_order'
+
+interface Role {
+ id: string;
+ tenantId: string | null; // null = systemova role, dostupna vsem firmam
+ key: string; // 'admin', 'agent', 'accountant'
+ name: string; // 'Ucetni'
+ permissions: PermissionKey[];
+}
+
+interface Membership {
+ tenantId: string;
+ roleIds: string[]; // misto role: 'admin' | 'agent'
+}
+```
+
+`admin` a `agent` zustanou jako systemove role s prednastavenym seznamem prav.
+Nic se tim nerozbije a zadny existujici ucet se nemusi predelavat.
+
+### Katalog prav
+
+Stejny princip jako katalog konektoru a widgetu: `src/data/permissions.ts`
+je zdroj pravdy o tom, jaka prava existuji, vcetne ceskych popisu a skupin.
+
+```
+GET /api/dashboard/permissions
+```
+
+Nastaveni role je pak zaskrtavatkova tabulka nad timto katalogem. Neznamy klic
+v roli se ignoruje a loguje, protoze pravo, ktere zmizelo z katalogu, nesmi
+shodit prihlaseni.
+
+Pravo k vlastni akci vznika **spolu s akci** jako `action:`. Admin pak
+nesestavuje prava, ale zaskrtava akce: "role Ucetni: Potvrdit objednavku,
+Vystavit fakturu". To je slovnik, ve kterem uvazuje.
+
+### Kontrola na dvou mistech, ale ne dvakrat pocitana
+
+- **Server pri spusteni** je autorita. Vzdy, i kdyz klient tlacitko nezobrazil.
+- **Server pri vypisu** rozhodne, ktera tlacitka klient dostane.
+
+Klient nikdy prava nevyhodnocuje. Pri vypisu i pri spusteni se vola tataz
+funkce v `access.ts`.
+
+`Access` se rozsiri:
+
+```ts
+interface Access {
+ scopes: TicketScope[];
+ tenants: Tenant[];
+ defaultTenantId: string | null;
+ personId: string | null;
+ canAssignOthers: boolean; // zustava, pocita se z permissions
+ permissions: PermissionKey[]; // efektivni prava ve zvolene firme
+ nav: NavItem[]; // viz bod 6
+}
+```
+
+**Pozor, tohle je nejvetsi zasah do stavajiciho kodu.** `accessFor(user)` uz
+nemuze stacit, prava jsou az uvnitr firmy: `accessFor(user, tenantId)`.
+Dotkne se to vsech rout, ktere `accessFor` volaji, a `requireRole`
+v `src/middleware/auth.ts` se nahradi `requirePermission(key)`.
+
+## 6 - Admin: prepinani a nastavovani zalozek nad klienty
+
+Tri oddelene veci, ktere se snadno slijou do jedne.
+
+### a) Prepinac firem
+
+Existuje. `platformAdmin` vidi vsechny firmy a pohled `all`. Beze zmeny.
+
+### b) Ktere zalozky firma vubec ma
+
+```ts
+interface TenantFeatures {
+ tenantId: string;
+ /** Ktere moduly firma ma. Klic odpovida zalozce v portalu. */
+ tabs: string[]; // ['overview','tickets','incidents','automations','connectors']
+ /** Ktere konektory smi pouzit. null = vsechny z katalogu. */
+ connectorIds: string[] | null;
+ limits: { automations: number; widgets: number; customActions: number };
+}
+```
+
+**Dve vrstvy s jinym vlastnikem, ktere se nesmi michat:**
+
+| Vrstva | Kdo nastavuje | Znamena |
+| --------------- | ------------------- | ------------------------------------ |
+| `TenantFeatures`| my, provozovatel | co ma firma zaplacene a zapnute |
+| `Role` | admin te firmy | kdo z jejich lidi to smi |
+
+Efektivni viditelnost je prunik. Vypnuty modul neexistuje ani pro admina te
+firmy - nema si ho jak zapnout, protoze ho nema. Kdyby to byla jedna vrstva,
+klientsky admin by si mohl zapnout to, co si nekoupil, nebo bychom my
+prenastavovali jejich interni prava.
+
+**Zpristupneni konektoru z bodu 9 je tataz vrstva.** `ConnectorGrant` a
+`TenantFeatures` odpovidaji na tutez otazku, jen jednou za modul a jednou za
+konektor, a nastavuje je tentyz clovek. Ma to proto byt **jedna obrazovka
+Nastaveni klienta**: zalozky, konektory, skripty, limity. Rozdelit to na tri
+obrazovky by znamenalo, ze pri onboardingu klienta se na jednu zapomene.
+
+Dusledek pro frontend: seznam zalozek v `web/src/components/dashboard/DashboardLayout.tsx`
+prestane byt konstanta a bude se brat z `access.nav`. Kompletni mapa cesta na
+komponentu zustane na klientovi, server posila jen klice a poradi.
+
+### c) Prepnout se a videt to jako konkretni clovek
+
+To uz neni prepinac firem, ale impersonace, a je to citliva vec. Navrh:
+
+```
+POST /api/admin/impersonate { "userId": "usr_..." } -> kratkodoby token
+POST /api/admin/impersonate/stop
+```
+
+Pravidla:
+
+- smi jen `platformAdmin`, a **nikdy na jineho** `platformAdmin`,
+- token plati nejvyse 30 minut a neda se obnovit, v payloadu nese `act`
+ s ID skutecneho cloveka,
+- **vychozi rezim je jen cteni.** Zapis se musi vyslovne zapnout pri startu
+ impersonace a kazdy zapis pak nese v auditu `actedBy`,
+- v portalu je po celou dobu vyrazny pruh s tim, za koho se uzivatel diva,
+ a tlacitkem zpet. Bez nej clovek zapomene a smaze neco cizim jmenem,
+- start, konec i kazda akce jde do auditu.
+
+### Audit log
+
+Bez nej nema impersonace smysl a `07-firmy-a-prava.md` ho uz vede jako chybejici.
+
+```ts
+interface AuditEntry {
+ id: string;
+ at: string;
+ tenantId: string | null;
+ userId: string;
+ actedBy: string | null; // vyplnene pri impersonaci
+ action: string; // 'ticket.assign', 'role.update', 'impersonate.start'
+ target: string | null;
+ detail: Record;
+ result: 'ok' | 'denied';
+}
+```
+
+Zapisuji se i **odepreni**. Dnes se jen loguji do konzole, takze opakovane
+pokusy o cizi firmu nikde nezustanou.
+
+## 4 - Vlastni widgety
+
+Dnes je `widgets.ts` pevny katalog, kde `kind` rika, kterou komponentu vykreslit.
+Pro vlastni widgety se to rozdeli na **jak se to kresli** a **odkud jsou data**.
+
+```ts
+type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge';
+
+type WidgetSource =
+ | { kind: 'ticketCount'; filter: TicketFilter; groupBy?: WidgetGroupBy }
+ | { kind: 'ticketList'; filter: TicketFilter; limit: number }
+ | { kind: 'ticketSeries'; filter: TicketFilter; bucket: 'day' | 'week' }
+ | { kind: 'runCount'; filter: RunFilter; groupBy?: WidgetGroupBy }
+ | { kind: 'workload'; groupIds?: string[] }
+ | { kind: 'connectorMetric'; connectionId: string; metricId: string; period: Period };
+
+type WidgetGroupBy = 'assignee' | 'group' | 'status' | 'type' | 'tag' | 'channel';
+
+interface CustomWidget {
+ id: string;
+ tenantId: string;
+ ownerId: string | null; // null = firemni widget, jinak osobni
+ name: string;
+ render: WidgetRender;
+ source: WidgetSource;
+ target?: number; // porovnavaci hodnota u gauge
+ sizes: WidgetSize[];
+ defaultSize: WidgetSize;
+}
+```
+
+### Uzivatel nepise dotazy
+
+`TicketFilter` je **tentyz filtr, ktery uz umi `GET /api/dashboard/tickets`**:
+stav, typ, priorita, kanal, resitel, skupina, obdobi. Validuje ho totez schema.
+
+Tim je bod "widgety skrz stavy ticketu" hotovy bez jedineho noveho dotazu do dat
+a bez jakekoliv moznosti napsat neco, co server polozi. Kdo si vymysli filtr,
+ktery seznam ticketu neumi, dostane 400, a je to zaroven signal, ze ten filtr
+patri nejdriv do seznamu.
+
+### Widgety skrz konektory
+
+Katalog konektoru dostane u operaci treti druh vedle `triggers` a `actions`:
+
+```ts
+interface ConnectorMetric {
+ id: string;
+ name: string;
+ /** number = jedno cislo, series = casova rada, list = radky */
+ shape: 'number' | 'series' | 'list';
+ unit?: string;
+ periods: Period[];
+}
+```
+
+Widget je pak "konektor Fakturoid, metrika Neuhrazene faktury, za 30 dni".
+Katalog zustava zdrojem pravdy presne jako u triggeru: pridani metriky je zaznam
+v katalogu, ne novy kod na webu.
+
+**Widget odkazuje na napojeni, ne na konektor.** Duvod je v bodu 9: metrika se
+tahne z konkretniho uctu s konkretnimi pristupy. Firma bez napojeni na iDoklad
+si takovy widget nemuze postavit, i kdyz iDoklad v katalogu vidi. A kdyz se
+napojeni zrusi, widget musi na svem miste rict, ze napojeni chybi - ne zhasnout
+prehled a ne ukazovat posledni znamou hodnotu jako aktualni.
+
+### Seskupovani
+
+`groupBy` je to, co dela z dlazdice pouzitelny prehled. "Tickety ve stavu selhalo
+vuci lidem, u kterych jsou" je `ticketCount` s filtrem na stav a `groupBy:
+'assignee'`. Bez seskupovani by to byl jeden widget na cloveka a po prichodu
+noveho kolegy by chybel.
+
+Vysledek seskupovani je seznam par nazev a cislo. Vykresli se jako pruhy, tabulka
+nebo kruh podle `render` - jsou to tataz data.
+
+### Poznamka k "selhalo"
+
+Ticket dnes ma stavy novy, otevreny, ceka a vyresen. Zadne selhalo. Ta otazka
+se musi rozhodnout, protoze jsou to dve rozdilne veci:
+
+- **selhal beh** automatizace nebo akce. Pak je to `runCount`, ne `ticketCount`,
+ a widget se pta na historii behu z bodu o runtimu.
+- **ticket je v koncovem stavu, ktery znamena nezdar** (objednavku neslo odeslat).
+ Pak je to vlastni stav v `TicketType.statuses`, tedy vlastni workflow za typ.
+
+Nejcasteji chce clovek prvni. Druhe je uzitecne az tam, kde typ ticketu ma
+opravdu jiny zivotni cyklus.
+
+### Dochazka neni widget
+
+Dochazka v aplikaci neexistuje. Je to samostatna domena, ne druh dlazdice.
+Spravna cesta je **konektor dochazky** s metrikami (kdo je dnes v praci, hodiny
+za mesic, chybejici prichody). Widget pak vznikne sam, protoze `connectorMetric`
+uz bude umet. Delat pro dochazku zvlastni typ widgetu by znamenalo, ze pro
+kazdou dalsi oblast pribude dalsi.
+
+### Nacitani dat
+
+Dnesni pravidlo je "data si nacita prehled, ne widgety" a je spravne. S vlastnimi
+widgety uz ale nejde predpovedet, kolik dotazu je potreba, takze:
+
+```
+POST /api/dashboard/widgets/data
+body: { "scope": "tenant", "tenantId": "tnt_automia", "instanceIds": ["w1","w2","w3"] }
+odpoved: { "w1": {...}, "w2": {...}, "w3": { "error": "..." } }
+```
+
+Jeden request na cely dashboard. Server si vysledky cachuje na par sekund podle
+podpisu zdroje, takze deset dlazdic nad stejnym filtrem je jeden dotaz.
+Widget, ktery selze, vraci chybu **na sve pozici** - jeden rozbity konektor
+nesmi zhasnout cely prehled. Obnovovani zustava na `refetchOn` a SSE.
+
+### Sdilene rozlozeni
+
+Rozlozeni je dnes za dvojici uzivatel a firma. Pribude treti uroven:
+
+1. rozlozeni uzivatele, kdyz si ho upravil,
+2. jinak rozlozeni pro jeho roli ve firme, kdyz ho admin nastavil,
+3. jinak vychozi z katalogu.
+
+Novy clovek v roli Skladnik tak hned uvidi dashboard skladnika, ne obecny.
+
+## 7 - Napojeni na Postgres
+
+Ano, a je to prvni vec, kterou udelat. Vsechno ostatni na ni stoji: role, akce,
+widgety, audit i historie behu jsou data, ktera nesmi zmizet restartem.
+
+Aplikace je na to pripravena. `src/data/` je oddelene od rout a
+`03-architektura-a-mapa-kodu.md` s tim pocita. Cilem je **zachovat signatury
+funkci ulozist** a prepsat jim vnitrek. Routy se menit nemaji.
+
+### Volby
+
+| Vec | Navrh | Proc |
+| ------------- | ------------------------------------ | ------------------------------------------- |
+| Databaze | PostgreSQL 16 | JSONB, `SKIP LOCKED`, `LISTEN/NOTIFY` |
+| Driver | `pg` | bez nadstavby, pool |
+| Dotazy | Drizzle (nebo Kysely) | typovane SQL, ne skryty ORM |
+| Migrace | `drizzle-kit`, soubory v repu | deterministicke, dohledatelne v gitu |
+| Pripojeni | `DATABASE_URL` jako AppFactory secret| nikdy v kodu, nikdy v logu |
+
+Prisny ORM se nedoporucuje. Cely projekt je psany tak, ze server je autorita
+a filtr na firmu je povinny argument - to se hlida lip nad viditelnym SQL.
+
+### Schema, na cem zalezi
+
+- **`tenant_id NOT NULL` na kazde business tabulce**, indexy zacinaji `tenant_id`.
+- Povinny argument `tenantIds` v ulozistich **zustava**. Je to prvni obrana
+ a funguje uz pri kompilaci. Row Level Security se da pridat pozdeji jako druha
+ vrstva, ne jako nahrada.
+- JSONB na to, co se cte a zapisuje cele: strom automatizace, vlastni pole
+ ticketu, zdroj widgetu, vstup a vystup kroku. Strom akci nerozkladat do tabulek,
+ nikdo se nad nim nedotazuje po castech.
+- Cizi klice s `ON DELETE RESTRICT` u vsecho, co nese historii. Smazany resitel
+ dnes ticket neshodi, to chovani musi zustat.
+
+### Poradi prevodu
+
+1. `tenants`, `users`, `roles`, `people` - identita, nejmensi objem, nejvic
+ zavislosti.
+2. `tickets`, `ticket_trace`, `ticket_types`, `ticket_tags`, `ticket_actions`.
+3. `connector_defs`, `connector_grants`, `connections`, `scripts` - bod 9.
+4. `automations`, `runs`, `run_steps`, `jobs` - stromy a runtime.
+5. `message_templates`, `dashboard_layouts`, `custom_widgets`, `audit`.
+
+Sifrovani pristupovych udaju z bodu 9 patri do treti skupiny a **nesmi se
+odlozit na potom**. Napojeni ulozene v plaintextu s tim, ze se to dosifruje
+pozdeji, je uz proteklo do zaloh.
+
+### Udalosti pres LISTEN/NOTIFY, ne Redis
+
+`src/events/bus.ts` je dnes `EventEmitter` v pameti jedne instance a dokumentace
+u toho pocita s Redisem. S Postgresem to neni potreba: `publish()` zapise
+udalost do tabulky a zavola `pg_notify`, `subscribe()` si drzi jedno pripojeni
+s `LISTEN`. Signatury zustanou stejne, SSE stream se nemeni.
+
+Dve veci k tomu:
+
+- **`pg_notify` ma limit 8000 bajtu.** Posilat jen typ a ID, ne cela data.
+ Nevadi to, `useApiQuery` uz na udalost stejne dela nove nacteni.
+- Tabulka udalosti navic vyresi "poslednich par udalosti po pripojeni", ktere
+ dnes zmizi restartem.
+
+### Health endpoint
+
+`/health` musi podle `AGENTS.md` vracet 200, kdyz aplikace zvladne obsluhovat
+provoz. **Nezavazovat ho na databazi.** Kratky vypadek DB by AppFactory vedl
+k restartovani containeru, coz nic nespravi. Ping na databazi patri na
+`/health/ready` s vysledkem cachovanym par sekund.
+
+## 8 - Cekani v automatizaci a sablony zprav
+
+### Krok "pockej"
+
+Novy druh kroku vedle akce a podminky:
+
+```ts
+| { kind: 'wait'; mode: 'duration'; amount: number;
+ unit: 'minutes' | 'hours' | 'days' }
+| { kind: 'wait'; mode: 'business'; amount: number;
+ unit: 'hours' | 'days' } // v pracovni dobe firmy
+| { kind: 'wait'; mode: 'until'; at: string } // sablona, napr. {{dueDate}}
+| { kind: 'wait'; mode: 'event'; eventType: string;
+ matchOn: string; timeout: WaitDuration } // pockej na odpoved
+```
+
+**Implementacne je to skoro zdarma**, a je to hlavni odmena za frontu v databazi
+z posledni sekce. Tabulka `job` uz ma `run_after`, takze "pockej 3 dny" je jeden
+`UPDATE` s posunutym casem. Zadny casovac v pameti, zadny `setTimeout`, restart
+containeru se behu nedotkne. S casovacem v pameti by tenhle bod nesel udelat
+vubec.
+
+Ctyri veci, ktere se u cekani snadno zapomenou:
+
+- **Pracovni doba.** "Pockej 2 dny a posli upominku" u SLA znamena dva **pracovni**
+ dny. Potrebuje to kalendar za firmu: pracovni doba, dny v tydnu, statni svatky,
+ casova zona. Dodelat to potom znamena predelat vsechny existujici cekaci kroky,
+ proto to ma byt v modelu od zacatku, i kdyby UI zpocatku nabizelo jen `duration`.
+- **Zruseni.** "Za 3 dny posli upominku" nesmi odejit, kdyz je ticket mezitim
+ vyreseny. Nejlevnejsi a nejsrozumitelnejsi je **podminka pri probuzeni**:
+ krok cekani ma `cancelWhen`, ktere se vyhodnoti az v okamziku probuzeni.
+ K tomu tlacitko "zrusit beh" na ticketu.
+- **Verze definice.** Beh, ktery ceka tyden, musi dobehnout podle stromu, ktery
+ platil pri jeho spusteni. Proto se verze automatizace nebo akce pinuje
+ na zacatku behu (`source_version` v tabulce `run`). Bez toho by uprava stromu
+ zmenila chovani nekolika tisic cekajicich behu.
+- **Prehled cekajicich behu.** Kdyz ceka 30 tisic behu, musi byt videt, na cem
+ ceka a kdy se probudi, a musi jit hromadne zrusit. Jinak to je neviditelna
+ bomba s casovym spinacem.
+
+Casy se ukladaji v UTC. Probuzeni se pocita jako **absolutni okamzik** dopredu,
+v casove zone firmy, aby prechod na letni cas neposunul vsechno o hodinu.
+
+### Sablony zprav
+
+Dnes se text pise do pole akce ve strome. To staci na jeden radek, ne na e-mail.
+Sablona je proto samostatna vec:
+
+```ts
+interface MessageTemplate {
+ id: string;
+ tenantId: string;
+ key: string; // 'order-confirmed'
+ name: string; // 'Potvrzeni objednavky'
+ channel: 'email' | 'whatsapp' | 'sms';
+ subject: string; // sablona
+ bodyText: string; // sablona, vzdy povinna
+ bodyHtml: string | null; // jen u e-mailu, nepovinna
+ /** Ktere parametry sablona potrebuje. Klic k validaci, viz nize. */
+ expects: string[];
+ locale: string;
+}
+```
+
+**Zadny novy sablonovaci jazyk.** Tentyz `{{parametr}}` a tentyz kod
+v `src/data/templates.ts`. Dva sablonovaci systemy v jedne aplikaci znamenaji,
+ze v jednom pujde `{{a.b}}` a v druhem ne, a nikdo nebude vedet, ve kterem prave je.
+
+**`expects` je to podstatne.** Sablona sama nevi, odkud se pouzije, takze pri
+ulozeni nema proti cemu overit `{{orderNumber}}`. Deklaruje proto, co potrebuje,
+a **krok, ktery ji pouzije, si overi, ze to jeho misto ve strome nabizi**. Tim se
+z prekvapeni pri behu stane `issue` v builderu, presne v tom rezimu, ktery uz
+projekt ma: nedodelek se ulozi, jen automatizaci nepusti.
+
+Dal k tomu patri:
+
+- **Rozvrzeni za firmu.** Hlavicka, logo, paticka a podpis jednou, ne v kazde
+ sablone. Sablona nese jen telo.
+- **Nahled s ukazkovymi daty.** `POST /api/dashboard/templates/:id/preview`
+ s hodnotami parametru. Bez nahledu se sablony ladi odesilanim skutecnych
+ e-mailu zakaznikum.
+- **Cisteni HTML.** Telo od uzivatele projde sanitizaci, styly inline. Jinak
+ je to XSS na kazdem, kdo si e-mail otevre ve webmailu.
+
+### Odesilani e-mailu vubec neexistuje
+
+`01-prehled-a-stav.md` vede odesilani e-mailu jako chybejici, dnes se poptavka
+jen loguje. Bod 8 tedy potrebuje i **transport**: napojeni na SMTP nebo
+poskytovatele za firmu, jako kterekoliv jine napojeni z bodu 9.
+
+Dve veci, ktere se resi az bolestive pozde:
+
+- **Odpoved zakaznika se ma vratit do ticketu**, ne zalozit novy. Znamena to
+ adresu s tokenem (`ticket+@...`) a parovani pri prijmu. Kdyz se to
+ nezavede hned, vznikne z kazde e-mailove konverzace pet ticketu.
+- **Doruditelnost.** SPF, DKIM a DMARC na domene klienta, jinak upominky konci
+ ve spamu a nikdo se to nedozvi. Patri to do onboardingu klienta, ne do kodu.
+
+## 9 - Jak jsou postavene konektory a skripty
+
+Tohle je nejdulezitejsi cast celeho navrhu, protoze rozhoduje o bezpecnostnim
+modelu. Dnes je katalog v `src/data/connectors.ts` spolecny pro vsechny firmy
+a `07-firmy-a-prava.md` vede "Tenant u konektoru" jako chybejici.
+
+### Tri vrstvy, ktere se nesmi slit do jedne
+
+Priklad ze zadani (vsichni mohou iDoklad, jen firma C vidi Polstryn SAP, firma B
+iDoklad vidi ale nema napojeni) nejde zapsat mene nez tremi vrstvami:
+
+| Vrstva | Co to je | Kdo to vlastni |
+| --------------- | ------------------------------------ | -------------------- |
+| **Definice** | ze iDoklad existuje a co umi | my, nebo firma |
+| **Zpristupneni**| kdo ho vubec smi videt | my |
+| **Napojeni** | ucet firmy s jejimi pristupy | firma |
+
+```ts
+interface ConnectorDefinition {
+ id: string;
+ key: string; // 'idoklad'
+ name: string;
+ category: ConnectorCategory;
+ icon: string;
+ /** platform = udelali jsme my, tenant = udelala si firma sama. */
+ ownerScope: 'platform' | 'tenant';
+ ownerTenantId: string | null;
+ /** public = vidi ho vsichni. restricted = jen komu je zpristupneny. */
+ visibility: 'public' | 'restricted';
+ status: 'available' | 'planned' | 'deprecated';
+ /** Jak se autorizuje: co se vyplnuje pri zakladani napojeni. */
+ auth: ConnectorAuth;
+ triggers: ConnectorOperation[];
+ actions: ConnectorOperation[];
+ metrics: ConnectorMetric[];
+ version: number;
+}
+
+/** Zpristupneni definice konkretni firme. Jen u visibility: 'restricted'. */
+interface ConnectorGrant {
+ connectorDefId: string;
+ tenantId: string;
+ grantedBy: string;
+ grantedAt: string;
+}
+
+interface ConnectorConnection {
+ id: string;
+ tenantId: string;
+ connectorDefId: string;
+ name: string; // 'iDoklad Celo' - firma muze mit dve
+ /** Sifrovane, nikdy se nevraci z API. Viz nize. */
+ secrets: EncryptedBlob;
+ /** Necitliva cast nastaveni: URL instance, cislo eshopu, vychozi rada. */
+ config: Record;
+ status: 'active' | 'error' | 'expired';
+ isDefault: boolean;
+ lastCheckAt: string | null;
+ lastError: string | null;
+}
+```
+
+### Stav konektoru se prestane cist a zacne pocitat
+
+Dnes je `ConnectorStatus = 'connected' | 'available' | 'planned'` pevne pole
+v katalogu. Ty tri hodnoty jsou ale presne to, co zadani popisuje, takze staci
+je **pocitat za firmu**:
+
+| Stav | Kdy |
+| ----------- | ------------------------------------------------------- |
+| `connected` | firma ma aktivni napojeni |
+| `available` | vidi definici, napojeni nema, muze si ho udelat |
+| `planned` | definice je na roadmape |
+| neviditelny | `restricted` bez zpristupneni - v odpovedi vubec neni |
+
+`GET /api/dashboard/connectors` tim prestava byt spolecny a zacina byt za firmu.
+Neviditelna definice se **nevraci se stavem, ale nevraci se vubec**. Firma A nesmi
+z odpovedi poznat, ze Polstryn SAP existuje.
+
+### Krok ve strome odkazuje na napojeni
+
+`FlowStep` dnes nese `connectorId` a `operationId`. Pribude `connectionId`:
+
+```ts
+{ kind: 'action'; connectorId: string; operationId: string;
+ connectionId: string | null; // null = vychozi napojeni firmy pro tento konektor
+ inputs?: Record }
+```
+
+Proc oboji a proc nullable:
+
+- **Explicitni `connectionId`** je potreba, kdyz firma ma dva ucty teze sluzby.
+- **`null` jako vychozi** dela strom prenositelnym. Vzorovy strom se da nabidnout
+ vsem firmam a kazde se dosadi jeji vlastni napojeni.
+
+Validace pri ulozeni: napojeni musi patrit **te same firme** a **te same definici**.
+Cizi `connectionId` se chova jako neexistujici, tedy 404, ne 403 - stejne pravidlo
+jako u ticketu v `07-firmy-a-prava.md`. Chybejici vychozi napojeni je nedodelek,
+ne chyba: strom se ulozi, automatizace nepujde zapnout.
+
+### Pristupove udaje
+
+Tohle je nejcitlivejsi misto cele aplikace.
+
+- **Sifrovane v Postgresu**, AES-256-GCM, klic z AppFactory secretu, nahodne IV
+ za zaznam a `keyVersion` u kazdeho radku, aby sla vymena klice.
+- **Pole jsou jen pro zapis.** API umi `clientSecret` nastavit a nikdy ho nevrati.
+ Odpoved nese `{ "clientSecret": { "isSet": true } }`, ne hodnotu. To je presne to,
+ co `AGENTS.md` zakazuje: secrets se nevraci z beznych endpointu.
+- **Redakce v logu ticketu.** Log ukazuje, co sluzba vratila, a to je jinak jista
+ cesta, jak se token dostane do trace, kdyz ho cizi API vrati v odpovedi nebo
+ v chybove zprave. Pred zapisem musi projit nahrada znamych tajnych hodnot za
+ hvezdicky. **Neni to volitelne dolazeni**, je to soucast zapisu do logu.
+- **OAuth spravuje runtime.** Definice deklaruje token endpoint, runtime drzi
+ access token s expiraci a obnovuje ho. Skript ani sablona se k tokenu nedostanou.
+- **Test napojeni** jako samostatny endpoint. Firma po zadani udaju zmackne
+ Overit a hned vi, jestli to funguje, misto aby to zjistila z padle automatizace
+ v noci.
+
+### Definice v DB, ale nase definice patri do repa
+
+Zadani rika dat konektory do DB. Ano, s jednou vyhradou, ktera se vyplati:
+
+- **Definice, ktere delame my** (iDoklad, Shoptet, WhatsApp) zijou **v repu**
+ a do DB se nasazuji migraci nebo seedem. Repo je zdroj pravdy. Jinak zmizi code
+ review, historie v gitu i moznost vratit zmenu, a vyvoj se rozejde s produkci.
+ Rucne upraveny radek v produkci nikdo za tri mesice nedohleda.
+- **Definice, ktere si udela firma**, zijou jen v DB. Tam repo neni od ceho,
+ vlastnikem je zakaznik.
+
+Runtime v obou pripadech cte z DB, takze se kod nemusi rozdvojovat.
+
+### Jak se konektor stavi
+
+Definice ma u kazde operace **implementaci**, a jsou tri druhy:
+
+| Druh | Kde je kod | Pro co |
+| --------- | ----------------- | ----------------------------------------- |
+| `builtin` | v repu, TypeScript| nase konektory, kde potrebujeme plnou moc |
+| `http` | zadny kod | vetsina REST API, i to, co si udela firma |
+| `script` | sandbox | prevod dat a divne protokoly |
+
+**`http` ma byt vychozi**, i pro nase konektory. Operace je pak zaznam:
+
+```ts
+interface HttpImplementation {
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
+ path: string; // sablona nad inputs a config
+ headers: Record; // sablony, tajemstvi jen odkazem
+ body: string | null; // sablona
+ /** Mapovani odpovedi na outputFields: cesta v JSONu na nazev vystupu. */
+ outputMap: Record; // { customerId: 'data.id' }
+ pagination?: { kind: 'page' | 'cursor'; ... };
+ /** Ktere HTTP kody jsou opakovatelne. Zbytek je koncova chyba. */
+ retryOn: number[];
+}
+```
+
+V portalu je to formular: adresa, metoda, hlavicky, telo, mapovani vystupu
+a tlacitko Vyzkouset s ukazkovymi daty. Tim je odpovezena i puvodni otazka
+"vytvorit produkt skrz custom skripty" - vetsinou to neni skript, ale jedno
+POST volani a mapovani odpovedi. A co je dulezitejsi, plati pro nej audit,
+retry i rate limit jako pro kazdy jiny krok.
+
+U `http` se musi ohlidat SSRF: povolene domeny za firmu, zakaz privatnich rozsahu
+IP a localhostu, timeout, limit velikosti odpovedi, zadne nasledovani presmerovani
+na jinou domenu.
+
+### Skripty
+
+Zbyde potreba prepocitat, spojit nebo preformatovat data mezi dvema kroky.
+
+```ts
+interface ScriptDef {
+ id: string;
+ name: string;
+ ownerScope: 'platform' | 'tenant';
+ ownerTenantId: string | null;
+ visibility: 'public' | 'restricted'; // stejny grant jako u konektoru
+ code: string;
+ version: number;
+ status: 'draft' | 'published' | 'disabled';
+ inputs: OperationField[];
+ outputFields: ProvidedField[]; // deklarovane dopredu
+}
+```
+
+Pravidla, na kterych to stoji:
+
+- **Skript nema sit ani tajemstvi.** Vstup JSON, vystup JSON, nic jineho. Cizi
+ sluzby vola runtime pres konektory. Jinak zmizi audit, retry i rate limit
+ a padly skript necha rozdelanou praci, kterou nikdo nedohleda.
+- **`node:vm` neni bezpecnostni hranice** a nesmi se pouzit. Bud `isolated-vm`,
+ nebo QuickJS ve WASM.
+- Limity: CPU radove desitky ms, pamet jednotky MB, velikost vystupu, zadne
+ `require`, zadne `fetch`, zadne casovace.
+- **Vystupy se deklaruji dopredu**, aby na ne sla postavit podminka jako na kazdy
+ jiny `outputFields`. Skript vracejici cokoliv by se v builderu nedal pouzit.
+- **Publikovana verze je nemenna**, uprava zaklada novou. Totez jako u definice akce
+ a ze stejneho duvodu.
+
+Kdo potrebuje libovolne IO, nedostane skript, ale **vlastni sluzbu v AppFactory** -
+presne k tomu AppFactory je - a v katalogu definici konektoru, ktera ji vola.
+Tim se z vyjimky stava normalni konektor.
+
+### Ctyri ruzne otazky, ktere se pletou do jedne
+
+U konektoru i skriptu se musi rozlisit, a kazda ma jineho vlastnika:
+
+| Otazka | Mechanismus | Nastavuje |
+| --------------------------------- | -------------------------- | --------------- |
+| Kdo to **vidi** | `visibility` plus grant | my |
+| Kdo si smi udelat **napojeni** | pravo `connector.manage` | admin firmy |
+| Kdo to smi **pouzit ve strome** | pravo `automation.edit` | admin firmy |
+| Kdo smi **spustit** rucni akci | pravo `action:` | admin firmy |
+| Kdo smi **upravit skript** | pravo `script.edit` | my |
+
+Zvlast posledni radek: uprava skriptu zmeni chovani vseho, co ho pouziva. To neni
+pravo, ktere se dava vedle prava zakladat tickety.
+
+## Cele flow z prikladu
+
+Kontrola navrhu proti zadani. Karel Novak, firmy Celo a Delo.
+
+### Zaznamy, ktere vzniknou
+
+```
+users usr_novak Karel Novak
+ memberships: [ (tnt_celo, admin), (tnt_delo, admin) ]
+
+connector_def cd_idoklad iDoklad platform public
+ cd_easyweb EasyWeb platform public
+ cd_shoptet Shoptet platform public
+ cd_sap_pol Polstryn SAP platform restricted
+connector_grant (cd_sap_pol, tnt_firmac)
+
+connection cn_1 tnt_celo cd_idoklad 'iDoklad Celo' secrets: clientId+secret
+ cn_2 tnt_celo cd_easyweb 'EasyWeb Celo'
+ cn_3 tnt_delo cd_shoptet 'Shoptet Delo'
+
+ticket_type tt_order tnt_celo key 'order' 'Objednavka'
+ fields: orderNumber, customerName, total
+
+ticket_action tka_send tnt_celo [tt_order] 'Odeslat do iDokladu'
+ body: operation cd_idoklad/create-invoice cn_1
+ inputs: { number: '{{orderNumber}}', ... }
+
+ tka_txt tnt_celo [tt_order] 'Vytvorit textak'
+ body: tree [ skript poskladej text, Soubory/create ]
+
+automation au_order tnt_celo trigger: EasyWeb objednavka prijata (cn_2)
+ steps: [ Zalozit ticket typu order,
+ iDoklad/create-invoice pres cn_1 ]
+```
+
+### Co Karel vidi ve firme Celo
+
+Katalog konektoru: iDoklad `connected`, EasyWeb `connected`, Shoptet `available`
+(vidi, ze si ho muze napojit, ale ve strome ho pouzit nejde). Polstryn SAP
+v odpovedi neni vubec.
+
+Ticket typu Objednavka: dve tlacitka, Odeslat do iDokladu a Vytvorit textak.
+Obe smi zmacknout, protoze ma roli s pravy `action:tka_send` a `action:tka_txt`.
+
+Automatizace `au_order` dela to same odesilani sama, ma ten krok ve svem strome.
+Odesilani do iDokladu je tedy nastavene na dvou mistech: v akci `tka_send` a ve
+strome `au_order`. **To je ta vedoma cena z uvodni sekce.** Obe mista pritom
+volaji tutez operaci katalogu pres totez napojeni `cn_1`, takze co se duplikuje,
+je mapovani poli, ne logika.
+
+### Co Karel vidi ve firme Delo
+
+Prepne firmu. Katalog: Shoptet `connected`, iDoklad a EasyWeb `available`
+(neni napojeni, jde si ho udelat). Polstryn SAP opet vubec.
+
+Typ ticketu Objednavka **neexistuje**, patri firme Celo. Tlacitka `tka_send`
+a `tka_txt` taky ne, a to ani kdyby si typ zalozil - definice akce nese
+`tenantId`.
+
+To je ten vysledek, o ktery v zadani jde: **tentyz clovek, tytez prihlaseni,
+uplne jina nabidka.** Drzi to jedina vec, a to `tenantId` na definici akce, typu
+ticketu, automatizace i napojeni. Kdyby kterakoliv z nich `tenantId` nemela, prosakne
+firma Celo do Dela.
+
+### Textovy soubor: pozor na jednu past
+
+"Vlastni akce mi ulozi textak s nejakym formatem" nejde udelat skriptem,
+protoze skript nema IO. A hlavne: **container ma docasny disk**. Soubor zapsany
+na disk zmizi pri kazdem nasazeni.
+
+Spravne je vestaveny konektor Soubory s akcemi `create` (obsah je sablona,
+vystup je odkaz), `attach-to-ticket` a `send`. Ulozeni jde do objektoveho
+uloziste, ne na disk containeru, a soubor se objevi jako priloha ticketu nebo ke
+stazeni. Skript, kdyz je vubec potreba, jen **poskladej text** a vrati ho jako
+hodnotu.
+
+Znamena to jednu infrastrukturni vec navic: S3 kompatibilni uloziste, treba MinIO.
+Je to jediny bod celeho navrhu, ktery si rika o dalsi sluzbu vedle Postgresu.
+
+### Widgety z prikladu
+
+| Widget | Zdroj |
+| ---------------------------------------- | -------------------------------------------------- |
+| Pocet objednavek od-do | `connectorMetric` nad `cn_1`, metrika a obdobi |
+| Pocet novych klientu od-do | `connectorMetric` nad `cn_1` |
+| Pocet ticketu typu Objednavka | `ticketCount`, filtr `typeIds: [tt_order]` |
+| Pocet padlych behu | `runCount`, filtr `status: failed` |
+| Padle tickety vuci lidem | `ticketCount` plus `groupBy: 'assignee'` |
+
+Prvni dva se ve firme Delo postavit nedaji, protoze `cn_1` do ni nepatri.
+Az bude mit Delo svuj iDoklad, postavi si je nad svym napojenim a cisla budou
+jeji.
+
+## Runtime a kapacita
+
+Vyhodnocovani kroku, prijem udalosti a co to znamena pri 150 klientech ma vlastni
+soubor: [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md). Odsud je dulezite
+jen tolik, ze prubeh behu drzi radek v databazi, ne pamet procesu, a ze diky tomu
+je cekaci krok z bodu 8 skoro zdarma.
+
+## Poradi prace
+
+| Vlna | Co | Zavisi na |
+| ---- | ----------------------------------------------------- | --------- |
+| 0 | Postgres, prevod ulozist, LISTEN/NOTIFY (bod 7) | - |
+| 1 | Konektory do DB: definice, zpristupneni, napojeni, sifrovani udaju (bod 9) | 0 |
+| 2 | Definice akci: telo jako operace, strom nebo skript, verzovani | 0, 1 |
+| 3 | Typy ticketu, tagy, vlastni pole, rucni akce, vestavene akce (1, 2, 3) | 0, 2 |
+| 4 | Role a prava jako data, zalozky ze serveru, Nastaveni klienta, audit, impersonace (5, 6) | 0, 1 |
+| 5 | Runtime: fronta, retry, idempotence, fairness, krok `wait` (bod 8) | 0, 2 |
+| 6 | Sablony zprav a odesilani e-mailu (bod 8.1) | 0, 5 |
+| 7 | Vlastni widgety, seskupovani, sdilene rozlozeni (bod 4) | 0, 1 |
+| 8 | Skripty v sandboxu | 1, 5 |
+
+Zmena proti prvni verzi: **konektory se posunuly na zacatek**. Bez rozdeleni na
+definici, zpristupneni a napojeni nema smysl delat typy ticketu ani widgety,
+protoze oboji uz na napojeni odkazuje. Prvni verze mela konektory az v posledni
+vlne a byla to chyba.
+
+Vlna 7 je nezavisla na 2 az 6 a da se zaradit kdykoliv po 1, kdyz je potreba
+neco ukazat. Vlna 3 nemusi cekat na 5, protoze rucni akce se zpocatku vykona
+synchronne - viz konec sekce 1 a 2.
+
+Vlna 8 je posledni zamerne. Az bude hotovy `http` druh implementace z bodu 9,
+casto se ukaze, ze skripty nikdo nepotrebuje.
+
+## Co se tim rozbije
+
+Seznam mist, ktera navrh meni a je potreba je hlidat.
+
+| Zmena | Dotkne se |
+| -------------------------------------------- | ------------------------------------------------------ |
+| `accessFor(user)` -> `accessFor(user, tenantId)` | vsechny routy dashboardu, prava jsou az uvnitr firmy |
+| `Membership.role` -> `roleIds` | `types.ts`, `users.ts`, `access.ts`, `middleware/auth.ts` |
+| `requireRole` -> `requirePermission` | `src/middleware/auth.ts` a vsechna jeho pouziti |
+| `Ticket` dostane `typeId` a `fields` | `ticketStore.ts`, `openapi.ts`, `web/src/types/dashboard.ts`, seznam, detail, simulace |
+| Katalog konektoru prestane byt spolecny | `connectors.ts`, `GET /connectors`, `Connectors.tsx` - vraci se za firmu |
+| `ConnectorStatus` se prestane cist z katalogu | pocita se z napojeni, dnes je to pevne pole |
+| `FlowStep` dostane `connectionId` | `automationStore.ts`, `flow.ts`, validace stromu, builder |
+| Zalozky ze serveru | `web/src/components/dashboard/DashboardLayout.tsx`, dnes konstanta |
+| `WidgetKind` -> `render` plus `source` | `widgets.ts`, `WidgetCard.tsx`, ulozena rozlozeni potrebuji prevod ID |
+| Novy druh kroku `wait` a `call` | `flow.ts`, `flowScope.ts`, `FlowCanvas.tsx`, validace |
+| `visibleWhen` potrebuje AND vice podminek | model podminek dnes umi jedno porovnani, viz `conditions.ts` |
+| Log ticketu potrebuje redakci tajemstvi | `ticketStore.ts`, zapis `response` do trace |
+| Data z pameti do Postgresu | cele `src/data/`, routy zustavaji |
+
+Dve veci k modelu podminek. `visibleWhen` u akce potrebuje spojit vic porovnani,
+zatim to jde jen vnorenim ve strome. Bud model podminek rozsirit o seznam
+s AND na obou stranach (`src/data/conditions.ts` i `web/src/lib/flow.ts`, vzdy
+obe), nebo AND drzet jen u akci a nemichat to do stromu. Druha varianta je
+levnejsi, prvni upravnejsi.
+
+A pri kazdem novem endpointu soucasne `src/openapi.ts` a tuhle dokumentaci.
+Swagger, ktery neodpovida chovani, je horsi nez zadny.
diff --git a/documentation/10-runtime-a-kapacita.md b/documentation/10-runtime-a-kapacita.md
new file mode 100644
index 0000000..d94e7e0
--- /dev/null
+++ b/documentation/10-runtime-a-kapacita.md
@@ -0,0 +1,410 @@
+# 10 - Runtime, vykonna cast a kapacita
+
+Navrh, ne popis stavu. Runtime neexistuje, dnes se ulozeny strom nevykonava.
+Souvisejici navrh datovych modelu je v [09-navrh-rozsireni.md](09-navrh-rozsireni.md).
+
+Tenhle soubor odpovida na tri veci: jak se vyhodnocuji kroky, jak se prijimaji
+udalosti a co to znamena pri 150 klientech.
+
+## Nejdriv cisla, pak architektura
+
+Zadani: 150 klientu, kazdy asi 5 systemu, z nich chodi radove desitky udalosti.
+To je 750 napojeni. "Desitky udalosti" ma dve cteni a **odpoved se mezi nimi
+podstatne lisi**, takze obe:
+
+| Scenar | Desitky udalosti za | Udalosti/den | Kroku/den | Prumer | Spicka |
+| ------ | ------------------- | ------------ | ---------- | ------- | -------- |
+| A | den a system | 37 tisic | 375 tisic | 4 kr/s | 30-60/s |
+| B | hodinu a system | 450 tisic | 4,5 mil | 52 kr/s | 200-400/s|
+
+Pocitano s 10 kroky na udalost, coz je stredni automatizace. Spicka vychazi
+z toho, ze provoz je v osmihodinovem okne a uvnitr nerovnomerny, tedy radove
+osmkrat nad prumerem.
+
+**Planovat se musi na kroky, ne na udalosti.** Jedna udalost s trisetkrokovym
+stromem stoji tristakrat vic nez udalost s jednim krokem. Az bude runtime bezet,
+je metrika kroku za sekundu ta jedina, podle ktere se da neco rict.
+
+### Co je a co neni uzke misto
+
+| Vec | Scenar A | Scenar B |
+| ----------------------- | --------------- | ------------------------------ |
+| Fronta v Postgresu | par procent | zvladne, ale s davkovym odberem|
+| Soubezne HTTP volani | 15 soubezne | 90 soubezne, Node se nezapoti |
+| **Zapis `run_step`** | 22 GB/mesic | **270 GB/mesic, nutne zkratit**|
+| Limity cizich API | uzke misto | uzke misto |
+
+Fronta nad Postgresem s `FOR UPDATE SKIP LOCKED` uklidne obslouzi radove
+200 az 500 uloh za sekundu na jednom uzlu, kdyz se odebira davkove. Scenar A
+je tedy nezajimavy a scenar B je v pohodlnem pasmu.
+
+**Skutecne uzke misto je objem zapisu.** Radek `run_step` s vstupem a vystupem
+v JSONB ma realne 1 az 3 kB. Pri scenari B je to 9 GB denne, coz za pul roku
+nikdo neuklidi. Reseni je v sekci o retenci a je to jedina vec z celeho navrhu,
+kterou **nelze odlozit na potom**.
+
+Druhe uzke misto je za nasimi hranicemi. iDoklad, WhatsApp ani ekonomicky system
+nesnesou desitky pozadavku za sekundu na jeden ucet. Skalovani workeru bez limitu
+za napojeni znamena jen rychleji dojit k odpovedi 429.
+
+### Co pri teto velikosti nepotrebujeme
+
+Rict to nahlas, aby se to nestavelo: **zadna Kafka, zadny Redis, zadny Kubernetes,
+zadne sharding.** 150 klientu je pro jeden Postgres a par procesu Node maly
+provoz. Usili patri do idempotence, spravedlnosti mezi klienty a pozorovatelnosti,
+ne do infrastruktury.
+
+Odhad velikosti:
+
+| Scenar | Postgres | Workeri | Kde to bezi |
+| ------ | ------------------ | --------------------------- | ----------- |
+| A | 4 vCPU, 16 GB | 2 procesy, 50 soubezne | jeden stroj |
+| B | 8-16 vCPU, 32 GB, NVMe | 4-6 procesu, 100 soubezne | dva stroje |
+
+## Moznosti u databaze
+
+| Varianta | Verdikt pri 150 klientech |
+| -------------------------------- | ------------------------------------------------ |
+| Jedno DB, `tenant_id` ve sloupci | **ano, tohle** |
+| Schema na klienta | ne: 150 x 20 tabulek je 3000 tabulek, migrace se stanou nespolehlivymi |
+| Databaze na klienta | ne, ale nechat si dvere otevrene |
+| Partitionovani podle klienta | ne, oddily by byly velikostne nesouvisle |
+| Partitionovani podle casu | **ano, u pripisovacich tabulek** |
+
+### Dvere k oddelene databazi za par korun
+
+Jednou prijde klient, ktery bude chtit vlastni databazi, nebo bude delat
+tricet procent provozu. Aby to pak nebyla prestavba, staci **jedna vec od zacatku**:
+pristup k poolu jen pres `dbFor(tenantId)`, i kdyz zpocatku vraci porad tentyz
+pool. K tomu **nikdy nespojovat dotazem dva klienty**, coz uz vynucuje povinny
+argument `tenantIds` v ulozistich.
+
+Splneni tehle dvou podminek znamena, ze presun jednoho klienta do vlastni
+databaze je konfigurace, ne prepisovani dotazu.
+
+### Pool a jedno pravidlo, na kterem to stoji nebo pada
+
+**Worker nesmi drzet spojeni do databaze po dobu volani ciziho API.** Je to
+nejcastejsi zpusob, jak takovy system umre. Volani do iDokladu trva 300 ms.
+Kdyz drzi spojeni, znamena 90 soubeznych kroku 90 obsazenych spojeni a pool
+skonci.
+
+Spravne poradi:
+
+```
+transakce: odeber ulohu (2 ms) - spojeni drzim
+uvolni spojeni
+volani ciziho API (300 ms) - spojeni nedrzim
+transakce: zapis vysledek (2 ms) - spojeni drzim znovu
+```
+
+Pri tomhle poradi staci na 90 soubeznych kroku 3 az 5 spojeni. Bez nej 90.
+
+Az bude workeru vic, prijde PgBouncer v transakcnim rezimu. **Pozor: v transakcnim
+rezimu nefunguje `LISTEN/NOTIFY`**, a prave na nem ma podle
+[09-navrh-rozsireni.md](09-navrh-rozsireni.md) stat sbernice udalosti pro SSE.
+Ta potrebuje prime spojeni mimo PgBouncer. Zjistit to az pri nasazeni znamena
+rozbity zivy dashboard.
+
+## Cesta udalosti: tri oddelene faze
+
+```
+POST /webhook/:token -> event_inbox 202, rychle a hloupe
+ |
+ dispatcher udalost na N behu
+ |
+ worker krok po kroku
+```
+
+Rozdeleni na tri faze neni akademicke. Kazda ma jinou vlastnost: prijem musi byt
+rychly, rozeslani musi byt idempotentni, vykonavani musi byt prerusitelne.
+
+### 1. Prijem: rychle a hloupe
+
+```sql
+event_inbox(id, tenant_id, token_id, source_kind, source_id,
+ idempotency_key, payload jsonb, received_at,
+ status, dispatched_at, cause_run_id, depth)
+
+unique index on (token_id, idempotency_key)
+```
+
+Webhook **nesmi vyhodnocovat strom**. Overi token, overi velikost, zapise jeden
+radek, vrati 202. Cil je p99 pod 50 ms.
+
+Duvod je praktickeho razu: odesilatel pri timeoutu opakuje. Kdyz webhook ceka
+na iDoklad, pomaly iDoklad zpusobi, ze tataz objednavka prijde tri krat.
+
+- **Idempotence pri prijmu.** Hlavicka `Idempotency-Key`, nebo hash tela, kdyz
+ ji odesilatel neposila. Unikatni index nad `(token_id, idempotency_key)`
+ v okne 24 hodin. Duplikat vrati 202 a stejne ID udalosti, ne chybu - pro
+ odesilatele to je uspech, protoze jeho udalost je prijata.
+- **Limit za token.** Jeden rozbity klient ve smycce nesmi zaplnit inbox.
+- **Strop velikosti tela**, radove 256 kB, vetsi 413.
+- **Backpressure.** Kdyz hloubka fronty prekroci hranici, vracet 429 tokenum,
+ ktere nejsou oznacene jako kriticke. Odesilatele 429 umi, na rozdil od tiche
+ latence rostouci do minut.
+
+### 2. Vlastni udalosti nechodi pres HTTP
+
+Podle zadani budou udalosti vznikat volanim na webhook z jinych automatizaci.
+U cizich odesilatelu ano. **U nasich vlastnich ne.**
+
+Volat vlastni HTTP endpoint na sebe pridava latenci, obsazuje spojeni a zaklada
+poruchu, ktera nemusi existovat. Vnitrni udalost zapise radek do `event_inbox`
+primo, prochazi tim samym dispatcherem a je v tom samem prehledu. Webhook zustava
+pro to, co prichazi zvenci.
+
+### 3. Ochrana proti smycce musi projit skrz udalost
+
+Tohle je nejvaznejsi dusledek toho, ze automatizace vyrabeji udalosti pro jine
+automatizace.
+
+`depth` na behu chrani jen **uvnitr jednoho behu**. Kdyz automatizace A vyrobi
+udalost, ktera spusti B, a B vyrobi udalost, ktera spusti A, tak kazdy jednotlivy
+beh ma hloubku 1 a kontrola nikdy nezasahne. Smycka pobezi, dokud ji nekdo
+nevsimne na uctu za cizi API.
+
+Proto `event_inbox` nese `cause_run_id` a `depth`, a plati:
+
+```
+depth nove udalosti = depth behu, ktery ji vyrobil, + 1
+depth > 5 -> udalost se odmitne, zapise se do logu a upozorni se
+```
+
+Bez tohohle jednoho sloupce je navrh z bodu 9 generator nekonecnych smycek.
+
+### 4. Rozeslani
+
+Dispatcher najde automatizace, ktere na dvojici klient a spoustec sedi, a zalozi
+beh pro kazdou.
+
+- **Filtr na spousteci se vyhodnoti tady**, jeste pred zalozenim behu. Je to
+ odpoved na otevrene rozhodnuti z konce [06-tickety.md](06-tickety.md) a pri
+ tomhle objemu to neni kosmetika: beh, ktery hned skonci, stejne zaplati zapis
+ do `run`, `run_step` i `ticket_trace`.
+- **Jedna transakce**: oznac udalost jako rozeslanou, zaloz behy, zaloz prvni
+ ulohy. Bud vse, nebo nic.
+- **Unikatni index nad `(event_id, automation_id)`.** Kdyz dispatcher padne
+ uprostred, opakovane rozeslani nezalozi druhy beh.
+
+## Vykonna cast: co ridi prechod mezi kroky
+
+Dve veci, oddelene.
+
+**1. Cista funkce.** `next(tree, path, outputs): FlowPath | null` rozhodne, ktery
+krok je dalsi. Zadny stav, zadne IO, testovatelne. Podminka se vyhodnoti nad
+`outputs` a vybere vetev. Chuze po strome uz z poloviny existuje ve
+`web/src/lib/flow.ts` a `src/data/flowScope.ts`.
+
+**2. Radek v tabulce.** Fakticky prubeh behu drzi zaznam ulohy v databazi,
+ne pamet procesu. **Nikdy `setTimeout`, nikdy dlouhy retez promisu, nikdy
+rekurze drzici cely beh.** Restart containeru je bezna vec a beh ho musi prezit.
+
+```sql
+run(id, tenant_id, source_kind, source_id, source_version,
+ event_id, trigger_type, status, depth, queue_key,
+ started_at, finished_at, error)
+
+run_step(id, run_id, path, step_id, attempt, status,
+ input jsonb, output jsonb, error, started_at, finished_at)
+
+job(id, run_id, tenant_id, next_path, run_after, attempts,
+ queue_key, locked_by, locked_until, priority)
+```
+
+`source_kind` a `source_version` rikaji, jestli beh patri automatizaci nebo akci
+a podle ktere verze jeji definice se ma dokoncit. To je to, co dela z cekaciho
+kroku fungujici vec: beh cekajici tyden dobehne podle stromu, ktery platil pri
+jeho spusteni.
+
+`run_after` v tabulce `job` je zaroven **cele cekani z bodu 8**. Krok `wait` neni
+v runtimu vyjimka, je to obycejny krok, ktery misto volani sluzby nastavi
+`run_after` a skonci. Proto ten bod skoro nic nestoji.
+
+### Smycka workeru
+
+```
+1. transakce: odeber DAVKU uloh
+ SELECT ... FROM job
+ WHERE run_after <= now()
+ AND (locked_until IS NULL OR locked_until < now())
+ AND tenant_id <> ALL (:klienti_na_stropu)
+ ORDER BY priority, run_after
+ FOR UPDATE SKIP LOCKED LIMIT 25
+ UPDATE job SET locked_by = :worker, locked_until = now() + interval '2 min'
+
+2. uvolni spojeni, vykonej ulohy soubezne, kazda PRAVE JEDEN krok
+
+3. za kazdou ulohu transakce: zapis vysledek kroku
+ smaz hotovou ulohu
+ zarad dalsi ulohu podle next()
+```
+
+**Krok 3 v jedne transakci je cely trik.** Bud se zapise vysledek i dalsi uloha,
+nebo nic. Nikdy nevznikne beh, ktery ma hotovy krok a nema pokracovani, ani
+dvakrat zarazeny stejny krok.
+
+**Davkovy odber je nejvetsi pacidlo na propustnost.** Po jedne uloze znamena pri
+300 krocich za sekundu 300 odberovych transakci za sekundu. Po dvaceti peti
+je jich dvanact. Je to jedna zmena `LIMIT` a nekolikanasobne mensi zatez.
+
+`SKIP LOCKED` znamena, ze workeru muze byt libovolne mnoho a nepotrebuji
+koordinatora. Za zvazeni stoji `graphile-worker` - je to tentyz princip nad
+Postgresem, overeny provozem. Rucne az kdyz bude potreba spravedlnost podle
+`queue_key`, kterou hotova knihovna neresi.
+
+### Spravedlnost mezi klienty
+
+Ciste FIFO znamena, ze jeden vecerni import u jednoho klienta zastavi ostatnich
+149. Pri 150 klientech to neni hypoteza, je to otazka casu.
+
+Prakticky pouzitelna verze je dvojice:
+
+- **Semafor za klienta ve workeru**: nejvyse N soubeznych kroku na klienta.
+- **Odberovy dotaz preskoci klienty na stropu** (`tenant_id <> ALL (...)`).
+ Seznam si worker drzi sam a je aktualni na jednu davku.
+
+Presna spravedlnost cistym SQL je slozita a nevyplati se. Tohle je odhadem
+o dva rady jednodussi a rozdil nikdo nepozna.
+
+K tomu dve dalsi hranice:
+
+- **Limit a rychlostni strop za napojeni, ne za konektor.** Kvota je na uctu
+ klienta v iDokladu, ne na tom, ze iDoklad existuje. Zetonovy kosik jednim
+ `UPDATE ... RETURNING` nad radkem napojeni.
+- **Serializace nad jednim ticketem.** `queue_key = ticket:` a jen jedna
+ bezici uloha na klic. Bez toho dve automatizace prepisuji stav teze veci
+ a poradi neni dane.
+
+### Pady
+
+- **Lease.** Worker padne uprostred kroku, `locked_until` vyprsi, ulohu si vezme
+ jiny. Nic se neztrati.
+- **Kroky musi byt idempotentni.** Kazdy krok dostane
+ `idempotencyKey = runId + ':' + path`, **stabilni pres vsechny pokusy**.
+ Konektory, ktere umi `Idempotency-Key`, ho dostanou a druhy pokus nevystavi
+ druhou fakturu. Klic za pokus by byl k nicemu, o tom to cele je.
+- **Retry.** Exponencialni backoff s jitterem, radove 5 pokusu. Rozlisit
+ opakovatelne (timeout, spojeni, 429, 5xx) od koncovych (400, 401, 403,
+ validace). Koncovou chybu neopakovat, jen se tim vypali kvota.
+- **Po vycerpani pokusu** dostane beh stav `failed`, zapise se do logu ticketu
+ a vznikne udalost. Beh zustane a **lze ho pokracovat od padleho kroku**,
+ protoze stav je per krok, ne per beh.
+- **Limit kroku na beh** a globalni timeout behu.
+- **Exactly-once neexistuje.** Cil je at-least-once plus idempotence. Kdo slibi
+ exactly-once, jen jeste nenasel pripad, kdy to nedrzi.
+
+## Kde bezi skripty
+
+Skripty z bodu 9 jsou cisty prevod dat, ale i tak maji vlastni provozni pravidlo,
+a je dulezite:
+
+**Skript nesmi bezet v hlavnim vlakne workeru.** Skript, ktery pocita
+pet set milisekund, zablokuje smycku udalosti a s ni **vsechny ostatni soubezne
+kroky toho workeru**. Jeden nepovedeny cyklus u jednoho klienta tim zastavi
+provoz vsech ostatnich, a v logu to vypada jako pomala cizi API.
+
+Navrh:
+
+- Bazen `worker_threads`, v kazdem `isolated-vm`. Radove tolik vlaken, kolik je
+ jader.
+- Tvrdy timeout 50 az 200 ms a **zabiti vlakna** pri prekroceni, ne zdvorile
+ preruseni. Prerusit smycku `while (true)` jinak nejde.
+- Zkompilovany skript se drzi v cache za verzi, kontext se po N spustenich zahodi
+ kvuli unikum pameti.
+- Rezie kontextu je radove milisekunda, takze tisic skriptovych kroku za sekundu
+ neni problem.
+
+Kdyz nekdy bude potreba skript, ktery neco vola nebo dlouho pocita, nedostane
+vic pravomoci. Stane se **vlastni sluzbou v AppFactory** a v katalogu konektorem,
+ktery ji vola. Tim pro nej zacne platit retry, rate limit i audit jako pro kazdy
+jiny krok.
+
+Skripty se nikdy nespousti v procesu API. Portal nesmi zpomalit kvuli tomu,
+ze nekdo ulozil spatny cyklus.
+
+## Retence a objem, hned pri navrhu schematu
+
+Tohle je jedina vec, ktera pri scenari B rozhoduje o tom, jestli to za pul roku
+jde provozovat.
+
+**Zkracovani obsahu.** Plny vstup a vystup kroku se uklada jen u kroku, ktere
+selhaly, plus u male vzorku uspesnych. U ostatnich se uklada velikost, hash
+a prvnich radove 512 bajtu. Snizi to objem radove desetkrat a neztrati to nic,
+co by nekdo cetl - do uspesneho kroku se nikdo nechodi divat.
+
+**Partitionovani po mesicich** u pripisovacich tabulek: `run_step`,
+`ticket_trace`, `event_inbox`, `audit`. Mazani stareho oddilu je pak
+`DROP TABLE`, ne `DELETE` bezici pres noc.
+
+**Retence** podle toho, kdo to cte:
+
+| Data | Jak dlouho |
+| --------------------- | ----------------- |
+| Vstupy a vystupy kroku| 30 dni |
+| Souhrn behu | 12 mesicu |
+| Log ticketu | 90 dni |
+| Audit | dele, dane pravni potrebou |
+
+Cisla patri do nastaveni za klienta, protoze delsi retence je dobry duvod
+pro drazsi tarif.
+
+## Co se monitoruje
+
+Ne CPU. Ctyri veci, a kazda odpovida na jinou otazku:
+
+| Metrika | Odpovida na |
+| ----------------------------------- | --------------------------------- |
+| Hloubka fronty | stiha se to |
+| **Vek nejstarsi pripravene ulohy** | je to zahlcene, nebo zaseknute |
+| Kroku za sekundu, p95 za konektor | kde to drhne |
+| Padle behy za hodinu, podil opakovani| co je rozbite |
+| Podil kroku za klienta | kdo je hlucny soused |
+| Zpozdeni inboxu (prijato az rozeslano) | stiha dispatcher |
+
+Bez veku nejstarsi ulohy se neda odlisit "je hodne prace" od "nic se nedeje",
+a to jsou dva uplne jine problemy se stejnou hloubkou fronty.
+
+## Kdy zmenit architekturu
+
+Aby se to nemuselo rozhodovat dopredu. Do te doby plati navrh vyse.
+
+| Signal | Co udelat |
+| ---------------------------------------- | ------------------------------------- |
+| Fronta zere nad 30 % CPU databaze | vetsi davky, pak fronta v Redisu (BullMQ) |
+| Zapisy `run_step` prevalcuji IO | zkratit obsah, vzorkovat, velka tela do objektoveho uloziste |
+| Jeden klient dela nad 30 % provozu | vlastni bazen workeru, pak vlastni databaze |
+| Fronta roste kazdy den ve spicce | pridat workery, jsou bezstavove |
+| Prevazuji chyby 429 z cizich API | limity za napojeni, pak vyjednat kvoty|
+| Cekajici behy jdou do stovek tisic | oddelena fronta pro dlouha cekani, aby nezdrzovala bezny odber |
+
+## Jeden container, dve role
+
+AppFactory nasazuje jednu aplikaci, takze worker nebude zvlastni sluzba.
+Rozdelit ho **procesne uvnitr image** pres `APP_ROLE=api|worker|both`
+s vychozim `both`. Az bude spicka takova, ze behy zpomaluji portal, nasadi se
+druha instance s `APP_ROLE=worker`. Zmena je jedna environment variable, zadny
+zasah do infrastruktury AppFactory.
+
+Migrace pri startu potrebuji poradovy zamek (`pg_advisory_lock`), aby je pri
+soubeznem nasazeni nespustilo vic instanci najednou.
+
+## Poradi, v jakem to stavet
+
+1. `event_inbox`, webhook, idempotence pri prijmu, `depth` skrz udalost.
+ Bez toho zbytek nema co zpracovavat a smycky jsou otevrene.
+2. `run`, `run_step`, `job`, `executeStep`, smycka po jedne uloze.
+ Nejmensi verze, ktera vykona strom.
+3. Retry, lease, idempotency key ke konektorum.
+4. Dispatcher s filtrem na spousteci.
+5. Krok `wait` a prehled cekajicich behu.
+6. Davkovy odber, semafor za klienta, limity za napojeni.
+7. Zkracovani obsahu, partitionovani, retence.
+8. Bazen vlaken pro skripty.
+
+Body 1 az 3 jsou nutne, aby vubec neco bezelo. Body 6 a 7 jsou to, co odlisuje
+scenar A od scenare B, a **daji se dodelat pozdeji bez prestavby** - vyzaduji ale,
+aby uz od zacatku existovaly sloupce `tenant_id` a `queue_key` v tabulce `job`
+a partitionovani u `run_step`. Pridat oddily do nejvetsi tabulky v systemu az
+potom je ta jedina cast, ktera by opravdu bolela.
diff --git a/documentation/11-skripty-konektoru.md b/documentation/11-skripty-konektoru.md
new file mode 100644
index 0000000..91d90ed
--- /dev/null
+++ b/documentation/11-skripty-konektoru.md
@@ -0,0 +1,315 @@
+# 11 - Skripty konektoru
+
+Tohle uz neni navrh, je to naprogramovane. Navrh, ze ktereho to vzniklo, je
+v [09-navrh-rozsireni.md](09-navrh-rozsireni.md), bod 9.
+
+## Co to je
+
+Skript je **vykonna cast konektoru**. Jeden soubor, ktery nese dve veci:
+
+- **manifest** - jak se operace jmenuje, co potrebuje na vstupu, co vraci na vystupu,
+- **kod** - co se ma opravdu udelat.
+
+Diky manifestu s nim umi pracovat strom automatizace, aniz by o kodu cokoliv
+vedel. Builder z manifestu vykresli pole kroku a podminka za krokem se muze
+zeptat na jeho vystupy.
+
+## Kde to je
+
+```
+scripts/ soubory skriptu, obycejny JavaScript
+ _sablona.js sablona ke zkopirovani (podtrzitko = nenacita se)
+ idoklad.get-issued-invoice.js
+ ...
+src/scripts/types.ts co je skript, zod schema manifestu
+src/scripts/values.ts kontrola vstupu a vystupu
+src/scripts/util.ts pomocne funkce pro skripty, redakce tajemstvi
+src/scripts/connections.ts kam se vola a cim se to autorizuje
+src/scripts/http.ts HTTP klient predany skriptu
+src/scripts/manifest.ts overeni manifestu, prevod na operaci katalogu
+src/scripts/registry.ts nacitani ze souboru, hot reload, ukladani
+src/scripts/runner.ts spusteni jednoho skriptu
+src/routes/scripts.ts API
+web/src/pages/dashboard/Scripts.tsx stranka /dashboard/skripty
+```
+
+## Nic se neotaci
+
+Soubory jsou zamerne **obycejny JavaScript, ne TypeScript**. TypeScript by se
+musel prelozit, a to je presne to otaceni, ktere tady nema byt.
+
+Registr si drzi cas zmeny souboru a pri zmene ho nacte znovu. Prohledava
+nejvyse jednou za sekundu, takze cteni katalogu neznamena stat na kazdy dotaz.
+
+Uprava tedy funguje trema cestami a vzdy stejne:
+
+| Kudy | Co se stane |
+| -------------------------------- | -------------------------------------------- |
+| Editor v portalu | ulozi soubor, registr ho nacte hned |
+| Rucni uprava souboru na serveru | registr si zmeny vsimne pri dalsim dotazu |
+| Novy soubor ve slozce | objevi se jako nova operace v katalogu |
+
+`POST /api/dashboard/scripts/reload` to jen vynuti hned, bez cekani.
+
+## Nazev souboru je ID
+
+Soubor se jmenuje `..js` a `manifest.id` musi byt stejne.
+Nesoulad je chyba, ne varovani - jinak by se skript ulozil pod jednim jmenem
+a nacetl pod druhym.
+
+```
+scripts/idoklad.get-issued-invoice.js
+ \_____/ \________________/
+ konektor operace
+```
+
+Z ID se dopocita, do ktereho konektoru operace patri, takze se to nepise
+dvakrat. Konektor **musi existovat** v `src/data/connectors.ts`, jinak se skript
+ohlasi jako problem.
+
+## Manifest
+
+```js
+export const manifest = {
+ id: 'idoklad.get-issued-invoice',
+ name: 'Získat vydanou fakturu',
+ description: 'Načte vydanou fakturu z iDokladu podle jejího ID.',
+ timeoutMs: 15000, // nepovinne
+ inputs: [ /* ScriptField */ ],
+ outputs: [ /* ScriptField */ ],
+};
+```
+
+Parametr je pro vstup i vystup **tentyz tvar**. Kontrola je pak jedna funkce,
+ne dve skoro stejne, ktere by se casem rozesly.
+
+| Klic | K cemu |
+| ----------- | ------------------------------------------------------------- |
+| `id` | pouziva se v sablonach jako `{{id}}`, jen pismena a podtrzitka |
+| `label` | co vidi uzivatel v builderu |
+| `type` | `string`, `number`, `boolean`, `date` |
+| `required` | u vstupu: bez hodnoty se skript nespusti. U vystupu: musi ho vratit |
+| `hint` | napoveda pod polem |
+| `options` | vyber z hodnot, jina neprojde |
+| `pattern` | dalsi kontrola regularnim vyrazem (jen `string`) |
+| `multiline` | pole na vic radku (jen `string`) |
+| `default` | dosadi se, kdyz hodnota chybi a parametr neni povinny |
+
+Schema manifestu je `.strict()`. Preklep v nazvu klice (`outputFileds`) se ohlasi,
+ne tise ignoruje.
+
+## Kontrola vstupu a vystupu
+
+Poradi je vzdy stejne: **overit vstup, spustit, overit vystup**.
+
+Overeni vystupu neni pridavek. Bez nej by strom veril parametru, ktery nikdy
+nedosel, a podminka za krokem by se rozhodovala podle `undefined`.
+
+Pravidla:
+
+- povinny parametr bez hodnoty je chyba, ne prazdny retezec,
+- nepovinny parametr bez hodnoty dostane `default`, jinak `null`,
+- hodnota se prevede na deklarovany typ, kdyz to jde bez hadani. Ceska
+ desetinna carka projde, `"ano"` u typu boolean taky,
+- parametr, ktery v manifestu neni, se zahodi a zaloguje. Stejne jako u webhooku:
+ odesilatele posilaji i vlastni data a odmitat je by rozbijelo integrace,
+- chyby se vraci **vsechny najednou**, ne jen prvni.
+
+Chybejici povinny vystup je chyba **skriptu**, ne uzivatele, a hlasi se jinym
+druhem (`output`).
+
+## Co skript ma a co nema
+
+Skript ma jen `ctx`. Zadny import, zadny pristup na sit mimo `ctx.http`
+a **zadne pristupove udaje**.
+
+```js
+export async function run(inputs, ctx) { /* ... */ }
+```
+
+| Na kontextu | K cemu |
+| ------------------ | ---------------------------------------------------------- |
+| `ctx.http` | `get`, `post`, `patch`, `put`, `del` 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.idempotencyKey` | stabilni pres vsechny pokusy tehoz kroku |
+| `ctx.fail` | koncova chyba, neopakuje se |
+| `ctx.retry` | docasna chyba, ma smysl zkusit znovu |
+
+Adresu i autorizacni hlavicky doplnuje runtime podle napojeni. Skript rika
+`GET /issued-invoices/12` a nic vic. Duvod je v bodu 9 navrhu: kdyby skript
+znal tajemstvi, staci jeden `ctx.log` a je v logu, ktery vidi klient.
+
+### Pomocne funkce
+
+Cizi API vraci pokazde jinak. iDoklad pouziva velka pocatecni pismena a nekde
+obaluje odpoved do `Data`. Bez tehle sady by to kazdy skript resil znovu a jeden
+z nich by to resil spatne.
+
+| Funkce | Co dela |
+| -------------------------- | -------------------------------------------------- |
+| `unwrap(body)` | rozbali `{ Data: x }` i `{ data: x }` |
+| `pick(obj, ...names)` | prvni existujici pole bez ohledu na velikost pismen |
+| `first(value)` | prvni prvek pole, nebo null |
+| `text`, `num`, `bool`, `date` | prevody s fallbackem |
+| `round(value, decimals)` | zaokrouhleni, uctuje se v halerich |
+| `need(value, label)` | vrati hodnotu, nebo skonci citelnou chybou |
+
+## Chyby: opakovatelne a koncove
+
+Rozdeleni je to podstatne. Timeout nebo 503 ma smysl zkusit znovu, spatny vstup
+nebo 403 ne - opakovat koncovou chybu jen vypali kvotu u cizi sluzby.
+
+| Druh | Kdy | Opakovat |
+| ------------ | ------------------------------------------ | -------- |
+| `not_found` | skript neexistuje | ne |
+| `config` | chybi pristupove udaje, 401, 403 | ne |
+| `validation` | vstup neprosel kontrolou | ne |
+| `output` | skript nevratil deklarovany vystup | ne |
+| `terminal` | 400, 404, jina koncova odpoved sluzby | ne |
+| `retryable` | 408, 429, 5xx, chyba spojeni | ano |
+| `timeout` | skript nedobehl v limitu | ano |
+| `internal` | neocekavana vyjimka ve skriptu | ne |
+
+Runner **nikdy nevyhodi vyjimku**. Vzdy vrati vysledek s `ok`, `outputs`, `logs`,
+`durationMs`, `httpCalls` a pripadne `error` vcetne `retryable`. Az bude
+existovat runtime automatizaci, bude tohle jeho jediny vstupni bod na kroku.
+
+## Napojeni
+
+Zatim jedno napojeni na konektor, sestavene z environment variables. Cilovy stav
+je napojeni za firmu v databazi, viz bod 9 navrhu. Az to bude, prepise se vnitrek
+`resolveConnection` a nic dalsiho.
+
+| Promenna | K cemu |
+| --------------------------- | --------------------------------------------------- |
+| `SERVICES_BASE_URL` | zaklad adres, vychozi `https://services.csbot.cz/apps` |
+| `_BASE_URL` | presmerovani jednoho konektoru |
+| `IDOKLAD_CLIENT_ID` | povinne pro iDoklad, jde do `X-ClientId` |
+| `IDOKLAD_CLIENT_SECRET` | povinne pro iDoklad, jde do `X-ClientSecret` |
+| `IDOKLAD_APPLICATION_ID` | jen partnerske aplikace |
+| `SCRIPTS_DIR` | jina slozka se skripty |
+| `SCRIPT_TIMEOUT_MS` | vychozi strop na beh, 15000 |
+| `SCRIPT_MAX_RESPONSE_BYTES` | strop na velikost odpovedi, 1000000 |
+| `ALLOW_PRIVATE_TARGETS` | povoli volani na localhost, **jen pro lokalni vyvoj** |
+
+Autorizace konektoru je popsana v `authSpecs` v `src/scripts/connections.ts`.
+Novy konektor s pristupovymi udaji znamena jeden zaznam v teto tabulce.
+
+**Hodnoty se z API nikdy nevraci.** `GET /api/dashboard/scripts` posila jen jmena
+chybejicich promennych a jmena vyplnenych hlavicek, nikdy hodnoty (AGENTS.md).
+
+## Redakce tajemstvi
+
+Cizi API rado vraci prijaty token v chybove zprave. Log ticketu ukazuje, co
+sluzba vratila, a zobrazuje se klientovi. Proto vsechno, co jde do logu nebo do
+chyby, projde nahradou znamych tajnych hodnot za hvezdicky.
+
+Neni to volitelne dolazeni, je to soucast zapisu.
+
+## Bezpecnostni hranice a co jeste chybi
+
+Skripty ve slozce jsou **nase**, prosly gitem a code review. Bezi proto v procesu
+serveru, ne v sandboxu. Plati pro ne:
+
+- **nemaji sit mimo `ctx.http`** a v nem nesmi mirit do vnitrni site
+ (`ALLOW_PRIVATE_TARGETS` je jen pro vyvoj),
+- **nedostanou pristupove udaje**,
+- timeout je hlidany pres `AbortSignal`, takze prerusi cekani na sit.
+
+Co to **neresi**: skript s `while (true)` timeout nezastavi. Runner vrati chybu,
+ale smycka bezi dal a blokuje hlavni vlakno. U nasich skriptu je to prijatelne,
+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.
+
+## Napojeni do katalogu
+
+Skript se domeri do katalogu konektoru jako akce s `implementation: 'script'`
+a `scriptId`. Kdyz nese ID operace, ktera uz v katalogu je, **skript vyhrava** -
+staticky zapis je popis toho, co umime, skript je to, co se opravdu stane.
+
+Prekryv drzi `src/data/connectors.ts` (`setScriptActions`, `actionsFor`).
+Je to zamerne tam, protoze vsechno ostatni se uz pta pres `findOperation`.
+Tim se skripty naraz objevi ve validaci stromu, ve vypoctu toho, co je v kterem
+kroku videt, i v sablonach - bez toho, aby se to psalo trikrat.
+
+V portalu jsou operace se skriptem oznacene ikonou v katalogu konektoru.
+
+## API
+
+| Metoda | Cesta | Kdo smi |
+| ------ | ----------------------------------------- | ---------------- |
+| GET | `/api/dashboard/scripts` | prihlaseny |
+| GET | `/api/dashboard/scripts/:id` | prihlaseny |
+| PUT | `/api/dashboard/scripts/:id` | spravce platformy |
+| POST | `/api/dashboard/scripts/:id/test` | spravce platformy |
+| POST | `/api/dashboard/scripts/reload` | spravce platformy |
+
+Cteni smi kazdy prihlaseny - builder potrebuje vedet, co skript umi. Uprava meni
+chovani vseho, co skript pouziva, takze to neni pravo vedle prava zakladat tickety.
+
+**Ukladani je bezpecne proti rozbiti.** Nejdriv se kod zapise do docasneho
+souboru, ten se nacte a overi, a az pak prepise puvodni. Rozbita uprava se
+neulozi a skript, ktery fungoval, funguje dal. Co presne nesedi, prijde
+v `issues`.
+
+**Zkusebni spusteni vola opravdovou sluzbu.** Vystavena faktura opravdu vznikne.
+Zamerne: test, ktery volani predstira, nerekne nic o tom, jestli skript funguje.
+Portal na to upozornuje nad tlacitkem.
+
+## Ukazkove skripty pro iDoklad
+
+Postavene proti skutecnemu API sluzby na `https://services.csbot.cz/apps/idoklad`.
+Kazdy ukazuje jiny vzor, at je z ceho vychazet.
+
+| Skript | Vzor |
+| --------------------------------- | --------------------------------------------- |
+| `idoklad.get-issued-invoice` | jedno volani a prevod odpovedi |
+| `idoklad.find-issued-invoice` | predvalidace: nenalezeno **neni** chyba |
+| `idoklad.find-contact` | vlastni kontrola vstupu (aspon jedno z dvojice) |
+| `idoklad.create-issued-invoice` | dve volani, vzor z `/default` a prepis jen znamych poli |
+| `idoklad.register-payment` | akce, ktera meni stav, plus idempotence |
+| `idoklad.send-invoice-email` | odpoved nic nevraci, vystup se sklada ze vstupu |
+
+### Proc se u zakladani bere vzor z `/default`
+
+iDoklad u faktury vyzaduje pole, ktera nikdo rucne vyplnovat nechce
+(`documentSerialNumber`, `isEet`, `isIncomeTax`). Skript proto nejdriv vezme
+predvyplneny vzor z `GET /issued-invoices/default` a prepise v nem **jen to,
+cemu rozumime**.
+
+Kdyby se telo skladalo od nuly, rozbila by ho kazda zmena povinnych poli na
+strane iDokladu.
+
+### Idempotence
+
+Kazde volani nese hlavicku `Idempotency-Key` s hodnotou `ctx.idempotencyKey`.
+Klic je pro tentyz krok **stabilni pres vsechny pokusy**, takze druhy pokus
+po timeoutu nevystavi druhou fakturu. Klic za pokus by byl k nicemu, o tom to
+cele je.
+
+## Jak pridat skript
+
+1. Zkopirovat `scripts/_sablona.js` na `..js`.
+2. Srovnat `manifest.id` s nazvem souboru.
+3. Vyplnit `inputs` a `outputs`.
+4. Napsat `run`.
+5. Kdyz konektor jeste neni v `src/data/connectors.ts`, pridat ho.
+6. Kdyz potrebuje pristupove udaje, pridat zaznam do `authSpecs`
+ v `src/scripts/connections.ts`.
+
+Katalog, builder i stranka skriptu si ho vezmou samy. Nic se nerestartuje.
+
+## Co chybi
+
+| Chybi | Poznamka |
+| ---------------------------- | --------------------------------------------------- |
+| Napojeni za firmu | zatim jedno na konektor z environment variables |
+| Skripty od zakazniku | potrebuji sandbox a vlastni vlakno, viz vyse |
+| 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 |
+| Metriky pro widgety | manifest to zatim nezna, viz bod 4 navrhu |
+| Ulozeni uprav mimo git | portal zapisuje do souboru v containeru, redeploy je vrati |
diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md
index 2952921..085c4c6 100644
--- a/documentation/99-zmeny.md
+++ b/documentation/99-zmeny.md
@@ -2,6 +2,70 @@
Nejnovejsi nahore.
+## 2026-08-12 - skripty konektoru
+
+Naprogramovana vykonna cast konektoru. Popis je
+v [11-skripty-konektoru.md](11-skripty-konektoru.md).
+
+### Pridano
+
+- `scripts/` se skripty konektoru. Jeden soubor nese manifest (vstupni a vystupni
+ parametry) i kod. Obycejny JavaScript, aby se nemusel prekladat.
+- Hot reload podle casu zmeny souboru. Uprava v portalu i rucni uprava souboru
+ se projevi bez restartu.
+- Kontrola vstupu i vystupu proti manifestu, jedna funkce pro obe strany.
+ Chybejici povinny vystup je chyba skriptu, ne uzivatele.
+- `ctx` predavany skriptu: `http` nad adresou napojeni, `util`, `log`, `config`,
+ `idempotencyKey`, `fail` a `retry`. Skript nedostane pristupove udaje.
+- Rozliseni opakovatelne a koncove chyby. Runner nikdy nevyhodi vyjimku, vzdy
+ vraci vysledek vcetne `retryable`.
+- Redakce tajnych hodnot pred zapisem do logu.
+- Napojeni z environment variables (`src/scripts/connections.ts`) vcetne iDokladu.
+- Sest ukazkovych skriptu pro iDoklad proti skutecnemu API sluzby
+ `services.csbot.cz/apps/idoklad`, kazdy na jiny vzor.
+- Stranka `/dashboard/skripty`: seznam, manifest, editor, zkusebni spusteni.
+ Formular testu se sklada z manifestu, nepise se pro kazdy skript.
+- Endpointy `/api/dashboard/scripts`, `/:id`, `PUT /:id`, `/:id/test` a `/reload`.
+ Vse ve Swaggeru.
+
+### Zmeneno
+
+- Katalog konektoru uz neni jen staticky seznam. Akce ze skriptu se domeruji
+ prekryvem v `src/data/connectors.ts`, takze se naraz objevi ve validaci stromu,
+ ve vypoctu scope i v sablonach. Pri stejnem ID operace vyhrava skript.
+- `ConnectorOperation` ma `implementation` a `scriptId`. Katalog v portalu operace
+ se skriptem oznacuje ikonou.
+- `ApiError` na klientovi nese cele telo odpovedi a umi z nej vytahnout `issues`.
+- Dockerfile kopiruje `scripts/` do vysledneho image.
+
+### Vedome neudelano
+
+Skripty bezi v procesu serveru, ne v sandboxu. Jsou nase a prosly gitem.
+Zakaznicke skripty budou potrebovat izolovany engine ve vlastnim vlakne, duvod
+je v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md).
+
+Ulozeni z portalu zapisuje do souboru v containeru. Bez trvaleho svazku ho
+redeploy vrati na verzi z gitu.
+
+## 2026-08-12 - navrhy
+
+Pridany [09-navrh-rozsireni.md](09-navrh-rozsireni.md)
+a [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md).
+
+09 popisuje datove modely: akce navazane na typ nebo tag ticketu s telem jako
+operaci, vlastnim stromem nebo skriptem, typy a tagy ticketu, role a prava jako
+data misto unionu, zalozky a zpristupneni konektoru za firmu, konektory rozdelene
+na definici, zpristupneni a napojeni, cekaci krok, sablony zprav, vlastni widgety
+se seskupovanim a prevod na Postgres. Soucasti je kontrola navrhu proti celemu
+prikladu se dvema firmami jednoho cloveka.
+
+10 popisuje vykonnou cast: cestu udalosti od webhooku pres inbox a dispatcher
+k workeru, frontu v Postgresu se `SKIP LOCKED`, davkovy odber, spravedlnost mezi
+klienty, idempotenci, retence a rozpocet na 150 klientu ve dvou scenarich objemu.
+
+Nic z toho neni naprogramovane, oba dokumenty jsou navrh k rozhodnuti.
+Kod se nemenil.
+
## 2026-08-03
Tickety predelane na plnohodnotny konektor. Prestavaji byt polozkou v seznamu
diff --git a/scripts/_sablona.js b/scripts/_sablona.js
new file mode 100644
index 0000000..ae874c0
--- /dev/null
+++ b/scripts/_sablona.js
@@ -0,0 +1,83 @@
+/**
+ * Sablona noveho skriptu. Soubory od podtrzitka se nenacitaji, takze tenhle
+ * nikde nevznikne jako operace - je tu jen ke zkopirovani.
+ *
+ * Postup:
+ * 1. zkopirovat na `..js`, napriklad `idoklad.get-contact.js`,
+ * 2. srovnat `manifest.id` s nazvem souboru, musi byt stejne,
+ * 3. vyplnit vstupy a vystupy,
+ * 4. napsat `run`.
+ *
+ * Nic se nerestartuje. Server si zmenu vsimne podle casu souboru a nacte ji
+ * pri dalsim dotazu. Totez plati pri uprave v portalu.
+ *
+ * Co ma skript k dispozici je jen `ctx`. Zadny import, zadny pristup k sitim
+ * mimo `ctx.http` a zadne pristupove udaje - ty dosazuje runtime podle napojeni.
+ */
+
+export const manifest = {
+ /** Musi odpovidat nazvu souboru bez .js. */
+ id: 'konektor.operace',
+ name: 'Nazev, ktery uvidi uzivatel v builderu',
+ description: 'Jedna veta o tom, co se stane. Cte to clovek, ktery staví strom.',
+
+ /**
+ * Co skript potrebuje. Presne tohle se v builderu vykresli jako pole kroku
+ * a server to pred spustenim overi.
+ *
+ * type: 'string' | 'number' | 'boolean' | 'date'
+ * required: true = bez hodnoty se skript vubec nespusti
+ * options: vyber z hodnot, jina neprojde
+ * multiline: pole na vic radku
+ * default: dosadi se, kdyz hodnota chybi a pole neni povinne
+ * pattern: dalsi kontrola regularnim vyrazem (jen u string)
+ */
+ inputs: [
+ {
+ id: 'prikladVstupu',
+ label: 'Příklad vstupu',
+ type: 'string',
+ required: true,
+ hint: 'Napoveda pod polem.',
+ },
+ ],
+
+ /**
+ * Co skript vraci. Tohle je to, s cim pak umi pracovat strom - podminka se
+ * na to muze zeptat a sablona to muze dosadit jako `{{prikladVystupu}}`.
+ *
+ * Povinny vystup, ktery skript nevrati, je chyba skriptu. Zamerne: strom by
+ * jinak veril parametru, ktery nikdy nedosel.
+ */
+ outputs: [
+ { id: 'prikladVystupu', label: 'Příklad výstupu', type: 'string', required: true },
+ ],
+};
+
+/**
+ * @param {Record} inputs
+ * Uz overene a prevedene na typy z manifestu.
+ * @param {{
+ * http: { get: Function, post: Function, patch: Function, put: Function, del: Function },
+ * util: { unwrap: Function, pick: Function, first: Function, text: Function,
+ * num: Function, bool: Function, date: Function, round: Function, need: Function },
+ * log: Function, config: Record, idempotencyKey: string,
+ * fail: Function, retry: Function,
+ * }} ctx
+ */
+export async function run(inputs, ctx) {
+ // Cesta je relativni k adrese napojeni, cela adresa se nikam nepise.
+ const { body } = await ctx.http.get('/nejaky-endpoint', {
+ query: { hledat: inputs.prikladVstupu },
+ });
+
+ const data = ctx.util.unwrap(body);
+
+ // ctx.fail = koncova chyba, neopakuje se.
+ // ctx.retry = docasna chyba, runtime to zkusi znovu.
+ if (!data) ctx.fail('Služba nic nevrátila.');
+
+ return {
+ prikladVystupu: ctx.util.need(ctx.util.text(ctx.util.pick(data, 'nazev')), 'název'),
+ };
+}
diff --git a/scripts/idoklad.create-issued-invoice.js b/scripts/idoklad.create-issued-invoice.js
new file mode 100644
index 0000000..616dd60
--- /dev/null
+++ b/scripts/idoklad.create-issued-invoice.js
@@ -0,0 +1,155 @@
+/**
+ * iDoklad: vystaveni vydane faktury s jednou polozkou.
+ *
+ * Sluzba: https://services.csbot.cz/apps/idoklad
+ * Endpointy: GET /issued-invoices/default, POST /issued-invoices
+ *
+ * Vzor **dvou volani za sebou**. iDoklad u faktury vyzaduje pole, ktera nikdo
+ * rucne vyplnovat nechce (`documentSerialNumber`, `isEet`, `isIncomeTax`),
+ * takze se nejdriv vezme predvyplneny vzor z `/issued-invoices/default`
+ * a prepisou se v nem jen ty veci, ktere prisly ze stromu.
+ *
+ * Kdyby se telo skladalo od nuly, rozbila by ho kazda zmena povinnych poli
+ * na strane iDokladu. Takhle se prepisuje jen to, cemu rozumime.
+ */
+
+export const manifest = {
+ id: 'idoklad.create-issued-invoice',
+ name: 'Vystavit vydanou fakturu',
+ description:
+ 'Vystaví v iDokladu vydanou fakturu s jednou položkou. Chybějící údaje ' +
+ 'se doplní z předvyplněného vzoru iDokladu.',
+ timeoutMs: 25000,
+
+ inputs: [
+ {
+ id: 'partnerId',
+ label: 'ID odběratele v iDokladu',
+ type: 'number',
+ required: true,
+ hint: 'Umí ho dohledat akce Najít kontakt.',
+ },
+ {
+ id: 'description',
+ label: 'Popis dokladu',
+ type: 'string',
+ required: true,
+ hint: 'Text v hlavičce faktury, například Objednávka {{orderNumber}}.',
+ },
+ { id: 'itemName', label: 'Název položky', type: 'string', required: true },
+ {
+ id: 'unitPrice',
+ label: 'Cena za jednotku',
+ type: 'number',
+ required: true,
+ hint: 'V měně dokladu. Desetinná čárka i tečka projdou.',
+ },
+ { id: 'amount', label: 'Počet jednotek', type: 'number', required: false, default: 1 },
+ { id: 'unit', label: 'Jednotka', type: 'string', required: false, default: 'ks' },
+ {
+ id: 'dateOfIssue',
+ label: 'Datum vystavení',
+ type: 'date',
+ required: false,
+ hint: 'Nevyplněno = dnes.',
+ },
+ {
+ id: 'maturityDays',
+ label: 'Splatnost ve dnech',
+ type: 'number',
+ required: false,
+ default: 14,
+ },
+ { id: 'variableSymbol', label: 'Variabilní symbol', type: 'string', required: false },
+ { id: 'note', label: 'Poznámka', type: 'string', required: false, multiline: true },
+ {
+ id: 'vatRateType',
+ label: 'Kód sazby DPH',
+ type: 'number',
+ required: false,
+ default: 0,
+ hint: 'Číselný kód VatRateType z iDokladu. Když nevíte, nechte 0.',
+ },
+ {
+ id: 'priceType',
+ label: 'Kód typu ceny',
+ type: 'number',
+ required: false,
+ default: 0,
+ hint: 'Číselný kód PriceType z iDokladu (cena s DPH nebo bez). Když nevíte, nechte 0.',
+ },
+ ],
+
+ outputs: [
+ { id: 'invoiceId', label: 'ID faktury', type: 'number', required: true },
+ { id: 'documentNumber', label: 'Číslo dokladu', type: 'string', required: true },
+ { id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: false },
+ { id: 'dateOfMaturity', label: 'Datum splatnosti', type: 'date', required: true },
+ ],
+};
+
+/** 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);
+ return date;
+}
+
+export async function run(inputs, ctx) {
+ const { unwrap, pick, text, num, date, need } = ctx.util;
+
+ if (inputs.unitPrice < 0) ctx.fail('Cena za jednotku nemůže být záporná.');
+ const amount = inputs.amount ?? 1;
+ if (amount <= 0) ctx.fail('Počet jednotek musí být větší než nula.');
+
+ // 1. Predvyplneny vzor. Nese povinna pole, ktera nechceme vyplnovat rucne.
+ const defaults = unwrap((await ctx.http.get('/issued-invoices/default')).body);
+ if (!defaults || typeof defaults !== 'object') {
+ ctx.retry('iDoklad nevrátil předvyplněný vzor faktury.');
+ }
+
+ const issuedAt = inputs.dateOfIssue ? new Date(inputs.dateOfIssue) : new Date();
+ const maturityAt = addDays(issuedAt, inputs.maturityDays ?? 14);
+
+ // 2. Prepisou se jen ta pole, kterym rozumime. Zbytek zustava ze vzoru.
+ const payload = {
+ ...defaults,
+ partnerId: inputs.partnerId,
+ description: inputs.description,
+ dateOfIssue: isoDay(issuedAt),
+ dateOfTaxing: isoDay(issuedAt),
+ dateOfMaturity: isoDay(maturityAt),
+ items: [
+ {
+ name: inputs.itemName,
+ amount,
+ unit: inputs.unit ?? 'ks',
+ unitPrice: inputs.unitPrice,
+ discountPercentage: 0,
+ isTaxMovement: false,
+ priceType: inputs.priceType ?? 0,
+ vatRateType: inputs.vatRateType ?? 0,
+ },
+ ],
+ };
+
+ if (inputs.variableSymbol) payload.variableSymbol = inputs.variableSymbol;
+ if (inputs.note) payload.note = inputs.note;
+
+ const { body } = await ctx.http.post('/issued-invoices', payload);
+ const invoice = unwrap(body);
+
+ ctx.log(`Faktura vystavena pro odběratele ${inputs.partnerId}.`);
+
+ return {
+ invoiceId: need(num(pick(invoice, 'id')), 'ID vystavené faktury'),
+ documentNumber: need(text(pick(invoice, 'documentNumber', 'number')), 'číslo dokladu'),
+ totalWithVat: num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')),
+ dateOfMaturity:
+ date(pick(invoice, 'dateOfMaturity')) ?? need(date(maturityAt), 'datum splatnosti'),
+ };
+}
diff --git a/scripts/idoklad.find-contact.js b/scripts/idoklad.find-contact.js
new file mode 100644
index 0000000..bc15385
--- /dev/null
+++ b/scripts/idoklad.find-contact.js
@@ -0,0 +1,97 @@
+/**
+ * iDoklad: dohledani kontaktu podle ICO nebo e-mailu.
+ *
+ * Sluzba: https://services.csbot.cz/apps/idoklad
+ * Endpoint: GET /contacts?filter=(IdentificationNumber~eq~12345678)
+ *
+ * Vzor **vlastni kontroly vstupu**. Manifest umi rict "tohle pole je povinne",
+ * ale ne "aspon jedno z dvojice". Takova pravidla patri do kodu, protoze jen
+ * tam jde napsat citelny duvod.
+ */
+
+export const manifest = {
+ id: 'idoklad.find-contact',
+ name: 'Najít kontakt',
+ description:
+ 'Dohledá odběratele v iDokladu podle IČO nebo e-mailu. Nic nezakládá. ' +
+ 'Výsledek se použije jako ID odběratele při vystavení faktury.',
+
+ inputs: [
+ {
+ id: 'identificationNumber',
+ label: 'IČO',
+ type: 'string',
+ required: false,
+ pattern: '^[0-9]{6,12}$',
+ hint: 'Jen číslice. Přesnější než e-mail, hledá se podle něj první.',
+ },
+ {
+ id: 'email',
+ label: 'E-mail',
+ type: 'string',
+ required: false,
+ hint: 'Použije se, když IČO není k dispozici.',
+ },
+ ],
+
+ outputs: [
+ { id: 'found', label: 'Kontakt nalezen', type: 'boolean', required: true },
+ { id: 'contactId', label: 'ID kontaktu', type: 'number', required: false },
+ { id: 'companyName', label: 'Název firmy', type: 'string', required: false },
+ { id: 'identificationNumber', label: 'IČO', type: 'string', required: false },
+ { id: 'email', label: 'E-mail', type: 'string', required: false },
+ { id: 'matchedBy', label: 'Podle čeho se našel', type: 'string', required: true },
+ ],
+};
+
+const notFound = {
+ found: false,
+ contactId: null,
+ companyName: null,
+ identificationNumber: null,
+ email: null,
+ matchedBy: 'nenalezeno',
+};
+
+export async function run(inputs, ctx) {
+ const { unwrap, pick, text, num } = ctx.util;
+
+ if (!inputs.identificationNumber && !inputs.email) {
+ ctx.fail('Vyplňte IČO nebo e-mail, jinak není podle čeho hledat.');
+ }
+
+ /** Jedno hledani podle jednoho pole. Vraci kontakt, nebo null. */
+ async function search(field, value) {
+ const { body } = await ctx.http.get('/contacts', {
+ query: { filter: `(${field}~eq~${value})`, filtertype: 'and', pageSize: 1 },
+ });
+ const items = unwrap(body);
+ return Array.isArray(items) && items.length > 0 ? items[0] : null;
+ }
+
+ // Poradi je zamer: ICO je jednoznacne, e-mail muze mit vic firem stejny.
+ const attempts = [];
+ if (inputs.identificationNumber) {
+ attempts.push(['IdentificationNumber', inputs.identificationNumber, 'IČO']);
+ }
+ if (inputs.email) attempts.push(['Email', inputs.email, 'e-mail']);
+
+ for (const [field, value, label] of attempts) {
+ const contact = await search(field, value);
+ if (!contact) {
+ ctx.log(`Podle ${label} ${value} se nic nenašlo.`);
+ continue;
+ }
+
+ return {
+ found: true,
+ contactId: num(pick(contact, 'id')),
+ companyName: text(pick(contact, 'companyName', 'name')),
+ identificationNumber: text(pick(contact, 'identificationNumber')),
+ email: text(pick(contact, 'email')),
+ matchedBy: label,
+ };
+ }
+
+ return notFound;
+}
diff --git a/scripts/idoklad.find-issued-invoice.js b/scripts/idoklad.find-issued-invoice.js
new file mode 100644
index 0000000..6f93adf
--- /dev/null
+++ b/scripts/idoklad.find-issued-invoice.js
@@ -0,0 +1,90 @@
+/**
+ * iDoklad: dohledani vydane faktury podle cisla dokladu.
+ *
+ * Sluzba: https://services.csbot.cz/apps/idoklad
+ * Endpoint: GET /issued-invoices?filter=(DocumentNumber~eq~2024001)
+ *
+ * Tohle je vzor **predvalidace**: skript nic nemeni, jen odpovi, jestli doklad
+ * existuje. Vystup `found` je pak to, na co se ve strome vetvi podminka.
+ * Stejny princip jako akce "Dohledat firmu" u CRM, viz documentation/06-tickety.md.
+ *
+ * Proto taky nenalezena faktura NENI chyba. Kdyby skript spadl, nesla by
+ * postavit vetev "doklad neznam, zaloz ho".
+ */
+
+export const manifest = {
+ id: 'idoklad.find-issued-invoice',
+ name: 'Najít vydanou fakturu',
+ description:
+ 'Zjistí, jestli v iDokladu existuje vydaná faktura s daným číslem dokladu. ' +
+ 'Nic nezakládá ani nemění. Podle výsledku se strom větví.',
+
+ inputs: [
+ {
+ id: 'documentNumber',
+ label: 'Číslo dokladu',
+ type: 'string',
+ required: true,
+ hint: 'Číslo, jak je na faktuře, například 2024001.',
+ },
+ ],
+
+ outputs: [
+ { id: 'found', label: 'Faktura nalezena', type: 'boolean', required: true },
+ { id: 'invoiceId', label: 'ID faktury', type: 'number', required: false },
+ { id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: false },
+ { id: 'dateOfMaturity', label: 'Datum splatnosti', type: 'date', required: false },
+ { id: 'isPaid', label: 'Je uhrazená', type: 'boolean', required: false },
+ {
+ id: 'ambiguous',
+ label: 'Odpovídá víc faktur',
+ type: 'boolean',
+ required: true,
+ },
+ ],
+};
+
+export async function run(inputs, ctx) {
+ const { unwrap, pick, num, bool, date } = ctx.util;
+
+ const { body } = await ctx.http.get('/issued-invoices', {
+ query: {
+ // Tvar filtru je dany iDokladem: (Pole~operator~hodnota)
+ filter: `(DocumentNumber~eq~${inputs.documentNumber})`,
+ filtertype: 'and',
+ // Dva staci: jeden na vysledek, druhy na zjisteni, ze neni jednoznacny.
+ pageSize: 2,
+ },
+ });
+
+ const items = unwrap(body);
+ const list = Array.isArray(items) ? items : [];
+
+ if (list.length === 0) {
+ ctx.log(`Faktura ${inputs.documentNumber} v iDokladu není.`);
+ return {
+ found: false,
+ invoiceId: null,
+ totalWithVat: null,
+ dateOfMaturity: null,
+ isPaid: null,
+ ambiguous: false,
+ };
+ }
+
+ if (list.length > 1) {
+ // Neni to chyba, ale nekdo to ma vedet - cislo dokladu ma byt jednoznacne.
+ ctx.log(`Číslu dokladu ${inputs.documentNumber} odpovídá víc faktur, beru první.`);
+ }
+
+ const invoice = list[0];
+
+ return {
+ found: true,
+ invoiceId: num(pick(invoice, 'id')),
+ totalWithVat: num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')),
+ dateOfMaturity: date(pick(invoice, 'dateOfMaturity')),
+ isPaid: bool(pick(invoice, 'isPaid')),
+ ambiguous: list.length > 1,
+ };
+}
diff --git a/scripts/idoklad.get-issued-invoice.js b/scripts/idoklad.get-issued-invoice.js
new file mode 100644
index 0000000..5e173a5
--- /dev/null
+++ b/scripts/idoklad.get-issued-invoice.js
@@ -0,0 +1,76 @@
+/**
+ * iDoklad: nacteni vydane faktury podle ID.
+ *
+ * Sluzba: https://services.csbot.cz/apps/idoklad
+ * Endpoint: GET /issued-invoices/{id}
+ *
+ * Nejjednodussi tvar skriptu: jedno volani a prevod odpovedi na vystupy.
+ *
+ * iDoklad vraci pole s velkym pocatecnim pismenem a nekdy obaluje odpoved
+ * do `Data`. Proto `unwrap` a `pick` - nespoléhá se na presny tvar odpovedi,
+ * protoze ten se u cizich sluzeb meni bez ohlaseni.
+ */
+
+export const manifest = {
+ id: 'idoklad.get-issued-invoice',
+ name: 'Získat vydanou fakturu',
+ description:
+ 'Načte vydanou fakturu z iDokladu podle jejího ID. Používá se před rozhodnutím, ' +
+ 'co s ní dál, například jestli je už uhrazená.',
+
+ inputs: [
+ {
+ id: 'invoiceId',
+ label: 'ID faktury v iDokladu',
+ type: 'number',
+ required: true,
+ hint: 'Interní ID, ne číslo dokladu. Číslo dokladu umí dohledat akce Najít vydanou fakturu.',
+ },
+ ],
+
+ outputs: [
+ { id: 'invoiceId', label: 'ID faktury', type: 'number', required: true },
+ { id: 'documentNumber', label: 'Číslo dokladu', type: 'string', required: true },
+ { id: 'variableSymbol', label: 'Variabilní symbol', type: 'string', required: false },
+ { id: 'partnerId', label: 'ID odběratele', type: 'number', required: false },
+ { id: 'partnerName', label: 'Odběratel', type: 'string', required: false },
+ { id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: true },
+ { id: 'currencyId', label: 'ID měny', type: 'number', required: false },
+ { id: 'dateOfIssue', label: 'Datum vystavení', type: 'date', required: true },
+ { id: 'dateOfMaturity', label: 'Datum splatnosti', type: 'date', required: true },
+ { id: 'isPaid', label: 'Je uhrazená', type: 'boolean', required: true },
+ ],
+};
+
+export async function run(inputs, ctx) {
+ const { unwrap, pick, text, num, bool, date, need } = ctx.util;
+
+ const { body } = await ctx.http.get(`/issued-invoices/${inputs.invoiceId}`);
+ const invoice = unwrap(body);
+
+ if (!invoice || typeof invoice !== 'object') {
+ ctx.fail(`Faktura ${inputs.invoiceId} v iDokladu neexistuje.`);
+ }
+
+ // Odberatel muze byt jak plocha hodnota, tak vnoreny objekt partnera.
+ const partner = pick(invoice, 'partner', 'customer');
+ const partnerName =
+ text(pick(invoice, 'partnerName', 'customerName')) ??
+ text(pick(partner, 'companyName', 'name'));
+
+ return {
+ invoiceId: need(num(pick(invoice, 'id')), 'ID faktury'),
+ documentNumber: need(text(pick(invoice, 'documentNumber', 'number')), 'číslo dokladu'),
+ variableSymbol: text(pick(invoice, 'variableSymbol')),
+ partnerId: num(pick(invoice, 'partnerId')) ?? num(pick(partner, 'id')),
+ partnerName,
+ totalWithVat: need(
+ num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')),
+ 'celkovou částku',
+ ),
+ currencyId: num(pick(invoice, 'currencyId')),
+ dateOfIssue: need(date(pick(invoice, 'dateOfIssue')), 'datum vystavení'),
+ dateOfMaturity: need(date(pick(invoice, 'dateOfMaturity')), 'datum splatnosti'),
+ isPaid: bool(pick(invoice, 'isPaid')),
+ };
+}
diff --git a/scripts/idoklad.register-payment.js b/scripts/idoklad.register-payment.js
new file mode 100644
index 0000000..5ec9f47
--- /dev/null
+++ b/scripts/idoklad.register-payment.js
@@ -0,0 +1,98 @@
+/**
+ * iDoklad: zapsani uhrady k vydane fakture.
+ *
+ * Sluzba: https://services.csbot.cz/apps/idoklad
+ * Endpointy: GET /issued-payments/default/{invoiceId}, POST /issued-payments
+ *
+ * Vzor akce, ktera **neco meni**. U te zalezi na idempotenci: kdyz runtime krok
+ * zopakuje po timeoutu, nesmi vzniknout druha uhrada. Klic `ctx.idempotencyKey`
+ * je pro tentyz krok stejny pres vsechny pokusy a runtime ho posila v hlavicce
+ * `Idempotency-Key` automaticky, takze skript nemusi delat nic navic.
+ *
+ * Castka se necha prazdna pro plnou uhradu - vzor z iDokladu uz nese zbytek
+ * k zaplaceni, takze se nemusi pocitat tady.
+ */
+
+export const manifest = {
+ id: 'idoklad.register-payment',
+ name: 'Zapsat úhradu faktury',
+ description:
+ 'Zapíše k vydané faktuře úhradu. Bez zadané částky se použije zbytek ' +
+ 'k zaplacení podle iDokladu.',
+
+ inputs: [
+ { id: 'invoiceId', label: 'ID faktury v iDokladu', type: 'number', required: true },
+ {
+ id: 'amount',
+ label: 'Uhrazená částka',
+ type: 'number',
+ required: false,
+ hint: 'Nevyplněno = celý zbytek k zaplacení.',
+ },
+ {
+ id: 'dateOfPayment',
+ label: 'Datum úhrady',
+ type: 'date',
+ required: false,
+ hint: 'Nevyplněno = dnes.',
+ },
+ {
+ id: 'sendConfirmation',
+ label: 'Poslat potvrzení odběrateli',
+ type: 'boolean',
+ required: false,
+ default: false,
+ },
+ ],
+
+ outputs: [
+ { id: 'paymentId', label: 'ID úhrady', type: 'number', required: true },
+ { id: 'amount', label: 'Zapsaná částka', type: 'number', required: true },
+ { id: 'dateOfPayment', label: 'Datum úhrady', type: 'date', required: true },
+ ],
+};
+
+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;
+
+ if (inputs.amount !== null && inputs.amount <= 0) {
+ ctx.fail('Uhrazená částka musí být větší než nula.');
+ }
+
+ // Vzor nese zbytek k zaplaceni i vychozi zpusob platby.
+ const defaults = unwrap((await ctx.http.get(`/issued-payments/default/${inputs.invoiceId}`)).body);
+ if (!defaults || typeof defaults !== 'object') {
+ ctx.fail(`K faktuře ${inputs.invoiceId} nejde zapsat úhradu, iDoklad ji nezná.`);
+ }
+
+ const suggested = num(pick(defaults, 'paymentAmount'));
+ const amount = inputs.amount ?? suggested;
+ if (amount === null) {
+ ctx.fail('iDoklad nevrátil zbytek k zaplacení, zadejte částku ručně.');
+ }
+
+ const paidAt = inputs.dateOfPayment ? new Date(inputs.dateOfPayment) : new Date();
+
+ const payload = {
+ ...defaults,
+ invoiceId: inputs.invoiceId,
+ paymentAmount: amount,
+ dateOfPayment: isoDay(paidAt),
+ sendPaymentConfirmation: inputs.sendConfirmation ?? false,
+ };
+
+ const { body } = await ctx.http.post('/issued-payments', payload);
+ const payment = unwrap(body);
+
+ ctx.log(`K faktuře ${inputs.invoiceId} zapsána úhrada ${amount}.`);
+
+ return {
+ paymentId: need(num(pick(payment, 'id')), 'ID úhrady'),
+ amount: need(num(pick(payment, 'paymentAmount')) ?? amount, 'zapsanou částku'),
+ dateOfPayment: need(date(pick(payment, 'dateOfPayment')) ?? date(paidAt), 'datum úhrady'),
+ };
+}
diff --git a/scripts/idoklad.send-invoice-email.js b/scripts/idoklad.send-invoice-email.js
new file mode 100644
index 0000000..a093b20
--- /dev/null
+++ b/scripts/idoklad.send-invoice-email.js
@@ -0,0 +1,80 @@
+/**
+ * iDoklad: odeslani vydane faktury e-mailem.
+ *
+ * Sluzba: https://services.csbot.cz/apps/idoklad
+ * Endpoint: POST /mail/issued-invoices/send
+ *
+ * Vzor akce, u ktere **odpoved sluzby nic nevraci**. Vystup se proto sklada
+ * z toho, co skript posilal, ne z toho, co prislo zpatky. Bez toho by strom
+ * za timhle krokem nemel na cem stavet podminku.
+ *
+ * Zaroven je to vzor toho, jak nahradit jeden vstup dvema chovanimi: kdyz je
+ * vyplneny e-mail, posle se na nej. Kdyz neni, posle se na adresu odberatele
+ * vedenou v iDokladu.
+ */
+
+export const manifest = {
+ id: 'idoklad.send-invoice-email',
+ name: 'Odeslat fakturu e-mailem',
+ description:
+ 'Odešle vydanou fakturu e-mailem. Bez zadané adresy jde na e-mail ' +
+ 'odběratele vedený v iDokladu.',
+ timeoutMs: 30000,
+
+ inputs: [
+ { id: 'invoiceId', label: 'ID faktury v iDokladu', type: 'number', required: true },
+ {
+ id: 'email',
+ label: 'E-mail příjemce',
+ type: 'string',
+ required: false,
+ pattern: '^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$',
+ hint: 'Nevyplněno = adresa odběratele z iDokladu.',
+ },
+ { id: 'subject', label: 'Předmět', type: 'string', required: false },
+ { id: 'body', label: 'Text e-mailu', type: 'string', required: false, multiline: true },
+ {
+ id: 'sendAttachment',
+ label: 'Přiložit PDF faktury',
+ type: 'boolean',
+ required: false,
+ default: true,
+ },
+ {
+ id: 'sendToSelf',
+ label: 'Poslat kopii sobě',
+ type: 'boolean',
+ required: false,
+ default: false,
+ },
+ ],
+
+ outputs: [
+ { id: 'sent', label: 'Odesláno', type: 'boolean', required: true },
+ { id: 'recipient', label: 'Komu se odeslalo', type: 'string', required: true },
+ ],
+};
+
+export async function run(inputs, ctx) {
+ const toGivenAddress = Boolean(inputs.email);
+
+ const payload = {
+ documentId: inputs.invoiceId,
+ // Vsechna tri pole jsou u iDokladu povinna, i kdyz jsou nepravdiva.
+ sendToPartner: !toGivenAddress,
+ sendToAccountant: false,
+ sendToSelf: inputs.sendToSelf ?? false,
+ sendAttachment: inputs.sendAttachment ?? true,
+ ...(toGivenAddress ? { otherRecipients: [inputs.email] } : {}),
+ ...(inputs.subject ? { emailSubject: inputs.subject } : {}),
+ ...(inputs.body ? { emailBody: inputs.body } : {}),
+ };
+
+ // Nektere instance vraci 204 bez tela, jine 200 s potvrzenim. Obojí je uspech.
+ const { status } = await ctx.http.post('/mail/issued-invoices/send', payload);
+
+ const recipient = toGivenAddress ? String(inputs.email) : 'odběratel z iDokladu';
+ ctx.log(`Faktura ${inputs.invoiceId} odeslána (${recipient}), HTTP ${status}.`);
+
+ return { sent: true, recipient };
+}
diff --git a/src/config.ts b/src/config.ts
index c902381..5b2bf1f 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -8,9 +8,15 @@
*/
import { randomBytes } from 'node:crypto';
+import path from 'node:path';
const isProduction = process.env.NODE_ENV === 'production';
+function positiveNumber(value: string | undefined, fallback: number): number {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
+}
+
/**
* Tajny klic pro podpis tokenu.
*
@@ -68,6 +74,31 @@ export const config = {
* relativni tvar - nikdy se nehardcoduje produkcni domena.
*/
publicOrigin: (process.env.PUBLIC_ORIGIN ?? '').trim().replace(/\/+$/, ''),
+
+ // ------------------------------------------------------- skripty konektoru
+
+ /**
+ * Adresar se skripty konektoru. Relativne k adresari, ze ktereho aplikace
+ * bezi, aby to fungovalo v containeru (`/app/scripts`) i lokalne.
+ */
+ scriptsDir: path.resolve(process.env.SCRIPTS_DIR ?? path.join(process.cwd(), 'scripts')),
+ /**
+ * Zaklad adres napojenych sluzeb, napr. "https://services.csbot.cz/apps".
+ * Konkretni konektor lze presmerovat pres `_BASE_URL`.
+ * Nikdy se nehardcoduje do logiky, viz AGENTS.md.
+ */
+ servicesBaseUrl: (process.env.SERVICES_BASE_URL ?? 'https://services.csbot.cz/apps')
+ .trim()
+ .replace(/\/+$/, ''),
+ /** Strop na jeden beh skriptu, kdyz si ho manifest neurci sam. */
+ scriptTimeoutMs: positiveNumber(process.env.SCRIPT_TIMEOUT_MS, 15_000),
+ /** Vetsi odpoved cizi sluzby se zahodi, misto aby snedla pamet procesu. */
+ scriptMaxResponseBytes: positiveNumber(process.env.SCRIPT_MAX_RESPONSE_BYTES, 1_000_000),
+ /**
+ * Povoli skriptum volat na localhost a do privatnich rozsahu IP.
+ * Jen pro lokalni vyvoj, v nasazeni musi zustat vypnute.
+ */
+ allowPrivateTargets: process.env.ALLOW_PRIVATE_TARGETS === 'true',
};
/** Zaklad verejne adresy aplikace vcetne prefixu proxy. */
diff --git a/src/data/connectors.ts b/src/data/connectors.ts
index c530def..26bb742 100644
--- a/src/data/connectors.ts
+++ b/src/data/connectors.ts
@@ -89,6 +89,14 @@ export interface ConnectorOperation {
* 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/catalog.ts`, rucne se to nepise.
+ */
+ implementation?: 'script';
+ /** Ktery skript operaci obsluhuje. Vyplnene spolu s `implementation`. */
+ scriptId?: string;
}
export interface Connector {
@@ -1097,6 +1105,57 @@ export function findConnector(connectorId: string): Connector | undefined {
return connectors.find((c) => c.id === connectorId);
}
+// ------------------------------------------------------- akce ze skriptu
+
+/**
+ * Akce domerene ze skriptu konektoru. Plni to `src/scripts/registry.ts`
+ * pri kazdem nacteni skriptu.
+ *
+ * Je to prekryv, ne zapis do `connectors`. Dva duvody: staticky katalog
+ * zustane citelny a z operace jde poznat, odkud je (`implementation`).
+ *
+ * Prekryv je zamerne tady, ne ve zvlastnim modulu. Vsechno ostatni v aplikaci
+ * uz se pta pres `findOperation`, takze tim se skripty naraz objevi ve validaci
+ * stromu, ve vypoctu scope i v sablonach - bez toho, aby se to psalo trikrat.
+ */
+const scriptActions = new Map();
+
+/** Nahradi cely prekryv. Volani je idempotentni, poradi nezalezi. */
+export function setScriptActions(byConnector: Map): void {
+ scriptActions.clear();
+ for (const [connectorId, operations] of byConnector) {
+ scriptActions.set(connectorId, operations);
+ }
+}
+
+/**
+ * Akce konektoru vcetne tech ze skriptu.
+ * Kdyz skript nese ID operace, ktera uz v katalogu je, **skript vyhrava**.
+ * Staticky zapis je popis toho, co umime, skript je to, co se opravdu stane.
+ */
+export function actionsFor(connectorId: string): ConnectorOperation[] {
+ const connector = findConnector(connectorId);
+ if (!connector) return [];
+
+ const fromScripts = scriptActions.get(connectorId);
+ if (!fromScripts || fromScripts.length === 0) return connector.actions;
+
+ const replaced = new Set(fromScripts.map((operation) => operation.id));
+ return [
+ ...connector.actions.filter((action) => !replaced.has(action.id)),
+ ...fromScripts,
+ ].sort((a, b) => a.name.localeCompare(b.name, 'cs'));
+}
+
+/** Katalog pro portal. Nemodifikuje `connectors`, sklada nove objekty. */
+export function connectorCatalog(): Connector[] {
+ return connectors.map((connector) =>
+ scriptActions.has(connector.id)
+ ? { ...connector, actions: actionsFor(connector.id) }
+ : connector,
+ );
+}
+
/**
* Overi, ze konektor existuje a ma danou operaci pozadovaneho druhu.
* Pouziva se pri ukladani stromu, aby se do nej nedostaly neexistujici kroky.
@@ -1108,7 +1167,7 @@ export function findOperation(
): ConnectorOperation | undefined {
const connector = findConnector(connectorId);
if (!connector) return undefined;
- const pool = type === 'trigger' ? connector.triggers : connector.actions;
+ const pool = type === 'trigger' ? connector.triggers : actionsFor(connectorId);
return pool.find((op) => op.id === operationId);
}
diff --git a/src/index.ts b/src/index.ts
index fccd2c7..43a4c31 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -16,6 +16,7 @@ import { contactRouter } from './routes/contact.js';
import { dashboardRouter } from './routes/dashboard.js';
import { simulateRouter } from './routes/simulate.js';
import { webhookRouter } from './routes/webhook.js';
+import { ensureLoaded, scriptsDir } from './scripts/registry.js';
const here = path.dirname(fileURLToPath(import.meta.url));
/** Zbuildovana SPA. Vite ji zapisuje do dist/public, viz vite.config.ts. */
@@ -168,11 +169,19 @@ app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
});
});
+/**
+ * Skripty konektoru se nactou jeste pred prijimanim provozu, protoze doplnuji
+ * katalog a bez nich by prvni ulozeni stromu neznalo operace ze skriptu.
+ * `ensureLoaded` chyby polyka a loguje, takze start nemuze shodit (AGENTS.md).
+ */
+await ensureLoaded(true);
+
// Poslouchat na vsech rozhranich containeru, ne jen na localhost (AGENTS.md).
const server = app.listen(config.port, '0.0.0.0', () => {
console.info(`[start] csbot-prototype bezi na portu ${config.port}`);
console.info(`[start] ROOT_PATH: ${config.rootPath || '(neni nastaven)'}`);
console.info(`[start] health: ${config.rootPath}/health, docs: ${config.rootPath}/docs`);
+ console.info(`[start] skripty konektoru: ${scriptsDir()}`);
});
server.on('error', (err: NodeJS.ErrnoException) => {
diff --git a/src/openapi.ts b/src/openapi.ts
index 1378543..a31066f 100644
--- a/src/openapi.ts
+++ b/src/openapi.ts
@@ -26,6 +26,7 @@ export function buildOpenApiDocument() {
{ name: 'Dashboard', description: 'Data klientskeho portalu' },
{ name: 'Tickety', description: 'Pozadavky, jejich resitele a log prubehu' },
{ name: 'Automatizace', description: 'Sprava automatizaci a stromu akci' },
+ { name: 'Skripty', description: 'Vykonna cast konektoru: manifest, kod a zkusebni beh' },
{ name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' },
{ name: 'Webhook', description: 'Verejny prijem dat do automatizace' },
{ name: 'Kontakt', description: 'Poptavkovy formular z webu' },
@@ -98,6 +99,145 @@ export function buildOpenApiDocument() {
personId: { type: 'string', nullable: true },
},
},
+ ScriptField: {
+ type: 'object',
+ description:
+ 'Parametr skriptu. Stejny tvar pro vstup i vystup - kontrola je pak ' +
+ 'jedna funkce, ne dve skoro stejne.',
+ required: ['id', 'label', 'type', 'required'],
+ properties: {
+ id: {
+ type: 'string',
+ example: 'invoiceId',
+ description: 'Pouziva se v sablonach jako {{invoiceId}}.',
+ },
+ label: { type: 'string', example: 'ID faktury v iDokladu' },
+ type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
+ required: { type: 'boolean' },
+ hint: { type: 'string' },
+ options: {
+ type: 'array',
+ description: 'Vyber z hodnot. Jina hodnota neprojde kontrolou.',
+ items: {
+ type: 'object',
+ properties: { value: { type: 'string' }, label: { type: 'string' } },
+ },
+ },
+ pattern: { type: 'string', description: 'Jen u typu string.' },
+ multiline: { type: 'boolean', description: 'Jen u typu string.' },
+ default: { description: 'Dosadi se, kdyz hodnota chybi a parametr neni povinny.' },
+ },
+ },
+ ScriptManifest: {
+ type: 'object',
+ description: 'Co skript umi. Podle nej s nim umi pracovat strom automatizace.',
+ properties: {
+ id: {
+ type: 'string',
+ example: 'idoklad.get-issued-invoice',
+ description: 'Tvar konektor.operace. Nazev souboru musi byt .js.',
+ },
+ connectorId: { type: 'string', example: 'idoklad' },
+ operationId: { type: 'string', example: 'get-issued-invoice' },
+ name: { type: 'string', example: 'Získat vydanou fakturu' },
+ description: { type: 'string' },
+ inputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
+ outputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
+ timeoutMs: { type: 'integer', example: 15000 },
+ },
+ },
+ ScriptProblem: {
+ type: 'object',
+ description:
+ 'Rozbity skript. Nesmi shodit ostatni ani tise zmizet, proto se vraci sem.',
+ properties: {
+ file: { type: 'string', example: 'idoklad.get-issued-invoice.js' },
+ scriptId: { type: 'string', nullable: true },
+ message: { type: 'string' },
+ issues: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: { field: { type: 'string' }, message: { type: 'string' } },
+ },
+ },
+ },
+ },
+ ConnectionStatus: {
+ type: 'object',
+ description:
+ 'Stav napojeni konektoru. Hodnoty pristupovych udaju se nevraci nikdy, ' +
+ 'jen jmena promennych, ktere chybi.',
+ properties: {
+ connectorId: { type: 'string', example: 'idoklad' },
+ baseUrl: { type: 'string', example: 'https://services.csbot.cz/apps/idoklad' },
+ ready: { type: 'boolean' },
+ missing: {
+ type: 'array',
+ items: { type: 'string' },
+ example: ['IDOKLAD_CLIENT_SECRET'],
+ },
+ headers: { type: 'array', items: { type: 'string' }, example: ['X-ClientId'] },
+ },
+ },
+ ScriptRunResult: {
+ type: 'object',
+ description:
+ 'Vysledek behu skriptu. `retryable` rika, jestli ma smysl zkusit to znovu - ' +
+ 'timeout ano, spatny vstup ne.',
+ properties: {
+ ok: { type: 'boolean' },
+ scriptId: { type: 'string' },
+ outputs: {
+ type: 'object',
+ additionalProperties: true,
+ description: 'Prazdne, kdyz beh selhal.',
+ },
+ logs: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ at: { type: 'string', format: 'date-time' },
+ message: { type: 'string' },
+ detail: { type: 'string' },
+ },
+ },
+ },
+ durationMs: { type: 'integer' },
+ httpCalls: { type: 'integer' },
+ error: {
+ type: 'object',
+ nullable: true,
+ properties: {
+ kind: {
+ type: 'string',
+ enum: [
+ 'not_found',
+ 'config',
+ 'validation',
+ 'output',
+ 'retryable',
+ 'terminal',
+ 'timeout',
+ 'internal',
+ ],
+ },
+ message: { type: 'string' },
+ retryable: { type: 'boolean' },
+ status: { type: 'integer' },
+ detail: { type: 'string' },
+ issues: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: { field: { type: 'string' }, message: { type: 'string' } },
+ },
+ },
+ },
+ },
+ },
+ },
LoginRequest: {
type: 'object',
required: ['email', 'password'],
@@ -811,10 +951,176 @@ export function buildOpenApiDocument() {
get: {
tags: ['Automatizace'],
summary: 'Katalog konektoru',
+ description:
+ 'Operace, ktere obsluhuje skript, nesou `implementation: script` a `scriptId`, ' +
+ 'a maji skutecne `inputs` a `outputFields` z manifestu toho skriptu.',
security: [{ bearerAuth: [] }],
responses: { '200': { description: 'Konektory, kategorie a operatory podminek' } },
},
},
+ '/api/dashboard/scripts': {
+ get: {
+ tags: ['Skripty'],
+ summary: 'Seznam skriptu konektoru',
+ description:
+ 'Manifesty vsech nactenych skriptu, rozbite skripty v `problems` ' +
+ 'a stav napojeni v `connections`. Pristupove udaje se nikdy nevraci, ' +
+ 'jen jmena chybejicich environment variables.',
+ security: [{ bearerAuth: [] }],
+ responses: {
+ '200': {
+ description: 'Skripty, problemy a stav napojeni',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ items: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/ScriptManifest' },
+ },
+ problems: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/ScriptProblem' },
+ },
+ connections: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/ConnectionStatus' },
+ },
+ directory: { type: 'string', example: '/app/scripts' },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ '/api/dashboard/scripts/reload': {
+ post: {
+ tags: ['Skripty'],
+ summary: 'Znovu nacist skripty ze slozky',
+ description:
+ 'Skripty se nacitaji samy podle casu zmeny souboru. Tenhle endpoint ' +
+ 'to jen vynuti hned, bez cekani.',
+ security: [{ bearerAuth: [] }],
+ responses: {
+ '200': { description: 'Skripty po nacteni' },
+ '403': { description: 'Jen spravce platformy' },
+ },
+ },
+ },
+ '/api/dashboard/scripts/{id}': {
+ get: {
+ tags: ['Skripty'],
+ summary: 'Manifest a kod skriptu',
+ security: [{ bearerAuth: [] }],
+ parameters: [
+ {
+ name: 'id',
+ in: 'path',
+ required: true,
+ schema: { type: 'string' },
+ example: 'idoklad.get-issued-invoice',
+ },
+ ],
+ responses: {
+ '200': {
+ description: 'Kod se vraci vzdy. `manifest` je null, kdyz je skript rozbity.',
+ },
+ '400': { description: 'Neplatne ID skriptu' },
+ '404': { description: 'Skript neexistuje' },
+ },
+ },
+ put: {
+ tags: ['Skripty'],
+ summary: 'Ulozit kod skriptu',
+ description:
+ 'Nejdriv se kod nacte a overi, az pak prepise soubor. Rozbita uprava ' +
+ 'se neulozi a puvodni skript dal funguje.',
+ security: [{ bearerAuth: [] }],
+ parameters: [
+ {
+ name: 'id',
+ in: 'path',
+ required: true,
+ schema: { type: 'string' },
+ example: 'idoklad.get-issued-invoice',
+ },
+ ],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ required: ['code'],
+ properties: {
+ code: {
+ type: 'string',
+ description: 'Cely obsah souboru vcetne exportu manifest a run.',
+ },
+ },
+ },
+ },
+ },
+ },
+ responses: {
+ '200': { description: 'Ulozeno, vraci se overeny manifest' },
+ '400': {
+ description: 'Kod nebo manifest neprosel, v `issues` je co opravit',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
+ },
+ '403': { description: 'Jen spravce platformy' },
+ },
+ },
+ },
+ '/api/dashboard/scripts/{id}/test': {
+ post: {
+ tags: ['Skripty'],
+ summary: 'Zkusebni spusteni skriptu',
+ description:
+ 'POZOR: vola opravdovou sluzbu. Vystavena faktura opravdu vznikne. ' +
+ 'Chyba skriptu neni chyba API, vraci se 200 s popisem v `error`.',
+ security: [{ bearerAuth: [] }],
+ parameters: [
+ {
+ name: 'id',
+ in: 'path',
+ required: true,
+ schema: { type: 'string' },
+ example: 'idoklad.get-issued-invoice',
+ },
+ ],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ inputs: {
+ type: 'object',
+ additionalProperties: true,
+ example: { invoiceId: 12345 },
+ },
+ },
+ },
+ },
+ },
+ },
+ responses: {
+ '200': {
+ description: 'Vysledek behu',
+ content: {
+ 'application/json': { schema: { $ref: '#/components/schemas/ScriptRunResult' } },
+ },
+ },
+ '400': { description: 'Neplatne ID nebo vstupy' },
+ '403': { description: 'Jen spravce platformy' },
+ },
+ },
+ },
'/api/dashboard/automations': {
get: {
tags: ['Automatizace'],
diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts
index 29173b1..d7fe115 100644
--- a/src/routes/dashboard.ts
+++ b/src/routes/dashboard.ts
@@ -18,8 +18,8 @@ import {
} from '../data/automationStore.js';
import { operatorAllowedForType, operatorsByType } from '../data/conditions.js';
import {
+ connectorCatalog,
connectorCategories,
- connectors,
findOperation,
providedFieldsFor,
} from '../data/connectors.js';
@@ -47,6 +47,7 @@ import {
type TicketStatus,
} from '../data/ticketStore.js';
import { requireAuth } from '../middleware/auth.js';
+import { scriptsRouter } from './scripts.js';
import { streamRouter } from './stream.js';
export const dashboardRouter = Router();
@@ -341,12 +342,20 @@ dashboardRouter.post('/tickets/:id/comment', (req, res) => {
// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo.
dashboardRouter.use('/stream', streamRouter);
+// Skripty konektoru. Taky pred obecnymi cestami.
+dashboardRouter.use('/scripts', scriptsRouter);
+
// ---------------------------------------------------------------- konektory
+/**
+ * Katalog uz neni jen staticky seznam. Operace, ktere obsluhuje skript, se
+ * domeruji z jeho manifestu, takze builder vidi skutecne vstupy a vystupy.
+ * Podrobnosti v `src/scripts/catalog.ts`.
+ */
dashboardRouter.get('/connectors', (_req, res) => {
res.json({
categories: connectorCategories,
- items: connectors,
+ items: connectorCatalog(),
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
// a jakou zakladni adresu ukazat u webhooku.
operatorsByType,
diff --git a/src/routes/scripts.ts b/src/routes/scripts.ts
new file mode 100644
index 0000000..0e535c1
--- /dev/null
+++ b/src/routes/scripts.ts
@@ -0,0 +1,145 @@
+/**
+ * Sprava skriptu konektoru z portalu.
+ *
+ * Cteni smi kazdy prihlaseny - builder potrebuje vedet, co skript umi.
+ * Uprava a spusteni smi jen spravce platformy. Uprava skriptu meni chovani
+ * vseho, co ho pouziva, takze to neni pravo, ktere se dava vedle prava
+ * zakladat tickety (viz documentation/09-navrh-rozsireni.md, bod 9).
+ */
+
+import { Router } from 'express';
+import { z } from 'zod';
+import { requirePlatformAdmin } from '../middleware/auth.js';
+import { connectionStatus, connectorsWithAuth } from '../scripts/connections.js';
+import { connectorIdOf, operationIdOf } from '../scripts/manifest.js';
+import {
+ ensureLoaded,
+ getScript,
+ isValidScriptId,
+ listManifests,
+ readSource,
+ saveSource,
+ scriptProblems,
+ scriptsDir,
+} from '../scripts/registry.js';
+import { runScript } from '../scripts/runner.js';
+
+export const scriptsRouter = Router();
+
+/** Manifest plus to, co si klient nema dopocitavat sam. */
+async function scriptSummaries() {
+ const manifests = await listManifests();
+ return manifests.map((manifest) => ({
+ ...manifest,
+ connectorId: connectorIdOf(manifest.id),
+ operationId: operationIdOf(manifest.id),
+ }));
+}
+
+scriptsRouter.get('/', async (_req, res) => {
+ const [items, problems] = await Promise.all([scriptSummaries(), scriptProblems()]);
+
+ res.json({
+ items,
+ problems,
+ connections: connectorsWithAuth().map(connectionStatus),
+ /** Kam se soubory ukladaji. Kdo ma na server pristup, upravi je i rucne. */
+ directory: scriptsDir(),
+ });
+});
+
+scriptsRouter.post('/reload', requirePlatformAdmin, async (_req, res) => {
+ await ensureLoaded(true);
+ const [items, problems] = await Promise.all([scriptSummaries(), scriptProblems()]);
+ res.json({ items, problems });
+});
+
+scriptsRouter.get('/:id', async (req, res) => {
+ const { id } = req.params;
+ if (!isValidScriptId(id)) {
+ return res.status(400).json({ error: 'validation_error', message: 'Neplatné ID skriptu.' });
+ }
+
+ const [script, source] = await Promise.all([getScript(id), readSource(id)]);
+ if (source === null) {
+ return res.status(404).json({ error: 'not_found', message: 'Skript neexistuje.' });
+ }
+
+ // Manifest muze chybet, kdyz je soubor rozbity. Kod se vrati vzdy, aby slo opravit.
+ const problems = await scriptProblems();
+ return res.json({
+ id,
+ connectorId: connectorIdOf(id),
+ operationId: operationIdOf(id),
+ manifest: script?.manifest ?? null,
+ code: source,
+ problem: problems.find((problem) => problem.scriptId === id) ?? null,
+ connection: connectionStatus(connectorIdOf(id)),
+ });
+});
+
+const saveSchema = z.object({
+ code: z.string().min(1, 'Kód skriptu nesmí být prázdný.'),
+});
+
+scriptsRouter.put('/:id', requirePlatformAdmin, async (req, res) => {
+ const { id } = req.params;
+ if (!isValidScriptId(id)) {
+ return res.status(400).json({
+ error: 'validation_error',
+ message: 'Neplatné ID skriptu. Povolený tvar je konektor.operace.',
+ });
+ }
+
+ 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.',
+ });
+ }
+
+ const result = await saveSource(id, parsed.data.code);
+ if (!result.ok) {
+ // Rozbita uprava se neulozi a puvodni skript dal funguje.
+ return res.status(400).json({
+ error: 'validation_error',
+ message: result.message,
+ issues: result.issues ?? [],
+ });
+ }
+
+ return res.json({ id, manifest: result.manifest });
+});
+
+const testSchema = z.object({
+ inputs: z.record(z.unknown()).default({}),
+});
+
+/**
+ * Zkusebni spusteni.
+ *
+ * Vola opravdovou sluzbu, tedy vystavena faktura opravdu vznikne. Zamerne:
+ * test, ktery volani predstira, nerekne nic o tom, jestli skript funguje.
+ * Portal na to upozorni pred stiskem.
+ */
+scriptsRouter.post('/:id/test', requirePlatformAdmin, async (req, res) => {
+ const { id } = req.params;
+ if (!isValidScriptId(id)) {
+ return res.status(400).json({ error: 'validation_error', message: 'Neplatné ID skriptu.' });
+ }
+
+ 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ů.',
+ });
+ }
+
+ const result = await runScript(id, parsed.data.inputs);
+ console.info(`[scripts] test ${id} uzivatelem ${req.user!.email}: ${result.ok ? 'ok' : 'chyba'}`);
+
+ // Chyba skriptu neni chyba API. Vysledek se vraci vzdy s 200 vcetne popisu.
+ return res.json(result);
+});
diff --git a/src/scripts/connections.ts b/src/scripts/connections.ts
new file mode 100644
index 0000000..ddc37d3
--- /dev/null
+++ b/src/scripts/connections.ts
@@ -0,0 +1,139 @@
+/**
+ * Napojeni konektoru: kam se vola a cim se to autorizuje.
+ *
+ * Zamerne oddelene od skriptu. Skript rika "GET /issued-invoices/12",
+ * napojeni rika, na jake adrese to je a jaké hlavicky se pridaji. Skript se
+ * tim k pristupovym udajum vubec nedostane.
+ *
+ * Tady je zatim jedno napojeni na konektor, sestavene z environment variables.
+ * Cilovy stav je napojeni za firmu v databazi, viz documentation/09, bod 9.
+ * Az to bude, prepise se vnitrek `resolveConnection` a nic dalsiho.
+ */
+
+import { config } from '../config.js';
+
+export interface ConnectorAuthSpec {
+ /** Hlavicka -> jmeno environment variable, ze ktere se plni. */
+ headers: Record;
+ /** Ktere hlavicky musi byt vyplnene, aby se dalo volat. */
+ required: string[];
+ /** Necitliva nastaveni pristupna skriptu jako `ctx.config`. */
+ config?: Record;
+}
+
+/**
+ * Autorizace jednotlivych konektoru.
+ *
+ * iDoklad podle https://services.csbot.cz/apps/idoklad/docs: sluzba prijima
+ * `X-ClientId` a `X-ClientSecret`, `X-ApplicationId` jen partnerske aplikace.
+ * OAuth tok resi ta sluzba, my posilame jen tyto hlavicky.
+ */
+const authSpecs: Record = {
+ idoklad: {
+ headers: {
+ 'X-ClientId': 'IDOKLAD_CLIENT_ID',
+ 'X-ClientSecret': 'IDOKLAD_CLIENT_SECRET',
+ 'X-ApplicationId': 'IDOKLAD_APPLICATION_ID',
+ },
+ required: ['X-ClientId', 'X-ClientSecret'],
+ config: { language: 'IDOKLAD_LANGUAGE' },
+ },
+};
+
+export interface ResolvedConnection {
+ connectorId: string;
+ name: string;
+ baseUrl: string;
+ /** Vcetne tajemstvi. Nikdy neposilat na klienta ani do logu. */
+ headers: Record;
+ /** Necitliva cast, skript ji vidi jako `ctx.config`. */
+ config: Record;
+ /** false = chybi pristupove udaje, volat nema smysl. */
+ ready: boolean;
+ /** Jmena environment variables, ktere chybi. */
+ missing: string[];
+}
+
+/** `search-console` -> `SEARCH_CONSOLE`, aby slo skladat jmena promennych. */
+function envPrefix(connectorId: string): string {
+ return connectorId.replace(/-/g, '_').toUpperCase();
+}
+
+function readEnv(name: string): string | undefined {
+ const value = process.env[name];
+ return value !== undefined && value.trim() !== '' ? value.trim() : undefined;
+}
+
+/**
+ * Vychozi adresa sluzby. Verejna domena se nikdy nehardcoduje do logiky,
+ * bere se z `SERVICES_BASE_URL` (viz AGENTS.md).
+ * Jednotlive konektory lze presmerovat pres `_BASE_URL`.
+ */
+function resolveBaseUrl(connectorId: string): string {
+ return (
+ readEnv(`${envPrefix(connectorId)}_BASE_URL`) ?? `${config.servicesBaseUrl}/${connectorId}`
+ );
+}
+
+export function resolveConnection(connectorId: string): ResolvedConnection {
+ const spec = authSpecs[connectorId];
+ const headers: Record = {};
+ const scriptConfig: Record = {};
+ const missing: string[] = [];
+
+ for (const [header, envName] of Object.entries(spec?.headers ?? {})) {
+ const value = readEnv(envName);
+ if (value !== undefined) headers[header] = value;
+ else if (spec?.required.includes(header)) missing.push(envName);
+ }
+
+ for (const [key, envName] of Object.entries(spec?.config ?? {})) {
+ const value = readEnv(envName);
+ if (value !== undefined) scriptConfig[key] = value;
+ }
+
+ return {
+ connectorId,
+ name: `${connectorId} (z environment variables)`,
+ baseUrl: resolveBaseUrl(connectorId),
+ headers,
+ config: scriptConfig,
+ ready: missing.length === 0,
+ missing,
+ };
+}
+
+/** Hodnoty, ktere se musi zredigovat, nez cokoliv skonci v logu. */
+export function connectionSecrets(connection: ResolvedConnection): string[] {
+ return Object.values(connection.headers);
+}
+
+export interface ConnectionStatus {
+ connectorId: string;
+ baseUrl: string;
+ ready: boolean;
+ /** Jen jmena chybejicich promennych, nikdy hodnoty. */
+ missing: string[];
+ /** Ktere hlavicky jsou vyplnene. Hodnoty se nevraci. */
+ headers: string[];
+}
+
+/**
+ * Stav napojeni pro portal. Vraci se **jen jmena**, nikdy hodnoty -
+ * secrets se z beznych endpointu nevraci (AGENTS.md).
+ */
+export function connectionStatus(connectorId: string): ConnectionStatus {
+ const connection = resolveConnection(connectorId);
+ return {
+ connectorId,
+ baseUrl: connection.baseUrl,
+ ready: connection.ready,
+ missing: connection.missing,
+ headers: Object.keys(connection.headers),
+ };
+}
+
+/** Ktere konektory maji popsanou autorizaci. */
+export function connectorsWithAuth(): string[] {
+ return Object.keys(authSpecs);
+}
diff --git a/src/scripts/http.ts b/src/scripts/http.ts
new file mode 100644
index 0000000..355235b
--- /dev/null
+++ b/src/scripts/http.ts
@@ -0,0 +1,201 @@
+/**
+ * HTTP klient, ktery dostane skript jako `ctx.http`.
+ *
+ * Delá ctyri veci, ktere by jinak resil kazdy skript znovu a spatne:
+ * - sklada adresu z napojeni, takze skript zna jen cestu,
+ * - pridava autorizacni hlavicky, takze skript nezna tajemstvi,
+ * - rozlisuje opakovatelnou chybu od koncove,
+ * - loguje volani bez hlavicek a bez tel, jen metodu, cestu a kod.
+ */
+
+import { config } from '../config.js';
+import type { ResolvedConnection } from './connections.js';
+import { ScriptError, type ScriptHttp, type ScriptHttpOptions, type ScriptHttpResponse } from './types.js';
+import { describe } from './util.js';
+
+/** Kody, u kterych ma smysl opakovat. Zbytek je koncova chyba. */
+const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
+
+/** Chyby spojeni od Node. Vsechny jsou docasne. */
+const retryableCodes = new Set([
+ 'ECONNRESET',
+ 'ECONNREFUSED',
+ 'ETIMEDOUT',
+ 'EAI_AGAIN',
+ 'EPIPE',
+ 'ENOTFOUND',
+ 'UND_ERR_SOCKET',
+ 'UND_ERR_CONNECT_TIMEOUT',
+]);
+
+const privateHostPattern =
+ /^(localhost|127\.|0\.0\.0\.0$|10\.|192\.168\.|169\.254\.|::1$|\[::1\]$|172\.(1[6-9]|2\d|3[01])\.)/i;
+
+function joinUrl(baseUrl: string, path: string): string {
+ const base = baseUrl.replace(/\/+$/, '');
+ const suffix = path.startsWith('/') ? path : `/${path}`;
+ return `${base}${suffix}`;
+}
+
+/**
+ * Adresu skladame my z napojeni, ale az budou napojeni nastavovat klienti,
+ * je tohle to jedine, co brani volani na vnitrni sit. Proto tady, ne pozdeji.
+ */
+function assertAllowedUrl(url: URL): void {
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
+ throw new ScriptError('config', `Adresa ${url.protocol} není povolená, jen http a https.`);
+ }
+ if (!config.allowPrivateTargets && privateHostPattern.test(url.hostname)) {
+ throw new ScriptError(
+ 'config',
+ `Adresa ${url.hostname} míří do vnitřní sítě. Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.`,
+ );
+ }
+}
+
+function buildUrl(connection: ResolvedConnection, path: string, options?: ScriptHttpOptions): URL {
+ let url: URL;
+ try {
+ url = new URL(joinUrl(connection.baseUrl, path));
+ } catch {
+ throw new ScriptError('config', `Neplatná adresa: ${joinUrl(connection.baseUrl, path)}`);
+ }
+
+ for (const [key, value] of Object.entries(options?.query ?? {})) {
+ if (value === undefined || value === null || value === '') continue;
+ url.searchParams.set(key, String(value));
+ }
+
+ assertAllowedUrl(url);
+ return url;
+}
+
+function statusError(status: number, url: URL, detail: string): ScriptError {
+ const where = `${url.pathname} vrátilo HTTP ${status}`;
+ if (retryableStatuses.has(status)) {
+ return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.`, {
+ status,
+ detail,
+ });
+ }
+ if (status === 401 || status === 403) {
+ return new ScriptError('config', `Přístup zamítnut: ${where}. Zkontrolujte přístupové údaje.`, {
+ status,
+ detail,
+ });
+ }
+ if (status === 404) {
+ return new ScriptError('terminal', `Záznam nenalezen: ${where}.`, { status, detail });
+ }
+ return new ScriptError('terminal', `Volání selhalo: ${where}.`, { status, detail });
+}
+
+function transportError(err: unknown, url: URL): ScriptError {
+ if (err instanceof ScriptError) return err;
+
+ const code =
+ err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).code) : '';
+ const name = err instanceof Error ? err.name : '';
+ const message = err instanceof Error ? err.message : String(err);
+
+ if (name === 'AbortError' || name === 'TimeoutError') {
+ return new ScriptError('timeout', `Volání ${url.pathname} nedoběhlo v limitu.`, { cause: err });
+ }
+ if (retryableCodes.has(code)) {
+ return new ScriptError('retryable', `Nepodařilo se spojit se službou (${code}).`, { cause: err });
+ }
+ return new ScriptError('retryable', `Volání ${url.pathname} selhalo: ${message}`, { cause: err });
+}
+
+export interface CreateHttpOptions {
+ connection: ResolvedConnection;
+ signal: AbortSignal;
+ idempotencyKey: string;
+ /** Zredigovana verze textu, aby se tajemstvi nedostalo do logu. */
+ redact: (value: string) => string;
+ log: (message: string, detail?: unknown) => void;
+ onCall: () => void;
+}
+
+export function createHttp(options: CreateHttpOptions): ScriptHttp {
+ const { connection, signal, idempotencyKey, redact, log, onCall } = options;
+
+ async function request(
+ method: string,
+ path: string,
+ body: unknown,
+ httpOptions?: ScriptHttpOptions,
+ ): Promise> {
+ const url = buildUrl(connection, path, httpOptions);
+ const hasBody = body !== undefined && method !== 'GET' && method !== 'DELETE';
+ const startedAt = Date.now();
+ onCall();
+
+ let response: Response;
+ try {
+ response = await fetch(url, {
+ method,
+ signal,
+ headers: {
+ Accept: 'application/json',
+ // Druhy pokus tehoz kroku nesmi vystavit druhou fakturu.
+ 'Idempotency-Key': idempotencyKey,
+ ...connection.headers,
+ ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
+ ...httpOptions?.headers,
+ },
+ body: hasBody ? JSON.stringify(body) : undefined,
+ });
+ } catch (err) {
+ throw transportError(err, url);
+ }
+
+ const declaredSize = Number(response.headers.get('content-length') ?? 0);
+ if (declaredSize > config.scriptMaxResponseBytes) {
+ throw new ScriptError(
+ 'terminal',
+ `Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
+ { status: response.status },
+ );
+ }
+
+ const raw = await response.text();
+ if (raw.length > config.scriptMaxResponseBytes) {
+ throw new ScriptError(
+ 'terminal',
+ `Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
+ { status: response.status },
+ );
+ }
+
+ const isJson = response.headers.get('content-type')?.includes('json') ?? false;
+ let parsed: unknown = raw;
+ if (isJson && raw.length > 0) {
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ throw new ScriptError('terminal', `Odpověď ${url.pathname} není platný JSON.`, {
+ status: response.status,
+ detail: redact(describe(raw, 300)),
+ });
+ }
+ }
+
+ log(`${method} ${url.pathname} -> ${response.status} (${Date.now() - startedAt} ms)`);
+
+ const allowed = httpOptions?.allowStatus ?? [];
+ if (!response.ok && !allowed.includes(response.status)) {
+ throw statusError(response.status, url, redact(describe(parsed, 400)));
+ }
+
+ return { status: response.status, body: parsed as T };
+ }
+
+ return {
+ get: (path, httpOptions) => request('GET', path, undefined, httpOptions),
+ post: (path, body, httpOptions) => request('POST', path, body, httpOptions),
+ patch: (path, body, httpOptions) => request('PATCH', path, body, httpOptions),
+ put: (path, body, httpOptions) => request('PUT', path, body, httpOptions),
+ del: (path, httpOptions) => request('DELETE', path, undefined, httpOptions),
+ };
+}
diff --git a/src/scripts/manifest.ts b/src/scripts/manifest.ts
new file mode 100644
index 0000000..8123d4e
--- /dev/null
+++ b/src/scripts/manifest.ts
@@ -0,0 +1,102 @@
+/**
+ * Prevody kolem manifestu skriptu.
+ *
+ * Skript je zdroj pravdy o svych parametrech. Katalog konektoru z nich jen
+ * odvozuje to, co potrebuje builder. Kdyby se pole psala na dvou mistech,
+ * jedno by se casem rozeslo a strom by nabizel parametr, ktery skript nezna.
+ */
+
+import type { ConnectorOperation, ProvidedField } from '../data/connectors.js';
+import type { OperationField } from '../data/connectors.js';
+import { scriptManifestSchema, type FieldIssue, type ScriptField, type ScriptManifest } from './types.js';
+
+/** `idoklad.get-issued-invoice` -> `idoklad` */
+export function connectorIdOf(scriptId: string): string {
+ return scriptId.slice(0, scriptId.indexOf('.'));
+}
+
+/** `idoklad.get-issued-invoice` -> `get-issued-invoice` */
+export function operationIdOf(scriptId: string): string {
+ return scriptId.slice(scriptId.indexOf('.') + 1);
+}
+
+export type ParseResult =
+ | { ok: true; manifest: ScriptManifest }
+ | { ok: false; issues: FieldIssue[] };
+
+/**
+ * Overi manifest a zaroven to, ze odpovida nazvu souboru.
+ * Nesoulad nazvu je chyba, ne varovani - jinak by se skript ulozil pod jednim
+ * jmenem a nacetl pod druhym.
+ */
+export function parseManifest(raw: unknown, expectedId: string): ParseResult {
+ const parsed = scriptManifestSchema.safeParse(raw);
+
+ if (!parsed.success) {
+ return {
+ ok: false,
+ issues: parsed.error.issues.map((issue) => ({
+ field: issue.path.join('.') || 'manifest',
+ message: issue.message,
+ })),
+ };
+ }
+
+ if (parsed.data.id !== expectedId) {
+ return {
+ ok: false,
+ issues: [
+ {
+ field: 'id',
+ message: `Manifest má id "${parsed.data.id}", ale soubor se jmenuje "${expectedId}.js". Musí být stejné.`,
+ },
+ ],
+ };
+ }
+
+ return { ok: true, manifest: parsed.data };
+}
+
+/**
+ * Nastavitelne pole akce pro builder.
+ * `kind` se odvodi z manifestu, aby se nemuselo psat dvakrat.
+ */
+function toOperationField(field: ScriptField): OperationField {
+ return {
+ id: field.id,
+ label: field.label,
+ kind: field.options ? 'choice' : field.multiline ? 'longtext' : 'text',
+ required: field.required,
+ ...(field.options ? { options: field.options } : {}),
+ ...(field.hint ? { hint: field.hint } : {}),
+ };
+}
+
+/**
+ * Vystup kroku pro strom.
+ *
+ * `id` nese prefix konektoru, protoze se na nej odkazuji podminky v ulozenych
+ * stromech a musi byt jednoznacne. `name` je to, co se pise do sablony -
+ * stejne rozdeleni jako u ostatnich konektoru, viz documentation/06-tickety.md.
+ */
+function toProvidedField(scriptId: string, field: ScriptField): ProvidedField {
+ return {
+ id: `${connectorIdOf(scriptId)}.${field.id}`,
+ name: field.id,
+ type: field.type,
+ required: field.required,
+ };
+}
+
+/** Operace katalogu odvozena ze skriptu. Tohle vidi builder. */
+export function toConnectorOperation(manifest: ScriptManifest): ConnectorOperation {
+ return {
+ id: operationIdOf(manifest.id),
+ name: manifest.name,
+ description: manifest.description,
+ inputs: manifest.inputs.map(toOperationField),
+ outputFields: manifest.outputs.map((field) => toProvidedField(manifest.id, field)),
+ implementation: 'script',
+ scriptId: manifest.id,
+ };
+}
diff --git a/src/scripts/registry.ts b/src/scripts/registry.ts
new file mode 100644
index 0000000..54978ab
--- /dev/null
+++ b/src/scripts/registry.ts
@@ -0,0 +1,318 @@
+/**
+ * Nacitani skriptu ze souboru.
+ *
+ * Myslenka: skript jde vytvorit nebo upravit v rozhrani i rucne v souboru
+ * a **nic se kvuli tomu neotaci**. Registr proto sleduje cas zmeny souboru
+ * a pri zmene ho nacte znovu. Nazev souboru je `.js`, takze mezi souborem
+ * a operaci v katalogu neni zadna mapa, ktera by mohla lhat.
+ *
+ * Soubory jsou zamerne obycejny JavaScript, ne TypeScript. TypeScript by se
+ * musel prelozit, a to je presne to otaceni, ktere tady nema byt.
+ *
+ * Rozbity skript nesmi shodit ostatni. Zapise se do `problems()` a portal ho
+ * ukaze - zadna ticha selhani.
+ */
+
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { config } from '../config.js';
+import { connectors, setScriptActions, type ConnectorOperation } from '../data/connectors.js';
+import { connectorIdOf, parseManifest, toConnectorOperation } from './manifest.js';
+import type { FieldIssue, ScriptContext, ScriptManifest, ScriptValues } from './types.js';
+
+export type ScriptRunFn = (
+ inputs: ScriptValues,
+ ctx: ScriptContext,
+) => Promise | unknown;
+
+export interface LoadedScript {
+ manifest: ScriptManifest;
+ run: ScriptRunFn;
+ file: string;
+ mtimeMs: number;
+}
+
+export interface ScriptProblem {
+ /** Nazev souboru, ne cela cesta - cesta na serveru nikomu nic nerekne. */
+ file: string;
+ scriptId: string | null;
+ message: string;
+ issues?: FieldIssue[];
+}
+
+/** ID smi byt jen tohle. Chrani zapis i cteni proti vyskoku z adresare. */
+const idPattern = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/;
+
+const scripts = new Map();
+const problems = new Map();
+
+/** Nez se znovu prohleda adresar. Bez toho by se stat volal pri kazdem dotazu. */
+const RESCAN_MS = 1000;
+let lastScanAt = 0;
+let scanning: Promise | null = null;
+
+export function scriptsDir(): string {
+ return config.scriptsDir;
+}
+
+export function isValidScriptId(id: string): boolean {
+ return idPattern.test(id);
+}
+
+function fileFor(id: string): string {
+ if (!isValidScriptId(id)) throw new Error(`Neplatné ID skriptu: ${id}`);
+ return path.join(scriptsDir(), `${id}.js`);
+}
+
+function idFor(fileName: string): string {
+ return fileName.replace(/\.js$/, '');
+}
+
+function problemMessage(err: unknown): string {
+ if (err instanceof Error) return err.message;
+ return String(err);
+}
+
+/**
+ * Nacte jeden soubor. Query `?v=` je nutna - bez ni si Node drzi prvni verzi
+ * modulu v cache a uprava souboru by se nikdy neprojevila.
+ */
+async function importScript(
+ file: string,
+ mtimeMs: number,
+ expectedId: string,
+): Promise {
+ const fileName = path.basename(file);
+ const url = `${pathToFileURL(file).href}?v=${mtimeMs}`;
+
+ let module: { manifest?: unknown; run?: unknown };
+ try {
+ module = (await import(url)) as { manifest?: unknown; run?: unknown };
+ } catch (err) {
+ return { file: fileName, scriptId: expectedId, message: `Soubor se nepodařilo načíst: ${problemMessage(err)}` };
+ }
+
+ if (typeof module.run !== 'function') {
+ return {
+ file: fileName,
+ scriptId: expectedId,
+ message: 'Soubor musí exportovat funkci run(inputs, ctx).',
+ };
+ }
+
+ const parsed = parseManifest(module.manifest, expectedId);
+ if (!parsed.ok) {
+ return {
+ file: fileName,
+ scriptId: expectedId,
+ message: 'Manifest není platný.',
+ issues: parsed.issues,
+ };
+ }
+
+ return {
+ manifest: parsed.manifest,
+ run: module.run as ScriptRunFn,
+ file: fileName,
+ mtimeMs,
+ };
+}
+
+function isProblem(value: LoadedScript | ScriptProblem): value is ScriptProblem {
+ return 'message' in value;
+}
+
+async function scan(): Promise {
+ const dir = scriptsDir();
+
+ let entries: string[];
+ try {
+ entries = await fs.readdir(dir);
+ } catch (err) {
+ // Chybejici adresar neni chyba aplikace, jen nejsou zadne skripty.
+ console.warn(`[scripts] adresar ${dir} nelze precist: ${problemMessage(err)}`);
+ scripts.clear();
+ problems.clear();
+ return;
+ }
+
+ // Soubory od podtrzitka jsou pomocne, nejsou to skripty.
+ const files = entries.filter((name) => name.endsWith('.js') && !name.startsWith('_'));
+ const seen = new Set();
+
+ for (const fileName of files) {
+ const id = idFor(fileName);
+ seen.add(id);
+ const file = path.join(dir, fileName);
+
+ if (!isValidScriptId(id)) {
+ problems.set(id, {
+ file: fileName,
+ scriptId: null,
+ message: 'Název souboru musí mít tvar konektor.operace.js, jen malá písmena a pomlčky.',
+ });
+ continue;
+ }
+
+ let mtimeMs: number;
+ try {
+ mtimeMs = (await fs.stat(file)).mtimeMs;
+ } catch (err) {
+ problems.set(id, { file: fileName, scriptId: id, message: problemMessage(err) });
+ continue;
+ }
+
+ const cached = scripts.get(id);
+ if (cached && cached.mtimeMs === mtimeMs) {
+ problems.delete(id);
+ continue;
+ }
+
+ const loaded = await importScript(file, mtimeMs, id);
+ if (isProblem(loaded)) {
+ // Rozbita uprava nesmi zahodit posledni funkcni verzi v pameti.
+ problems.set(id, loaded);
+ console.error(`[scripts] ${fileName}: ${loaded.message}`);
+ continue;
+ }
+
+ scripts.set(id, loaded);
+ problems.delete(id);
+ console.info(`[scripts] nacten ${id} (${loaded.manifest.name})`);
+ }
+
+ for (const id of [...scripts.keys()]) {
+ if (!seen.has(id)) {
+ scripts.delete(id);
+ console.info(`[scripts] ${id} zmizel z adresare`);
+ }
+ }
+ for (const id of [...problems.keys()]) {
+ if (!seen.has(id)) problems.delete(id);
+ }
+
+ publishToCatalog();
+}
+
+/**
+ * Prenese nactene skripty do katalogu konektoru.
+ *
+ * Tim se naraz objevi ve validaci stromu, ve vypoctu toho, co je v kterem kroku
+ * videt, i v sablonach - vsechno se uz pta pres `findOperation`.
+ */
+function publishToCatalog(): void {
+ const byConnector = new Map();
+
+ for (const script of scripts.values()) {
+ const connectorId = connectorIdOf(script.manifest.id);
+ if (!connectors.some((connector) => connector.id === connectorId)) {
+ problems.set(script.manifest.id, {
+ file: script.file,
+ scriptId: script.manifest.id,
+ message: `Konektor ${connectorId} v katalogu neexistuje. Skript se nedá použít ve stromu.`,
+ });
+ continue;
+ }
+ const list = byConnector.get(connectorId) ?? [];
+ list.push(toConnectorOperation(script.manifest));
+ byConnector.set(connectorId, list);
+ }
+
+ setScriptActions(byConnector);
+}
+
+/** Prohleda adresar, nejvyse jednou za RESCAN_MS. Soubezne volani se sdili. */
+export async function ensureLoaded(force = false): Promise {
+ if (!force && Date.now() - lastScanAt < RESCAN_MS) return;
+ if (scanning) return scanning;
+
+ scanning = scan()
+ .catch((err: unknown) => {
+ console.error('[scripts] nacitani selhalo:', err);
+ })
+ .finally(() => {
+ lastScanAt = Date.now();
+ scanning = null;
+ });
+
+ return scanning;
+}
+
+export async function listScripts(): Promise {
+ await ensureLoaded();
+ return [...scripts.values()].sort((a, b) => a.manifest.id.localeCompare(b.manifest.id));
+}
+
+export async function listManifests(): Promise {
+ return (await listScripts()).map((script) => script.manifest);
+}
+
+export async function getScript(id: string): Promise {
+ await ensureLoaded();
+ return scripts.get(id);
+}
+
+export async function scriptProblems(): Promise {
+ await ensureLoaded();
+ return [...problems.values()].sort((a, b) => a.file.localeCompare(b.file));
+}
+
+export async function readSource(id: string): Promise {
+ if (!isValidScriptId(id)) return null;
+ try {
+ return await fs.readFile(fileFor(id), 'utf8');
+ } catch {
+ return null;
+ }
+}
+
+export type SaveResult =
+ | { ok: true; manifest: ScriptManifest }
+ | { ok: false; message: string; issues?: FieldIssue[] };
+
+/**
+ * Ulozi kod skriptu.
+ *
+ * Poradi je zamerne: nejdriv se zapise do docasneho souboru, ten se nacte
+ * a overi, a az pak prepise puvodni. Rozbita uprava tim nikdy neshodi
+ * skript, ktery fungoval.
+ */
+export async function saveSource(id: string, code: string): Promise {
+ if (!isValidScriptId(id)) {
+ return { ok: false, message: 'Neplatné ID skriptu. Povolený tvar je konektor.operace.' };
+ }
+ if (code.trim().length === 0) {
+ return { ok: false, message: 'Kód skriptu nesmí být prázdný.' };
+ }
+
+ const target = fileFor(id);
+ // Cas v nazvu, aby si Node nenacetl predchozi pokus z cache.
+ const temp = path.join(scriptsDir(), `_tmp.${id}.${Date.now()}.js`);
+
+ try {
+ await fs.mkdir(scriptsDir(), { recursive: true });
+ await fs.writeFile(temp, code, 'utf8');
+
+ const mtimeMs = (await fs.stat(temp)).mtimeMs;
+ const loaded = await importScript(temp, mtimeMs, id);
+
+ if (isProblem(loaded)) {
+ return { ok: false, message: loaded.message, issues: loaded.issues };
+ }
+
+ await fs.rename(temp, target);
+ // Nova mtime, at si registr vezme skutecny soubor a ne docasny.
+ const finalMtime = (await fs.stat(target)).mtimeMs;
+ scripts.set(id, { ...loaded, file: `${id}.js`, mtimeMs: finalMtime });
+ problems.delete(id);
+ publishToCatalog();
+ console.info(`[scripts] ulozen ${id}`);
+
+ return { ok: true, manifest: loaded.manifest };
+ } catch (err) {
+ return { ok: false, message: `Uložení selhalo: ${problemMessage(err)}` };
+ } finally {
+ await fs.rm(temp, { force: true }).catch(() => undefined);
+ }
+}
diff --git a/src/scripts/runner.ts b/src/scripts/runner.ts
new file mode 100644
index 0000000..1f7e123
--- /dev/null
+++ b/src/scripts/runner.ts
@@ -0,0 +1,229 @@
+/**
+ * Spusteni jednoho skriptu.
+ *
+ * Runner nikdy nevyhodi vyjimku. Vzdy vrati vysledek, ve kterem je bud vystup,
+ * nebo popsana chyba vcetne toho, jestli ma smysl zkusit to znovu. Az bude
+ * existovat runtime automatizaci, bude tohle jeho jediny vstupni bod na kroku,
+ * takze fronta nemusi resit nic z toho, co je tady.
+ *
+ * Poradi je vzdy stejne: overit vstup, spustit, overit vystup. Neoverený
+ * vystup by znamenal, ze strom veri parametru, ktery neexistuje.
+ */
+
+import { createHash } from 'node:crypto';
+import { config } from '../config.js';
+import { connectionSecrets, resolveConnection } from './connections.js';
+import { createHttp } from './http.js';
+import { connectorIdOf } from './manifest.js';
+import { getScript } from './registry.js';
+import {
+ isRetryableKind,
+ ScriptError,
+ type ScriptContext,
+ type ScriptErrorKind,
+ type ScriptLogEntry,
+ type ScriptRunResult,
+} from './types.js';
+import { createRedactor, describe, scriptUtil } from './util.js';
+import { validateValues } from './values.js';
+
+export interface RunScriptOptions {
+ /**
+ * Stabilni pres vsechny pokusy tehoz kroku. Kdyz chybi, dopocita se
+ * ze skriptu a vstupu - dva stejne pokusy tak dostanou stejny klic.
+ */
+ idempotencyKey?: string;
+ timeoutMs?: number;
+}
+
+function defaultIdempotencyKey(scriptId: string, inputs: unknown): string {
+ const hash = createHash('sha256')
+ .update(scriptId)
+ .update(JSON.stringify(inputs) ?? '')
+ .digest('base64url');
+ return `${scriptId}:${hash.slice(0, 24)}`;
+}
+
+function toRunError(
+ err: unknown,
+ redact: (value: string) => string,
+): { kind: ScriptErrorKind; message: string; status?: number; detail?: string } {
+ if (err instanceof ScriptError) {
+ return {
+ kind: err.kind,
+ message: redact(err.message),
+ ...(err.status !== undefined ? { status: err.status } : {}),
+ ...(err.detail !== undefined ? { detail: redact(err.detail) } : {}),
+ };
+ }
+
+ const message = err instanceof Error ? err.message : String(err);
+ // Neocekavana vyjimka ve skriptu. Opakovat ji nema smysl, kod se sam nespravi.
+ return { kind: 'internal', message: redact(`Skript selhal: ${message}`) };
+}
+
+export async function runScript(
+ scriptId: string,
+ rawInputs: unknown,
+ options: RunScriptOptions = {},
+): Promise {
+ const startedAt = Date.now();
+ const logs: ScriptLogEntry[] = [];
+ let httpCalls = 0;
+
+ const finish = (
+ partial: Pick,
+ ): ScriptRunResult => ({
+ scriptId,
+ logs,
+ durationMs: Date.now() - startedAt,
+ httpCalls,
+ ...partial,
+ });
+
+ const script = await getScript(scriptId);
+ if (!script) {
+ return finish({
+ ok: false,
+ outputs: {},
+ error: {
+ kind: 'not_found',
+ message: `Skript ${scriptId} neexistuje nebo se nepodařilo načíst.`,
+ retryable: false,
+ },
+ });
+ }
+
+ const { manifest } = script;
+ const connection = resolveConnection(connectorIdOf(scriptId));
+ const redact = createRedactor(connectionSecrets(connection));
+
+ if (!connection.ready) {
+ return finish({
+ ok: false,
+ outputs: {},
+ error: {
+ kind: 'config',
+ message: `Napojení na ${connection.connectorId} není nastavené. Chybí: ${connection.missing.join(', ')}.`,
+ retryable: false,
+ },
+ });
+ }
+
+ const validatedInputs = validateValues(manifest.inputs, rawInputs, {
+ logLabel: `${scriptId} vstup`,
+ });
+ if (!validatedInputs.ok) {
+ return finish({
+ ok: false,
+ outputs: {},
+ error: {
+ kind: 'validation',
+ message: 'Vstupní parametry nejsou v pořádku.',
+ retryable: false,
+ issues: validatedInputs.issues,
+ },
+ });
+ }
+
+ const idempotencyKey =
+ options.idempotencyKey ?? defaultIdempotencyKey(scriptId, validatedInputs.values);
+
+ const timeoutMs = options.timeoutMs ?? manifest.timeoutMs ?? config.scriptTimeoutMs;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+
+ const log = (message: string, detail?: unknown) => {
+ // Log muze cist klient, proto vzdy pres redakci a vzdy zkraceny.
+ logs.push({
+ at: new Date().toISOString(),
+ message: redact(describe(message, 300)),
+ ...(detail !== undefined ? { detail: redact(describe(detail)) } : {}),
+ });
+ };
+
+ const ctx: ScriptContext = {
+ http: createHttp({
+ connection,
+ signal: controller.signal,
+ idempotencyKey,
+ redact,
+ log,
+ onCall: () => {
+ httpCalls += 1;
+ },
+ }),
+ util: scriptUtil,
+ log,
+ config: Object.freeze({ ...connection.config }),
+ idempotencyKey,
+ fail(message, detail) {
+ throw new ScriptError('terminal', message, { detail: describe(detail) });
+ },
+ retry(message, detail) {
+ throw new ScriptError('retryable', message, { detail: describe(detail) });
+ },
+ };
+
+ let returned: unknown;
+ try {
+ returned = await script.run(validatedInputs.values, ctx);
+ } catch (err) {
+ const error = toRunError(err, redact);
+ console.warn(`[scripts] ${scriptId} selhal (${error.kind}): ${error.message}`);
+ return finish({
+ ok: false,
+ outputs: {},
+ error: { ...error, retryable: isRetryableKind(error.kind) },
+ });
+ } finally {
+ clearTimeout(timer);
+ }
+
+ if (controller.signal.aborted) {
+ return finish({
+ ok: false,
+ outputs: {},
+ error: {
+ kind: 'timeout',
+ message: `Skript nedoběhl v limitu ${timeoutMs} ms.`,
+ retryable: true,
+ },
+ });
+ }
+
+ const validatedOutputs = validateValues(manifest.outputs, returned, {
+ logLabel: `${scriptId} výstup`,
+ });
+ if (!validatedOutputs.ok) {
+ // Chyba skriptu, ne uzivatele. Strom by jinak veril parametru, ktery nedosel.
+ console.error(
+ `[scripts] ${scriptId} nevratil deklarovane vystupy: ${validatedOutputs.issues
+ .map((issue) => issue.message)
+ .join(' ')}`,
+ );
+ return finish({
+ ok: false,
+ outputs: {},
+ error: {
+ kind: 'output',
+ message: 'Skript nevrátil parametry, které má v manifestu.',
+ retryable: false,
+ issues: validatedOutputs.issues,
+ },
+ });
+ }
+
+ return finish({ ok: true, outputs: validatedOutputs.values, error: null });
+}
+
+/** Vysledek jako jeden radek do logu ticketu. Az bude runtime, pouzije tohle. */
+export function summarizeRun(result: ScriptRunResult): string {
+ if (result.ok) {
+ const pairs = Object.entries(result.outputs)
+ .map(([key, value]) => `${key}=${value === null ? '-' : String(value)}`)
+ .join(', ');
+ return `${result.scriptId} ok za ${result.durationMs} ms${pairs ? ` (${pairs})` : ''}`;
+ }
+ return `${result.scriptId} selhal: ${result.error?.message ?? 'neznámá chyba'}`;
+}
diff --git a/src/scripts/types.ts b/src/scripts/types.ts
new file mode 100644
index 0000000..ea1b5a1
--- /dev/null
+++ b/src/scripts/types.ts
@@ -0,0 +1,271 @@
+/**
+ * Co je skript konektoru.
+ *
+ * Skript je jeden soubor, ktery nese dve veci: **manifest** (jak se jmenuje,
+ * co potrebuje na vstupu, co vraci na vystupu) a **kod**, ktery to udela.
+ * Diky manifestu s nim umi pracovat strom automatizace, aniz by o jeho kodu
+ * cokoliv vedel.
+ *
+ * Zdroj pravdy o tvaru manifestu je zod schema tady v tomhle souboru. Typy
+ * se z nej odvozuji, aby nebyl na dvou mistech a jednou se nerozesel.
+ *
+ * Souvisejici navrh: documentation/09-navrh-rozsireni.md, bod 9.
+ */
+
+import { z } from 'zod';
+
+// ------------------------------------------------------------------- hodnoty
+
+/** Skript pracuje jen s temito hodnotami. Zadne objekty ani pole. */
+export type ScriptValue = string | number | boolean | null;
+export type ScriptValues = Record;
+
+// -------------------------------------------------------------------- schema
+
+const fieldTypeSchema = z.enum(['string', 'number', 'boolean', 'date']);
+
+/**
+ * Jeden parametr, vstupni nebo vystupni. Zamerne je to jeden typ pro obe
+ * strany - validace je pak taky jedna funkce, ne dve skoro stejne.
+ *
+ * `id` se pouziva v sablonach jako `{{id}}`, proto smi obsahovat jen to,
+ * co jde napsat bez preklepu.
+ */
+export const scriptFieldSchema = z
+ .object({
+ id: z
+ .string()
+ .regex(
+ /^[A-Za-z][A-Za-z0-9_]*$/,
+ 'ID parametru musí začínat písmenem a obsahovat jen písmena, číslice a podtržítko.',
+ ),
+ label: z.string().min(1, 'Popis parametru nesmí být prázdný.'),
+ type: fieldTypeSchema,
+ required: z.boolean(),
+ /** Napoveda pod polem v builderu. */
+ hint: z.string().optional(),
+ /** Vyber z hodnot. Jina hodnota neprojde validaci. */
+ options: z
+ .array(z.object({ value: z.string(), label: z.string() }))
+ .min(1)
+ .optional(),
+ /** Jen u typu string: dalsi kontrola regularnim vyrazem. */
+ pattern: z.string().optional(),
+ /** Jen u typu string: pole na vic radku. Builder ho vykresli jako longtext. */
+ multiline: z.boolean().optional(),
+ /** Dosadi se, kdyz hodnota chybi a parametr neni povinny. */
+ default: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
+ })
+ .strict();
+
+export type ScriptField = z.infer;
+
+/**
+ * Manifest skriptu.
+ *
+ * `id` ma tvar `.`, napriklad `idoklad.get-issued-invoice`.
+ * Z nej se dopocita, do ktereho konektoru operace patri, takze se to nepise
+ * dvakrat. Nazev souboru musi byt `.js`.
+ *
+ * `.strict()` je zamer: preklep v nazvu klice (`outputFileds`) se ma ohlasit,
+ * ne tise ignorovat.
+ */
+export const scriptManifestSchema = z
+ .object({
+ id: z
+ .string()
+ .regex(
+ /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/,
+ 'ID skriptu musí mít tvar konektor.operace, například idoklad.get-issued-invoice.',
+ ),
+ name: z.string().min(1, 'Název skriptu nesmí být prázdný.'),
+ description: z.string().min(1, 'Popis skriptu nesmí být prázdný.'),
+ inputs: z.array(scriptFieldSchema).default([]),
+ outputs: z.array(scriptFieldSchema).default([]),
+ /** Strop na jeden beh. Kdyz chybi, pouzije se hodnota z konfigurace. */
+ timeoutMs: z.number().int().min(1000).max(120_000).optional(),
+ })
+ .strict()
+ .superRefine((manifest, ctx) => {
+ for (const key of ['inputs', 'outputs'] as const) {
+ const seen = new Set();
+ for (const field of manifest[key]) {
+ if (seen.has(field.id)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: [key],
+ message: `Parametr ${field.id} je uveden dvakrát.`,
+ });
+ }
+ seen.add(field.id);
+ }
+ }
+ });
+
+export type ScriptManifest = z.infer;
+
+// --------------------------------------------------------------------- chyby
+
+/**
+ * Druh selhani. Rozdeleni na `retryable` a `terminal` je to podstatne:
+ * timeout nebo 503 ma smysl zkusit znovu, chyba ve vstupu nebo 403 ne.
+ * Opakovat koncovou chybu jen vypali kvotu u cizi sluzby.
+ */
+export type ScriptErrorKind =
+ | 'not_found'
+ | 'config'
+ | 'validation'
+ | 'output'
+ | 'retryable'
+ | 'terminal'
+ | 'timeout'
+ | 'internal';
+
+const retryableKinds: ScriptErrorKind[] = ['retryable', 'timeout'];
+
+export function isRetryableKind(kind: ScriptErrorKind): boolean {
+ return retryableKinds.includes(kind);
+}
+
+export class ScriptError extends Error {
+ readonly kind: ScriptErrorKind;
+ /** HTTP kod cizi sluzby, kdyz chyba prisla z volani. */
+ readonly status?: number;
+ /** Kratky detail k zobrazeni. Uz zredigovany, bez tajemstvi. */
+ readonly detail?: string;
+
+ constructor(
+ kind: ScriptErrorKind,
+ message: string,
+ options: { status?: number; detail?: string; cause?: unknown } = {},
+ ) {
+ super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
+ this.name = 'ScriptError';
+ this.kind = kind;
+ this.status = options.status;
+ this.detail = options.detail;
+ }
+}
+
+// ------------------------------------------------------------------- kontext
+
+export interface ScriptHttpResponse {
+ status: number;
+ body: T;
+}
+
+export interface ScriptHttpOptions {
+ query?: Record;
+ headers?: Record;
+ /** Kody, ktere se nemaji brat jako chyba. Vychozi je 2xx. */
+ allowStatus?: number[];
+}
+
+/**
+ * HTTP klient predany skriptu. Adresu a autorizaci doplnuje runtime podle
+ * napojeni, takze **skript se k pristupovym udajum nedostane**.
+ */
+export interface ScriptHttp {
+ get(path: string, options?: ScriptHttpOptions): Promise>;
+ post(
+ path: string,
+ body?: unknown,
+ options?: ScriptHttpOptions,
+ ): Promise>;
+ patch(
+ path: string,
+ body?: unknown,
+ options?: ScriptHttpOptions,
+ ): Promise>;
+ put(
+ path: string,
+ body?: unknown,
+ options?: ScriptHttpOptions,
+ ): Promise>;
+ del(path: string, options?: ScriptHttpOptions): Promise>;
+}
+
+/**
+ * Pomocne funkce. Jsou na kontextu, ne v importu, ze dvou duvodu: skript
+ * nemusi resit relativni cesty a stejny podpis bude fungovat i pozdeji
+ * v sandboxu, kde zadny import neni.
+ */
+export interface ScriptUtil {
+ /** Rozbali obalku odpovedi, tedy `{ Data: ... }` i `{ data: ... }`. */
+ unwrap(body: unknown): T;
+ /** Prvni existujici pole bez ohledu na velka a mala pismena. */
+ pick(source: unknown, ...names: string[]): unknown;
+ /** Prvni prvek pole, nebo null. */
+ first(value: unknown): T | null;
+ text(value: unknown, fallback?: string | null): string | null;
+ num(value: unknown, fallback?: number | null): number | null;
+ bool(value: unknown): boolean;
+ /** Datum jako ISO retezec, nebo null. */
+ date(value: unknown): string | null;
+ /** Zaokrouhli na dane desetinne misto. Uctuje se v halerich. */
+ round(value: number, decimals?: number): number;
+ /**
+ * Vrati hodnotu, nebo skonci chybou s citelnou zpravou.
+ * Pro povinne vystupy: kdyz je cizi odpoved jina, nez skript ceka, ma se to
+ * poznat hned a s nazvem pole, ne az na chybejicim parametru ve strome.
+ */
+ need(value: T | null | undefined, label: string): T;
+}
+
+export interface ScriptContext {
+ http: ScriptHttp;
+ util: ScriptUtil;
+ /** Zapise radek do logu behu. Nikdy sem nedavat pristupove udaje. */
+ log(message: string, detail?: unknown): void;
+ /** Necitliva cast nastaveni napojeni. */
+ config: Readonly>;
+ /**
+ * Stabilni pres vsechny pokusy tehoz kroku. Predava se cizim sluzbam jako
+ * `Idempotency-Key`, aby druhy pokus nevystavil druhou fakturu.
+ */
+ idempotencyKey: string;
+ /** Koncova chyba, neopakuje se. Typicky nesmyslny vstup nebo 404 od sluzby. */
+ fail(message: string, detail?: unknown): never;
+ /** Opakovatelna chyba. Typicky vypadek nebo docasna nedostupnost. */
+ retry(message: string, detail?: unknown): never;
+}
+
+/** Co soubor skriptu exportuje. */
+export interface ScriptModule {
+ manifest: unknown;
+ run: (inputs: ScriptValues, ctx: ScriptContext) => Promise | unknown;
+}
+
+// -------------------------------------------------------------------- vysledek
+
+export interface ScriptLogEntry {
+ at: string;
+ message: string;
+ detail?: string;
+}
+
+export interface ScriptRunError {
+ kind: ScriptErrorKind;
+ message: string;
+ retryable: boolean;
+ status?: number;
+ detail?: string;
+ /** Vyplnene jen u chyb ve vstupu nebo vystupu. */
+ issues?: FieldIssue[];
+}
+
+export interface FieldIssue {
+ field: string;
+ message: string;
+}
+
+export interface ScriptRunResult {
+ ok: boolean;
+ scriptId: string;
+ /** Prazdne, kdyz beh selhal. */
+ outputs: ScriptValues;
+ logs: ScriptLogEntry[];
+ durationMs: number;
+ httpCalls: number;
+ error: ScriptRunError | null;
+}
diff --git a/src/scripts/util.ts b/src/scripts/util.ts
new file mode 100644
index 0000000..c1b4d76
--- /dev/null
+++ b/src/scripts/util.ts
@@ -0,0 +1,127 @@
+/**
+ * Pomocne funkce predane skriptu jako `ctx.util`, plus redakce tajemstvi.
+ *
+ * Duvod, proc to neni v kazdem skriptu znovu: cizi API vraci pokazde jinak.
+ * iDoklad pouziva velka pocatecni pismena a nekde obaluje odpoved do `Data`,
+ * jine sluzby ne. Bez `unwrap` a `pick` by kazdy skript resil totez a jeden
+ * z nich by to resil spatne.
+ */
+
+import { ScriptError, type ScriptUtil } from './types.js';
+
+/** Rozbali obalku odpovedi. `{ Data: x }` i `{ data: x }` vrati `x`. */
+function unwrap(body: unknown): T {
+ if (body === null || typeof body !== 'object') return body as T;
+ const record = body as Record;
+ if ('Data' in record) return record.Data as T;
+ if ('data' in record) return record.data as T;
+ return body as T;
+}
+
+/**
+ * Prvni existujici pole bez ohledu na velikost pismen.
+ * `pick(invoice, 'documentNumber')` najde `DocumentNumber` i `documentNumber`.
+ */
+function pick(source: unknown, ...names: string[]): unknown {
+ if (source === null || typeof source !== 'object') return undefined;
+ const record = source as Record;
+
+ for (const name of names) {
+ if (record[name] !== undefined) return record[name];
+ }
+
+ const lowered = new Map();
+ for (const [key, value] of Object.entries(record)) lowered.set(key.toLowerCase(), value);
+ for (const name of names) {
+ const value = lowered.get(name.toLowerCase());
+ if (value !== undefined) return value;
+ }
+ return undefined;
+}
+
+function first(value: unknown): T | null {
+ if (Array.isArray(value)) return (value[0] as T) ?? null;
+ const unwrapped = unwrap(value);
+ if (Array.isArray(unwrapped)) return (unwrapped[0] as T) ?? null;
+ return null;
+}
+
+function text(value: unknown, fallback: string | null = null): string | null {
+ if (value === undefined || value === null) return fallback;
+ if (typeof value === 'object') return fallback;
+ const result = String(value).trim();
+ return result === '' ? fallback : result;
+}
+
+function num(value: unknown, fallback: number | null = null): number | null {
+ if (value === undefined || value === null || value === '') return fallback;
+ const parsed = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+function bool(value: unknown): boolean {
+ if (typeof value === 'boolean') return value;
+ const lowered = String(value ?? '').trim().toLowerCase();
+ return lowered === 'true' || lowered === '1' || lowered === 'yes' || lowered === 'ano';
+}
+
+function date(value: unknown): string | null {
+ if (value === undefined || value === null || value === '') return null;
+ const parsed = Date.parse(value instanceof Date ? value.toISOString() : String(value));
+ return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
+}
+
+function round(value: number, decimals = 2): number {
+ const factor = 10 ** decimals;
+ return Math.round(value * factor) / factor;
+}
+
+function need(value: T | null | undefined, label: string): T {
+ if (value === undefined || value === null || value === '') {
+ throw new ScriptError('output', `Odpověď služby neobsahuje ${label}.`);
+ }
+ return value;
+}
+
+export const scriptUtil: ScriptUtil = { unwrap, pick, first, text, num, bool, date, round, need };
+
+// ------------------------------------------------------------------- redakce
+
+/**
+ * Nahradi tajne hodnoty hvezdickami.
+ *
+ * Neni to kosmetika. Log ticketu ukazuje, co sluzba vratila, a cizi API rado
+ * vraci prijaty token v chybove zprave. Bez redakce by tajemstvi skoncilo
+ * v logu, ktery se navic zobrazuje klientovi.
+ */
+export function createRedactor(secrets: Array): (value: string) => string {
+ // Kratke hodnoty se neredigují - nahradit "1" hvezdickami by rozbilo cely text.
+ const values = secrets
+ .filter((value): value is string => typeof value === 'string' && value.length >= 6)
+ .sort((a, b) => b.length - a.length);
+
+ if (values.length === 0) return (value) => value;
+
+ return (value: string) => {
+ let result = value;
+ for (const secret of values) result = result.split(secret).join('***');
+ return result;
+ };
+}
+
+/** Zkrati text na danou delku, aby jeden log nezabral megabajt. */
+export function truncate(value: string, max = 600): string {
+ if (value.length <= max) return value;
+ return `${value.slice(0, max)} (zkráceno, celkem ${value.length} znaků)`;
+}
+
+/** Bezpecne prevede cokoliv na kratky text do logu. */
+export function describe(value: unknown, max = 600): string {
+ if (value === undefined) return '';
+ if (typeof value === 'string') return truncate(value, max);
+ try {
+ return truncate(JSON.stringify(value) ?? String(value), max);
+ } catch {
+ return truncate(String(value), max);
+ }
+}
diff --git a/src/scripts/values.ts b/src/scripts/values.ts
new file mode 100644
index 0000000..0d5a569
--- /dev/null
+++ b/src/scripts/values.ts
@@ -0,0 +1,138 @@
+/**
+ * Kontrola parametru skriptu.
+ *
+ * Jedna funkce pro vstup i vystup. Kdyby to byly dve, jedna by se casem
+ * opravila a druha ne, a strom by pak veril vystupu, ktery nikdo neoveril.
+ *
+ * Pravidla:
+ * - povinny parametr bez hodnoty je chyba, ne prazdny retezec,
+ * - nepovinny parametr bez hodnoty dostane `default`, jinak `null`,
+ * - hodnota se prevede na deklarovany typ, kdyz to jde bez hadani,
+ * - parametr, ktery v manifestu neni, se zahodi a zaloguje.
+ */
+
+import type { FieldIssue, ScriptField, ScriptValue, ScriptValues } from './types.js';
+
+export type ValidationResult =
+ | { ok: true; values: ScriptValues }
+ | { ok: false; issues: FieldIssue[] };
+
+/** Retezce, ktere lidi i sluzby pouzivaji pro ano a ne. */
+const truthy = new Set(['true', '1', 'yes', 'y', 'ano', 'on']);
+const falsy = new Set(['false', '0', 'no', 'n', 'ne', 'off']);
+
+function isMissing(value: unknown): boolean {
+ return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
+}
+
+/**
+ * Prevede jednu hodnotu na deklarovany typ.
+ * Vraci bud hodnotu, nebo text chyby - nikdy nehada.
+ */
+function coerce(field: ScriptField, raw: unknown): { value: ScriptValue } | { error: string } {
+ switch (field.type) {
+ case 'string': {
+ if (typeof raw === 'object') return { error: 'Očekává se text, přišel objekt.' };
+ const text = field.multiline ? String(raw) : String(raw).trim();
+ if (field.pattern) {
+ let regex: RegExp;
+ try {
+ regex = new RegExp(field.pattern);
+ } catch {
+ return { error: `Manifest má neplatný pattern: ${field.pattern}` };
+ }
+ if (!regex.test(text)) return { error: `Hodnota neodpovídá tvaru ${field.pattern}.` };
+ }
+ return { value: text };
+ }
+
+ case 'number': {
+ if (typeof raw === 'boolean') return { error: 'Očekává se číslo, přišlo ano/ne.' };
+ // Ceska desetinna carka je bezna, nema smysl na ni padat.
+ const text = typeof raw === 'string' ? raw.trim().replace(',', '.') : raw;
+ const num = typeof text === 'number' ? text : Number(text);
+ if (!Number.isFinite(num)) return { error: `"${String(raw)}" není číslo.` };
+ return { value: num };
+ }
+
+ case 'boolean': {
+ if (typeof raw === 'boolean') return { value: raw };
+ const text = String(raw).trim().toLowerCase();
+ if (truthy.has(text)) return { value: true };
+ if (falsy.has(text)) return { value: false };
+ return { error: `"${String(raw)}" není ano ani ne.` };
+ }
+
+ case 'date': {
+ const text = raw instanceof Date ? raw.toISOString() : String(raw).trim();
+ const parsed = Date.parse(text);
+ if (Number.isNaN(parsed)) return { error: `"${text}" není platné datum.` };
+ return { value: new Date(parsed).toISOString() };
+ }
+
+ default: {
+ // Vetev je nedosazitelna, dokud FieldType nema dalsi hodnotu.
+ return { error: 'Neznámý typ parametru.' };
+ }
+ }
+}
+
+export interface ValidateOptions {
+ /** Kam se zapisuje varovani o parametrech navic. */
+ logLabel: string;
+}
+
+/**
+ * Overi a prevede sadu hodnot proti deklaraci parametru.
+ * Vraci vsechny chyby najednou, ne jen prvni - uzivatel ma opravit vse.
+ */
+export function validateValues(
+ fields: ScriptField[],
+ raw: unknown,
+ options: ValidateOptions,
+): ValidationResult {
+ const source: Record =
+ raw !== null && typeof raw === 'object' ? (raw as Record) : {};
+
+ const issues: FieldIssue[] = [];
+ const values: ScriptValues = {};
+
+ for (const field of fields) {
+ const incoming = source[field.id];
+
+ if (isMissing(incoming)) {
+ if (field.required) {
+ issues.push({ field: field.id, message: `${field.label} je povinné.` });
+ continue;
+ }
+ values[field.id] = field.default ?? null;
+ continue;
+ }
+
+ const result = coerce(field, incoming);
+ if ('error' in result) {
+ issues.push({ field: field.id, message: `${field.label}: ${result.error}` });
+ continue;
+ }
+
+ if (field.options && !field.options.some((option) => option.value === String(result.value))) {
+ const allowed = field.options.map((option) => option.value).join(', ');
+ issues.push({
+ field: field.id,
+ message: `${field.label}: povolené hodnoty jsou ${allowed}.`,
+ });
+ continue;
+ }
+
+ values[field.id] = result.value;
+ }
+
+ // Parametry navic neodmitame, jen o nich chceme vedet. Stejne jako u webhooku.
+ const declared = new Set(fields.map((field) => field.id));
+ const extra = Object.keys(source).filter((key) => !declared.has(key));
+ if (extra.length > 0) {
+ console.warn(`[scripts] ${options.logLabel}: parametry mimo manifest: ${extra.join(', ')}`);
+ }
+
+ return issues.length > 0 ? { ok: false, issues } : { ok: true, values };
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index f59f3b4..4dfa204 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -17,6 +17,7 @@ const Overview = lazy(() => import('@/pages/dashboard/Overview'));
const Automations = lazy(() => import('@/pages/dashboard/Automations'));
const AutomationDetail = lazy(() => import('@/pages/dashboard/AutomationDetail'));
const Connectors = lazy(() => import('@/pages/dashboard/Connectors'));
+const Scripts = lazy(() => import('@/pages/dashboard/Scripts'));
const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
const TicketDetail = lazy(() => import('@/pages/dashboard/TicketDetail'));
const Incidents = lazy(() => import('@/pages/dashboard/Incidents'));
@@ -61,6 +62,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/web/src/components/dashboard/DashboardLayout.tsx b/web/src/components/dashboard/DashboardLayout.tsx
index a60ae08..1d72f65 100644
--- a/web/src/components/dashboard/DashboardLayout.tsx
+++ b/web/src/components/dashboard/DashboardLayout.tsx
@@ -7,6 +7,7 @@ import {
LogOut,
Menu,
Plug,
+ ScrollText,
Settings,
Workflow,
X,
@@ -27,6 +28,7 @@ const nav = [
{ to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true },
{ to: '/dashboard/automatizace', label: 'Automatizace', icon: Workflow, end: false },
{ to: '/dashboard/konektory', label: 'Konektory', icon: Plug, end: false },
+ { to: '/dashboard/skripty', label: 'Skripty', icon: ScrollText, end: false },
{ to: '/dashboard/tickety', label: 'Tickety', icon: LifeBuoy, end: false },
{ to: '/dashboard/incidenty', label: 'Incidenty', icon: AlarmClock, end: false },
{ to: '/dashboard/nastaveni', label: 'Nastavení', icon: Settings, end: false },
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 0272dda..866f22a 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -25,10 +25,29 @@ export class ApiError extends Error {
message: string,
readonly status: number,
readonly code?: string,
+ /** Cele telo odpovedi. Nektere endpointy vraci vedle zpravy i podrobnosti. */
+ readonly payload?: unknown,
) {
super(message);
this.name = 'ApiError';
}
+
+ /**
+ * Dilci problemy z odpovedi, napriklad co presne v manifestu skriptu nesedi.
+ * Prazdne pole, kdyz je odpoved neposila - volajici pak nemusi nic hlidat.
+ */
+ issues(): Array<{ field: string; message: string }> {
+ if (this.payload === null || typeof this.payload !== 'object') return [];
+ const value = (this.payload as { issues?: unknown }).issues;
+ if (!Array.isArray(value)) return [];
+ return value.filter(
+ (item): item is { field: string; message: string } =>
+ item !== null &&
+ typeof item === 'object' &&
+ typeof (item as { field?: unknown }).field === 'string' &&
+ typeof (item as { message?: unknown }).message === 'string',
+ );
+ }
}
export function getToken(): string | null {
@@ -85,7 +104,7 @@ export async function apiFetch(path: string, options: RequestOptions = {}): P
? String((payload as { error: unknown }).error)
: undefined;
console.error(`[api] ${path} -> ${response.status} ${code ?? ''} ${message}`);
- throw new ApiError(message, response.status, code);
+ throw new ApiError(message, response.status, code, isJson ? payload : undefined);
}
return payload as T;
diff --git a/web/src/pages/dashboard/Connectors.tsx b/web/src/pages/dashboard/Connectors.tsx
index c78bec7..8301382 100644
--- a/web/src/pages/dashboard/Connectors.tsx
+++ b/web/src/pages/dashboard/Connectors.tsx
@@ -1,4 +1,4 @@
-import { CheckCircle2, Clock, Plug, Search, Zap } from 'lucide-react';
+import { CheckCircle2, Clock, Plug, Search, Terminal, Zap } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { DataState } from '@/components/dashboard/DataState';
@@ -8,7 +8,13 @@ import { cn } from '@/lib/cn';
import { connectorIcon } from '@/lib/connectorIcons';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
-import type { Connector, ConnectorCatalog, ConnectorCategory, ConnectorStatus } from '@/types/dashboard';
+import type {
+ Connector,
+ ConnectorCatalog,
+ ConnectorCategory,
+ ConnectorOperation,
+ ConnectorStatus,
+} from '@/types/dashboard';
const statusMeta: Record = {
connected: { label: 'Napojeno', tone: 'ok' },
@@ -223,13 +229,13 @@ function ConnectorCard({ connector }: { connector: Connector }) {
}
- names={connector.triggers.map((t) => t.name)}
+ operations={connector.triggers}
emptyLabel="Nelze použít jako spouštěč"
/>
}
- names={connector.actions.map((a) => a.name)}
+ operations={connector.actions}
emptyLabel="Žádné akce"
/>
@@ -240,12 +246,12 @@ function ConnectorCard({ connector }: { connector: Connector }) {
function OperationList({
title,
icon,
- names,
+ operations,
emptyLabel,
}: {
title: string;
icon: ReactNode;
- names: string[];
+ operations: ConnectorOperation[];
emptyLabel: string;
}) {
return (
@@ -254,13 +260,20 @@ function OperationList({
{icon}
{title}
+ {operation.name}
+ {/* Operace se skriptem se opravdu vykona, ostatni jsou zatim popis. */}
+ {operation.implementation === 'script' && (
+
+ )}
))}
diff --git a/web/src/pages/dashboard/Scripts.tsx b/web/src/pages/dashboard/Scripts.tsx
new file mode 100644
index 0000000..4a104dd
--- /dev/null
+++ b/web/src/pages/dashboard/Scripts.tsx
@@ -0,0 +1,668 @@
+import {
+ AlertTriangle,
+ CheckCircle2,
+ Code2,
+ FileWarning,
+ Play,
+ RefreshCw,
+ Save,
+} from 'lucide-react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { ReactNode } from 'react';
+import { useAuth } from '@/auth/AuthContext';
+import { DataState } from '@/components/dashboard/DataState';
+import { Badge } from '@/components/ui/Badge';
+import { Button } from '@/components/ui/Button';
+import { apiFetch, ApiError } from '@/lib/api';
+import { cn } from '@/lib/cn';
+import { useApiQuery } from '@/lib/useApiQuery';
+import { usePageMeta } from '@/lib/usePageMeta';
+import type {
+ ConnectionStatus,
+ ScriptCatalog,
+ ScriptDetail,
+ ScriptField,
+ ScriptRunResult,
+} from '@/types/dashboard';
+
+/**
+ * Vykonna cast konektoru. Jeden skript = jeden soubor, ktery nese manifest
+ * (vstupy a vystupy) a kod. Upravit ho jde tady i rucne v souboru, server si
+ * zmenu vsimne sam - nic se nerestartuje.
+ *
+ * Popis modelu je v documentation/11-skripty-konektoru.md.
+ */
+export default function Scripts() {
+ usePageMeta({ title: 'Skripty konektorů - portál Automia' });
+
+ const { user } = useAuth();
+ const canEdit = user?.platformAdmin === true;
+
+ const catalog = useApiQuery('/api/dashboard/scripts');
+ const [selectedId, setSelectedId] = useState(null);
+
+ const items = catalog.data?.items ?? [];
+
+ // Prvni skript se vybere sam, jinak by stranka po nacteni vypadala prazdne.
+ useEffect(() => {
+ if (selectedId === null && items.length > 0) setSelectedId(items[0].id);
+ }, [items, selectedId]);
+
+ const connectionsById = useMemo(() => {
+ const map = new Map();
+ for (const connection of catalog.data?.connections ?? []) {
+ map.set(connection.connectorId, connection);
+ }
+ return map;
+ }, [catalog.data]);
+
+ const grouped = useMemo(() => {
+ const map = new Map();
+ for (const item of items) {
+ const list = map.get(item.connectorId) ?? [];
+ list.push(item);
+ map.set(item.connectorId, list);
+ }
+ return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
+ }, [items]);
+
+ return (
+
+
+
Skripty konektorů
+
+ Výkonná část konektorů. Každý skript nese vstupní i výstupní parametry, takže
+ s ním umí pracovat strom automatizace. Upravit ho jde tady nebo přímo v souboru,
+ server si změny všimne sám.
+
+ Napojení není nastavené, skript nepůjde spustit. Chybí:{' '}
+ {detail.connection.missing.join(', ')}. Nastavte je jako
+ proměnné aplikace v AppFactory.
+
+ );
+}
+
+/**
+ * Tabulka parametru. Zamerne jedna komponenta pro vstupy i vystupy - je to
+ * tentyz tvar dat a dve skoro stejne tabulky by se rozesly.
+ */
+function FieldTable({ title, fields }: { title: string; fields: ScriptField[] }) {
+ return (
+