From 6f6b287d7e96474b5661a17b0b1e1dc8e0f1ec8c Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:37:58 +0200 Subject: [PATCH] Skripty konektoru: vykonna cast s manifestem a kontrolou parametru Konektory dostaly vykonnou cast. Jeden skript je jeden soubor, ktery nese manifest (vstupni a vystupni parametry) i kod. Diky manifestu s nim umi pracovat strom automatizace, aniz by o kodu cokoliv vedel. Soubory jsou zamerne obycejny JavaScript, ne TypeScript. TypeScript by se musel prelozit a to je presne to otaceni, ktere tady nema byt. Registr sleduje cas zmeny souboru, takze uprava v portalu, rucni uprava souboru i novy soubor ve slozce funguji stejne a bez restartu. Pridano: - scripts/ se skripty konektoru, nazev souboru je zaroven ID operace - kontrola vstupu i vystupu proti manifestu, jedna funkce pro obe strany. Chybejici povinny vystup je chyba skriptu, ne uzivatele - jinak by strom veril parametru, ktery nikdy nedosel - 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. Cizi API rado vraci prijaty token v chybove zprave a log ticketu vidi klient - napojeni z environment variables 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 vcetne 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 vyhrava skript - ConnectorOperation ma implementation a scriptId - ApiError na klientovi nese cele telo odpovedi a umi z nej vytahnout issues - Dockerfile kopiruje scripts/ do vysledneho image Ukladani nemuze rozbit fungujici skript: kod se nejdriv zapise do docasneho souboru, ten se nacte a overi, a az pak prepise puvodni. K tomu tri dokumenty navrhu dalsich kroku: 09 datove modely a prava, 10 runtime a rozpocet na 150 klientu, 11 popis skriptu konektoru. Overeno: npm run typecheck prochazi na serveru i webu. Co-Authored-By: Claude Opus 5 (1M context) --- Dockerfile | 3 + documentation/01-prehled-a-stav.md | 10 + documentation/04-api.md | 24 + documentation/09-navrh-rozsireni.md | 1142 +++++++++++++++++ documentation/10-runtime-a-kapacita.md | 410 ++++++ documentation/11-skripty-konektoru.md | 315 +++++ documentation/99-zmeny.md | 64 + scripts/_sablona.js | 83 ++ scripts/idoklad.create-issued-invoice.js | 155 +++ scripts/idoklad.find-contact.js | 97 ++ scripts/idoklad.find-issued-invoice.js | 90 ++ scripts/idoklad.get-issued-invoice.js | 76 ++ scripts/idoklad.register-payment.js | 98 ++ scripts/idoklad.send-invoice-email.js | 80 ++ src/config.ts | 31 + src/data/connectors.ts | 61 +- src/index.ts | 9 + src/openapi.ts | 306 +++++ src/routes/dashboard.ts | 13 +- src/routes/scripts.ts | 145 +++ src/scripts/connections.ts | 139 ++ src/scripts/http.ts | 201 +++ src/scripts/manifest.ts | 102 ++ src/scripts/registry.ts | 318 +++++ src/scripts/runner.ts | 229 ++++ src/scripts/types.ts | 271 ++++ src/scripts/util.ts | 127 ++ src/scripts/values.ts | 138 ++ web/src/App.tsx | 2 + .../components/dashboard/DashboardLayout.tsx | 2 + web/src/lib/api.ts | 21 +- web/src/pages/dashboard/Connectors.tsx | 33 +- web/src/pages/dashboard/Scripts.tsx | 668 ++++++++++ web/src/types/dashboard.ts | 97 ++ 34 files changed, 5546 insertions(+), 14 deletions(-) create mode 100644 documentation/09-navrh-rozsireni.md create mode 100644 documentation/10-runtime-a-kapacita.md create mode 100644 documentation/11-skripty-konektoru.md create mode 100644 scripts/_sablona.js create mode 100644 scripts/idoklad.create-issued-invoice.js create mode 100644 scripts/idoklad.find-contact.js create mode 100644 scripts/idoklad.find-issued-invoice.js create mode 100644 scripts/idoklad.get-issued-invoice.js create mode 100644 scripts/idoklad.register-payment.js create mode 100644 scripts/idoklad.send-invoice-email.js create mode 100644 src/routes/scripts.ts create mode 100644 src/scripts/connections.ts create mode 100644 src/scripts/http.ts create mode 100644 src/scripts/manifest.ts create mode 100644 src/scripts/registry.ts create mode 100644 src/scripts/runner.ts create mode 100644 src/scripts/types.ts create mode 100644 src/scripts/util.ts create mode 100644 src/scripts/values.ts create mode 100644 web/src/pages/dashboard/Scripts.tsx 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}

