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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bbc2236c0d
commit
6f6b287d7e
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -37,6 +37,11 @@ Vyzaduji `Authorization: Bearer <token>`:
|
||||
| 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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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:<id>` 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.
|
||||
@@ -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 `<konektor>.<operace>.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` |
|
||||
| `<KONEKTOR>_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 `<konektor>.<operace>.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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<konektor>.<operace>.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<string, string | number | boolean | null>} 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<string, string>, 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'),
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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')),
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 `<KONEKTOR>_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. */
|
||||
|
||||
+60
-1
@@ -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<string, ConnectorOperation[]>();
|
||||
|
||||
/** Nahradi cely prekryv. Volani je idempotentni, poradi nezalezi. */
|
||||
export function setScriptActions(byConnector: Map<string, ConnectorOperation[]>): 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+306
@@ -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 <id>.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'],
|
||||
|
||||
+11
-2
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
/** Ktere hlavicky musi byt vyplnene, aby se dalo volat. */
|
||||
required: string[];
|
||||
/** Necitliva nastaveni pristupna skriptu jako `ctx.config`. */
|
||||
config?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, ConnectorAuthSpec> = {
|
||||
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<string, string>;
|
||||
/** Necitliva cast, skript ji vidi jako `ctx.config`. */
|
||||
config: Record<string, string>;
|
||||
/** 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 `<KONEKTOR>_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<string, string> = {};
|
||||
const scriptConfig: Record<string, string> = {};
|
||||
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);
|
||||
}
|
||||
@@ -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<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body: unknown,
|
||||
httpOptions?: ScriptHttpOptions,
|
||||
): Promise<ScriptHttpResponse<T>> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 `<id>.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> | 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<string, LoadedScript>();
|
||||
const problems = new Map<string, ScriptProblem>();
|
||||
|
||||
/** Nez se znovu prohleda adresar. Bez toho by se stat volal pri kazdem dotazu. */
|
||||
const RESCAN_MS = 1000;
|
||||
let lastScanAt = 0;
|
||||
let scanning: Promise<void> | 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<LoadedScript | ScriptProblem> {
|
||||
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<void> {
|
||||
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<string>();
|
||||
|
||||
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<string, ConnectorOperation[]>();
|
||||
|
||||
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<void> {
|
||||
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<LoadedScript[]> {
|
||||
await ensureLoaded();
|
||||
return [...scripts.values()].sort((a, b) => a.manifest.id.localeCompare(b.manifest.id));
|
||||
}
|
||||
|
||||
export async function listManifests(): Promise<ScriptManifest[]> {
|
||||
return (await listScripts()).map((script) => script.manifest);
|
||||
}
|
||||
|
||||
export async function getScript(id: string): Promise<LoadedScript | undefined> {
|
||||
await ensureLoaded();
|
||||
return scripts.get(id);
|
||||
}
|
||||
|
||||
export async function scriptProblems(): Promise<ScriptProblem[]> {
|
||||
await ensureLoaded();
|
||||
return [...problems.values()].sort((a, b) => a.file.localeCompare(b.file));
|
||||
}
|
||||
|
||||
export async function readSource(id: string): Promise<string | null> {
|
||||
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<SaveResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<ScriptRunResult> {
|
||||
const startedAt = Date.now();
|
||||
const logs: ScriptLogEntry[] = [];
|
||||
let httpCalls = 0;
|
||||
|
||||
const finish = (
|
||||
partial: Pick<ScriptRunResult, 'ok' | 'outputs' | 'error'>,
|
||||
): 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'}`;
|
||||
}
|
||||
@@ -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<string, ScriptValue>;
|
||||
|
||||
// -------------------------------------------------------------------- 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<typeof scriptFieldSchema>;
|
||||
|
||||
/**
|
||||
* Manifest skriptu.
|
||||
*
|
||||
* `id` ma tvar `<konektor>.<operace>`, napriklad `idoklad.get-issued-invoice`.
|
||||
* Z nej se dopocita, do ktereho konektoru operace patri, takze se to nepise
|
||||
* dvakrat. Nazev souboru musi byt `<id>.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<string>();
|
||||
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<typeof scriptManifestSchema>;
|
||||
|
||||
// --------------------------------------------------------------------- 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<T = unknown> {
|
||||
status: number;
|
||||
body: T;
|
||||
}
|
||||
|
||||
export interface ScriptHttpOptions {
|
||||
query?: Record<string, string | number | boolean | undefined | null>;
|
||||
headers?: Record<string, string>;
|
||||
/** 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<T = unknown>(path: string, options?: ScriptHttpOptions): Promise<ScriptHttpResponse<T>>;
|
||||
post<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: ScriptHttpOptions,
|
||||
): Promise<ScriptHttpResponse<T>>;
|
||||
patch<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: ScriptHttpOptions,
|
||||
): Promise<ScriptHttpResponse<T>>;
|
||||
put<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: ScriptHttpOptions,
|
||||
): Promise<ScriptHttpResponse<T>>;
|
||||
del<T = unknown>(path: string, options?: ScriptHttpOptions): Promise<ScriptHttpResponse<T>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T = unknown>(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<T = unknown>(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<T>(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<Record<string, string>>;
|
||||
/**
|
||||
* 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> | 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;
|
||||
}
|
||||
@@ -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<T = unknown>(body: unknown): T {
|
||||
if (body === null || typeof body !== 'object') return body as T;
|
||||
const record = body as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
for (const name of names) {
|
||||
if (record[name] !== undefined) return record[name];
|
||||
}
|
||||
|
||||
const lowered = new Map<string, unknown>();
|
||||
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<T = unknown>(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<T>(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<string | undefined>): (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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> =
|
||||
raw !== null && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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() {
|
||||
<Route path="automatizace" element={<Automations />} />
|
||||
<Route path="automatizace/:id" element={<AutomationDetail />} />
|
||||
<Route path="konektory" element={<Connectors />} />
|
||||
<Route path="skripty" element={<Scripts />} />
|
||||
<Route path="tickety" element={<Tickets />} />
|
||||
<Route path="tickety/:id" element={<TicketDetail />} />
|
||||
<Route path="incidenty" element={<Incidents />} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
+20
-1
@@ -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<T>(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;
|
||||
|
||||
@@ -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<ConnectorStatus, { label: string; tone: 'ok' | 'neutral' | 'warn' }> = {
|
||||
connected: { label: 'Napojeno', tone: 'ok' },
|
||||
@@ -223,13 +229,13 @@ function ConnectorCard({ connector }: { connector: Connector }) {
|
||||
<OperationList
|
||||
title="Spouštěče"
|
||||
icon={<Zap className="size-3 text-brand-300" />}
|
||||
names={connector.triggers.map((t) => t.name)}
|
||||
operations={connector.triggers}
|
||||
emptyLabel="Nelze použít jako spouštěč"
|
||||
/>
|
||||
<OperationList
|
||||
title="Akce"
|
||||
icon={<Plug className="size-3 text-accent-300" />}
|
||||
names={connector.actions.map((a) => a.name)}
|
||||
operations={connector.actions}
|
||||
emptyLabel="Žádné akce"
|
||||
/>
|
||||
</div>
|
||||
@@ -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}
|
||||
</p>
|
||||
{names.length === 0 ? (
|
||||
{operations.length === 0 ? (
|
||||
<p className="text-xs text-white/25">{emptyLabel}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{names.map((name) => (
|
||||
<li key={name} className="text-sm text-white/60">
|
||||
{name}
|
||||
{operations.map((operation) => (
|
||||
<li key={operation.id} className="flex items-start gap-1.5 text-sm text-white/60">
|
||||
<span>{operation.name}</span>
|
||||
{/* Operace se skriptem se opravdu vykona, ostatni jsou zatim popis. */}
|
||||
{operation.implementation === 'script' && (
|
||||
<Terminal
|
||||
className="mt-1 size-3 shrink-0 text-ok-400"
|
||||
aria-label="Obsluhuje skript, opravdu se vykoná"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -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<ScriptCatalog>('/api/dashboard/scripts');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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<string, ConnectionStatus>();
|
||||
for (const connection of catalog.data?.connections ?? []) {
|
||||
map.set(connection.connectorId, connection);
|
||||
}
|
||||
return map;
|
||||
}, [catalog.data]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, ScriptCatalog['items']>();
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Skripty konektorů</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DataState
|
||||
loading={catalog.loading}
|
||||
error={catalog.error}
|
||||
onRetry={catalog.reload}
|
||||
empty={items.length === 0 && (catalog.data?.problems.length ?? 0) === 0}
|
||||
emptyLabel="Ve složce skriptů nic není."
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{catalog.data && <Problems problems={catalog.data.problems} />}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)]">
|
||||
<aside className="space-y-5">
|
||||
{grouped.map(([connectorId, scripts]) => (
|
||||
<ConnectorGroup
|
||||
key={connectorId}
|
||||
connectorId={connectorId}
|
||||
connection={connectionsById.get(connectorId)}
|
||||
scripts={scripts}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{catalog.data && (
|
||||
<p className="px-1 text-xs break-all text-white/30">
|
||||
Složka: <code>{catalog.data.directory}</code>
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{selectedId ? (
|
||||
<ScriptPanel
|
||||
key={selectedId}
|
||||
scriptId={selectedId}
|
||||
canEdit={canEdit}
|
||||
onSaved={catalog.reload}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-10 text-center text-sm text-white/40">Vyberte skript vlevo.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- seznam
|
||||
|
||||
function ConnectorGroup({
|
||||
connectorId,
|
||||
connection,
|
||||
scripts,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
connectorId: string;
|
||||
connection: ConnectionStatus | undefined;
|
||||
scripts: ScriptCatalog['items'];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass rounded-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<p className="font-semibold text-white">{connectorId}</p>
|
||||
{connection && (
|
||||
<Badge tone={connection.ready ? 'ok' : 'warn'}>
|
||||
{connection.ready ? 'Napojeno' : 'Chybí údaje'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{scripts.map((script) => (
|
||||
<li key={script.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(script.id)}
|
||||
className={cn(
|
||||
'w-full rounded-lg px-3 py-2 text-left text-sm transition-colors',
|
||||
script.id === selectedId
|
||||
? 'bg-brand-500/12 text-brand-200'
|
||||
: 'text-white/60 hover:bg-white/5 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{script.name}
|
||||
<span className="mt-0.5 block font-mono text-xs text-white/30">
|
||||
{script.operationId}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Problems({ problems }: { problems: ScriptCatalog['problems'] }) {
|
||||
if (problems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-danger-500/40 bg-danger-500/8 p-4">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold text-danger-400">
|
||||
<FileWarning className="size-4 shrink-0" />
|
||||
{problems.length === 1 ? 'Jeden skript se nenačetl' : `${problems.length} skriptů se nenačetlo`}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-2 text-sm text-white/70">
|
||||
{problems.map((problem) => (
|
||||
<li key={problem.file}>
|
||||
<code className="text-white/90">{problem.file}</code>: {problem.message}
|
||||
{problem.issues && problem.issues.length > 0 && (
|
||||
<ul className="mt-1 ml-4 list-disc text-xs text-white/50">
|
||||
{problem.issues.map((issue) => (
|
||||
<li key={`${issue.field}-${issue.message}`}>
|
||||
{issue.field}: {issue.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- detail
|
||||
|
||||
function ScriptPanel({
|
||||
scriptId,
|
||||
canEdit,
|
||||
onSaved,
|
||||
}: {
|
||||
scriptId: string;
|
||||
canEdit: boolean;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [detail, setDetail] = useState<ScriptDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
apiFetch<ScriptDetail>(`/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 (
|
||||
<DataState loading={loading} error={error} onRetry={load}>
|
||||
{detail && (
|
||||
<div className="space-y-5">
|
||||
<div className="glass rounded-card p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-white">{detail.manifest?.name ?? detail.id}</h2>
|
||||
<p className="mt-1 font-mono text-xs text-white/35">{detail.id}.js</p>
|
||||
</div>
|
||||
<ConnectionBadge connection={detail.connection} />
|
||||
</div>
|
||||
|
||||
{detail.manifest && (
|
||||
<p className="mt-3 text-sm text-white/55">{detail.manifest.description}</p>
|
||||
)}
|
||||
|
||||
{detail.problem && (
|
||||
<p className="mt-3 flex items-start gap-2 rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm text-danger-400">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
{detail.problem.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!detail.connection.ready && (
|
||||
<p className="mt-3 text-sm text-warn-400">
|
||||
Napojení není nastavené, skript nepůjde spustit. Chybí:{' '}
|
||||
<code>{detail.connection.missing.join(', ')}</code>. Nastavte je jako
|
||||
proměnné aplikace v AppFactory.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detail.manifest && (
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<FieldTable title="Vstupní parametry" fields={detail.manifest.inputs} />
|
||||
<FieldTable title="Výstupní parametry" fields={detail.manifest.outputs} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.manifest && canEdit && (
|
||||
<TestPanel
|
||||
scriptId={detail.id}
|
||||
fields={detail.manifest.inputs}
|
||||
ready={detail.connection.ready}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CodeEditor
|
||||
scriptId={detail.id}
|
||||
code={detail.code}
|
||||
canEdit={canEdit}
|
||||
onSaved={() => {
|
||||
load();
|
||||
onSaved();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DataState>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionBadge({ connection }: { connection: ConnectionStatus }) {
|
||||
return (
|
||||
<div className="text-right">
|
||||
<Badge tone={connection.ready ? 'ok' : 'warn'}>
|
||||
{connection.ready ? 'Napojení připravené' : 'Napojení nenastavené'}
|
||||
</Badge>
|
||||
<p className="mt-1 font-mono text-xs break-all text-white/30">{connection.baseUrl}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="glass rounded-card p-5">
|
||||
<p className="mb-3 text-xs font-semibold tracking-wide text-white/45 uppercase">{title}</p>
|
||||
|
||||
{fields.length === 0 ? (
|
||||
<p className="text-sm text-white/30">Žádné.</p>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{fields.map((field) => (
|
||||
<li key={field.id} className="border-b border-ink-600/40 pb-2.5 last:border-0 last:pb-0">
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<code className="text-white">{field.id}</code>
|
||||
<span className="text-xs text-white/35">{field.type}</span>
|
||||
{field.required && <Badge tone="warn">povinné</Badge>}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-white/55">{field.label}</p>
|
||||
{field.hint && <p className="mt-0.5 text-xs text-white/35">{field.hint}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- 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 (
|
||||
<select className={className} value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">Nevyplněno</option>
|
||||
{field.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<select className={className} value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">Nevyplněno</option>
|
||||
<option value="true">Ano</option>
|
||||
<option value="false">Ne</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.multiline) {
|
||||
return (
|
||||
<textarea
|
||||
rows={3}
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-white/70">
|
||||
{field.label}
|
||||
{field.required && <span className="text-warn-400"> *</span>}
|
||||
</span>
|
||||
{control()}
|
||||
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TestPanel({
|
||||
scriptId,
|
||||
fields,
|
||||
ready,
|
||||
}: {
|
||||
scriptId: string;
|
||||
fields: ScriptField[];
|
||||
ready: boolean;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [result, setResult] = useState<ScriptRunResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
async function handleRun() {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
// Prazdne hodnoty se neposilaji, aby se uplatnily vychozi hodnoty z manifestu.
|
||||
const inputs = Object.fromEntries(
|
||||
Object.entries(values).filter(([, value]) => value !== ''),
|
||||
);
|
||||
setResult(await apiFetch<ScriptRunResult>(`/api/dashboard/scripts/${scriptId}/test`, {
|
||||
method: 'POST',
|
||||
body: { inputs },
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof ApiError ? err.message : 'Zkušební spuštění se nepodařilo odeslat.';
|
||||
setError(message);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass rounded-card p-5">
|
||||
<p className="text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
Zkušební spuštění
|
||||
</p>
|
||||
<p className="mt-1 mb-4 text-sm text-warn-400">
|
||||
Volá se opravdová služba. Co skript založí, opravdu vznikne.
|
||||
</p>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{fields.map((field) => (
|
||||
<FieldInput
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={values[field.id] ?? ''}
|
||||
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<Button size="sm" onClick={handleRun} disabled={running || !ready}>
|
||||
<Play className="size-4" />
|
||||
{running ? 'Spouštím...' : 'Spustit'}
|
||||
</Button>
|
||||
{!ready && <span className="text-sm text-white/40">Nejdřív nastavte napojení.</span>}
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
|
||||
{result && <RunResult result={result} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunResult({ result }: { result: ScriptRunResult }) {
|
||||
return (
|
||||
<div className="mt-4 space-y-3 border-t border-ink-600/50 pt-4">
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm font-semibold',
|
||||
result.ok ? 'text-ok-400' : 'text-danger-400',
|
||||
)}
|
||||
>
|
||||
{result.ok ? (
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="size-4 shrink-0" />
|
||||
)}
|
||||
{result.ok ? 'Proběhlo' : 'Selhalo'}
|
||||
<span className="font-normal text-white/40">
|
||||
{result.durationMs} ms, volání služby: {result.httpCalls}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{result.error && (
|
||||
<div className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
|
||||
<p className="text-danger-400">{result.error.message}</p>
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
Druh: {result.error.kind}
|
||||
{result.error.status ? `, HTTP ${result.error.status}` : ''},{' '}
|
||||
{result.error.retryable ? 'má smysl zkusit znovu' : 'opakování nepomůže'}
|
||||
</p>
|
||||
{result.error.issues && result.error.issues.length > 0 && (
|
||||
<ul className="mt-2 ml-4 list-disc text-xs text-white/60">
|
||||
{result.error.issues.map((issue) => (
|
||||
<li key={`${issue.field}-${issue.message}`}>{issue.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{result.error.detail && (
|
||||
<pre className="mt-2 overflow-x-auto rounded bg-ink-950/60 p-2 text-xs text-white/50">
|
||||
{result.error.detail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.ok && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
Výstupy
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(result.outputs).map(([key, value]) => (
|
||||
<li key={key} className="flex flex-wrap gap-2">
|
||||
<code className="text-brand-200">{key}</code>
|
||||
<span className="text-white/70">{value === null ? '-' : String(value)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.logs.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
Průběh
|
||||
</p>
|
||||
<ul className="space-y-1 font-mono text-xs text-white/50">
|
||||
{result.logs.map((entry, index) => (
|
||||
<li key={`${entry.at}-${index}`}>
|
||||
{entry.message}
|
||||
{entry.detail && <span className="text-white/30"> {entry.detail}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- editor
|
||||
|
||||
function CodeEditor({
|
||||
scriptId,
|
||||
code,
|
||||
canEdit,
|
||||
onSaved,
|
||||
}: {
|
||||
scriptId: string;
|
||||
code: string;
|
||||
canEdit: boolean;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(code);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [issues, setIssues] = useState<Array<{ field: string; message: string }>>([]);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
// Po nacteni jineho skriptu nebo po ulozeni se koncept srovna se serverem.
|
||||
useEffect(() => {
|
||||
setDraft(code);
|
||||
setIssues([]);
|
||||
setError(null);
|
||||
}, [code]);
|
||||
|
||||
const dirty = draft !== code;
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setIssues([]);
|
||||
setSaved(false);
|
||||
try {
|
||||
await apiFetch(`/api/dashboard/scripts/${scriptId}`, { method: 'PUT', body: { code: draft } });
|
||||
setSaved(true);
|
||||
onSaved();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
|
||||
// Server posila v `issues`, co presne v manifestu nesedi.
|
||||
if (err instanceof ApiError) setIssues(err.issues());
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass rounded-card p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="flex items-center gap-2 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
<Code2 className="size-3.5" />
|
||||
Kód skriptu
|
||||
</p>
|
||||
|
||||
{canEdit ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{dirty && <span className="text-xs text-warn-400">Neuložené změny</span>}
|
||||
{saved && !dirty && <span className="text-xs text-ok-400">Uloženo</span>}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setDraft(code)}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Zahodit
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
|
||||
<Save className="size-4" />
|
||||
{saving ? 'Ukládám...' : 'Uložit'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-white/35">Upravovat smí jen správce platformy.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
spellCheck={false}
|
||||
readOnly={!canEdit}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
className="h-96 w-full resize-y rounded-xl border border-ink-600/70 bg-ink-950/70 p-4 font-mono text-xs leading-relaxed text-white/85 focus:border-brand-400/70 focus:outline-none"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
|
||||
<p className="text-danger-400">{error}</p>
|
||||
{issues.length > 0 && (
|
||||
<ul className="mt-2 ml-4 list-disc text-xs text-white/60">
|
||||
{issues.map((issue) => (
|
||||
<li key={`${issue.field}-${issue.message}`}>
|
||||
{issue.field}: {issue.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/40">
|
||||
Nic se neuložilo, původní skript funguje dál.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -190,6 +190,12 @@ export interface ConnectorOperation {
|
||||
inputs?: OperationField[];
|
||||
/** Jen u akci: co krok vrati dalsim krokum. Podminka se na to muze ptat. */
|
||||
outputFields?: TriggerField[];
|
||||
/**
|
||||
* `script` = operaci obsluhuje skript, tedy se opravdu vykona.
|
||||
* Kdyz chybi, je to zatim jen zapis v katalogu.
|
||||
*/
|
||||
implementation?: 'script';
|
||||
scriptId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,3 +359,94 @@ export interface LayoutResponse {
|
||||
/** false = uzivatel kouka na vychozi rozlozeni, nic si neulozil. */
|
||||
custom: boolean;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- skripty konektoru
|
||||
|
||||
/**
|
||||
* Parametr skriptu. Stejny tvar pro vstup i vystup.
|
||||
* Zdroj pravdy je manifest na serveru, viz src/scripts/types.ts.
|
||||
*/
|
||||
export interface ScriptField {
|
||||
id: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
required: boolean;
|
||||
hint?: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
pattern?: string;
|
||||
multiline?: boolean;
|
||||
default?: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export interface ScriptManifest {
|
||||
id: string;
|
||||
connectorId: string;
|
||||
operationId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
inputs: ScriptField[];
|
||||
outputs: ScriptField[];
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ScriptProblem {
|
||||
file: string;
|
||||
scriptId: string | null;
|
||||
message: string;
|
||||
issues?: Array<{ field: string; message: string }>;
|
||||
}
|
||||
|
||||
/** Stav napojeni. Hodnoty pristupovych udaju se ze serveru nikdy nevraci. */
|
||||
export interface ConnectionStatus {
|
||||
connectorId: string;
|
||||
baseUrl: string;
|
||||
ready: boolean;
|
||||
/** Jmena chybejicich environment variables. */
|
||||
missing: string[];
|
||||
headers: string[];
|
||||
}
|
||||
|
||||
export interface ScriptCatalog {
|
||||
items: ScriptManifest[];
|
||||
problems: ScriptProblem[];
|
||||
connections: ConnectionStatus[];
|
||||
directory: string;
|
||||
}
|
||||
|
||||
export interface ScriptDetail {
|
||||
id: string;
|
||||
connectorId: string;
|
||||
operationId: string;
|
||||
/** null = soubor je rozbity, manifest se nepodarilo precist. */
|
||||
manifest: ScriptManifest | null;
|
||||
code: string;
|
||||
problem: ScriptProblem | null;
|
||||
connection: ConnectionStatus;
|
||||
}
|
||||
|
||||
export type ScriptErrorKind =
|
||||
| 'not_found'
|
||||
| 'config'
|
||||
| 'validation'
|
||||
| 'output'
|
||||
| 'retryable'
|
||||
| 'terminal'
|
||||
| 'timeout'
|
||||
| 'internal';
|
||||
|
||||
export interface ScriptRunResult {
|
||||
ok: boolean;
|
||||
scriptId: string;
|
||||
outputs: Record<string, string | number | boolean | null>;
|
||||
logs: Array<{ at: string; message: string; detail?: string }>;
|
||||
durationMs: number;
|
||||
httpCalls: number;
|
||||
error: {
|
||||
kind: ScriptErrorKind;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
status?: number;
|
||||
detail?: string;
|
||||
issues?: Array<{ field: string; message: string }>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user