- {names.length === 0 ? ( + {operations.length === 0 ? (

{emptyLabel}

) : (
    - {names.map((name) => ( -
  • - {name} + {operations.map((operation) => ( +
  • + {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. +

+
+ + +
+ {catalog.data && } + +
+ + + {selectedId ? ( + + ) : ( +

Vyberte skript vlevo.

+ )} +
+
+
+
+ ); +} + +// ------------------------------------------------------------------- seznam + +function ConnectorGroup({ + connectorId, + connection, + scripts, + selectedId, + onSelect, +}: { + connectorId: string; + connection: ConnectionStatus | undefined; + scripts: ScriptCatalog['items']; + selectedId: string | null; + onSelect: (id: string) => void; +}) { + return ( +
+
+

{connectorId}

+ {connection && ( + + {connection.ready ? 'Napojeno' : 'Chybí údaje'} + + )} +
+ +
    + {scripts.map((script) => ( +
  • + +
  • + ))} +
+
+ ); +} + +function Problems({ problems }: { problems: ScriptCatalog['problems'] }) { + if (problems.length === 0) return null; + + return ( +
+

+ + {problems.length === 1 ? 'Jeden skript se nenačetl' : `${problems.length} skriptů se nenačetlo`} +

+
    + {problems.map((problem) => ( +
  • + {problem.file}: {problem.message} + {problem.issues && problem.issues.length > 0 && ( +
      + {problem.issues.map((issue) => ( +
    • + {issue.field}: {issue.message} +
    • + ))} +
    + )} +
  • + ))} +
+
+ ); +} + +// ------------------------------------------------------------------- detail + +function ScriptPanel({ + scriptId, + canEdit, + onSaved, +}: { + scriptId: string; + canEdit: boolean; + onSaved: () => void; +}) { + const [detail, setDetail] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(() => { + setLoading(true); + setError(null); + apiFetch(`/api/dashboard/scripts/${scriptId}`) + .then(setDetail) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : 'Skript se nepodařilo načíst.'); + }) + .finally(() => setLoading(false)); + }, [scriptId]); + + useEffect(load, [load]); + + return ( + + {detail && ( +
+
+
+
+

{detail.manifest?.name ?? detail.id}

+

{detail.id}.js

+
+ +
+ + {detail.manifest && ( +

{detail.manifest.description}

+ )} + + {detail.problem && ( +

+ + {detail.problem.message} +

+ )} + + {!detail.connection.ready && ( +

+ Napojení není nastavené, skript nepůjde spustit. Chybí:{' '} + {detail.connection.missing.join(', ')}. Nastavte je jako + proměnné aplikace v AppFactory. +

+ )} +
+ + {detail.manifest && ( +
+ + +
+ )} + + {detail.manifest && canEdit && ( + + )} + + { + load(); + onSaved(); + }} + /> +
+ )} +
+ ); +} + +function ConnectionBadge({ connection }: { connection: ConnectionStatus }) { + return ( +
+ + {connection.ready ? 'Napojení připravené' : 'Napojení nenastavené'} + +

{connection.baseUrl}

+
+ ); +} + +/** + * 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 ( +
+

{title}

+ + {fields.length === 0 ? ( +

Žádné.

+ ) : ( +
    + {fields.map((field) => ( +
  • +

    + {field.id} + {field.type} + {field.required && povinné} +

    +

    {field.label}

    + {field.hint &&

    {field.hint}

    } +
  • + ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------- test + +/** + * Jedno vstupni pole podle deklarovaneho typu. Diky tomu neni testovaci + * formular rucne psany pro kazdy skript, ale vznika z manifestu. + */ +function FieldInput({ + field, + value, + onChange, +}: { + field: ScriptField; + value: string; + onChange: (value: string) => void; +}) { + const className = + 'w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none'; + + const control = (): ReactNode => { + if (field.options) { + return ( + + ); + } + + if (field.type === 'boolean') { + return ( + + ); + } + + if (field.multiline) { + return ( +