Rozdeleni na sluzby a konektory, pristupove udaje do konektoru
Slovo "konektor" v kodu znamenalo katalog toho, co umime. Ted znamena napojeni jedne firmy, tedy to, co tim mysli i uzivatel. Popis modelu je v documentation/12-sluzby-a-konektory.md. Tri vrstvy: - Sluzba: ze iDoklad existuje, co umi a co potrebuje k napojeni. Nase. - Skript: kod, ktery jednu operaci sluzby opravdu vykona. Nas. - Konektor: ucet firmy vcetne jejich pristupovych udaju. Firemni. Pristupove udaje se prestaly cist z environment variables. Cela instance by mela jedny udaje spolecne a dve firmy by fakturovaly z jednoho uctu. Napojeni je vlastnost firmy, ne prostredi. Z prostredi zustava jen SERVICES_BASE_URL. Pridano: - src/data/services.ts: sluzba nese general, appId, visibility, credentials a verifyPath. Kategorie "obecne" sdruzuje veci, ktere ma kazdy a nepotrebuji konektor: webhook, planovac, tickety, transformace dat, HTTP pozadavek, pauza, zapis do logu - viditelnost sluzby: vsichni, jen uvedene firmy a lide, nebo jen spravce platformy. Neviditelna sluzba se z API nevraci vubec, ne se stavem 403 - firma nema poznat, ze takova sluzba existuje - src/data/connectorStore.ts: konektory za firmu vcetne hodnot udaju. Hodnoty se z API nikdy nevraci, jen filled a missing. Prazdne pole hodnotu nemeni, takze ulozeni formularu bez tajnych hodnot nic nepresepe - FlowStep.connectorId: krok rika, pod kterym napojenim volat. null = vychozi konektor firmy, diky tomu je vzorovy strom prenositelny mezi firmami - overeni konektoru pres verifyPath, tedy cteci volani vyzadujici autorizaci. U sluzby bez nej se overi jen dostupnost a odpoved to rekne nahlas, jinak by zeleny vysledek uzivateli lhal - stranky /dashboard/sluzby a /dashboard/konektory vcetne formularu udaju - endpointy /api/dashboard/services a CRUD /api/dashboard/connectors ve Swaggeru - predvyplnene prihlaseni spravcem platformy a prepinac demo uctu na login strance, kvuli testovani prototypu Zmeneno: - stav "napojeno" se prestal cist z katalogu a zacal pocitat z konektoru firmy. Sluzba ma jen available nebo planned - validace stromu overuje i konektor. Cizi konektor je chyba, chybejici napojeni nedodelek - rozdelana prace se nezahazuje - prejmenovani napric kodem: Connector na Service, FlowStep.connectorId na serviceId, GET /connectors na GET /services, connectorIcons na serviceIcons, stranka Konektory (katalog) na Sluzby. Prevodni tabulka je v dokumentu 12 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
6f6b287d7e
commit
8ad91a6c28
@@ -17,7 +17,7 @@ React aplikaci ze slozky `dist/public`.
|
||||
| Dashboard | hotovo | prehled, tickety, incidenty, automatizace, nastaveni |
|
||||
| Zivy dashboard pres SSE | hotovo | zmeny se projevi bez obnoveni stranky |
|
||||
| Simulace provozu | hotovo | tlacitko v postrannim menu portalu |
|
||||
| Katalog konektoru | hotovo | 29 sluzeb, 9 kategorii |
|
||||
| Katalog sluzeb | hotovo | 29 sluzeb, 7 kategorii vcetne Obecne |
|
||||
| Builder automatizaci | hotovo | strom akci, vetveni podminkou |
|
||||
| Webhook s registrovanou adresou | hotovo | token generuje server, verejny endpoint validuje data |
|
||||
| Tickety na konkretni lidi | hotovo | resitel, filtr moje, prehled vytizeni tymu |
|
||||
@@ -31,6 +31,7 @@ React aplikaci ze slozky `dist/public`.
|
||||
| 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 |
|
||||
| Konektory za firmu | hotovo | pristupove udaje v konektoru, overeni napojeni |
|
||||
| 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 |
|
||||
|
||||
@@ -36,7 +36,14 @@ Vyzaduji `Authorization: Bearer <token>`:
|
||||
| POST | `/api/dashboard/tickets/:id/status` |
|
||||
| POST | `/api/dashboard/tickets/:id/comment` |
|
||||
| GET | `/api/dashboard/incidents` |
|
||||
| GET | `/api/dashboard/services` |
|
||||
| GET | `/api/dashboard/connectors/services` |
|
||||
| GET | `/api/dashboard/connectors` |
|
||||
| POST | `/api/dashboard/connectors` |
|
||||
| GET | `/api/dashboard/connectors/:id` |
|
||||
| PATCH | `/api/dashboard/connectors/:id` |
|
||||
| DELETE | `/api/dashboard/connectors/:id` |
|
||||
| POST | `/api/dashboard/connectors/:id/test` |
|
||||
| GET | `/api/dashboard/scripts` |
|
||||
| GET | `/api/dashboard/scripts/:id` |
|
||||
| PUT | `/api/dashboard/scripts/:id` |
|
||||
@@ -171,6 +178,22 @@ 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.
|
||||
|
||||
## Sluzby a konektory
|
||||
|
||||
Popis modelu je v [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md), tady jen API.
|
||||
|
||||
Sluzba je to, co umime. Konektor je napojeni jedne firmy vcetne jejich
|
||||
pristupovych udaju.
|
||||
|
||||
**Hodnoty pristupovych udaju se nikdy nevraci**, jen `filled` a `missing`.
|
||||
V `PATCH` staci poslat jen to, co se meni: prazdny retezec hodnotu smaze,
|
||||
chybejici klic ji nechava.
|
||||
|
||||
**Sluzba, kterou uzivatel nevidi, se nevraci vubec**, ne se stavem 403.
|
||||
|
||||
`POST /connectors/:id/test` vraci 200 i pri neuspechu. `checked` rika, co se
|
||||
vlastne overilo - u sluzby bez `verifyPath` jen dostupnost, ne udaje.
|
||||
|
||||
## Skripty konektoru
|
||||
|
||||
Popis modelu je v [11-skripty-konektoru.md](11-skripty-konektoru.md), tady jen API.
|
||||
|
||||
@@ -5,7 +5,7 @@ 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:
|
||||
Skript je **vykonna cast sluzby**. 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.
|
||||
@@ -24,7 +24,7 @@ scripts/ soubory skriptu, obycejny JavaScript
|
||||
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/connections.ts kam se vola a cim se to autorizuje (podle konektoru)
|
||||
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
|
||||
@@ -53,19 +53,19 @@ Uprava tedy funguje trema cestami a vzdy stejne:
|
||||
|
||||
## Nazev souboru je ID
|
||||
|
||||
Soubor se jmenuje `<konektor>.<operace>.js` a `manifest.id` musi byt stejne.
|
||||
Soubor se jmenuje `<sluzba>.<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
|
||||
sluzba 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.
|
||||
Z ID se dopocita, do ktere sluzby operace patri, takze se to nepise dvakrat.
|
||||
Sluzba **musi existovat** v `src/data/services.ts`, jinak se skript ohlasi
|
||||
jako problem.
|
||||
|
||||
## Manifest
|
||||
|
||||
@@ -178,27 +178,23 @@ 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.
|
||||
Skript se k pristupovym udajum nedostane. Adresu i hlavicky doplnuje runtime
|
||||
podle **konektoru** firmy, popis je v [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md).
|
||||
|
||||
Z environment variables uz nechodi zadne pristupove udaje, jen provozni
|
||||
nastaveni:
|
||||
|
||||
| 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 |
|
||||
| `<SLUZBA>_BASE_URL` | presmerovani jedne sluzby |
|
||||
| `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).
|
||||
Co ktera sluzba vyzaduje, je v `credentials` u sluzby v `src/data/services.ts`.
|
||||
Hodnoty patri konektoru a zadavaji se v portalu.
|
||||
|
||||
## Redakce tajemstvi
|
||||
|
||||
@@ -226,16 +222,16 @@ o skriptech.
|
||||
|
||||
## Napojeni do katalogu
|
||||
|
||||
Skript se domeri do katalogu konektoru jako akce s `implementation: 'script'`
|
||||
Skript se domeri do katalogu sluzeb 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`).
|
||||
Prekryv drzi `src/data/services.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.
|
||||
V portalu jsou operace se skriptem oznacene ikonou v katalogu sluzeb.
|
||||
|
||||
## API
|
||||
|
||||
@@ -292,13 +288,13 @@ cele je.
|
||||
|
||||
## Jak pridat skript
|
||||
|
||||
1. Zkopirovat `scripts/_sablona.js` na `<konektor>.<operace>.js`.
|
||||
1. Zkopirovat `scripts/_sablona.js` na `<sluzba>.<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`.
|
||||
5. Kdyz sluzba jeste neni v `src/data/services.ts`, pridat ji.
|
||||
6. Kdyz potrebuje pristupove udaje, popsat je v `credentials` u te sluzby.
|
||||
Hodnoty pak zada firma v konektoru.
|
||||
|
||||
Katalog, builder i stranka skriptu si ho vezmou samy. Nic se nerestartuje.
|
||||
|
||||
@@ -306,7 +302,6 @@ Katalog, builder i stranka skriptu si ho vezmou samy. Nic se nerestartuje.
|
||||
|
||||
| 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 |
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# 12 - Sluzby a konektory
|
||||
|
||||
Naprogramovano. Popis vykonne casti sluzeb je
|
||||
v [11-skripty-konektoru.md](11-skripty-konektoru.md).
|
||||
|
||||
## Tri vrstvy a co je co
|
||||
|
||||
Slovo "konektor" driv v kodu znamenalo katalog toho, co umime. Ted znamena
|
||||
napojeni jedne firmy. Rozdeleni je takove:
|
||||
|
||||
| Vrstva | Co to je | Kdo to vlastni |
|
||||
| ----------- | --------------------------------------------------- | -------------- |
|
||||
| **Sluzba** | ze iDoklad existuje, co umi a co potrebuje k napojeni | my |
|
||||
| **Skript** | kod, ktery jednu operaci sluzby opravdu vykona | my |
|
||||
| **Konektor**| ucet firmy vcetne jejich pristupovych udaju | firma |
|
||||
|
||||
Sluzba tedy rika "iDoklad chce hlavicky `X-ClientId` a `X-ClientSecret`",
|
||||
konektor rika "a tohle jsou nase".
|
||||
|
||||
```
|
||||
Sluzba iDoklad definujeme my
|
||||
| credentials: X-ClientId, X-ClientSecret
|
||||
| skripty: get-issued-invoice, create-issued-invoice, ...
|
||||
|
|
||||
+-- Konektor "iDoklad Celo" zaklada si firma Celo
|
||||
| values: clientId=..., clientSecret=...
|
||||
|
|
||||
+-- Konektor "iDoklad Delo" jina firma, jine udaje
|
||||
```
|
||||
|
||||
Krok automatizace pak nese oboji: **kterou operaci** (`serviceId` plus
|
||||
`operationId`) a **pod cim ji zavolat** (`connectorId`).
|
||||
|
||||
## Pristupove udaje patri konektoru, ne prostredi
|
||||
|
||||
Driv se cetly z environment variables. To bylo spatne: cela instance by mela
|
||||
jedny udaje spolecne a dve firmy by fakturovaly z jednoho uctu. Napojeni je
|
||||
vlastnost firmy, ne prostredi.
|
||||
|
||||
Z prostredi proto zustava jen `SERVICES_BASE_URL`, tedy zaklad adres. Zadne
|
||||
pristupove udaje.
|
||||
|
||||
Pravidla, ktera se u toho nesmi porusit:
|
||||
|
||||
- **Hodnoty se z API nikdy nevraci.** Odpoved nese `filled` (co je vyplnene)
|
||||
a `missing` (ktera povinna pole chybi), nikdy hodnoty. Tajne pole se z portalu
|
||||
jen zapisuje.
|
||||
- **Prazdne pole hodnotu nemeni.** Ulozeni formulare, ktery tajne hodnoty
|
||||
neposila, tedy nic nepresepe. Smazat hodnotu jde poslanim prazdneho retezce.
|
||||
- **Volani vzdy dela server.** Z prohlizece by to znamenalo poslat pristupove
|
||||
udaje do prohlizece, a stejne by to neproslo - sluzby kontroluji IP.
|
||||
- **Redakce v logu.** Nez cokoliv skonci v logu nebo v chybe, projde nahradou
|
||||
znamych tajnych hodnot za hvezdicky.
|
||||
- **Zmena udaju rusi predchozi overeni.** Konektor se vrati na `untested`,
|
||||
jinak by zelena znacka lhala.
|
||||
|
||||
## Obecne sluzby
|
||||
|
||||
Kategorie `obecne`, priznak `general: true`. Jsou dostupne vsem, nepotrebuji
|
||||
konektor a viditelnost se u nich neresi:
|
||||
|
||||
| Sluzba | K cemu |
|
||||
| ----------------- | ------------------------------------------ |
|
||||
| Webhook | prijem pozadavku zvenci |
|
||||
| Planovac | spousteni podle casu |
|
||||
| Rucni spusteni | tlacitko |
|
||||
| Webovy formular | odeslani formulare |
|
||||
| Tickety | servicedesk: zalozit, priradit, komentovat |
|
||||
| Transformace dat | premapovani a cisteni mezi kroky |
|
||||
| HTTP pozadavek | zavolani API, ktere vlastni sluzbu nema |
|
||||
| Pauza | cekani |
|
||||
| Zapis do logu | zaznam pro ladeni |
|
||||
|
||||
Duvod, proc je to zvlast kategorie a ne jen priznak: v builderu i v katalogu
|
||||
je chce clovek videt pohromade a hned. Nejsou to integrace, jsou to stavebni
|
||||
kameny.
|
||||
|
||||
## Viditelnost sluzby
|
||||
|
||||
```ts
|
||||
interface ServiceVisibility {
|
||||
mode: 'everyone' | 'restricted' | 'admin';
|
||||
tenantIds: string[]; // jen u restricted
|
||||
userIds: string[]; // jen u restricted, konkretni lide bez ohledu na firmu
|
||||
}
|
||||
```
|
||||
|
||||
| Mode | Kdo vidi |
|
||||
| ------------ | ---------------------------------------------- |
|
||||
| `everyone` | vsichni prihlaseni |
|
||||
| `restricted` | uvedene firmy a jmenovite uvedeni lide |
|
||||
| `admin` | jen spravce platformy |
|
||||
|
||||
Spravce platformy vidi vzdy vsechno. Obecne sluzby vidi vzdy vsichni.
|
||||
|
||||
**Sluzba, kterou uzivatel nevidi, se nevraci vubec.** Ne se stavem "nemate
|
||||
pravo" - firma nema z odpovedi poznat, ze takova sluzba existuje. Je to stejne
|
||||
pravidlo jako u ticketu v [07-firmy-a-prava.md](07-firmy-a-prava.md).
|
||||
|
||||
V katalogu je jako ukazka `polstryn-sap`, ktery vidi jen LogiTrans. Ostatni
|
||||
firmy ho v odpovedi nedostanou.
|
||||
|
||||
## Stav se prestal cist a zacal pocitat
|
||||
|
||||
Driv byl v katalogu `status: 'connected' | 'available' | 'planned'`. Prvni
|
||||
hodnota tam nepatrila: jestli je sluzba napojena, je vlastnost **firmy**, ne
|
||||
sluzby.
|
||||
|
||||
Sluzba proto ma jen `available` nebo `planned` a portal si stav dopocita:
|
||||
|
||||
| Co uzivatel vidi | Kdy |
|
||||
| ------------------ | -------------------------------------------------- |
|
||||
| Napojeno | obecna sluzba, nebo firma ma aspon jeden konektor |
|
||||
| Muzete napojit | sluzbu umime, firma konektor nema |
|
||||
| Na roadmape | `status: 'planned'` |
|
||||
| nic | sluzbu uzivatel nevidi, v odpovedi neni |
|
||||
|
||||
`GET /api/dashboard/connectors/services` proto vraci `connectorCount`.
|
||||
|
||||
## Vychozi konektor
|
||||
|
||||
Firma muze mit dva ucty teze sluzby. Krok stromu proto nese `connectorId`, ale
|
||||
smi byt `null`:
|
||||
|
||||
- **`null`** znamena vychozi konektor firmy pro tuhle sluzbu. Diky tomu je
|
||||
vzorovy strom prenositelny - kazde firme se dosadi jeji vlastni napojeni.
|
||||
- **Vyplnene** je potreba, kdyz firma ma dva ucty a zalezi na tom, ktery.
|
||||
|
||||
Prvni konektor na sluzbu se stane vychozim sam. Kdyz vychozi zmizi, prevezme to
|
||||
prvni zbyly - jinak by kroky bez vyberu prestaly fungovat, aniz by se cokoliv
|
||||
jineho zmenilo.
|
||||
|
||||
## Validace stromu
|
||||
|
||||
| Situace | Vysledek |
|
||||
| ------------------------------------------- | --------- |
|
||||
| Krok odkazuje na neexistujici sluzbu/operaci | chyba 400 |
|
||||
| Krok odkazuje na cizi konektor | chyba 400 |
|
||||
| Konektor patri jine sluzbe nez krok | chyba 400 |
|
||||
| Sluzba neni obecna a firma nema konektor | nedodelek |
|
||||
| Konektor existuje, ale chybi mu udaje | nedodelek |
|
||||
|
||||
Cizi konektor je chyba, ne nedodelek: strom, ktery by volal cizim jmenem, se
|
||||
nesmi ulozit. Chybejici napojeni je naopak nedodelek - rozdelana prace se
|
||||
nezahazuje, jen automatizace nepujde zapnout.
|
||||
|
||||
## Overeni konektoru
|
||||
|
||||
Sluzba muze deklarovat `verifyPath`, tedy levne **cteci** volani vyzadujici
|
||||
autorizaci. iDoklad ma `/account/agenda`.
|
||||
|
||||
Kdyz ho sluzba nema, overi se jen `/health`. Odpoved to v `checked` rekne
|
||||
nahlas - test, ktery projde i se spatnymi udaji, by uzivateli rikal nepravdu.
|
||||
|
||||
Neuspesne overeni **neni chyba API**. Vraci se 200 s `ok: false` a popisem,
|
||||
protoze vysledek "nefunguje to" je platna odpoved na otazku "funguje to?".
|
||||
|
||||
## API
|
||||
|
||||
| Metoda | Cesta | Popis |
|
||||
| ------ | ----------------------------------------- | ---------------------------- |
|
||||
| GET | `/api/dashboard/services` | katalog pro builder |
|
||||
| GET | `/api/dashboard/connectors/services` | katalog ocima firmy |
|
||||
| GET | `/api/dashboard/connectors` | konektory firmy |
|
||||
| POST | `/api/dashboard/connectors` | zalozit |
|
||||
| GET | `/api/dashboard/connectors/:id` | detail |
|
||||
| PATCH | `/api/dashboard/connectors/:id` | upravit |
|
||||
| DELETE | `/api/dashboard/connectors/:id` | smazat |
|
||||
| POST | `/api/dashboard/connectors/:id/test` | overit napojeni |
|
||||
|
||||
## Stranky portalu
|
||||
|
||||
```
|
||||
/dashboard/sluzby katalog: co umime, co uz mate napojene
|
||||
/dashboard/konektory napojeni teto firmy, formulare udaju, overeni
|
||||
/dashboard/skripty vykonna cast sluzeb
|
||||
```
|
||||
|
||||
## Jak pridat sluzbu
|
||||
|
||||
1. Zaznam do `services` v `src/data/services.ts`: kategorie, ikona, `general`,
|
||||
`appId`, `visibility`, `credentials`, pripadne `verifyPath`.
|
||||
2. Pokud pouziva novou ikonu, doplnit klic do `web/src/lib/serviceIcons.ts`.
|
||||
3. Skripty operaci do `scripts/<sluzba>.<operace>.js`, viz
|
||||
[11-skripty-konektoru.md](11-skripty-konektoru.md).
|
||||
|
||||
Katalog, builder, stranka Sluzby i zakladani konektoru si ji vezmou samy.
|
||||
|
||||
## Co se prejmenovalo
|
||||
|
||||
Kdo se v kodu orientoval podle stareho pojmenovani:
|
||||
|
||||
| Driv | Ted |
|
||||
| ----------------------------- | ------------------------------ |
|
||||
| `src/data/connectors.ts` | `src/data/services.ts` |
|
||||
| `Connector`, `ConnectorOperation` | `Service`, `ServiceOperation` |
|
||||
| `connectorCategories` | `serviceCategories` |
|
||||
| `findConnector` | `findService` |
|
||||
| `FlowStep.connectorId` | `FlowStep.serviceId` |
|
||||
| `GET /api/dashboard/connectors` | `GET /api/dashboard/services` |
|
||||
| `web/src/lib/connectorIcons.ts` | `web/src/lib/serviceIcons.ts` |
|
||||
| stranka Konektory (katalog) | stranka Sluzby |
|
||||
|
||||
`Connector` a `connectorId` v kodu ted znamenaji napojeni firmy, tedy to, co
|
||||
tim mysli i uzivatel.
|
||||
|
||||
## Co chybi
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| ---------------------------- | ----------------------------------------------------- |
|
||||
| Databaze a sifrovani udaju | hodnoty jsou v pameti procesu, restart je smaze |
|
||||
| Nastaveni viditelnosti z portalu | `visibility` jde zmenit jen v kodu |
|
||||
| Zamek pri soubeznem overovani| dva testy tehoz konektoru si prepisou stav |
|
||||
| Historie zmen konektoru | kdo kdy prepsal udaje, se nikde neuklada |
|
||||
| OAuth toky | zatim jen hlavicky, obnovovani tokenu resi sluzba |
|
||||
| Vyber konektoru v builderu | krok uz `connectorId` nese, UI ho zatim nenabizi |
|
||||
@@ -2,6 +2,48 @@
|
||||
|
||||
Nejnovejsi nahore.
|
||||
|
||||
## 2026-08-12 - sluzby a konektory
|
||||
|
||||
Rozdeleni na sluzbu a konektor. Popis
|
||||
v [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md).
|
||||
|
||||
Slovo "konektor" driv v kodu znamenalo katalog toho, co umime. Ted znamena
|
||||
napojeni jedne firmy, tedy to, co tim mysli i uzivatel.
|
||||
|
||||
### Pridano
|
||||
|
||||
- `src/data/services.ts`: sluzba nese `general`, `appId`, `visibility`,
|
||||
`credentials` a `verifyPath`. Kategorie `obecne` sdruzuje veci, ktere ma kazdy
|
||||
a nepotrebuji konektor: webhook, planovac, tickety, transformace dat,
|
||||
HTTP pozadavek, pauza, log.
|
||||
- Viditelnost sluzby: vsichni, jen uvedene firmy a lide, nebo jen spravce
|
||||
platformy. Neviditelna sluzba se z API nevraci vubec.
|
||||
- `src/data/connectorStore.ts`: konektory za firmu vcetne hodnot pristupovych
|
||||
udaju. Hodnoty se z API nikdy nevraci, jen `filled` a `missing`.
|
||||
- `FlowStep.connectorId`: krok rika, pod kterym napojenim se ma volat.
|
||||
`null` = vychozi konektor firmy, takze vzorovy strom je prenositelny.
|
||||
- Overeni konektoru pres `verifyPath`, tedy cteci volani vyzadujici autorizaci.
|
||||
U sluzby bez nej se overi jen dostupnost a odpoved to rekne nahlas.
|
||||
- Stranky `/dashboard/sluzby` a `/dashboard/konektory` vcetne formularu udaju.
|
||||
- Endpointy `/api/dashboard/services` a CRUD `/api/dashboard/connectors`
|
||||
vcetne Swaggeru.
|
||||
- Predvyplnene prihlaseni spravcem platformy a prepinac demo uctu na login
|
||||
strance. Kvuli testovani prototypu, pred ostrym pouzitim odebrat.
|
||||
|
||||
### Zmeneno
|
||||
|
||||
- **Pristupove udaje se prestaly cist z environment variables.** Cela instance
|
||||
by mela jedny udaje spolecne a dve firmy by fakturovaly z jednoho uctu.
|
||||
Z prostredi zustava jen `SERVICES_BASE_URL`.
|
||||
- Stav "napojeno" se prestal cist z katalogu a zacal pocitat z konektoru firmy.
|
||||
Sluzba ma jen `available` nebo `planned`.
|
||||
- Prejmenovani napric kodem: `Connector` na `Service`, `FlowStep.connectorId`
|
||||
na `serviceId`, `GET /connectors` na `GET /services`, `connectorIcons` na
|
||||
`serviceIcons`, stranka Konektory (katalog) na Sluzby. Prevodni tabulka je
|
||||
v dokumentu 12.
|
||||
- Validace stromu overuje i konektor. Cizi konektor je chyba, chybejici
|
||||
napojeni nedodelek.
|
||||
|
||||
## 2026-08-12 - skripty konektoru
|
||||
|
||||
Naprogramovana vykonna cast konektoru. Popis je
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
* nikde nevznikne jako operace - je tu jen ke zkopirovani.
|
||||
*
|
||||
* Postup:
|
||||
* 1. zkopirovat na `<konektor>.<operace>.js`, napriklad `idoklad.get-contact.js`,
|
||||
* 1. zkopirovat na `<sluzba>.<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`.
|
||||
@@ -16,8 +16,8 @@
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
/** Musi odpovidat nazvu souboru bez .js. */
|
||||
id: 'konektor.operace',
|
||||
/** Musi odpovidat nazvu souboru bez .js. Sluzba musi existovat v katalogu. */
|
||||
id: 'sluzba.operace',
|
||||
name: 'Nazev, ktery uvidi uzivatel v builderu',
|
||||
description: 'Jedna veta o tom, co se stane. Cte to clovek, ktery staví strom.',
|
||||
|
||||
|
||||
+67
-57
@@ -9,7 +9,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { publish } from '../events/bus.js';
|
||||
import { isUnary, type ConditionOperator, type FieldType } from './conditions.js';
|
||||
import { actionInputsFor, findConnector } from './connectors.js';
|
||||
import { actionInputsFor, findService } from './services.js';
|
||||
import { collectScopes, duplicateNames, scopeFor } from './flowScope.js';
|
||||
import { referencedFields } from './templates.js';
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface TriggerField {
|
||||
}
|
||||
|
||||
export interface FlowTrigger {
|
||||
connectorId: string;
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
/** Deklarovane vstupni parametry. Podminky se odkazuji na jejich `id`. */
|
||||
fields: TriggerField[];
|
||||
@@ -37,15 +37,25 @@ export interface FlowTrigger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Krok stromu. `action` je jeden ukon nad konektorem, `condition` rozdeluje
|
||||
* Krok stromu. `action` je jeden ukon nad sluzbou, `condition` rozdeluje
|
||||
* beh na dve vetve podle hodnoty vstupniho parametru - proto je to strom.
|
||||
*/
|
||||
export type FlowStep =
|
||||
| {
|
||||
id: string;
|
||||
kind: 'action';
|
||||
connectorId: string;
|
||||
/** Ktera sluzba a jeji operace. Sluzba je to, co umime. */
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
/**
|
||||
* Ktery konektor firmy se pouzije, tedy pod jakymi udaji se to zavola.
|
||||
*
|
||||
* `null` znamena vychozi konektor firmy pro tuhle sluzbu. Diky tomu je
|
||||
* vzorovy strom prenositelny mezi firmami - kazde se dosadi jeji vlastni
|
||||
* napojeni. U obecnych sluzeb (webhook, pauza, transformace) je vzdy null,
|
||||
* ty konektor nepotrebuji.
|
||||
*/
|
||||
connectorId?: string | null;
|
||||
/**
|
||||
* Nastaveni akce: klic je `OperationField.id` z katalogu, hodnota je
|
||||
* sablona s odkazy na parametry spoustece (`{{subject}}`).
|
||||
@@ -145,11 +155,11 @@ function actionInputIssues(
|
||||
available: TriggerField[],
|
||||
): string[] {
|
||||
const knownNames = new Set(available.map((field) => field.name));
|
||||
const catalog = actionInputsFor(step.connectorId, step.operationId);
|
||||
const catalog = actionInputsFor(step.serviceId, step.operationId);
|
||||
if (catalog.length === 0) return [];
|
||||
|
||||
const issues: string[] = [];
|
||||
const operationName = findConnector(step.connectorId)?.name ?? step.connectorId;
|
||||
const operationName = findService(step.serviceId)?.name ?? step.serviceId;
|
||||
|
||||
for (const field of catalog) {
|
||||
const value = step.inputs?.[field.id] ?? '';
|
||||
@@ -188,7 +198,7 @@ export function collectFlowIssues(flow: AutomationFlow): string[] {
|
||||
}
|
||||
|
||||
// Webhook bez registrovaneho tokenu nelze zavolat.
|
||||
const isWebhook = flow.trigger.connectorId === 'webhook';
|
||||
const isWebhook = flow.trigger.serviceId === 'webhook';
|
||||
if (isWebhook && !flow.trigger.webhookToken) {
|
||||
issues.push('Webhook nemá vygenerovanou adresu.');
|
||||
}
|
||||
@@ -244,7 +254,7 @@ function flowUsesCategory(steps: FlowStep[], category: string): boolean {
|
||||
if (step.kind === 'condition') {
|
||||
return flowUsesCategory(step.yes, category) || flowUsesCategory(step.no, category);
|
||||
}
|
||||
return findConnector(step.connectorId)?.category === category;
|
||||
return findService(step.serviceId)?.category === category;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -256,9 +266,9 @@ function flowUsesCategory(steps: FlowStep[], category: string): boolean {
|
||||
function deriveKind(flow: AutomationFlow): AutomationKind {
|
||||
if (!flow.trigger) return 'workflow';
|
||||
|
||||
const connector = findConnector(flow.trigger.connectorId);
|
||||
const connector = findService(flow.trigger.serviceId);
|
||||
if (!connector) {
|
||||
console.warn(`[automations] spoustec odkazuje na neznamy konektor: ${flow.trigger.connectorId}`);
|
||||
console.warn(`[automations] spoustec odkazuje na neznama sluzba: ${flow.trigger.serviceId}`);
|
||||
return 'workflow';
|
||||
}
|
||||
|
||||
@@ -313,7 +323,7 @@ seed({
|
||||
lastRunAt: minutesAgo(3),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'eshop',
|
||||
serviceId: 'eshop',
|
||||
operationId: 'order-created',
|
||||
fields: [
|
||||
{ id: 'f_1', name: 'orderId', type: 'string', required: true },
|
||||
@@ -322,8 +332,8 @@ seed({
|
||||
],
|
||||
},
|
||||
steps: [
|
||||
{ id: 'st_1', kind: 'action', connectorId: 'transform', operationId: 'map-fields' },
|
||||
{ id: 'st_2', kind: 'action', connectorId: 'eshop', operationId: 'update-stock' },
|
||||
{ id: 'st_1', kind: 'action', serviceId: 'transform', operationId: 'map-fields' },
|
||||
{ id: 'st_2', kind: 'action', serviceId: 'eshop', operationId: 'update-stock' },
|
||||
{
|
||||
id: 'st_3',
|
||||
kind: 'condition',
|
||||
@@ -331,12 +341,12 @@ seed({
|
||||
operator: 'gte',
|
||||
value: '5000',
|
||||
yes: [
|
||||
{ id: 'st_4', kind: 'action', connectorId: 'idoklad', operationId: 'create-proforma' },
|
||||
{ id: 'st_5', kind: 'action', connectorId: 'email', operationId: 'send' },
|
||||
{ id: 'st_4', kind: 'action', serviceId: 'idoklad', operationId: 'create-proforma' },
|
||||
{ id: 'st_5', kind: 'action', serviceId: 'email', operationId: 'send' },
|
||||
],
|
||||
no: [{ id: 'st_6', kind: 'action', connectorId: 'idoklad', operationId: 'create-invoice' }],
|
||||
no: [{ id: 'st_6', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' }],
|
||||
},
|
||||
{ id: 'st_7', kind: 'action', connectorId: 'ppl', operationId: 'create-shipment' },
|
||||
{ id: 'st_7', kind: 'action', serviceId: 'ppl', operationId: 'create-shipment' },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -350,7 +360,7 @@ seed({
|
||||
lastRunAt: minutesAgo(11),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'voicebot',
|
||||
serviceId: 'voicebot',
|
||||
operationId: 'call-received',
|
||||
fields: [
|
||||
{ id: 'f_11', name: 'callerNumber', type: 'string', required: true },
|
||||
@@ -358,18 +368,18 @@ seed({
|
||||
],
|
||||
},
|
||||
steps: [
|
||||
{ id: 'st_11', kind: 'action', connectorId: 'voicebot', operationId: 'play-scenario' },
|
||||
{ id: 'st_12', kind: 'action', connectorId: 'transcription', operationId: 'transcribe' },
|
||||
{ id: 'st_13', kind: 'action', connectorId: 'ai-text', operationId: 'classify' },
|
||||
{ id: 'st_11', kind: 'action', serviceId: 'voicebot', operationId: 'play-scenario' },
|
||||
{ id: 'st_12', kind: 'action', serviceId: 'transcription', operationId: 'transcribe' },
|
||||
{ id: 'st_13', kind: 'action', serviceId: 'ai-text', operationId: 'classify' },
|
||||
{
|
||||
id: 'st_14',
|
||||
kind: 'condition',
|
||||
fieldId: 'f_12',
|
||||
operator: 'isTrue',
|
||||
yes: [{ id: 'st_15', kind: 'action', connectorId: 'voicebot', operationId: 'transfer' }],
|
||||
yes: [{ id: 'st_15', kind: 'action', serviceId: 'voicebot', operationId: 'transfer' }],
|
||||
no: [
|
||||
{ id: 'st_16', kind: 'action', connectorId: 'raynet', operationId: 'create-lead' },
|
||||
{ id: 'st_17', kind: 'action', connectorId: 'email', operationId: 'send' },
|
||||
{ id: 'st_16', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' },
|
||||
{ id: 'st_17', kind: 'action', serviceId: 'email', operationId: 'send' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -385,14 +395,14 @@ seed({
|
||||
lastRunAt: minutesAgo(26),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'company-changed',
|
||||
fields: [{ id: 'f_21', name: 'companyId', type: 'string', required: true }],
|
||||
},
|
||||
steps: [
|
||||
{ id: 'st_21', kind: 'action', connectorId: 'transform', operationId: 'deduplicate' },
|
||||
{ id: 'st_22', kind: 'action', connectorId: 'idoklad', operationId: 'create-invoice' },
|
||||
{ id: 'st_23', kind: 'action', connectorId: 'log', operationId: 'write' },
|
||||
{ id: 'st_21', kind: 'action', serviceId: 'transform', operationId: 'deduplicate' },
|
||||
{ id: 'st_22', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' },
|
||||
{ id: 'st_23', kind: 'action', serviceId: 'log', operationId: 'write' },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -405,13 +415,13 @@ seed({
|
||||
avgDurationMs: 18_400,
|
||||
lastRunAt: minutesAgo(1_020),
|
||||
flow: {
|
||||
trigger: { connectorId: 'scheduler', operationId: 'interval', fields: [] },
|
||||
trigger: { serviceId: 'scheduler', operationId: 'interval', fields: [] },
|
||||
steps: [
|
||||
{ id: 'st_31', kind: 'action', connectorId: 'ga4', operationId: 'run-report' },
|
||||
{ id: 'st_32', kind: 'action', connectorId: 'google-ads', operationId: 'campaign-report' },
|
||||
{ id: 'st_33', kind: 'action', connectorId: 'sklik', operationId: 'campaign-report' },
|
||||
{ id: 'st_34', kind: 'action', connectorId: 'ai-text', operationId: 'generate' },
|
||||
{ id: 'st_35', kind: 'action', connectorId: 'email', operationId: 'send' },
|
||||
{ id: 'st_31', kind: 'action', serviceId: 'ga4', operationId: 'run-report' },
|
||||
{ id: 'st_32', kind: 'action', serviceId: 'google-ads', operationId: 'campaign-report' },
|
||||
{ id: 'st_33', kind: 'action', serviceId: 'sklik', operationId: 'campaign-report' },
|
||||
{ id: 'st_34', kind: 'action', serviceId: 'ai-text', operationId: 'generate' },
|
||||
{ id: 'st_35', kind: 'action', serviceId: 'email', operationId: 'send' },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -426,7 +436,7 @@ seed({
|
||||
lastRunAt: minutesAgo(18),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'webhook',
|
||||
serviceId: 'webhook',
|
||||
operationId: 'received',
|
||||
webhookToken: generateWebhookToken(),
|
||||
fields: [
|
||||
@@ -442,12 +452,12 @@ seed({
|
||||
fieldId: 'f_42',
|
||||
operator: 'gte',
|
||||
value: '15',
|
||||
yes: [{ id: 'st_42', kind: 'action', connectorId: 'raynet', operationId: 'add-activity' }],
|
||||
yes: [{ id: 'st_42', kind: 'action', serviceId: 'raynet', operationId: 'add-activity' }],
|
||||
no: [
|
||||
{
|
||||
id: 'st_43',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
inputs: {
|
||||
subject: 'Nízké hodnocení od {{customer}}',
|
||||
@@ -457,7 +467,7 @@ seed({
|
||||
assigneeId: 'ppl_vomacka',
|
||||
},
|
||||
},
|
||||
{ id: 'st_44', kind: 'action', connectorId: 'microsoft365', operationId: 'post-teams' },
|
||||
{ id: 'st_44', kind: 'action', serviceId: 'microsoft365', operationId: 'post-teams' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -478,7 +488,7 @@ seed({
|
||||
lastRunAt: minutesAgo(7),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'whatsapp',
|
||||
serviceId: 'whatsapp',
|
||||
operationId: 'message-received',
|
||||
fields: [
|
||||
{ id: 'whatsapp.phone', name: 'phone', type: 'string', required: true },
|
||||
@@ -493,7 +503,7 @@ seed({
|
||||
{
|
||||
id: 'st_51',
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'find-company',
|
||||
inputs: { phone: '{{phone}}' },
|
||||
},
|
||||
@@ -507,7 +517,7 @@ seed({
|
||||
{
|
||||
id: 'st_53',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
inputs: {
|
||||
subject: 'WhatsApp od {{profileName}}',
|
||||
@@ -524,7 +534,7 @@ seed({
|
||||
{
|
||||
id: 'st_54',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
inputs: {
|
||||
subject: 'WhatsApp od neznámého čísla',
|
||||
@@ -535,7 +545,7 @@ seed({
|
||||
assigneeId: '',
|
||||
},
|
||||
},
|
||||
{ id: 'st_55', kind: 'action', connectorId: 'raynet', operationId: 'create-lead' },
|
||||
{ id: 'st_55', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -551,7 +561,7 @@ seed({
|
||||
lastRunAt: minutesAgo(52),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'facebook',
|
||||
serviceId: 'facebook',
|
||||
operationId: 'message-received',
|
||||
fields: [
|
||||
{ id: 'facebook.senderId', name: 'senderId', type: 'string', required: true },
|
||||
@@ -567,7 +577,7 @@ seed({
|
||||
{
|
||||
id: 'st_61',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
inputs: {
|
||||
subject: 'Facebook od {{senderName}}',
|
||||
@@ -591,7 +601,7 @@ seed({
|
||||
lastRunAt: minutesAgo(14),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'email',
|
||||
serviceId: 'email',
|
||||
operationId: 'received',
|
||||
fields: [
|
||||
{ id: 'email.from', name: 'from', type: 'string', required: true },
|
||||
@@ -605,7 +615,7 @@ seed({
|
||||
{
|
||||
id: 'st_65',
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'find-company',
|
||||
inputs: { email: '{{from}}' },
|
||||
},
|
||||
@@ -618,7 +628,7 @@ seed({
|
||||
{
|
||||
id: 'st_67',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
inputs: {
|
||||
subject: '{{subject}}',
|
||||
@@ -635,7 +645,7 @@ seed({
|
||||
{
|
||||
id: 'st_68',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
inputs: {
|
||||
subject: '{{subject}}',
|
||||
@@ -649,7 +659,7 @@ seed({
|
||||
{
|
||||
id: 'st_69',
|
||||
kind: 'action',
|
||||
connectorId: 'email',
|
||||
serviceId: 'email',
|
||||
operationId: 'send',
|
||||
inputs: {
|
||||
to: '{{from}}',
|
||||
@@ -676,7 +686,7 @@ seed({
|
||||
lastRunAt: minutesAgo(4),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'created',
|
||||
fields: [
|
||||
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
|
||||
@@ -708,7 +718,7 @@ seed({
|
||||
{
|
||||
id: 'st_73',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_kriz' },
|
||||
},
|
||||
@@ -724,7 +734,7 @@ seed({
|
||||
{
|
||||
id: 'st_75',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_novakova' },
|
||||
},
|
||||
@@ -733,7 +743,7 @@ seed({
|
||||
{
|
||||
id: 'st_76',
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_vomacka' },
|
||||
},
|
||||
@@ -873,7 +883,7 @@ function withWebhookToken(
|
||||
): AutomationFlow {
|
||||
if (!next.trigger) return next;
|
||||
|
||||
if (next.trigger.connectorId !== 'webhook') {
|
||||
if (next.trigger.serviceId !== 'webhook') {
|
||||
if (next.trigger.webhookToken) {
|
||||
console.info(`[automations] ${id}: spoustec neni webhook, zahazuji token`);
|
||||
}
|
||||
@@ -882,7 +892,7 @@ function withWebhookToken(
|
||||
|
||||
// Existujici token drzime, aby se uz zaregistrovana adresa nezmenila pod rukama.
|
||||
const keptToken =
|
||||
previous.trigger?.connectorId === 'webhook' ? previous.trigger.webhookToken : undefined;
|
||||
previous.trigger?.serviceId === 'webhook' ? previous.trigger.webhookToken : undefined;
|
||||
|
||||
if (keptToken) {
|
||||
return { ...next, trigger: { ...next.trigger, webhookToken: keptToken } };
|
||||
@@ -903,7 +913,7 @@ export function regenerateWebhookToken(
|
||||
console.warn(`[automations] regenerace tokenu pro nedostupnou automatizaci: ${id}`);
|
||||
return undefined;
|
||||
}
|
||||
if (stored.flow.trigger?.connectorId !== 'webhook') {
|
||||
if (stored.flow.trigger?.serviceId !== 'webhook') {
|
||||
console.warn(`[automations] ${id}: regenerace tokenu, ale spoustec neni webhook`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Konektory = napojeni jedne firmy na jednu sluzbu.
|
||||
*
|
||||
* Sluzba (`services.ts`) rika, co je potreba: "iDoklad chce hlavicky
|
||||
* X-ClientId a X-ClientSecret". Konektor drzi hodnoty. Kazda firma ma svoje.
|
||||
*
|
||||
* Proto **pristupove udaje nejsou v environment variables**. Kdyby byly,
|
||||
* mela by je cela instance spolecne a dve firmy by fakturovaly ze stejneho
|
||||
* uctu. Napojeni je vlastnost firmy, ne prostredi.
|
||||
*
|
||||
* POZOR: data jsou v pameti procesu, restart je vrati na vychozi sadu.
|
||||
* Cilovy stav je Postgres se sifrovanymi hodnotami, viz
|
||||
* documentation/09-navrh-rozsireni.md, bod 9.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { findService, type Service, type ServiceCredentialField } from './services.js';
|
||||
|
||||
export interface Connector {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
serviceId: string;
|
||||
/** Firma muze mit dva ucty teze sluzby, proto vlastni nazev. */
|
||||
name: string;
|
||||
/** Prepis adresy sluzby. null = vychozi podle `appId` a SERVICES_BASE_URL. */
|
||||
baseUrl: string | null;
|
||||
/** Hodnoty poli z `Service.credentials`. Tajne se z API nikdy nevraci. */
|
||||
values: Record<string, string>;
|
||||
enabled: boolean;
|
||||
/** untested = jeste se neoverovalo, ok = posledni overeni proslo. */
|
||||
status: 'untested' | 'ok' | 'error';
|
||||
lastCheckAt: string | null;
|
||||
lastError: string | null;
|
||||
/** Krok stromu bez vybraneho konektoru pouzije vychozi. */
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verze pro klienta. **Tajne hodnoty tady nejsou** a nikdy nesmi byt -
|
||||
* secrets se nevraci z beznych endpointu (AGENTS.md).
|
||||
*/
|
||||
export interface PublicConnector {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
serviceId: string;
|
||||
name: string;
|
||||
baseUrl: string | null;
|
||||
enabled: boolean;
|
||||
status: Connector['status'];
|
||||
lastCheckAt: string | null;
|
||||
lastError: string | null;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** ID poli, ktera jsou vyplnena. Hodnoty se nevraci. */
|
||||
filled: string[];
|
||||
/** ID povinnych poli, ktera jeste chybi. */
|
||||
missing: string[];
|
||||
/** Necitliva nastaveni. Tajna pole tu nejsou vubec. */
|
||||
config: Record<string, string>;
|
||||
/** true = vsechna povinna pole jsou vyplnena, jde volat. */
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
const connectors: Connector[] = [];
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/** Povinna pole, ktera nejsou vyplnena. */
|
||||
function missingFields(service: Service, values: Record<string, string>): string[] {
|
||||
return service.credentials
|
||||
.filter((field) => field.required && (values[field.id] ?? '').trim() === '')
|
||||
.map((field) => field.id);
|
||||
}
|
||||
|
||||
export function toPublicConnector(connector: Connector): PublicConnector {
|
||||
const service = findService(connector.serviceId);
|
||||
const credentials = service?.credentials ?? [];
|
||||
|
||||
const filled = Object.entries(connector.values)
|
||||
.filter(([, value]) => value.trim() !== '')
|
||||
.map(([key]) => key);
|
||||
|
||||
const config: Record<string, string> = {};
|
||||
for (const field of credentials) {
|
||||
if (field.secret) continue;
|
||||
const value = connector.values[field.id];
|
||||
if (value !== undefined && value.trim() !== '') config[field.id] = value;
|
||||
}
|
||||
|
||||
const missing = service ? missingFields(service, connector.values) : [];
|
||||
|
||||
const { values: _values, ...rest } = connector;
|
||||
return { ...rest, filled, missing, config, ready: missing.length === 0 };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- cteni
|
||||
|
||||
/**
|
||||
* Filtr na firmu je **povinny argument**, ne volitelny. Zapomenuty filtr tak
|
||||
* neznamena "vse", ale nezkompiluje se. Stejne jako u ticketu.
|
||||
*/
|
||||
export function listConnectors(
|
||||
tenantIds: string[],
|
||||
options: { serviceId?: string } = {},
|
||||
): Connector[] {
|
||||
return connectors
|
||||
.filter((connector) => tenantIds.includes(connector.tenantId))
|
||||
.filter((connector) => !options.serviceId || connector.serviceId === options.serviceId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
}
|
||||
|
||||
/** Cizi konektor se chova jako neexistujici, tedy undefined, ne chyba prava. */
|
||||
export function getConnector(id: string, tenantIds: string[]): Connector | undefined {
|
||||
const connector = connectors.find((item) => item.id === id);
|
||||
if (!connector) return undefined;
|
||||
return tenantIds.includes(connector.tenantId) ? connector : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vychozi konektor firmy pro danou sluzbu.
|
||||
* Krok stromu bez vybraneho konektoru pouzije tenhle, diky tomu je vzorovy
|
||||
* strom prenositelny mezi firmami.
|
||||
*/
|
||||
export function defaultConnectorFor(tenantId: string, serviceId: string): Connector | undefined {
|
||||
const forService = connectors.filter(
|
||||
(connector) =>
|
||||
connector.tenantId === tenantId && connector.serviceId === serviceId && connector.enabled,
|
||||
);
|
||||
return forService.find((connector) => connector.isDefault) ?? forService[0];
|
||||
}
|
||||
|
||||
/** Kolik konektoru ma firma na kterou sluzbu. Podle toho se kresli stav v katalogu. */
|
||||
export function connectorCountsByService(tenantIds: string[]): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const connector of listConnectors(tenantIds)) {
|
||||
counts.set(connector.serviceId, (counts.get(connector.serviceId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- validace
|
||||
|
||||
export interface ConnectorIssue {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overi hodnoty proti tomu, co sluzba vyzaduje.
|
||||
*
|
||||
* Nevyplnene povinne pole **neni chyba** ukladani, ale nedodelek: konektor se
|
||||
* ulozi a jen nepujde pouzit. Stejny rezim jako u rozdelane automatizace -
|
||||
* rozdelana prace se nezahazuje.
|
||||
*
|
||||
* Chyba je jen pole, ktere sluzba vubec nema. To uz je rozbite napojeni.
|
||||
*/
|
||||
export function validateConnectorValues(
|
||||
service: Service,
|
||||
values: Record<string, string>,
|
||||
): ConnectorIssue[] {
|
||||
const known = new Map<string, ServiceCredentialField>(
|
||||
service.credentials.map((field) => [field.id, field]),
|
||||
);
|
||||
|
||||
const issues: ConnectorIssue[] = [];
|
||||
for (const key of Object.keys(values)) {
|
||||
if (!known.has(key)) {
|
||||
issues.push({ field: key, message: `Služba ${service.name} pole „${key}" nemá.` });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- zapis
|
||||
|
||||
export interface CreateConnectorInput {
|
||||
tenantId: string;
|
||||
serviceId: string;
|
||||
name: string;
|
||||
baseUrl?: string | null;
|
||||
values?: Record<string, string>;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export function createConnector(input: CreateConnectorInput): Connector {
|
||||
const timestamp = now();
|
||||
const existing = listConnectors([input.tenantId], { serviceId: input.serviceId });
|
||||
|
||||
const connector: Connector = {
|
||||
id: `con_${randomUUID().slice(0, 8)}`,
|
||||
tenantId: input.tenantId,
|
||||
serviceId: input.serviceId,
|
||||
name: input.name,
|
||||
baseUrl: input.baseUrl ?? null,
|
||||
values: { ...(input.values ?? {}) },
|
||||
enabled: true,
|
||||
status: 'untested',
|
||||
lastCheckAt: null,
|
||||
lastError: null,
|
||||
// Prvni konektor na sluzbu je vychozi, jinak by krok bez vyberu nemel co vzit.
|
||||
isDefault: input.isDefault ?? existing.length === 0,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
if (connector.isDefault) clearDefaults(input.tenantId, input.serviceId);
|
||||
connectors.push(connector);
|
||||
console.info(`[connectors] zalozen ${connector.id} (${connector.serviceId}) pro ${connector.tenantId}`);
|
||||
return connector;
|
||||
}
|
||||
|
||||
function clearDefaults(tenantId: string, serviceId: string): void {
|
||||
for (const connector of connectors) {
|
||||
if (connector.tenantId === tenantId && connector.serviceId === serviceId) {
|
||||
connector.isDefault = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateConnectorInput {
|
||||
name?: string;
|
||||
baseUrl?: string | null;
|
||||
/**
|
||||
* Jen pole, ktera se meni. Prazdny retezec hodnotu **smaze**, chybejici klic
|
||||
* ji nechá. Diky tomu jde ulozit formular, ktery tajne hodnoty neposila.
|
||||
*/
|
||||
values?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export function updateConnector(
|
||||
id: string,
|
||||
patch: UpdateConnectorInput,
|
||||
tenantIds: string[],
|
||||
): Connector | undefined {
|
||||
const connector = getConnector(id, tenantIds);
|
||||
if (!connector) return undefined;
|
||||
|
||||
if (patch.name !== undefined) connector.name = patch.name;
|
||||
if (patch.baseUrl !== undefined) connector.baseUrl = patch.baseUrl;
|
||||
if (patch.enabled !== undefined) connector.enabled = patch.enabled;
|
||||
|
||||
if (patch.values) {
|
||||
for (const [key, value] of Object.entries(patch.values)) {
|
||||
if (value === '') delete connector.values[key];
|
||||
else connector.values[key] = value;
|
||||
}
|
||||
// Zmena udaju znamena, ze predchozi overeni uz nic nerika.
|
||||
connector.status = 'untested';
|
||||
connector.lastError = null;
|
||||
}
|
||||
|
||||
if (patch.isDefault === true) {
|
||||
clearDefaults(connector.tenantId, connector.serviceId);
|
||||
connector.isDefault = true;
|
||||
}
|
||||
|
||||
connector.updatedAt = now();
|
||||
return connector;
|
||||
}
|
||||
|
||||
export function deleteConnector(id: string, tenantIds: string[]): boolean {
|
||||
const connector = getConnector(id, tenantIds);
|
||||
if (!connector) return false;
|
||||
|
||||
const index = connectors.indexOf(connector);
|
||||
connectors.splice(index, 1);
|
||||
|
||||
// Kdyz zmizel vychozi, prevezme to prvni zbyly - jinak by kroky bez vyberu
|
||||
// prestaly fungovat, aniz by se cokoliv jineho zmenilo.
|
||||
if (connector.isDefault) {
|
||||
const next = listConnectors([connector.tenantId], { serviceId: connector.serviceId })[0];
|
||||
if (next) next.isDefault = true;
|
||||
}
|
||||
|
||||
console.info(`[connectors] smazan ${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Vysledek overeni napojeni. Zapisuje ho endpoint pro test. */
|
||||
export function setConnectorStatus(
|
||||
id: string,
|
||||
status: Connector['status'],
|
||||
error: string | null,
|
||||
tenantIds: string[],
|
||||
): Connector | undefined {
|
||||
const connector = getConnector(id, tenantIds);
|
||||
if (!connector) return undefined;
|
||||
|
||||
connector.status = status;
|
||||
connector.lastError = error;
|
||||
connector.lastCheckAt = now();
|
||||
connector.updatedAt = connector.lastCheckAt;
|
||||
return connector;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- vychozi sada
|
||||
|
||||
/**
|
||||
* Jeden ukazkovy konektor bez vyplnenych udaju.
|
||||
*
|
||||
* Zamerne bez nich: ukazuje presne ten stav, ve kterem konektor vznikne, tedy
|
||||
* "sluzba je napojena, ale chybi pristupove udaje". Vyplnit je jde v portalu.
|
||||
*/
|
||||
createConnector({
|
||||
tenantId: 'tnt_automia',
|
||||
serviceId: 'idoklad',
|
||||
name: 'iDoklad Automia',
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
* Server je autorita, kopie na klientovi existuje jen kvuli UI.
|
||||
*/
|
||||
|
||||
import { actionOutputsFor } from './connectors.js';
|
||||
import { actionOutputsFor } from './services.js';
|
||||
import type { AutomationFlow, FlowStep, TriggerField } from './automationStore.js';
|
||||
|
||||
export interface FlowScopes {
|
||||
@@ -30,7 +30,7 @@ export interface FlowScopes {
|
||||
|
||||
/** Vystupy akce jako pole se stabilnim ID v ramci celeho stromu. */
|
||||
function outputsOf(step: Extract<FlowStep, { kind: 'action' }>): TriggerField[] {
|
||||
return actionOutputsFor(step.connectorId, step.operationId).map((output) => ({
|
||||
return actionOutputsFor(step.serviceId, step.operationId).map((output) => ({
|
||||
// Prefix ID krokem - dva stejne kroky ve strome se nesmi prekryt.
|
||||
id: `${step.id}.${output.id}`,
|
||||
name: output.name,
|
||||
|
||||
@@ -1,30 +1,83 @@
|
||||
/**
|
||||
* Katalog konektoru = zdroj pravdy o tom, co lze v automatizaci pouzit.
|
||||
* Katalog SLUZEB = zdroj pravdy o tom, co lze v automatizaci a v akcich pouzit.
|
||||
*
|
||||
* Kazdy konektor ma:
|
||||
* Pozor na dve veci, ktere se snadno pletou:
|
||||
* - **Sluzba** je to, co umime. iDoklad, Shoptet, tickety, HTTP pozadavek.
|
||||
* Definujeme ji my, ma svoje operace a rika, co je potreba k napojeni.
|
||||
* - **Konektor** je napojeni jedne firmy na jednu sluzbu vcetne jejich
|
||||
* pristupovych udaju. Zaklada si ho firma, uloziste je `connectorStore.ts`.
|
||||
*
|
||||
* Sluzba tedy rika "iDoklad potrebuje X-ClientId a X-ClientSecret",
|
||||
* konektor rika "a tohle jsou nase".
|
||||
*
|
||||
* Kazda sluzba ma:
|
||||
* - triggers: udalosti, kterymi muze automatizace ZACIT (spoustec)
|
||||
* - actions: co se s nim da UDELAT uprostred behu
|
||||
* - actions: co se s ni da UDELAT uprostred behu
|
||||
*
|
||||
* Konektor muze mit jen triggery (webhook), jen akce (odeslani e-mailu), nebo obojí.
|
||||
* Jak pridat novy konektor: documentation/05-dashboard-a-builder.md
|
||||
* Sluzba muze mit jen triggery (webhook), jen akce (odeslani e-mailu), nebo obojí.
|
||||
* Jak pridat sluzbu: documentation/12-sluzby-a-konektory.md
|
||||
*/
|
||||
|
||||
import type { FieldType } from './conditions.js';
|
||||
import { people } from './people.js';
|
||||
import type { User } from '../types.js';
|
||||
|
||||
export type ConnectorCategory =
|
||||
| 'spoustece'
|
||||
| 'servicedesk'
|
||||
export type ServiceCategory =
|
||||
/** Obecne veci, ktere ma kazdy. Nepotrebuji konektor. */
|
||||
| 'obecne'
|
||||
| 'crm'
|
||||
| 'ekonomika'
|
||||
| 'logistika'
|
||||
| 'komunikace'
|
||||
| 'analytika'
|
||||
| 'ai'
|
||||
| 'nastroje';
|
||||
| 'ai';
|
||||
|
||||
/** connected = klient ho ma napojeny, available = umime napojit, planned = na roadmape */
|
||||
export type ConnectorStatus = 'connected' | 'available' | 'planned';
|
||||
/**
|
||||
* available = sluzbu umime, planned = je na roadmape.
|
||||
*
|
||||
* "Napojeno" tady zamerne NENI. Jestli je sluzba napojena, zavisi na tom,
|
||||
* jestli si firma vytvorila konektor - to je vlastnost firmy, ne sluzby.
|
||||
* Pocita se v `serviceViewsFor`.
|
||||
*/
|
||||
export type ServiceStatus = 'available' | 'planned';
|
||||
|
||||
/**
|
||||
* Kdo sluzbu vidi.
|
||||
*
|
||||
* `everyone` vsichni prihlaseni
|
||||
* `restricted` jen uvedene firmy a jmenovite uvedeni lide
|
||||
* `admin` jen spravce platformy
|
||||
*
|
||||
* Spravce platformy vidi vzdy vsechno. Obecne sluzby (`general: true`) taky
|
||||
* vidi vzdy vsichni, viditelnost se u nich neresi.
|
||||
*/
|
||||
export interface ServiceVisibility {
|
||||
mode: 'everyone' | 'restricted' | 'admin';
|
||||
/** Jen u `restricted`: firmy, ktere sluzbu vidi. */
|
||||
tenantIds: string[];
|
||||
/** Jen u `restricted`: konkretni lide bez ohledu na firmu. */
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeden udaj, ktery je potreba vyplnit pri zakladani konektoru.
|
||||
*
|
||||
* `target: 'header'` znamena, ze se hodnota posila jako hlavicka requestu
|
||||
* pojmenovana v `name`. Tim je popsane, co ktera sluzba vyzaduje, a konektor
|
||||
* uz jen doplni hodnoty.
|
||||
*
|
||||
* `secret: true` se **nikdy nevraci z API** a redaguje se v logu.
|
||||
*/
|
||||
export interface ServiceCredentialField {
|
||||
id: string;
|
||||
label: string;
|
||||
target: 'header' | 'config';
|
||||
/** Jmeno hlavicky u `header`, jmeno klice v `ctx.config` u `config`. */
|
||||
name: string;
|
||||
required: boolean;
|
||||
secret: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parametr, ktery spoustec sam preda do stromu. Tvarem odpovida `TriggerField`
|
||||
@@ -58,7 +111,7 @@ export interface OperationField {
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ConnectorOperation {
|
||||
export interface ServiceOperation {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -92,23 +145,48 @@ export interface ConnectorOperation {
|
||||
/**
|
||||
* `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.
|
||||
* Doplnuje `src/scripts/registry.ts`, rucne se to nepise.
|
||||
*/
|
||||
implementation?: 'script';
|
||||
/** Ktery skript operaci obsluhuje. Vyplnene spolu s `implementation`. */
|
||||
scriptId?: string;
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
category: ConnectorCategory;
|
||||
category: ServiceCategory;
|
||||
description: string;
|
||||
/** Klic ikony - frontend si ho mapuje na komponentu (lib/connectorIcons.ts). */
|
||||
/** Klic ikony - frontend si ho mapuje na komponentu (lib/serviceIcons.ts). */
|
||||
icon: string;
|
||||
status: ConnectorStatus;
|
||||
triggers: ConnectorOperation[];
|
||||
actions: ConnectorOperation[];
|
||||
status: ServiceStatus;
|
||||
/**
|
||||
* true = sluzba funguje bez konektoru. Obecne veci, ktere ma kazdy:
|
||||
* webhook, planovac, tickety, transformace dat, HTTP pozadavek, pauza, log.
|
||||
*/
|
||||
general: boolean;
|
||||
/**
|
||||
* ID aplikace v services.csbot.cz/apps. Zaklad adresy se sklada z nej
|
||||
* a ze `SERVICES_BASE_URL`, verejna domena se nikdy nehardcoduje (AGENTS.md).
|
||||
* null = sluzba se nevola pres jednu pevnou adresu.
|
||||
*/
|
||||
appId: string | null;
|
||||
visibility: ServiceVisibility;
|
||||
/**
|
||||
* Co je potreba vyplnit pri zakladani konektoru.
|
||||
* Hodnoty patri konektoru, ne prostredi - kazda firma ma svoje.
|
||||
* Prazdne pole u obecnych sluzeb a u tech, ktere jeste nemame popsane.
|
||||
*/
|
||||
credentials: ServiceCredentialField[];
|
||||
/**
|
||||
* Levne cteci volani, kterym se overi, ze konektor funguje.
|
||||
* Musi to byt neco, co **nic nemeni** a co vyzaduje autorizaci - jinak by test
|
||||
* prosel i se spatnymi udaji a rekl uzivateli nepravdu.
|
||||
* Kdyz chybi, overi se jen dostupnost sluzby na `/health`.
|
||||
*/
|
||||
verifyPath?: string;
|
||||
triggers: ServiceOperation[];
|
||||
actions: ServiceOperation[];
|
||||
}
|
||||
|
||||
/** Nabidka resitelu do vyberu u akci. Bere se ze seznamu lidi, aby se nerozesla. */
|
||||
@@ -131,27 +209,29 @@ const statusOptions = [
|
||||
{ value: 'resolved', label: 'Vyřešeno' },
|
||||
];
|
||||
|
||||
export const connectorCategories: Array<{ id: ConnectorCategory; label: string }> = [
|
||||
{ id: 'spoustece', label: 'Spouštěče' },
|
||||
{ id: 'servicedesk', label: 'Tickety a servicedesk' },
|
||||
export const serviceCategories: Array<{ id: ServiceCategory; label: string }> = [
|
||||
{ id: 'obecne', label: 'Obecné' },
|
||||
{ id: 'crm', label: 'CRM' },
|
||||
{ id: 'ekonomika', label: 'Ekonomika a banky' },
|
||||
{ id: 'logistika', label: 'Logistika' },
|
||||
{ id: 'komunikace', label: 'Komunikace' },
|
||||
{ id: 'analytika', label: 'Analytika' },
|
||||
{ id: 'ai', label: 'AI a hlas' },
|
||||
{ id: 'nastroje', label: 'Nástroje' },
|
||||
];
|
||||
|
||||
export const connectors: Connector[] = [
|
||||
// ---------------------------------------------------------------- spoustece
|
||||
export const services: Service[] = [
|
||||
// ------------------------------------------------- obecne: spoustece
|
||||
{
|
||||
id: 'webhook',
|
||||
name: 'Webhook',
|
||||
category: 'spoustece',
|
||||
category: 'obecne',
|
||||
description: 'Spustí automatizaci příchozím HTTP požadavkem z libovolného systému.',
|
||||
icon: 'Webhook',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'received',
|
||||
@@ -166,10 +246,14 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'scheduler',
|
||||
name: 'Plánovač',
|
||||
category: 'spoustece',
|
||||
description: 'Spouštění podle času — každou hodinu, denně, nebo podle cron výrazu.',
|
||||
category: 'obecne',
|
||||
description: 'Spouštění podle času, každou hodinu, denně, nebo podle cron výrazu.',
|
||||
icon: 'Clock',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'interval',
|
||||
@@ -183,10 +267,14 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'manual',
|
||||
name: 'Ruční spuštění',
|
||||
category: 'spoustece',
|
||||
category: 'obecne',
|
||||
description: 'Automatizaci spustí člověk tlačítkem v portálu. Vhodné pro testování.',
|
||||
icon: 'MousePointerClick',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'button',
|
||||
@@ -199,10 +287,14 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'form',
|
||||
name: 'Webový formulář',
|
||||
category: 'spoustece',
|
||||
description: 'Odeslání formuláře z webu — poptávka, registrace, reklamace.',
|
||||
category: 'obecne',
|
||||
description: 'Odeslání formuláře z webu: poptávka, registrace, reklamace.',
|
||||
icon: 'FileInput',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'submitted',
|
||||
@@ -223,11 +315,15 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'ticket',
|
||||
name: 'Tickety',
|
||||
category: 'servicedesk',
|
||||
category: 'obecne',
|
||||
description:
|
||||
'Servicedesk. Požadavek od zákazníka, který má svého řešitele a dohledatelný průběh.',
|
||||
icon: 'LifeBuoy',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'created',
|
||||
@@ -398,7 +494,11 @@ export const connectors: Connector[] = [
|
||||
category: 'crm',
|
||||
description: 'Firmy, kontakty, obchodní případy a aktivity v RAYNET CRM.',
|
||||
icon: 'Users',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'raynet',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'lead-created',
|
||||
@@ -470,9 +570,52 @@ export const connectors: Connector[] = [
|
||||
id: 'idoklad',
|
||||
name: 'iDoklad',
|
||||
category: 'ekonomika',
|
||||
description: 'Fakturace — vydané i přijaté doklady, kontakty, úhrady.',
|
||||
description: 'Fakturace: vydané i přijaté doklady, kontakty, úhrady.',
|
||||
icon: 'Receipt',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'idoklad',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [
|
||||
{
|
||||
id: 'clientId',
|
||||
label: 'Client ID',
|
||||
target: 'header',
|
||||
name: 'X-ClientId',
|
||||
required: true,
|
||||
secret: false,
|
||||
hint: 'Z vývojářského portálu iDokladu.',
|
||||
},
|
||||
{
|
||||
id: 'clientSecret',
|
||||
label: 'Client Secret',
|
||||
target: 'header',
|
||||
name: 'X-ClientSecret',
|
||||
required: true,
|
||||
secret: true,
|
||||
hint: 'Uloží se jen pro odesílání a nikdy se nevrací zpátky.',
|
||||
},
|
||||
{
|
||||
id: 'applicationId',
|
||||
label: 'Application ID',
|
||||
target: 'header',
|
||||
name: 'X-ApplicationId',
|
||||
required: false,
|
||||
secret: false,
|
||||
hint: 'Jen partnerské aplikace. Běžná aplikace ho nepotřebuje.',
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
label: 'Jazyk odpovědí',
|
||||
target: 'header',
|
||||
name: 'X-Idoklad-Language',
|
||||
required: false,
|
||||
secret: false,
|
||||
hint: 'Cz, Sk nebo En.',
|
||||
},
|
||||
],
|
||||
// Vrati udaje o agende: nic nemeni a bez platnych udaju neprojde.
|
||||
verifyPath: '/account/agenda',
|
||||
triggers: [
|
||||
{
|
||||
id: 'invoice-paid',
|
||||
@@ -512,7 +655,11 @@ export const connectors: Connector[] = [
|
||||
category: 'ekonomika',
|
||||
description: 'Bankovní pohyby a zůstatky přes PSD2 rozhraní ČSOB.',
|
||||
icon: 'Landmark',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'csob',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'payment-received',
|
||||
@@ -543,7 +690,11 @@ export const connectors: Connector[] = [
|
||||
category: 'logistika',
|
||||
description: 'Zásilky, štítky a svozy v systému PPL.',
|
||||
icon: 'Truck',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'ppl',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'shipment-delivered',
|
||||
@@ -579,6 +730,10 @@ export const connectors: Connector[] = [
|
||||
description: 'Objednávky, sklad a zákazníci z e-shopu (Shoptet, WooCommerce, vlastní).',
|
||||
icon: 'ShoppingCart',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'eshop',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'order-created',
|
||||
@@ -614,7 +769,11 @@ export const connectors: Connector[] = [
|
||||
category: 'komunikace',
|
||||
description: 'Příjem i odesílání e-mailů včetně příloh.',
|
||||
icon: 'Mail',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'email',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'received',
|
||||
@@ -655,7 +814,11 @@ export const connectors: Connector[] = [
|
||||
category: 'komunikace',
|
||||
description: 'Příjem a odesílání zpráv přes WhatsApp Business. Nejrychlejší cesta k ticketu.',
|
||||
icon: 'MessageCircle',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'whatsapp',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'message-received',
|
||||
@@ -700,7 +863,11 @@ export const connectors: Connector[] = [
|
||||
category: 'komunikace',
|
||||
description: 'Zprávy z firemní stránky na Facebooku.',
|
||||
icon: 'Facebook',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'facebook',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'message-received',
|
||||
@@ -740,6 +907,10 @@ export const connectors: Connector[] = [
|
||||
description: 'Přímé zprávy na firemním účtu Instagramu.',
|
||||
icon: 'Instagram',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'instagram',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'message-received',
|
||||
@@ -777,7 +948,11 @@ export const connectors: Connector[] = [
|
||||
category: 'komunikace',
|
||||
description: 'Outlook, kalendář, Teams, SharePoint a OneDrive.',
|
||||
icon: 'Building2',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'microsoft365',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'calendar-event',
|
||||
@@ -813,6 +988,10 @@ export const connectors: Connector[] = [
|
||||
description: 'Odesílání SMS zpráv zákazníkům nebo obsluze.',
|
||||
icon: 'MessageSquare',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'sms',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -830,6 +1009,10 @@ export const connectors: Connector[] = [
|
||||
description: 'Notifikace a interní komunikace v Slacku.',
|
||||
icon: 'Hash',
|
||||
status: 'planned',
|
||||
general: false,
|
||||
appId: 'slack',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -848,7 +1031,11 @@ export const connectors: Connector[] = [
|
||||
category: 'analytika',
|
||||
description: 'Návštěvnost, konverze a chování uživatelů.',
|
||||
icon: 'BarChart3',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'ga4',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -865,7 +1052,11 @@ export const connectors: Connector[] = [
|
||||
category: 'analytika',
|
||||
description: 'Pozice ve vyhledávání, dotazy a prokliky.',
|
||||
icon: 'Search',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'search-console',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -882,7 +1073,11 @@ export const connectors: Connector[] = [
|
||||
category: 'analytika',
|
||||
description: 'Výkon kampaní a náklady na reklamu.',
|
||||
icon: 'Megaphone',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'google-ads',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -899,7 +1094,11 @@ export const connectors: Connector[] = [
|
||||
category: 'analytika',
|
||||
description: 'Kampaně a náklady v Skliku.',
|
||||
icon: 'MousePointer',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'sklik',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -916,9 +1115,13 @@ export const connectors: Connector[] = [
|
||||
id: 'voicebot',
|
||||
name: 'Voicebot',
|
||||
category: 'ai',
|
||||
description: 'Hlasová linka — příjem hovorů, rozpoznání záměru, předání operátorovi.',
|
||||
description: 'Hlasová linka: příjem hovorů, rozpoznání záměru, předání operátorovi.',
|
||||
icon: 'PhoneCall',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'voicebot',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [
|
||||
{
|
||||
id: 'call-received',
|
||||
@@ -964,7 +1167,11 @@ export const connectors: Connector[] = [
|
||||
category: 'ai',
|
||||
description: 'Přepis zvuku na text (Deepgram + Whisper) se sloučením výsledků.',
|
||||
icon: 'FileAudio',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'transcription',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -987,7 +1194,11 @@ export const connectors: Connector[] = [
|
||||
category: 'ai',
|
||||
description: 'Klasifikace, extrakce dat a generování textu jazykovým modelem.',
|
||||
icon: 'Sparkles',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: false,
|
||||
appId: 'ai-text',
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -1024,14 +1235,69 @@ export const connectors: Connector[] = [
|
||||
],
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------- nastroje
|
||||
/**
|
||||
* Ukazka omezene viditelnosti: tuhle sluzbu vidi jen LogiTrans a spravce
|
||||
* platformy. Ostatni firmy ji v katalogu vubec nedostanou, takze se ani
|
||||
* nedozvi, ze existuje.
|
||||
*/
|
||||
{
|
||||
id: 'polstryn-sap',
|
||||
name: 'Polstryn SAP',
|
||||
category: 'ekonomika',
|
||||
description: 'Zakázková integrace na podnikový systém jednoho klienta.',
|
||||
icon: 'Boxes',
|
||||
status: 'planned',
|
||||
general: false,
|
||||
appId: 'polstryn-sap',
|
||||
visibility: { mode: 'restricted', tenantIds: ['tnt_logitrans'], userIds: [] },
|
||||
credentials: [
|
||||
{
|
||||
id: 'apiKey',
|
||||
label: 'API klíč',
|
||||
target: 'header',
|
||||
name: 'X-Api-Key',
|
||||
required: true,
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
id: 'plant',
|
||||
label: 'Číslo závodu',
|
||||
target: 'config',
|
||||
name: 'plant',
|
||||
required: true,
|
||||
secret: false,
|
||||
hint: 'Předá se skriptu jako ctx.config.plant.',
|
||||
},
|
||||
],
|
||||
triggers: [
|
||||
{
|
||||
id: 'order-released',
|
||||
name: 'Uvolněna výrobní zakázka',
|
||||
description: 'Spustí se, jakmile SAP uvolní zakázku do výroby.',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 'post-goods-issue',
|
||||
name: 'Zaúčtovat výdej materiálu',
|
||||
description: 'Zapíše výdej materiálu k zakázce.',
|
||||
fields: ['Číslo zakázky', 'Materiál', 'Množství'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// -------------------------------------------------- obecne: nastroje
|
||||
{
|
||||
id: 'http',
|
||||
name: 'HTTP požadavek',
|
||||
category: 'nastroje',
|
||||
category: 'obecne',
|
||||
description: 'Zavolá libovolné API, které nemá vlastní konektor.',
|
||||
icon: 'Globe',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -1045,10 +1311,14 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'transform',
|
||||
name: 'Transformace dat',
|
||||
category: 'nastroje',
|
||||
category: 'obecne',
|
||||
description: 'Přemapování polí, formátování a čištění dat mezi kroky.',
|
||||
icon: 'Shuffle',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -1068,10 +1338,14 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'delay',
|
||||
name: 'Pauza',
|
||||
category: 'nastroje',
|
||||
category: 'obecne',
|
||||
description: 'Pozdrží běh o daný čas nebo do konkrétního okamžiku.',
|
||||
icon: 'Timer',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
@@ -1085,90 +1359,125 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'log',
|
||||
name: 'Zápis do logu',
|
||||
category: 'nastroje',
|
||||
category: 'obecne',
|
||||
description: 'Uloží zprávu do provozního logu automatizace.',
|
||||
icon: 'ScrollText',
|
||||
status: 'connected',
|
||||
status: 'available',
|
||||
general: true,
|
||||
appId: null,
|
||||
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
|
||||
credentials: [],
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
id: 'write',
|
||||
name: 'Zapsat zprávu',
|
||||
description: 'Přidá záznam do historie běhu — užitečné při ladění.',
|
||||
description: 'Přidá záznam do historie běhu, užitečné při ladění.',
|
||||
fields: ['Úroveň', 'Zpráva'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function findConnector(connectorId: string): Connector | undefined {
|
||||
return connectors.find((c) => c.id === connectorId);
|
||||
export function findService(serviceId: string): Service | undefined {
|
||||
return services.find((service) => service.id === serviceId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- kdo co vidi
|
||||
|
||||
/**
|
||||
* Vidi uzivatel tuhle sluzbu?
|
||||
*
|
||||
* Obecne sluzby vidi vzdy vsichni - webhook, pauza nebo transformace dat nejsou
|
||||
* nic, co by se komu odepiralo. Spravce platformy vidi vzdy vsechno.
|
||||
*
|
||||
* Sluzba, kterou uzivatel nevidi, se **nevraci vubec**, ne se stavem "nemate
|
||||
* pravo". Firma nema z odpovedi poznat, ze taková sluzba existuje - stejne
|
||||
* pravidlo jako u ticketu v documentation/07-firmy-a-prava.md.
|
||||
*/
|
||||
export function canSeeService(service: Service, user: User, tenantId: string | null): boolean {
|
||||
if (service.general) return true;
|
||||
if (user.platformAdmin) return true;
|
||||
|
||||
switch (service.visibility.mode) {
|
||||
case 'everyone':
|
||||
return true;
|
||||
case 'admin':
|
||||
return false;
|
||||
case 'restricted':
|
||||
if (service.visibility.userIds.includes(user.id)) return true;
|
||||
return tenantId !== null && service.visibility.tenantIds.includes(tenantId);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function visibleServices(user: User, tenantId: string | null): Service[] {
|
||||
return services.filter((service) => canSeeService(service, user, tenantId));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- akce ze skriptu
|
||||
|
||||
/**
|
||||
* Akce domerene ze skriptu konektoru. Plni to `src/scripts/registry.ts`
|
||||
* Akce domerene ze skriptu sluzby. 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`).
|
||||
* Je to prekryv, ne zapis do `services`. 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[]>();
|
||||
const scriptActions = new Map<string, ServiceOperation[]>();
|
||||
|
||||
/** Nahradi cely prekryv. Volani je idempotentni, poradi nezalezi. */
|
||||
export function setScriptActions(byConnector: Map<string, ConnectorOperation[]>): void {
|
||||
export function setScriptActions(byService: Map<string, ServiceOperation[]>): void {
|
||||
scriptActions.clear();
|
||||
for (const [connectorId, operations] of byConnector) {
|
||||
scriptActions.set(connectorId, operations);
|
||||
for (const [serviceId, operations] of byService) {
|
||||
scriptActions.set(serviceId, operations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Akce konektoru vcetne tech ze skriptu.
|
||||
* Akce sluzby 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 [];
|
||||
export function actionsFor(serviceId: string): ServiceOperation[] {
|
||||
const service = findService(serviceId);
|
||||
if (!service) return [];
|
||||
|
||||
const fromScripts = scriptActions.get(connectorId);
|
||||
if (!fromScripts || fromScripts.length === 0) return connector.actions;
|
||||
const fromScripts = scriptActions.get(serviceId);
|
||||
if (!fromScripts || fromScripts.length === 0) return service.actions;
|
||||
|
||||
const replaced = new Set(fromScripts.map((operation) => operation.id));
|
||||
return [
|
||||
...connector.actions.filter((action) => !replaced.has(action.id)),
|
||||
...service.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,
|
||||
/** Katalog sluzeb vcetne akci ze skriptu. Nemodifikuje `services`. */
|
||||
export function serviceCatalog(): Service[] {
|
||||
return services.map((service) =>
|
||||
scriptActions.has(service.id) ? { ...service, actions: actionsFor(service.id) } : service,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overi, ze konektor existuje a ma danou operaci pozadovaneho druhu.
|
||||
* Overi, ze sluzba existuje a ma danou operaci pozadovaneho druhu.
|
||||
* Pouziva se pri ukladani stromu, aby se do nej nedostaly neexistujici kroky.
|
||||
*/
|
||||
export function findOperation(
|
||||
connectorId: string,
|
||||
serviceId: string,
|
||||
operationId: string,
|
||||
type: 'trigger' | 'action',
|
||||
): ConnectorOperation | undefined {
|
||||
const connector = findConnector(connectorId);
|
||||
if (!connector) return undefined;
|
||||
const pool = type === 'trigger' ? connector.triggers : actionsFor(connectorId);
|
||||
return pool.find((op) => op.id === operationId);
|
||||
): ServiceOperation | undefined {
|
||||
const service = findService(serviceId);
|
||||
if (!service) return undefined;
|
||||
const pool = type === 'trigger' ? service.triggers : actionsFor(serviceId);
|
||||
return pool.find((operation) => operation.id === operationId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1176,18 +1485,18 @@ export function findOperation(
|
||||
* deklaruje uzivatel (webhook, formular) - katalog do toho nemluvi.
|
||||
*/
|
||||
export function providedFieldsFor(
|
||||
connectorId: string,
|
||||
serviceId: string,
|
||||
operationId: string,
|
||||
): ProvidedField[] | undefined {
|
||||
return findOperation(connectorId, operationId, 'trigger')?.providedFields;
|
||||
return findOperation(serviceId, operationId, 'trigger')?.providedFields;
|
||||
}
|
||||
|
||||
/** Nastavitelna pole akce. Prazdne pole = akci zatim nejde konfigurovat. */
|
||||
export function actionInputsFor(connectorId: string, operationId: string): OperationField[] {
|
||||
return findOperation(connectorId, operationId, 'action')?.inputs ?? [];
|
||||
export function actionInputsFor(serviceId: string, operationId: string): OperationField[] {
|
||||
return findOperation(serviceId, operationId, 'action')?.inputs ?? [];
|
||||
}
|
||||
|
||||
/** Co akce vrati dalsim krokum. Prazdne pole = nic, na co by se dalo ptat. */
|
||||
export function actionOutputsFor(connectorId: string, operationId: string): ProvidedField[] {
|
||||
return findOperation(connectorId, operationId, 'action')?.outputFields ?? [];
|
||||
export function actionOutputsFor(serviceId: string, operationId: string): ProvidedField[] {
|
||||
return findOperation(serviceId, operationId, 'action')?.outputFields ?? [];
|
||||
}
|
||||
+25
-25
@@ -61,7 +61,7 @@ export interface TicketTraceEntry {
|
||||
parentId: string | null;
|
||||
kind: TraceKind;
|
||||
/** Ktera sluzba to byla. null u poznamek a podminek. */
|
||||
connectorId: string | null;
|
||||
serviceId: string | null;
|
||||
operationId: string | null;
|
||||
label: string;
|
||||
status: TraceStatus;
|
||||
@@ -135,7 +135,7 @@ export interface TraceInput {
|
||||
kind: TraceKind;
|
||||
label: string;
|
||||
status: TraceStatus;
|
||||
connectorId?: string | null;
|
||||
serviceId?: string | null;
|
||||
operationId?: string | null;
|
||||
response?: string | null;
|
||||
durationMs?: number | null;
|
||||
@@ -158,7 +158,7 @@ function flattenTrace(
|
||||
id: nextTraceId(),
|
||||
parentId,
|
||||
kind: input.kind,
|
||||
connectorId: input.connectorId ?? null,
|
||||
serviceId: input.serviceId ?? null,
|
||||
operationId: input.operationId ?? null,
|
||||
label: input.label,
|
||||
status: input.status,
|
||||
@@ -219,7 +219,7 @@ seed(
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'voicebot',
|
||||
serviceId: 'voicebot',
|
||||
operationId: 'call-received',
|
||||
label: 'Příchozí hovor na linku 800 100 200',
|
||||
status: 'ok',
|
||||
@@ -229,7 +229,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'transcription',
|
||||
serviceId: 'transcription',
|
||||
operationId: 'transcribe',
|
||||
label: 'Přepis nahrávky',
|
||||
status: 'ok',
|
||||
@@ -240,7 +240,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ai-text',
|
||||
serviceId: 'ai-text',
|
||||
operationId: 'classify',
|
||||
label: 'Zařazení do kategorie',
|
||||
status: 'ok',
|
||||
@@ -250,7 +250,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'upsert-contact',
|
||||
label: 'Dohledání firmy podle telefonu',
|
||||
status: 'ok',
|
||||
@@ -267,7 +267,7 @@ seed(
|
||||
children: [
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
@@ -277,7 +277,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
label: 'Přiřazení řešitele podle služby',
|
||||
status: 'ok',
|
||||
@@ -289,7 +289,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'microsoft365',
|
||||
serviceId: 'microsoft365',
|
||||
operationId: 'post-teams',
|
||||
label: 'Upozornění do Teams',
|
||||
status: 'error',
|
||||
@@ -333,7 +333,7 @@ seed(
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'email',
|
||||
serviceId: 'email',
|
||||
operationId: 'received',
|
||||
label: 'Přijat e-mail do schránky podpora@',
|
||||
status: 'ok',
|
||||
@@ -343,7 +343,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ai-text',
|
||||
serviceId: 'ai-text',
|
||||
operationId: 'extract',
|
||||
label: 'Vytažení údajů z textu',
|
||||
status: 'ok',
|
||||
@@ -353,7 +353,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'upsert-contact',
|
||||
label: 'Dohledání firmy podle e-mailu',
|
||||
status: 'ok',
|
||||
@@ -363,7 +363,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
@@ -373,7 +373,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'email',
|
||||
serviceId: 'email',
|
||||
operationId: 'send',
|
||||
label: 'Potvrzení zadavateli',
|
||||
status: 'ok',
|
||||
@@ -414,7 +414,7 @@ seed(
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'whatsapp',
|
||||
serviceId: 'whatsapp',
|
||||
operationId: 'message-received',
|
||||
label: 'Přijata zpráva z WhatsApp',
|
||||
status: 'ok',
|
||||
@@ -425,7 +425,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ai-text',
|
||||
serviceId: 'ai-text',
|
||||
operationId: 'classify',
|
||||
label: 'Zařazení do kategorie',
|
||||
status: 'ok',
|
||||
@@ -435,7 +435,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'upsert-contact',
|
||||
label: 'Dohledání firmy podle telefonu',
|
||||
status: 'error',
|
||||
@@ -452,7 +452,7 @@ seed(
|
||||
children: [
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu bez napojení na firmu',
|
||||
status: 'ok',
|
||||
@@ -462,7 +462,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'create-lead',
|
||||
label: 'Založení obchodního případu k dohledání',
|
||||
status: 'ok',
|
||||
@@ -472,7 +472,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
label: 'Přiřazení řešitele',
|
||||
status: 'skipped',
|
||||
@@ -510,7 +510,7 @@ seed(
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'form',
|
||||
serviceId: 'form',
|
||||
operationId: 'submitted',
|
||||
label: 'Odeslán formulář Požadavek na úpravu',
|
||||
status: 'ok',
|
||||
@@ -520,7 +520,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
@@ -569,7 +569,7 @@ seed(
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'form',
|
||||
serviceId: 'form',
|
||||
operationId: 'submitted',
|
||||
label: 'Nahlášeno z portálu',
|
||||
status: 'ok',
|
||||
@@ -579,7 +579,7 @@ seed(
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'transform',
|
||||
serviceId: 'transform',
|
||||
operationId: 'deduplicate',
|
||||
label: 'Kontrola duplicit v CRM',
|
||||
status: 'ok',
|
||||
|
||||
+212
-8
@@ -26,7 +26,9 @@ 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: 'Sluzby', description: 'Katalog toho, co umime napojit' },
|
||||
{ name: 'Konektory', description: 'Napojeni firmy na sluzbu vcetne pristupovych udaju' },
|
||||
{ name: 'Skripty', description: 'Vykonna cast sluzby: 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' },
|
||||
@@ -99,6 +101,43 @@ export function buildOpenApiDocument() {
|
||||
personId: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
Connector: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Napojeni firmy na jednu sluzbu. Hodnoty pristupovych udaju tady zamerne ' +
|
||||
'nejsou a nikdy nebudou - secrets se z beznych endpointu nevraci.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'con_1a2b3c4d' },
|
||||
tenantId: { type: 'string', example: 'tnt_automia' },
|
||||
serviceId: { type: 'string', example: 'idoklad' },
|
||||
name: { type: 'string', example: 'iDoklad Automia' },
|
||||
baseUrl: { type: 'string', nullable: true },
|
||||
enabled: { type: 'boolean' },
|
||||
status: { type: 'string', enum: ['untested', 'ok', 'error'] },
|
||||
lastCheckAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
lastError: { type: 'string', nullable: true },
|
||||
isDefault: {
|
||||
type: 'boolean',
|
||||
description: 'Krok stromu bez vybraneho konektoru pouzije tenhle.',
|
||||
},
|
||||
filled: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'ID poli, ktera jsou vyplnena. Hodnoty se nevraci.',
|
||||
},
|
||||
missing: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'ID povinnych poli, ktera chybi.',
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Necitliva nastaveni. Tajna pole tu nejsou vubec.',
|
||||
},
|
||||
ready: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
ScriptField: {
|
||||
type: 'object',
|
||||
description:
|
||||
@@ -135,9 +174,10 @@ export function buildOpenApiDocument() {
|
||||
id: {
|
||||
type: 'string',
|
||||
example: 'idoklad.get-issued-invoice',
|
||||
description: 'Tvar konektor.operace. Nazev souboru musi byt <id>.js.',
|
||||
description: 'Tvar sluzba.operace. Nazev souboru musi byt <id>.js.',
|
||||
},
|
||||
connectorId: { type: 'string', example: 'idoklad' },
|
||||
serviceId: { type: 'string', example: 'idoklad' },
|
||||
serviceName: { type: 'string', example: 'iDoklad' },
|
||||
operationId: { type: 'string', example: 'get-issued-invoice' },
|
||||
name: { type: 'string', example: 'Získat vydanou fakturu' },
|
||||
description: { type: 'string' },
|
||||
@@ -947,15 +987,179 @@ export function buildOpenApiDocument() {
|
||||
responses: { '200': { description: 'Proud udalosti text/event-stream' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/services': {
|
||||
get: {
|
||||
tags: ['Sluzby'],
|
||||
summary: 'Katalog sluzeb pro builder',
|
||||
description:
|
||||
'Vraci jen sluzby, ktere uzivatel vidi. Neviditelna sluzba v odpovedi neni ' +
|
||||
'vubec, ne se stavem "nemate pravo". Operace, ktere obsluhuje skript, nesou ' +
|
||||
'implementation: script a maji skutecne inputs a outputFields.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Sluzby, kategorie a operatory podminek' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/connectors/services': {
|
||||
get: {
|
||||
tags: ['Sluzby'],
|
||||
summary: 'Katalog sluzeb ocima firmy',
|
||||
description:
|
||||
'Jako /services, navic connectorCount, tedy kolik konektoru na sluzbu firma ma. ' +
|
||||
'Podle toho se rozlisi napojeno od muzete si napojit. Neni to vlastnost sluzby, ' +
|
||||
'ale te firmy.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ name: 'tenantId', in: 'query', schema: { type: 'string' } }],
|
||||
responses: { '200': { description: 'Sluzby vcetne pouziti ve firme' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/connectors': {
|
||||
get: {
|
||||
tags: ['Automatizace'],
|
||||
summary: 'Katalog konektoru',
|
||||
tags: ['Konektory'],
|
||||
summary: 'Konektory firmy',
|
||||
description:
|
||||
'Operace, ktere obsluhuje skript, nesou `implementation: script` a `scriptId`, ' +
|
||||
'a maji skutecne `inputs` a `outputFields` z manifestu toho skriptu.',
|
||||
'Hodnoty pristupovych udaju se NIKDY nevraci, jen filled (co je vyplnene) ' +
|
||||
'a missing (ktera povinna pole chybi).',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Konektory, kategorie a operatory podminek' } },
|
||||
parameters: [
|
||||
{ name: 'tenantId', in: 'query', schema: { type: 'string' } },
|
||||
{ name: 'serviceId', in: 'query', schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Konektory firmy',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/Connector' },
|
||||
},
|
||||
tenantId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ['Konektory'],
|
||||
summary: 'Zalozit konektor',
|
||||
description:
|
||||
'Pristupove udaje se posilaji ve values s klici podle Service.credentials. ' +
|
||||
'Nevyplnene povinne pole neni chyba, konektor se ulozi a jen nepujde pouzit.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['serviceId', 'name'],
|
||||
properties: {
|
||||
serviceId: { type: 'string', example: 'idoklad' },
|
||||
name: { type: 'string', example: 'iDoklad Celo' },
|
||||
baseUrl: { type: 'string', nullable: true },
|
||||
values: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'201': {
|
||||
description: 'Zalozeno',
|
||||
content: {
|
||||
'application/json': { schema: { $ref: '#/components/schemas/Connector' } },
|
||||
},
|
||||
},
|
||||
'400': { description: 'Obecna sluzba konektor nepotrebuje, nebo nezname pole' },
|
||||
'404': { description: 'Sluzba neexistuje nebo ji uzivatel nevidi' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/connectors/{id}': {
|
||||
get: {
|
||||
tags: ['Konektory'],
|
||||
summary: 'Detail konektoru',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
responses: { '200': { description: 'Konektor' }, '404': { description: 'Neexistuje' } },
|
||||
},
|
||||
patch: {
|
||||
tags: ['Konektory'],
|
||||
summary: 'Upravit konektor',
|
||||
description:
|
||||
'Ve values staci poslat jen to, co se meni. PRAZDNY RETEZEC hodnotu smaze, ' +
|
||||
'chybejici klic ji nechava - diky tomu jde ulozit formular, ktery tajne hodnoty ' +
|
||||
'neposila. Zmena udaju vzdy zrusi predchozi overeni.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
baseUrl: { type: 'string', nullable: true },
|
||||
values: { type: 'object', additionalProperties: { type: 'string' } },
|
||||
enabled: { type: 'boolean' },
|
||||
isDefault: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { '200': { description: 'Upraveno' }, '404': { description: 'Neexistuje' } },
|
||||
},
|
||||
delete: {
|
||||
tags: ['Konektory'],
|
||||
summary: 'Smazat konektor',
|
||||
description:
|
||||
'Kdyz zmizel vychozi konektor, prevezme to prvni zbyly - jinak by kroky bez ' +
|
||||
'vybraneho konektoru prestaly fungovat.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
responses: { '204': { description: 'Smazano' }, '404': { description: 'Neexistuje' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/connectors/{id}/test': {
|
||||
post: {
|
||||
tags: ['Konektory'],
|
||||
summary: 'Overit napojeni',
|
||||
description:
|
||||
'Zavola verifyPath sluzby, coz je zamerne cteci volani vyzadujici autorizaci. ' +
|
||||
'Kdyz ho sluzba nema, overi se jen /health a odpoved to v checked rekne - aby ' +
|
||||
'si nikdo nemyslel, ze jsou overene i pristupove udaje. Neuspesne overeni neni ' +
|
||||
'chyba API, vraci se 200 s ok: false.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Vysledek overeni',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
checked: { type: 'string' },
|
||||
status: { type: 'integer' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'404': { description: 'Konektor neexistuje' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/scripts': {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Sluzby a konektory.
|
||||
*
|
||||
* Sluzba je to, co umime. Konektor je napojeni jedne firmy na jednu sluzbu
|
||||
* vcetne jejich pristupovych udaju. Rozdeleni je popsane
|
||||
* v documentation/12-sluzby-a-konektory.md.
|
||||
*
|
||||
* Dve pravidla, ktera se tady nesmi porusit:
|
||||
* - **hodnoty pristupovych udaju se nikdy nevraci**, jen jmena vyplnenych poli,
|
||||
* - **sluzba, kterou uzivatel nevidi, se nevraci vubec**, ne se stavem
|
||||
* "nemate pravo". Firma nema z odpovedi poznat, ze takova sluzba existuje.
|
||||
*
|
||||
* Volani cizich sluzeb dela vzdy server. Z prohlizece by to neproslo ani
|
||||
* technicky (sluzby kontroluji IP) a hlavne by to znamenalo poslat pristupove
|
||||
* udaje do prohlizece.
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { accessFor } from '../data/access.js';
|
||||
import {
|
||||
connectorCountsByService,
|
||||
createConnector,
|
||||
deleteConnector,
|
||||
getConnector,
|
||||
listConnectors,
|
||||
setConnectorStatus,
|
||||
toPublicConnector,
|
||||
updateConnector,
|
||||
validateConnectorValues,
|
||||
} from '../data/connectorStore.js';
|
||||
import {
|
||||
canSeeService,
|
||||
findService,
|
||||
serviceCatalog,
|
||||
serviceCategories,
|
||||
visibleServices,
|
||||
type Service,
|
||||
} from '../data/services.js';
|
||||
import { resolveTarget, serviceBaseUrl } from '../scripts/connections.js';
|
||||
import { createHttp } from '../scripts/http.js';
|
||||
import { ScriptError } from '../scripts/types.js';
|
||||
import { createRedactor, describe } from '../scripts/util.js';
|
||||
|
||||
export const connectorsRouter = Router();
|
||||
|
||||
/**
|
||||
* Firma, do ktere se zapisuje. Konektor je vzdy jedne firmy, i kdyz uzivatel
|
||||
* kouka na pohled "vse" - jinak by nebylo kam ho zaloz1t.
|
||||
*/
|
||||
function tenantOrDeny(req: Request, res: Response): string | null {
|
||||
const access = accessFor(req.user!);
|
||||
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
|
||||
const tenantId = requested ?? access.defaultTenantId;
|
||||
|
||||
if (!tenantId) {
|
||||
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
|
||||
return null;
|
||||
}
|
||||
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
|
||||
console.warn(`[connectors] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
|
||||
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
|
||||
return null;
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/** Sluzba, kterou uzivatel vidi. Neviditelna se chova jako neexistujici. */
|
||||
function serviceOrDeny(
|
||||
req: Request,
|
||||
res: Response,
|
||||
serviceId: string,
|
||||
tenantId: string,
|
||||
): Service | null {
|
||||
const service = findService(serviceId);
|
||||
if (!service || !canSeeService(service, req.user!, tenantId)) {
|
||||
console.warn(`[connectors] ${req.user!.email}: pokus o sluzbu ${serviceId} bez pristupu`);
|
||||
res.status(404).json({ error: 'not_found', message: 'Služba neexistuje.' });
|
||||
return null;
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- sluzby
|
||||
|
||||
/**
|
||||
* Katalog sluzeb za firmu.
|
||||
*
|
||||
* `connectorCount` je to, co dela z katalogu neco uzitecneho: podle nej klient
|
||||
* pozna rozdil mezi "napojeno" a "muzete si napojit". Neni to vlastnost sluzby,
|
||||
* ale te firmy, proto se to pocita tady a ne v katalogu.
|
||||
*/
|
||||
connectorsRouter.get('/services', (req, res) => {
|
||||
const access = accessFor(req.user!);
|
||||
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
|
||||
const tenantId = requested ?? access.defaultTenantId;
|
||||
|
||||
const visible = visibleServices(req.user!, tenantId);
|
||||
const withScripts = new Map(serviceCatalog().map((service) => [service.id, service]));
|
||||
const counts = tenantId ? connectorCountsByService([tenantId]) : new Map<string, number>();
|
||||
|
||||
const items = visible.map((service) => {
|
||||
const merged = withScripts.get(service.id) ?? service;
|
||||
return {
|
||||
...merged,
|
||||
/** Vychozi adresa, aby uzivatel videl, kam to vlastne pujde. */
|
||||
baseUrl: serviceBaseUrl(merged),
|
||||
connectorCount: counts.get(service.id) ?? 0,
|
||||
/** Kolik operaci uz opravdu neco dela, tedy ma skript. */
|
||||
implementedActions: merged.actions.filter((action) => action.implementation === 'script')
|
||||
.length,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ categories: serviceCategories, items, tenantId: tenantId ?? null });
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- konektory
|
||||
|
||||
connectorsRouter.get('/', (req, res) => {
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const serviceId = typeof req.query.serviceId === 'string' ? req.query.serviceId : undefined;
|
||||
const items = listConnectors([tenantId], serviceId ? { serviceId } : {})
|
||||
// Konektor na sluzbu, kterou uzivatel nevidi, mu taky ukazovat nebudeme.
|
||||
.filter((connector) => {
|
||||
const service = findService(connector.serviceId);
|
||||
return service !== undefined && canSeeService(service, req.user!, tenantId);
|
||||
})
|
||||
.map(toPublicConnector);
|
||||
|
||||
return res.json({ items, tenantId });
|
||||
});
|
||||
|
||||
connectorsRouter.get('/:id', (req, res) => {
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const connector = getConnector(req.params.id, [tenantId]);
|
||||
if (!connector) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
|
||||
}
|
||||
if (!serviceOrDeny(req, res, connector.serviceId, tenantId)) return;
|
||||
|
||||
return res.json(toPublicConnector(connector));
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
serviceId: z.string().min(1),
|
||||
name: z.string().trim().min(1, 'Konektor musí mít název.').max(80),
|
||||
baseUrl: z.string().trim().url('Adresa musí být platná URL.').nullable().optional(),
|
||||
values: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
connectorsRouter.post('/', (req, res) => {
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const parsed = createSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
|
||||
const service = serviceOrDeny(req, res, parsed.data.serviceId, tenantId);
|
||||
if (!service) return;
|
||||
|
||||
if (service.general) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: `${service.name} je obecná služba, konektor nepotřebuje.`,
|
||||
});
|
||||
}
|
||||
|
||||
const issues = validateConnectorValues(service, parsed.data.values ?? {});
|
||||
if (issues.length > 0) {
|
||||
return res.status(400).json({ error: 'validation_error', message: issues[0].message, issues });
|
||||
}
|
||||
|
||||
const connector = createConnector({
|
||||
tenantId,
|
||||
serviceId: service.id,
|
||||
name: parsed.data.name,
|
||||
baseUrl: parsed.data.baseUrl ?? null,
|
||||
values: parsed.data.values,
|
||||
});
|
||||
|
||||
return res.status(201).json(toPublicConnector(connector));
|
||||
});
|
||||
|
||||
const updateSchema = z.object({
|
||||
name: z.string().trim().min(1).max(80).optional(),
|
||||
baseUrl: z.string().trim().url('Adresa musí být platná URL.').nullable().optional(),
|
||||
/** Prazdny retezec hodnotu smaze, chybejici klic ji nechá. */
|
||||
values: z.record(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
isDefault: z.literal(true).optional(),
|
||||
});
|
||||
|
||||
connectorsRouter.patch('/:id', (req, res) => {
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const existing = getConnector(req.params.id, [tenantId]);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
|
||||
}
|
||||
|
||||
const service = serviceOrDeny(req, res, existing.serviceId, tenantId);
|
||||
if (!service) return;
|
||||
|
||||
const parsed = updateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.values) {
|
||||
const issues = validateConnectorValues(service, parsed.data.values);
|
||||
if (issues.length > 0) {
|
||||
return res.status(400).json({ error: 'validation_error', message: issues[0].message, issues });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = updateConnector(req.params.id, parsed.data, [tenantId]);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
|
||||
}
|
||||
return res.json(toPublicConnector(updated));
|
||||
});
|
||||
|
||||
connectorsRouter.delete('/:id', (req, res) => {
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
if (!deleteConnector(req.params.id, [tenantId])) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
|
||||
}
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------- overeni
|
||||
|
||||
/**
|
||||
* Overeni konektoru.
|
||||
*
|
||||
* Vola `verifyPath` sluzby, coz je zamerne **cteci** volani, ktere vyzaduje
|
||||
* autorizaci. Kdyz ho sluzba nema, overi se jen `/health` - a odpoved to rekne
|
||||
* nahlas, aby si nikdo nemyslel, ze jsou overene i pristupove udaje.
|
||||
*/
|
||||
connectorsRouter.post('/:id/test', async (req, res) => {
|
||||
const tenantId = tenantOrDeny(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const connector = getConnector(req.params.id, [tenantId]);
|
||||
if (!connector) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
|
||||
}
|
||||
const service = serviceOrDeny(req, res, connector.serviceId, tenantId);
|
||||
if (!service) return;
|
||||
|
||||
const target = resolveTarget(service.id, connector);
|
||||
if (!target.ready) {
|
||||
setConnectorStatus(connector.id, 'error', target.missing.join(', '), [tenantId]);
|
||||
return res.json({
|
||||
ok: false,
|
||||
checked: 'nic',
|
||||
message: `Napojení není hotové: ${target.missing.join(', ')}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const path = service.verifyPath ?? '/health';
|
||||
const checked = service.verifyPath ? 'přístupové údaje' : 'jen dostupnost služby';
|
||||
const redact = createRedactor(Object.values(target.headers));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
|
||||
try {
|
||||
const http = createHttp({
|
||||
target,
|
||||
signal: controller.signal,
|
||||
idempotencyKey: `verify:${connector.id}`,
|
||||
redact,
|
||||
log: (message) => console.info(`[connectors] test ${connector.id}: ${message}`),
|
||||
onCall: () => undefined,
|
||||
});
|
||||
|
||||
const response = await http.get(path);
|
||||
setConnectorStatus(connector.id, 'ok', null, [tenantId]);
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
checked,
|
||||
status: response.status,
|
||||
message: service.verifyPath
|
||||
? 'Napojení funguje, přístupové údaje jsou platné.'
|
||||
: 'Služba odpovídá. Přístupové údaje se tímhle neověřily, služba na to nemá čtecí volání.',
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ScriptError ? redact(err.message) : redact(describe(err, 200));
|
||||
setConnectorStatus(connector.id, 'error', message, [tenantId]);
|
||||
console.warn(`[connectors] test ${connector.id} selhal: ${message}`);
|
||||
|
||||
// Neuspesne overeni neni chyba API, je to vysledek. Proto 200.
|
||||
return res.json({ ok: false, checked, message });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
+83
-24
@@ -18,11 +18,17 @@ import {
|
||||
} from '../data/automationStore.js';
|
||||
import { operatorAllowedForType, operatorsByType } from '../data/conditions.js';
|
||||
import {
|
||||
connectorCatalog,
|
||||
connectorCategories,
|
||||
defaultConnectorFor,
|
||||
getConnector,
|
||||
} from '../data/connectorStore.js';
|
||||
import {
|
||||
findOperation,
|
||||
findService,
|
||||
providedFieldsFor,
|
||||
} from '../data/connectors.js';
|
||||
serviceCatalog,
|
||||
serviceCategories,
|
||||
visibleServices,
|
||||
} from '../data/services.js';
|
||||
import {
|
||||
getLayout,
|
||||
hasCustomLayout,
|
||||
@@ -47,6 +53,7 @@ import {
|
||||
type TicketStatus,
|
||||
} from '../data/ticketStore.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { connectorsRouter } from './connectors.js';
|
||||
import { scriptsRouter } from './scripts.js';
|
||||
import { streamRouter } from './stream.js';
|
||||
|
||||
@@ -345,17 +352,30 @@ dashboardRouter.use('/stream', streamRouter);
|
||||
// Skripty konektoru. Taky pred obecnymi cestami.
|
||||
dashboardRouter.use('/scripts', scriptsRouter);
|
||||
|
||||
// ---------------------------------------------------------------- konektory
|
||||
// Sluzby a konektory. `/connectors/services` je uvnitr toho routeru.
|
||||
dashboardRouter.use('/connectors', connectorsRouter);
|
||||
|
||||
// ------------------------------------------------------------------- sluzby
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* Katalog sluzeb pro builder.
|
||||
*
|
||||
* Operace, ktere obsluhuje skript, se domeruji z jeho manifestu, takze builder
|
||||
* vidi skutecne vstupy a vystupy. Podrobnosti v `src/scripts/registry.ts`.
|
||||
*
|
||||
* Vraci se **jen sluzby, ktere uzivatel vidi**. Neviditelna sluzba v odpovedi
|
||||
* neni vubec, ne se stavem "nemate pravo".
|
||||
*/
|
||||
dashboardRouter.get('/connectors', (_req, res) => {
|
||||
dashboardRouter.get('/services', (req, res) => {
|
||||
const access = accessFor(req.user!);
|
||||
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
|
||||
const tenantId = requested ?? access.defaultTenantId;
|
||||
|
||||
const visible = new Set(visibleServices(req.user!, tenantId).map((service) => service.id));
|
||||
|
||||
res.json({
|
||||
categories: connectorCategories,
|
||||
items: connectorCatalog(),
|
||||
categories: serviceCategories,
|
||||
items: serviceCatalog().filter((service) => visible.has(service.id)),
|
||||
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
|
||||
// a jakou zakladni adresu ukazat u webhooku.
|
||||
operatorsByType,
|
||||
@@ -374,8 +394,10 @@ const stepSchema: z.ZodType<FlowStep> = z.lazy(() =>
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
kind: z.literal('action'),
|
||||
connectorId: z.string().min(1),
|
||||
serviceId: z.string().min(1),
|
||||
operationId: z.string().min(1),
|
||||
// null = vychozi konektor firmy. Prislusnost k firme se overuje nize.
|
||||
connectorId: z.string().min(1).nullable().optional(),
|
||||
// Klic je ID pole z katalogu, hodnota sablona. Overuje se nize.
|
||||
inputs: z.record(z.string()).optional(),
|
||||
}),
|
||||
@@ -419,7 +441,7 @@ const fieldSchema = z.object({
|
||||
const flowSchema = z.object({
|
||||
trigger: z
|
||||
.object({
|
||||
connectorId: z.string().min(1),
|
||||
serviceId: z.string().min(1),
|
||||
operationId: z.string().min(1),
|
||||
fields: z.array(fieldSchema),
|
||||
// Token generuje server. Cokoliv od klienta se ignoruje.
|
||||
@@ -447,25 +469,33 @@ const updateSchema = z.object({
|
||||
function normalizeTriggerFields(flow: z.infer<typeof flowSchema>): z.infer<typeof flowSchema> {
|
||||
if (!flow.trigger) return flow;
|
||||
|
||||
const provided = providedFieldsFor(flow.trigger.connectorId, flow.trigger.operationId);
|
||||
const provided = providedFieldsFor(flow.trigger.serviceId, flow.trigger.operationId);
|
||||
if (!provided) return flow;
|
||||
|
||||
return { ...flow, trigger: { ...flow.trigger, fields: provided.map((field) => ({ ...field })) } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Overi, ze kazdy krok odkazuje na existujici konektor a operaci.
|
||||
* Overi, ze kazdy krok odkazuje na existujici sluzbu, operaci a konektor.
|
||||
* Vraci seznam problemu - prazdny znamena, ze je strom v poradku.
|
||||
*
|
||||
* `tenantIds` je potreba kvuli konektorum: cizi konektor se musi chovat jako
|
||||
* neexistujici, jinak by strom mohl volat cizim jmenem.
|
||||
*/
|
||||
function validateFlowReferences(flow: z.infer<typeof flowSchema>): string[] {
|
||||
function validateFlowReferences(
|
||||
flow: z.infer<typeof flowSchema>,
|
||||
tenantIds: string[],
|
||||
): { problems: string[]; issues: string[] } {
|
||||
const problems: string[] = [];
|
||||
/** Nedodelky: strom se ulozi, jen automatizace nepujde zapnout. */
|
||||
const issues: string[] = [];
|
||||
|
||||
if (!flow.trigger) return problems;
|
||||
if (!flow.trigger) return { problems, issues };
|
||||
|
||||
const trigger = findOperation(flow.trigger.connectorId, flow.trigger.operationId, 'trigger');
|
||||
const trigger = findOperation(flow.trigger.serviceId, flow.trigger.operationId, 'trigger');
|
||||
if (!trigger) {
|
||||
problems.push(
|
||||
`Spouštěč ${flow.trigger.connectorId}/${flow.trigger.operationId} neexistuje v katalogu.`,
|
||||
`Spouštěč ${flow.trigger.serviceId}/${flow.trigger.operationId} neexistuje v katalogu.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -497,9 +527,9 @@ function validateFlowReferences(flow: z.infer<typeof flowSchema>): string[] {
|
||||
walk(step.no);
|
||||
continue;
|
||||
}
|
||||
const action = findOperation(step.connectorId, step.operationId, 'action');
|
||||
const action = findOperation(step.serviceId, step.operationId, 'action');
|
||||
if (!action) {
|
||||
problems.push(`Akce ${step.connectorId}/${step.operationId} neexistuje v katalogu.`);
|
||||
problems.push(`Akce ${step.serviceId}/${step.operationId} neexistuje v katalogu.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -509,15 +539,39 @@ function validateFlowReferences(flow: z.infer<typeof flowSchema>): string[] {
|
||||
for (const key of Object.keys(step.inputs ?? {})) {
|
||||
if (!allowed.has(key)) {
|
||||
problems.push(
|
||||
`Akce ${step.connectorId}/${step.operationId} nemá nastavitelné pole „${key}".`,
|
||||
`Akce ${step.serviceId}/${step.operationId} nemá nastavitelné pole „${key}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Vybrany konektor musi patrit te same firme a te same sluzbe.
|
||||
// Cizi konektor je rozbity strom, ne nedodelek.
|
||||
if (step.connectorId) {
|
||||
const connector = getConnector(step.connectorId, tenantIds);
|
||||
if (!connector) {
|
||||
problems.push(`Krok odkazuje na konektor, který neexistuje (${step.connectorId}).`);
|
||||
} else if (connector.serviceId !== step.serviceId) {
|
||||
problems.push(
|
||||
`Konektor ${connector.name} patří jiné službě než krok ${step.serviceId}.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const service = findService(step.serviceId);
|
||||
// Chybejici napojeni je nedodelek, ne chyba - rozdelana prace se ulozi.
|
||||
if (service && !service.general && tenantIds.length === 1) {
|
||||
const fallback = defaultConnectorFor(tenantIds[0], step.serviceId);
|
||||
if (!fallback) {
|
||||
issues.push(
|
||||
`Služba ${service.name} nemá v této firmě konektor. Vytvořte ho v Konektorech.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(flow.steps);
|
||||
|
||||
return problems;
|
||||
return { problems, issues };
|
||||
}
|
||||
|
||||
dashboardRouter.get('/automations', (req, res) => {
|
||||
@@ -569,8 +623,10 @@ dashboardRouter.put('/automations/:id', (req, res) => {
|
||||
|
||||
const flow = parsed.data.flow ? normalizeTriggerFields(parsed.data.flow) : undefined;
|
||||
|
||||
let connectorIssues: string[] = [];
|
||||
|
||||
if (flow) {
|
||||
const problems = validateFlowReferences(flow);
|
||||
const { problems, issues } = validateFlowReferences(flow, writableTenants(req));
|
||||
if (problems.length > 0) {
|
||||
console.warn(`[automations] ${req.params.id}: neplatny strom - ${problems.join(' ')}`);
|
||||
return res.status(400).json({
|
||||
@@ -579,13 +635,16 @@ dashboardRouter.put('/automations/:id', (req, res) => {
|
||||
issues: problems.map((message) => ({ path: 'flow', message })),
|
||||
});
|
||||
}
|
||||
connectorIssues = issues;
|
||||
}
|
||||
|
||||
const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, writableTenants(req));
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
return res.json(updated);
|
||||
|
||||
// Chybejici konektor je nedodelek: strom se ulozil, jen to nepobezi.
|
||||
return res.json({ ...updated, issues: [...updated.issues, ...connectorIssues] });
|
||||
});
|
||||
|
||||
/** Nova adresa webhooku. Stara okamzite prestane fungovat - zamer, ne chyba. */
|
||||
|
||||
+86
-13
@@ -9,9 +9,11 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { accessFor } from '../data/access.js';
|
||||
import { defaultConnectorFor, getConnector, toPublicConnector } from '../data/connectorStore.js';
|
||||
import { findService } from '../data/services.js';
|
||||
import { requirePlatformAdmin } from '../middleware/auth.js';
|
||||
import { connectionStatus, connectorsWithAuth } from '../scripts/connections.js';
|
||||
import { connectorIdOf, operationIdOf } from '../scripts/manifest.js';
|
||||
import { operationIdOf, serviceIdOf } from '../scripts/manifest.js';
|
||||
import {
|
||||
ensureLoaded,
|
||||
getScript,
|
||||
@@ -29,20 +31,39 @@ 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),
|
||||
}));
|
||||
return manifests.map((manifest) => {
|
||||
const serviceId = serviceIdOf(manifest.id);
|
||||
return {
|
||||
...manifest,
|
||||
serviceId,
|
||||
serviceName: findService(serviceId)?.name ?? serviceId,
|
||||
operationId: operationIdOf(manifest.id),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
scriptsRouter.get('/', async (_req, res) => {
|
||||
scriptsRouter.get('/', async (req, res) => {
|
||||
const [items, problems] = await Promise.all([scriptSummaries(), scriptProblems()]);
|
||||
const access = accessFor(req.user!);
|
||||
const tenantId =
|
||||
(typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ??
|
||||
access.defaultTenantId;
|
||||
|
||||
// Ke kazde sluzbe vychozi konektor firmy, aby portal poznal, jestli je cim volat.
|
||||
const connectors = Object.fromEntries(
|
||||
items
|
||||
.map((item) => {
|
||||
const connector = tenantId ? defaultConnectorFor(tenantId, item.serviceId) : undefined;
|
||||
return connector ? [item.serviceId, toPublicConnector(connector)] : null;
|
||||
})
|
||||
.filter((entry): entry is [string, ReturnType<typeof toPublicConnector>] => entry !== null),
|
||||
);
|
||||
|
||||
res.json({
|
||||
items,
|
||||
problems,
|
||||
connections: connectorsWithAuth().map(connectionStatus),
|
||||
connectors,
|
||||
tenantId: tenantId ?? null,
|
||||
/** Kam se soubory ukladaji. Kdo ma na server pristup, upravi je i rucne. */
|
||||
directory: scriptsDir(),
|
||||
});
|
||||
@@ -67,14 +88,23 @@ scriptsRouter.get('/:id', async (req, res) => {
|
||||
|
||||
// Manifest muze chybet, kdyz je soubor rozbity. Kod se vrati vzdy, aby slo opravit.
|
||||
const problems = await scriptProblems();
|
||||
const serviceId = serviceIdOf(id);
|
||||
const access = accessFor(req.user!);
|
||||
const tenantId =
|
||||
(typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ??
|
||||
access.defaultTenantId;
|
||||
const connector = tenantId ? defaultConnectorFor(tenantId, serviceId) : undefined;
|
||||
|
||||
return res.json({
|
||||
id,
|
||||
connectorId: connectorIdOf(id),
|
||||
serviceId,
|
||||
serviceName: findService(serviceId)?.name ?? serviceId,
|
||||
operationId: operationIdOf(id),
|
||||
manifest: script?.manifest ?? null,
|
||||
code: source,
|
||||
problem: problems.find((problem) => problem.scriptId === id) ?? null,
|
||||
connection: connectionStatus(connectorIdOf(id)),
|
||||
/** Vychozi konektor firmy, pod kterym se skript da vyzkouset. */
|
||||
connector: connector ? toPublicConnector(connector) : null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +117,7 @@ scriptsRouter.put('/:id', requirePlatformAdmin, async (req, res) => {
|
||||
if (!isValidScriptId(id)) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Neplatné ID skriptu. Povolený tvar je konektor.operace.',
|
||||
message: 'Neplatné ID skriptu. Povolený tvar je sluzba.operace.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,6 +144,8 @@ scriptsRouter.put('/:id', requirePlatformAdmin, async (req, res) => {
|
||||
|
||||
const testSchema = z.object({
|
||||
inputs: z.record(z.unknown()).default({}),
|
||||
/** Pod jakym konektorem se to zavola. Chybi = vychozi konektor firmy. */
|
||||
connectorId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -137,7 +169,48 @@ scriptsRouter.post('/:id/test', requirePlatformAdmin, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await runScript(id, parsed.data.inputs);
|
||||
const access = accessFor(req.user!);
|
||||
const tenantId =
|
||||
(typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ??
|
||||
access.defaultTenantId;
|
||||
if (!tenantId) {
|
||||
return res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
|
||||
}
|
||||
|
||||
const serviceId = serviceIdOf(id);
|
||||
const service = findService(serviceId);
|
||||
|
||||
// Konektor musi patrit te same firme. Cizi se chova jako neexistujici.
|
||||
const connector = parsed.data.connectorId
|
||||
? getConnector(parsed.data.connectorId, [tenantId])
|
||||
: defaultConnectorFor(tenantId, serviceId);
|
||||
|
||||
if (parsed.data.connectorId && !connector) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
|
||||
}
|
||||
if (connector && connector.serviceId !== serviceId) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Vybraný konektor patří jiné službě.',
|
||||
});
|
||||
}
|
||||
if (!connector && service && !service.general) {
|
||||
return res.json({
|
||||
ok: false,
|
||||
scriptId: id,
|
||||
outputs: {},
|
||||
logs: [],
|
||||
durationMs: 0,
|
||||
httpCalls: 0,
|
||||
error: {
|
||||
kind: 'config',
|
||||
message: `Služba ${service.name} potřebuje konektor. Vytvořte ho v Konektorech.`,
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = await runScript(id, parsed.data.inputs, { connector: connector ?? null });
|
||||
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.
|
||||
|
||||
+16
-16
@@ -70,7 +70,7 @@ const schema = z.discriminatedUnion('action', [
|
||||
const channelPresets: Record<
|
||||
TicketChannel,
|
||||
{
|
||||
connectorId: string;
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
triggerLabel: string;
|
||||
subjects: string[];
|
||||
@@ -83,7 +83,7 @@ const channelPresets: Record<
|
||||
}
|
||||
> = {
|
||||
whatsapp: {
|
||||
connectorId: 'whatsapp',
|
||||
serviceId: 'whatsapp',
|
||||
operationId: 'message-received',
|
||||
triggerLabel: 'Přijata zpráva z WhatsApp',
|
||||
subjects: [
|
||||
@@ -98,7 +98,7 @@ const channelPresets: Record<
|
||||
`{ "from": "${reply.replace(/\s/g, '')}", "profileName": "${contact}", "text": "${subject}" }`,
|
||||
},
|
||||
facebook: {
|
||||
connectorId: 'facebook',
|
||||
serviceId: 'facebook',
|
||||
operationId: 'message-received',
|
||||
triggerLabel: 'Přijata zpráva z Facebook Messengeru',
|
||||
subjects: [
|
||||
@@ -113,7 +113,7 @@ const channelPresets: Record<
|
||||
`{ "senderId": "${reply.replace('fb:', '')}", "senderName": "${contact}", "text": "${subject}" }`,
|
||||
},
|
||||
instagram: {
|
||||
connectorId: 'instagram',
|
||||
serviceId: 'instagram',
|
||||
operationId: 'message-received',
|
||||
triggerLabel: 'Přijata přímá zpráva na Instagramu',
|
||||
subjects: [
|
||||
@@ -128,7 +128,7 @@ const channelPresets: Record<
|
||||
`{ "username": "${contact}", "text": "${subject}" }`,
|
||||
},
|
||||
email: {
|
||||
connectorId: 'email',
|
||||
serviceId: 'email',
|
||||
operationId: 'received',
|
||||
triggerLabel: 'Přijat e-mail do schránky podpora@',
|
||||
subjects: [
|
||||
@@ -143,7 +143,7 @@ const channelPresets: Record<
|
||||
`{ "from": "${reply}", "subject": "${subject}", "attachments": 1 }`,
|
||||
},
|
||||
voice: {
|
||||
connectorId: 'voicebot',
|
||||
serviceId: 'voicebot',
|
||||
operationId: 'call-received',
|
||||
triggerLabel: 'Příchozí hovor na linku 800 100 200',
|
||||
subjects: [
|
||||
@@ -158,7 +158,7 @@ const channelPresets: Record<
|
||||
`{ "callId": "cl_${Math.floor(Math.random() * 90_000) + 10_000}", "from": "${reply.replace(/\s/g, '')}", "durationSec": 74 }`,
|
||||
},
|
||||
form: {
|
||||
connectorId: 'form',
|
||||
serviceId: 'form',
|
||||
operationId: 'submitted',
|
||||
triggerLabel: 'Odeslán formulář Požadavek na úpravu',
|
||||
subjects: [
|
||||
@@ -173,7 +173,7 @@ const channelPresets: Record<
|
||||
`{ "company": "LogiTrans", "contact": "${contact}", "topic": "${subject}" }`,
|
||||
},
|
||||
portal: {
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'created',
|
||||
triggerLabel: 'Založeno ručně z portálu',
|
||||
subjects: [
|
||||
@@ -227,7 +227,7 @@ function buildIntakeTrace(input: {
|
||||
const trace: TraceInput[] = [
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: preset.connectorId,
|
||||
serviceId: preset.serviceId,
|
||||
operationId: preset.operationId,
|
||||
label: preset.triggerLabel,
|
||||
status: 'ok',
|
||||
@@ -236,7 +236,7 @@ function buildIntakeTrace(input: {
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ai-text',
|
||||
serviceId: 'ai-text',
|
||||
operationId: 'classify',
|
||||
label: 'Zařazení do kategorie',
|
||||
status: 'ok',
|
||||
@@ -245,7 +245,7 @@ function buildIntakeTrace(input: {
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'upsert-contact',
|
||||
label: 'Dohledání firmy v CRM',
|
||||
status: input.known ? 'ok' : 'error',
|
||||
@@ -265,7 +265,7 @@ function buildIntakeTrace(input: {
|
||||
children: [
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
@@ -274,7 +274,7 @@ function buildIntakeTrace(input: {
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
label: 'Přiřazení řešitele podle kanálu',
|
||||
status: 'ok',
|
||||
@@ -294,7 +294,7 @@ function buildIntakeTrace(input: {
|
||||
children: [
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu bez napojení na firmu',
|
||||
status: 'ok',
|
||||
@@ -303,7 +303,7 @@ function buildIntakeTrace(input: {
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'create-lead',
|
||||
label: 'Založení obchodního případu k dohledání',
|
||||
status: 'ok',
|
||||
@@ -312,7 +312,7 @@ function buildIntakeTrace(input: {
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
label: 'Přiřazení řešitele',
|
||||
status: 'skipped',
|
||||
|
||||
+68
-105
@@ -1,139 +1,102 @@
|
||||
/**
|
||||
* Napojeni konektoru: kam se vola a cim se to autorizuje.
|
||||
* 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.
|
||||
* Skript rika `GET /issued-invoices/12`. Zbytek dodava tenhle soubor ze dvou
|
||||
* mist: **sluzba** rika, ktere hlavicky jsou potreba, **konektor** firmy nese
|
||||
* jejich hodnoty.
|
||||
*
|
||||
* 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.
|
||||
* Diky tomu se skript k pristupovym udajum vubec nedostane a dve firmy volaji
|
||||
* tutez sluzbu kazda pod svym uctem.
|
||||
*
|
||||
* Z environment variables sem uz nechodi zadne pristupove udaje, jen zaklad
|
||||
* adresy (`SERVICES_BASE_URL`). Udaje patri konektoru.
|
||||
*/
|
||||
|
||||
import { config } from '../config.js';
|
||||
import type { Connector } from '../data/connectorStore.js';
|
||||
import { findService, type Service } from '../data/services.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;
|
||||
export interface ResolvedTarget {
|
||||
serviceId: string;
|
||||
connectorId: string | null;
|
||||
/** Prazdne u obecnych sluzeb - `ctx.http` pak vyzaduje absolutni adresu. */
|
||||
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. */
|
||||
serviceConfig: Record<string, string>;
|
||||
/** false = chybi povinne udaje, volat nema smysl. */
|
||||
ready: boolean;
|
||||
/** Jmena environment variables, ktere chybi. */
|
||||
/** Popisy chybejicich poli pro uzivatele. */
|
||||
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`.
|
||||
* bere se ze `SERVICES_BASE_URL` (AGENTS.md).
|
||||
*/
|
||||
function resolveBaseUrl(connectorId: string): string {
|
||||
return (
|
||||
readEnv(`${envPrefix(connectorId)}_BASE_URL`) ?? `${config.servicesBaseUrl}/${connectorId}`
|
||||
);
|
||||
export function serviceBaseUrl(service: Service): string {
|
||||
if (service.appId === null) return '';
|
||||
return `${config.servicesBaseUrl}/${service.appId}`;
|
||||
}
|
||||
|
||||
export function resolveConnection(connectorId: string): ResolvedConnection {
|
||||
const spec = authSpecs[connectorId];
|
||||
const headers: Record<string, string> = {};
|
||||
const scriptConfig: Record<string, string> = {};
|
||||
const missing: string[] = [];
|
||||
/**
|
||||
* Slozi adresu a hlavicky pro beh skriptu.
|
||||
*
|
||||
* `connector` je null u obecnych sluzeb, ktere napojeni nepotrebuji. U ostatnich
|
||||
* je jeho absence duvod, proc se skript nespusti - a rekne se to nahlas, ne ze
|
||||
* se zavola bez autorizace a spadne to az na 401.
|
||||
*/
|
||||
export function resolveTarget(serviceId: string, connector: Connector | null): ResolvedTarget {
|
||||
const service = findService(serviceId);
|
||||
|
||||
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);
|
||||
if (!service) {
|
||||
return {
|
||||
serviceId,
|
||||
connectorId: connector?.id ?? null,
|
||||
baseUrl: '',
|
||||
headers: {},
|
||||
serviceConfig: {},
|
||||
ready: false,
|
||||
missing: [`Služba ${serviceId} v katalogu neexistuje.`],
|
||||
};
|
||||
}
|
||||
|
||||
for (const [key, envName] of Object.entries(spec?.config ?? {})) {
|
||||
const value = readEnv(envName);
|
||||
if (value !== undefined) scriptConfig[key] = value;
|
||||
const headers: Record<string, string> = {};
|
||||
const serviceConfig: Record<string, string> = {};
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const field of service.credentials) {
|
||||
const value = (connector?.values[field.id] ?? '').trim();
|
||||
|
||||
if (value === '') {
|
||||
if (field.required) missing.push(field.label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.target === 'header') headers[field.name] = value;
|
||||
else serviceConfig[field.name] = value;
|
||||
}
|
||||
|
||||
if (!service.general && connector === null) {
|
||||
missing.push(`Služba ${service.name} potřebuje konektor.`);
|
||||
}
|
||||
if (connector !== null && !connector.enabled) {
|
||||
missing.push(`Konektor ${connector.name} je vypnutý.`);
|
||||
}
|
||||
|
||||
return {
|
||||
connectorId,
|
||||
name: `${connectorId} (z environment variables)`,
|
||||
baseUrl: resolveBaseUrl(connectorId),
|
||||
serviceId,
|
||||
connectorId: connector?.id ?? null,
|
||||
baseUrl: connector?.baseUrl ?? serviceBaseUrl(service),
|
||||
headers,
|
||||
config: scriptConfig,
|
||||
serviceConfig,
|
||||
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);
|
||||
export function targetSecrets(target: ResolvedTarget): string[] {
|
||||
return Object.values(target.headers);
|
||||
}
|
||||
|
||||
+18
-8
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { config } from '../config.js';
|
||||
import type { ResolvedConnection } from './connections.js';
|
||||
import type { ResolvedTarget } from './connections.js';
|
||||
import { ScriptError, type ScriptHttp, type ScriptHttpOptions, type ScriptHttpResponse } from './types.js';
|
||||
import { describe } from './util.js';
|
||||
|
||||
@@ -53,12 +53,22 @@ function assertAllowedUrl(url: URL): void {
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(connection: ResolvedConnection, path: string, options?: ScriptHttpOptions): URL {
|
||||
function buildUrl(target: ResolvedTarget, path: string, options?: ScriptHttpOptions): URL {
|
||||
// Obecna sluzba nema pevnou adresu, skript pak musi poslat absolutni.
|
||||
const absolute = target.baseUrl === '';
|
||||
if (absolute && !/^https?:\/\//i.test(path)) {
|
||||
throw new ScriptError(
|
||||
'config',
|
||||
`Služba ${target.serviceId} nemá základní adresu, skript musí volat absolutní URL.`,
|
||||
);
|
||||
}
|
||||
|
||||
const raw = absolute ? path : joinUrl(target.baseUrl, path);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(joinUrl(connection.baseUrl, path));
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new ScriptError('config', `Neplatná adresa: ${joinUrl(connection.baseUrl, path)}`);
|
||||
throw new ScriptError('config', `Neplatná adresa: ${raw}`);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(options?.query ?? {})) {
|
||||
@@ -108,7 +118,7 @@ function transportError(err: unknown, url: URL): ScriptError {
|
||||
}
|
||||
|
||||
export interface CreateHttpOptions {
|
||||
connection: ResolvedConnection;
|
||||
target: ResolvedTarget;
|
||||
signal: AbortSignal;
|
||||
idempotencyKey: string;
|
||||
/** Zredigovana verze textu, aby se tajemstvi nedostalo do logu. */
|
||||
@@ -118,7 +128,7 @@ export interface CreateHttpOptions {
|
||||
}
|
||||
|
||||
export function createHttp(options: CreateHttpOptions): ScriptHttp {
|
||||
const { connection, signal, idempotencyKey, redact, log, onCall } = options;
|
||||
const { target, signal, idempotencyKey, redact, log, onCall } = options;
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
@@ -126,7 +136,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
|
||||
body: unknown,
|
||||
httpOptions?: ScriptHttpOptions,
|
||||
): Promise<ScriptHttpResponse<T>> {
|
||||
const url = buildUrl(connection, path, httpOptions);
|
||||
const url = buildUrl(target, path, httpOptions);
|
||||
const hasBody = body !== undefined && method !== 'GET' && method !== 'DELETE';
|
||||
const startedAt = Date.now();
|
||||
onCall();
|
||||
@@ -140,7 +150,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
|
||||
Accept: 'application/json',
|
||||
// Druhy pokus tehoz kroku nesmi vystavit druhou fakturu.
|
||||
'Idempotency-Key': idempotencyKey,
|
||||
...connection.headers,
|
||||
...target.headers,
|
||||
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
...httpOptions?.headers,
|
||||
},
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
* 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 type { OperationField, ProvidedField, ServiceOperation } from '../data/services.js';
|
||||
import { scriptManifestSchema, type FieldIssue, type ScriptField, type ScriptManifest } from './types.js';
|
||||
|
||||
/** `idoklad.get-issued-invoice` -> `idoklad` */
|
||||
export function connectorIdOf(scriptId: string): string {
|
||||
export function serviceIdOf(scriptId: string): string {
|
||||
return scriptId.slice(0, scriptId.indexOf('.'));
|
||||
}
|
||||
|
||||
@@ -81,7 +80,7 @@ function toOperationField(field: ScriptField): OperationField {
|
||||
*/
|
||||
function toProvidedField(scriptId: string, field: ScriptField): ProvidedField {
|
||||
return {
|
||||
id: `${connectorIdOf(scriptId)}.${field.id}`,
|
||||
id: `${serviceIdOf(scriptId)}.${field.id}`,
|
||||
name: field.id,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
@@ -89,7 +88,7 @@ function toProvidedField(scriptId: string, field: ScriptField): ProvidedField {
|
||||
}
|
||||
|
||||
/** Operace katalogu odvozena ze skriptu. Tohle vidi builder. */
|
||||
export function toConnectorOperation(manifest: ScriptManifest): ConnectorOperation {
|
||||
export function toServiceOperation(manifest: ScriptManifest): ServiceOperation {
|
||||
return {
|
||||
id: operationIdOf(manifest.id),
|
||||
name: manifest.name,
|
||||
|
||||
+13
-13
@@ -17,8 +17,8 @@ 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 { services, setScriptActions, type ServiceOperation } from '../data/services.js';
|
||||
import { parseManifest, serviceIdOf, toServiceOperation } from './manifest.js';
|
||||
import type { FieldIssue, ScriptContext, ScriptManifest, ScriptValues } from './types.js';
|
||||
|
||||
export type ScriptRunFn = (
|
||||
@@ -150,7 +150,7 @@ async function scan(): Promise<void> {
|
||||
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.',
|
||||
message: 'Název souboru musí mít tvar sluzba.operace.js, jen malá písmena a pomlčky.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -196,30 +196,30 @@ async function scan(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prenese nactene skripty do katalogu konektoru.
|
||||
* Prenese nactene skripty do katalogu sluzeb.
|
||||
*
|
||||
* 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[]>();
|
||||
const byService = new Map<string, ServiceOperation[]>();
|
||||
|
||||
for (const script of scripts.values()) {
|
||||
const connectorId = connectorIdOf(script.manifest.id);
|
||||
if (!connectors.some((connector) => connector.id === connectorId)) {
|
||||
const serviceId = serviceIdOf(script.manifest.id);
|
||||
if (!services.some((service) => service.id === serviceId)) {
|
||||
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.`,
|
||||
message: `Služba ${serviceId} 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);
|
||||
const list = byService.get(serviceId) ?? [];
|
||||
list.push(toServiceOperation(script.manifest));
|
||||
byService.set(serviceId, list);
|
||||
}
|
||||
|
||||
setScriptActions(byConnector);
|
||||
setScriptActions(byService);
|
||||
}
|
||||
|
||||
/** Prohleda adresar, nejvyse jednou za RESCAN_MS. Soubezne volani se sdili. */
|
||||
@@ -280,7 +280,7 @@ export type SaveResult =
|
||||
*/
|
||||
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.' };
|
||||
return { ok: false, message: 'Neplatné ID skriptu. Povolený tvar je sluzba.operace.' };
|
||||
}
|
||||
if (code.trim().length === 0) {
|
||||
return { ok: false, message: 'Kód skriptu nesmí být prázdný.' };
|
||||
|
||||
+15
-8
@@ -12,9 +12,10 @@
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { config } from '../config.js';
|
||||
import { connectionSecrets, resolveConnection } from './connections.js';
|
||||
import type { Connector } from '../data/connectorStore.js';
|
||||
import { resolveTarget, targetSecrets } from './connections.js';
|
||||
import { createHttp } from './http.js';
|
||||
import { connectorIdOf } from './manifest.js';
|
||||
import { serviceIdOf } from './manifest.js';
|
||||
import { getScript } from './registry.js';
|
||||
import {
|
||||
isRetryableKind,
|
||||
@@ -28,6 +29,12 @@ import { createRedactor, describe, scriptUtil } from './util.js';
|
||||
import { validateValues } from './values.js';
|
||||
|
||||
export interface RunScriptOptions {
|
||||
/**
|
||||
* Konektor firmy, pod jehoz udaji se to zavola. `null` je v poradku jen
|
||||
* u obecnych sluzeb - u ostatnich se skript nespusti a rekne se to nahlas.
|
||||
* Prislusnost konektoru k firme si overuje volajici.
|
||||
*/
|
||||
connector?: Connector | null;
|
||||
/**
|
||||
* Stabilni pres vsechny pokusy tehoz kroku. Kdyz chybi, dopocita se
|
||||
* ze skriptu a vstupu - dva stejne pokusy tak dostanou stejny klic.
|
||||
@@ -95,16 +102,16 @@ export async function runScript(
|
||||
}
|
||||
|
||||
const { manifest } = script;
|
||||
const connection = resolveConnection(connectorIdOf(scriptId));
|
||||
const redact = createRedactor(connectionSecrets(connection));
|
||||
const target = resolveTarget(serviceIdOf(scriptId), options.connector ?? null);
|
||||
const redact = createRedactor(targetSecrets(target));
|
||||
|
||||
if (!connection.ready) {
|
||||
if (!target.ready) {
|
||||
return finish({
|
||||
ok: false,
|
||||
outputs: {},
|
||||
error: {
|
||||
kind: 'config',
|
||||
message: `Napojení na ${connection.connectorId} není nastavené. Chybí: ${connection.missing.join(', ')}.`,
|
||||
message: `Napojení není hotové: ${target.missing.join(', ')}.`,
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
@@ -144,7 +151,7 @@ export async function runScript(
|
||||
|
||||
const ctx: ScriptContext = {
|
||||
http: createHttp({
|
||||
connection,
|
||||
target,
|
||||
signal: controller.signal,
|
||||
idempotencyKey,
|
||||
redact,
|
||||
@@ -155,7 +162,7 @@ export async function runScript(
|
||||
}),
|
||||
util: scriptUtil,
|
||||
log,
|
||||
config: Object.freeze({ ...connection.config }),
|
||||
config: Object.freeze({ ...target.serviceConfig }),
|
||||
idempotencyKey,
|
||||
fail(message, detail) {
|
||||
throw new ScriptError('terminal', message, { detail: describe(detail) });
|
||||
|
||||
@@ -76,7 +76,7 @@ export const scriptManifestSchema = 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.',
|
||||
'ID skriptu musí mít tvar sluzba.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ý.'),
|
||||
|
||||
@@ -16,6 +16,7 @@ import Services from '@/pages/Services';
|
||||
const Overview = lazy(() => import('@/pages/dashboard/Overview'));
|
||||
const Automations = lazy(() => import('@/pages/dashboard/Automations'));
|
||||
const AutomationDetail = lazy(() => import('@/pages/dashboard/AutomationDetail'));
|
||||
const DashboardServices = lazy(() => import('@/pages/dashboard/Services'));
|
||||
const Connectors = lazy(() => import('@/pages/dashboard/Connectors'));
|
||||
const Scripts = lazy(() => import('@/pages/dashboard/Scripts'));
|
||||
const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
|
||||
@@ -61,6 +62,7 @@ export default function App() {
|
||||
<Route index element={<Overview />} />
|
||||
<Route path="automatizace" element={<Automations />} />
|
||||
<Route path="automatizace/:id" element={<AutomationDetail />} />
|
||||
<Route path="sluzby" element={<DashboardServices />} />
|
||||
<Route path="konektory" element={<Connectors />} />
|
||||
<Route path="skripty" element={<Scripts />} />
|
||||
<Route path="tickety" element={<Tickets />} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LogOut,
|
||||
Menu,
|
||||
Plug,
|
||||
Blocks,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Workflow,
|
||||
@@ -27,6 +28,7 @@ import type { Access } from '@/types/dashboard';
|
||||
const nav = [
|
||||
{ to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true },
|
||||
{ to: '/dashboard/automatizace', label: 'Automatizace', icon: Workflow, end: false },
|
||||
{ to: '/dashboard/sluzby', label: 'Služby', icon: Blocks, 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 },
|
||||
|
||||
@@ -2,9 +2,9 @@ import { AlertCircle, CheckCircle2, GitBranch, Info, MinusCircle, Zap } from 'lu
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { connectorIcon } from '@/lib/connectorIcons';
|
||||
import { serviceIcon } from '@/lib/serviceIcons';
|
||||
import { formatDateTime, formatDuration } from '@/lib/format';
|
||||
import type { Connector, TicketTraceEntry, TraceStatus } from '@/types/dashboard';
|
||||
import type { Service, TicketTraceEntry, TraceStatus } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Log ticketu jako strom. Vetev podminky je zanorena pod ni,
|
||||
@@ -55,10 +55,10 @@ function buildTree(entries: TicketTraceEntry[]): TraceNode[] {
|
||||
|
||||
export function TicketTrace({
|
||||
entries,
|
||||
connectors,
|
||||
services,
|
||||
}: {
|
||||
entries: TicketTraceEntry[];
|
||||
connectors: Connector[];
|
||||
services: Service[];
|
||||
}) {
|
||||
const tree = useMemo(() => buildTree(entries), [entries]);
|
||||
|
||||
@@ -73,16 +73,16 @@ export function TicketTrace({
|
||||
return (
|
||||
<ol className="space-y-2.5">
|
||||
{tree.map((node) => (
|
||||
<TraceRow key={node.id} node={node} connectors={connectors} />
|
||||
<TraceRow key={node.id} node={node} services={services} />
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceRow({ node, connectors }: { node: TraceNode; connectors: Connector[] }) {
|
||||
function TraceRow({ node, services }: { node: TraceNode; services: Service[] }) {
|
||||
const meta = statusMeta[node.status];
|
||||
const Icon = node.kind === 'condition' ? GitBranch : node.kind === 'trigger' ? Zap : meta.icon;
|
||||
const service = describeService(node, connectors);
|
||||
const service = describeService(node, services);
|
||||
|
||||
return (
|
||||
<li>
|
||||
@@ -124,7 +124,7 @@ function TraceRow({ node, connectors }: { node: TraceNode; connectors: Connector
|
||||
{node.children.length > 0 && (
|
||||
<ol className={cn('mt-2.5 space-y-2.5 border-l-2 pl-4 sm:pl-6', meta.line)}>
|
||||
{node.children.map((child) => (
|
||||
<TraceRow key={child.id} node={child} connectors={connectors} />
|
||||
<TraceRow key={child.id} node={child} services={services} />
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
@@ -133,11 +133,11 @@ function TraceRow({ node, connectors }: { node: TraceNode; connectors: Connector
|
||||
}
|
||||
|
||||
/** "RAYNET CRM / Dohledani kontaktu" - kdyz konektor v katalogu neni, aspon jeho ID. */
|
||||
function describeService(node: TraceNode, connectors: Connector[]): string | null {
|
||||
if (!node.connectorId) return null;
|
||||
function describeService(node: TraceNode, services: Service[]): string | null {
|
||||
if (!node.serviceId) return null;
|
||||
|
||||
const connector = connectors.find((c) => c.id === node.connectorId);
|
||||
if (!connector) return `${node.connectorId}${node.operationId ? ` / ${node.operationId}` : ''}`;
|
||||
const connector = services.find((c) => c.id === node.serviceId);
|
||||
if (!connector) return `${node.serviceId}${node.operationId ? ` / ${node.operationId}` : ''}`;
|
||||
|
||||
if (!node.operationId) return connector.name;
|
||||
|
||||
@@ -150,7 +150,7 @@ function describeService(node: TraceNode, connectors: Connector[]): string | nul
|
||||
export function TraceSummary({ entries }: { entries: TicketTraceEntry[] }) {
|
||||
const failed = entries.filter((e) => e.status === 'error').length;
|
||||
const skipped = entries.filter((e) => e.status === 'skipped').length;
|
||||
const services = new Set(entries.map((e) => e.connectorId).filter(Boolean)).size;
|
||||
const services = new Set(entries.map((e) => e.serviceId).filter(Boolean)).size;
|
||||
|
||||
return (
|
||||
<p className="text-xs text-white/40">
|
||||
@@ -162,8 +162,8 @@ export function TraceSummary({ entries }: { entries: TicketTraceEntry[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Ikona konektoru pro pouziti nad logem (napr. u kanalu ticketu). */
|
||||
export function serviceIcon(connectorId: string, connectors: Connector[]): LucideIcon | null {
|
||||
const connector = connectors.find((c) => c.id === connectorId);
|
||||
return connector ? connectorIcon(connector.icon) : null;
|
||||
/** Ikona sluzby pro pouziti nad logem (napr. u kanalu ticketu). */
|
||||
export function traceServiceIcon(serviceId: string, services: Service[]): LucideIcon | null {
|
||||
const service = services.find((item) => item.id === serviceId);
|
||||
return service ? serviceIcon(service.icon) : null;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { StepInputs } from '@/components/dashboard/flow/StepInputs';
|
||||
import { TriggerConfig } from '@/components/dashboard/flow/TriggerConfig';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { connectorIcon } from '@/lib/connectorIcons';
|
||||
import { serviceIcon } from '@/lib/serviceIcons';
|
||||
import {
|
||||
collectScopes,
|
||||
defaultOperatorFor,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import type {
|
||||
AutomationFlow,
|
||||
ConditionOperator,
|
||||
Connector,
|
||||
Service,
|
||||
FlowStep,
|
||||
TriggerField,
|
||||
} from '@/types/dashboard';
|
||||
@@ -51,7 +51,7 @@ export function FlowCanvas({
|
||||
...callbacks
|
||||
}: CanvasCallbacks & {
|
||||
flow: AutomationFlow;
|
||||
connectors: Connector[];
|
||||
connectors: Service[];
|
||||
webhookBaseUrl: string;
|
||||
regenerating: boolean;
|
||||
onPickTrigger: () => void;
|
||||
@@ -95,7 +95,7 @@ function TriggerCard({
|
||||
onRegenerateToken,
|
||||
}: {
|
||||
flow: AutomationFlow;
|
||||
connectors: Connector[];
|
||||
connectors: Service[];
|
||||
webhookBaseUrl: string;
|
||||
regenerating: boolean;
|
||||
onPick: () => void;
|
||||
@@ -124,7 +124,7 @@ function TriggerCard({
|
||||
|
||||
const resolved = resolveOperation(
|
||||
connectors,
|
||||
flow.trigger.connectorId,
|
||||
flow.trigger.serviceId,
|
||||
flow.trigger.operationId,
|
||||
'trigger',
|
||||
);
|
||||
@@ -133,13 +133,13 @@ function TriggerCard({
|
||||
return (
|
||||
<BrokenCard
|
||||
label="Spouštěč odkazuje na neznámý konektor"
|
||||
detail={`${flow.trigger.connectorId} / ${flow.trigger.operationId}`}
|
||||
detail={`${flow.trigger.serviceId} / ${flow.trigger.operationId}`}
|
||||
onFix={onPick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = connectorIcon(resolved.connector.icon);
|
||||
const Icon = serviceIcon(resolved.connector.icon);
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-brand-400/35 bg-gradient-to-br from-brand-500/12 to-transparent p-5">
|
||||
@@ -191,7 +191,7 @@ function StepSequence({
|
||||
}: CanvasCallbacks & {
|
||||
steps: FlowStep[];
|
||||
path: FlowPath;
|
||||
connectors: Connector[];
|
||||
connectors: Service[];
|
||||
/** Co je videt v kterem kroku. Klic je ID kroku. */
|
||||
scopes: Map<string, TriggerField[]>;
|
||||
/** Pouzije se, kdyz krok ve scope jeste neni (prave pridany). */
|
||||
@@ -279,7 +279,7 @@ function ActionCard({
|
||||
onUpdateInputs,
|
||||
}: {
|
||||
step: Extract<FlowStep, { kind: 'action' }>;
|
||||
connectors: Connector[];
|
||||
connectors: Service[];
|
||||
/** Parametry viditelne prave v tomto miste stromu. */
|
||||
available: TriggerField[];
|
||||
canMoveUp: boolean;
|
||||
@@ -288,19 +288,19 @@ function ActionCard({
|
||||
onMove: (offset: number) => void;
|
||||
onUpdateInputs: (inputs: Record<string, string>) => void;
|
||||
}) {
|
||||
const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action');
|
||||
const resolved = resolveOperation(connectors, step.serviceId, step.operationId, 'action');
|
||||
|
||||
if (!resolved) {
|
||||
return (
|
||||
<BrokenCard
|
||||
label="Krok odkazuje na neznámou akci"
|
||||
detail={`${step.connectorId} / ${step.operationId}`}
|
||||
detail={`${step.serviceId} / ${step.operationId}`}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = connectorIcon(resolved.connector.icon);
|
||||
const Icon = serviceIcon(resolved.connector.icon);
|
||||
const inputs = resolved.operation.inputs;
|
||||
|
||||
return (
|
||||
@@ -359,7 +359,7 @@ function ConditionCard({
|
||||
}: CanvasCallbacks & {
|
||||
step: Extract<FlowStep, { kind: 'condition' }>;
|
||||
path: FlowPath;
|
||||
connectors: Connector[];
|
||||
connectors: Service[];
|
||||
scopes: Map<string, TriggerField[]>;
|
||||
fallbackFields: TriggerField[];
|
||||
available: TriggerField[];
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { connectorIcon } from '@/lib/connectorIcons';
|
||||
import type { Connector, ConnectorCategory, ConnectorOperation } from '@/types/dashboard';
|
||||
import { serviceIcon } from '@/lib/serviceIcons';
|
||||
import type { Service, ServiceCategory, ServiceOperation } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Vyber toho, co se ma pridat do stromu.
|
||||
@@ -28,19 +28,19 @@ export function StepPicker({
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: 'trigger' | 'action';
|
||||
connectors: Connector[];
|
||||
categories: Array<{ id: ConnectorCategory; label: string }>;
|
||||
connectors: Service[];
|
||||
categories: Array<{ id: ServiceCategory; label: string }>;
|
||||
onClose: () => void;
|
||||
onPickOperation: (connectorId: string, operationId: string) => void;
|
||||
onPickOperation: (serviceId: string, operationId: string) => void;
|
||||
onPickCondition?: () => void;
|
||||
/** false = spoustec nema zadne parametry, nebylo by podle ceho se rozhodovat */
|
||||
canAddCondition?: boolean;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Connector | null>(null);
|
||||
const [selected, setSelected] = useState<Service | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<ConnectorCategory | 'all'>('all');
|
||||
const [category, setCategory] = useState<ServiceCategory | 'all'>('all');
|
||||
|
||||
const operationsOf = (connector: Connector) =>
|
||||
const operationsOf = (connector: Service) =>
|
||||
mode === 'trigger' ? connector.triggers : connector.actions;
|
||||
|
||||
// Konektory, ktere v tomhle rezimu vubec maji co nabidnout.
|
||||
@@ -65,7 +65,7 @@ export function StepPicker({
|
||||
// Ploche vysledky hledani - hleda v nazvu konektoru i v nazvech operaci.
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searching) return [];
|
||||
const results: Array<{ connector: Connector; operation: ConnectorOperation }> = [];
|
||||
const results: Array<{ connector: Service; operation: ServiceOperation }> = [];
|
||||
for (const connector of usable) {
|
||||
for (const operation of operationsOf(connector)) {
|
||||
const haystack = `${connector.name} ${operation.name} ${operation.description}`.toLowerCase();
|
||||
@@ -82,7 +82,7 @@ export function StepPicker({
|
||||
onClose();
|
||||
}
|
||||
|
||||
function pick(connector: Connector, operation: ConnectorOperation) {
|
||||
function pick(connector: Service, operation: ServiceOperation) {
|
||||
if (connector.status === 'planned') {
|
||||
console.warn(`[picker] konektor ${connector.id} je na roadmape, nelze pouzit`);
|
||||
return;
|
||||
@@ -267,8 +267,8 @@ function CategoryChip({
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectorGlyph({ connector }: { connector: Connector }) {
|
||||
const Icon = connectorIcon(connector.icon);
|
||||
function ConnectorGlyph({ connector }: { connector: Service }) {
|
||||
const Icon = serviceIcon(connector.icon);
|
||||
return (
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
|
||||
<Icon className="size-5" />
|
||||
@@ -281,7 +281,7 @@ function ConnectorCard({
|
||||
operationCount,
|
||||
onClick,
|
||||
}: {
|
||||
connector: Connector;
|
||||
connector: Service;
|
||||
operationCount: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
@@ -324,8 +324,8 @@ function OperationRow({
|
||||
showConnectorName = false,
|
||||
onClick,
|
||||
}: {
|
||||
connector: Connector;
|
||||
operation: ConnectorOperation;
|
||||
connector: Service;
|
||||
operation: ServiceOperation;
|
||||
showConnectorName?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { fieldTypeLabels, newFieldId } from '@/lib/flow';
|
||||
import type { Connector, FieldType, FlowTrigger, TriggerField } from '@/types/dashboard';
|
||||
import type { Service, FieldType, FlowTrigger, TriggerField } from '@/types/dashboard';
|
||||
|
||||
const fieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
|
||||
|
||||
@@ -20,13 +20,13 @@ export function TriggerConfig({
|
||||
regenerating,
|
||||
}: {
|
||||
trigger: FlowTrigger;
|
||||
connector: Connector | undefined;
|
||||
connector: Service | undefined;
|
||||
webhookBaseUrl: string;
|
||||
onChangeFields: (fields: TriggerField[]) => void;
|
||||
onRegenerateToken: () => void;
|
||||
regenerating: boolean;
|
||||
}) {
|
||||
const isWebhook = trigger.connectorId === 'webhook';
|
||||
const isWebhook = trigger.serviceId === 'webhook';
|
||||
const operation = connector?.triggers.find((t) => t.id === trigger.operationId);
|
||||
const editable = operation?.customPayload === true;
|
||||
/**
|
||||
|
||||
+13
-13
@@ -1,8 +1,8 @@
|
||||
import type {
|
||||
AutomationFlow,
|
||||
ConditionOperator,
|
||||
Connector,
|
||||
ConnectorOperation,
|
||||
Service,
|
||||
ServiceOperation,
|
||||
FieldType,
|
||||
FlowStep,
|
||||
TriggerField,
|
||||
@@ -30,8 +30,8 @@ export function newStepId(): string {
|
||||
return `st_${Date.now().toString(36)}_${idCounter}`;
|
||||
}
|
||||
|
||||
export function createActionStep(connectorId: string, operationId: string): FlowStep {
|
||||
return { id: newStepId(), kind: 'action', connectorId, operationId };
|
||||
export function createActionStep(serviceId: string, operationId: string): FlowStep {
|
||||
return { id: newStepId(), kind: 'action', serviceId, operationId };
|
||||
}
|
||||
|
||||
export function createConditionStep(field: TriggerField): FlowStep {
|
||||
@@ -139,13 +139,13 @@ export function describeCondition(
|
||||
*/
|
||||
export function collectScopes(
|
||||
flow: AutomationFlow,
|
||||
connectors: Connector[],
|
||||
connectors: Service[],
|
||||
): Map<string, TriggerField[]> {
|
||||
const byStep = new Map<string, TriggerField[]>();
|
||||
const triggerFields = flow.trigger?.fields ?? [];
|
||||
|
||||
const outputsOf = (step: Extract<FlowStep, { kind: 'action' }>): TriggerField[] => {
|
||||
const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action');
|
||||
const resolved = resolveOperation(connectors, step.serviceId, step.operationId, 'action');
|
||||
return (resolved?.operation.outputFields ?? []).map((output) => ({
|
||||
// Prefix ID krokem - dva stejne kroky ve strome se nesmi prekryt.
|
||||
id: `${step.id}.${output.id}`,
|
||||
@@ -305,8 +305,8 @@ export function countSteps(steps: FlowStep[]): number {
|
||||
}
|
||||
|
||||
interface ResolvedOperation {
|
||||
connector: Connector;
|
||||
operation: ConnectorOperation;
|
||||
connector: Service;
|
||||
operation: ServiceOperation;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,21 +314,21 @@ interface ResolvedOperation {
|
||||
* (napr. konektor byl z katalogu odebran) - volajici to musi zobrazit.
|
||||
*/
|
||||
export function resolveOperation(
|
||||
connectors: Connector[],
|
||||
connectorId: string,
|
||||
connectors: Service[],
|
||||
serviceId: string,
|
||||
operationId: string,
|
||||
type: 'trigger' | 'action',
|
||||
): ResolvedOperation | null {
|
||||
const connector = connectors.find((c) => c.id === connectorId);
|
||||
const connector = connectors.find((c) => c.id === serviceId);
|
||||
if (!connector) {
|
||||
console.warn(`[flow] neznamy konektor ${connectorId}`);
|
||||
console.warn(`[flow] neznamy konektor ${serviceId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const pool = type === 'trigger' ? connector.triggers : connector.actions;
|
||||
const operation = pool.find((op) => op.id === operationId);
|
||||
if (!operation) {
|
||||
console.warn(`[flow] konektor ${connectorId} nema ${type} ${operationId}`);
|
||||
console.warn(`[flow] konektor ${serviceId} nema ${type} ${operationId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Boxes,
|
||||
Building2,
|
||||
Clock,
|
||||
Facebook,
|
||||
@@ -32,12 +33,13 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* API posila u konektoru jen klic ikony (string) - React komponentu poslat nemuze.
|
||||
* API posila u sluzby jen klic ikony (string) - React komponentu poslat nemuze.
|
||||
* Mapovani je tady. Neznamy klic se zaloguje a dostane obecnou ikonu,
|
||||
* aby chybejici zapis nerozbil celou stranku.
|
||||
*/
|
||||
const icons: Record<string, LucideIcon> = {
|
||||
BarChart3,
|
||||
Boxes,
|
||||
Building2,
|
||||
Clock,
|
||||
Facebook,
|
||||
@@ -67,10 +69,10 @@ const icons: Record<string, LucideIcon> = {
|
||||
Webhook,
|
||||
};
|
||||
|
||||
export function connectorIcon(key: string): LucideIcon {
|
||||
export function serviceIcon(key: string): LucideIcon {
|
||||
const icon = icons[key];
|
||||
if (!icon) {
|
||||
console.warn(`[connectorIcons] neznamy klic ikony "${key}", pouzivam zastupnou`);
|
||||
console.warn(`[serviceIcons] neznamy klic ikony "${key}", pouzivam zastupnou`);
|
||||
return Plug;
|
||||
}
|
||||
return icon;
|
||||
+51
-3
@@ -8,6 +8,24 @@ import { Button } from '@/components/ui/Button';
|
||||
import { brand } from '@/config/brand';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
|
||||
/**
|
||||
* Demo ucty prototypu.
|
||||
*
|
||||
* Prvni je predvyplneny, aby se pri testovani nemuselo porad psat prihlaseni.
|
||||
* Pred ostrym pouzitim cely tenhle blok i `defaultValue` odebrat.
|
||||
*/
|
||||
interface DemoAccount {
|
||||
email: string;
|
||||
password: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
const demoAccounts: DemoAccount[] = [
|
||||
{ email: 'admin@automia.cz', password: 'demo1234', note: 'správce platformy, vidí všechny firmy' },
|
||||
{ email: 'karel.vomacka@automia.cz', password: 'demo1234', note: 'dvě firmy, jiná role v každé' },
|
||||
{ email: 'martin.kriz@automia.cz', password: 'demo1234', note: 'řešitel jedné firmy' },
|
||||
];
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-4 py-3 text-sm text-white placeholder:text-white/30 transition-colors focus:border-brand-400/70 focus:outline-none';
|
||||
|
||||
@@ -22,6 +40,20 @@ export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Predvyplni formular vybranym demo uctem.
|
||||
* Sahame na DOM misto stavu, protoze formular je nerizeny - jinak by se
|
||||
* musela prepsat obe pole na controlled a nic dalsiho z toho nezískáme.
|
||||
*/
|
||||
function fillAccount(account: DemoAccount) {
|
||||
const form = document.querySelector<HTMLFormElement>('form');
|
||||
if (!form) return;
|
||||
const email = form.elements.namedItem('email');
|
||||
const password = form.elements.namedItem('password');
|
||||
if (email instanceof HTMLInputElement) email.value = account.email;
|
||||
if (password instanceof HTMLInputElement) password.value = account.password;
|
||||
}
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Kam po prihlaseni - bud puvodni cil z RequireAuth, nebo dashboard.
|
||||
@@ -86,6 +118,8 @@ export default function Login() {
|
||||
required
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
// Predvyplneno kvuli testovani prototypu. Pred ostrym pouzitim odebrat.
|
||||
defaultValue={demoAccounts[0].email}
|
||||
className={inputClass}
|
||||
placeholder="vas@email.cz"
|
||||
/>
|
||||
@@ -104,6 +138,7 @@ export default function Login() {
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
defaultValue={demoAccounts[0].password}
|
||||
className={inputClass}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
@@ -127,9 +162,22 @@ export default function Login() {
|
||||
<ShieldCheck className="size-3.5" />
|
||||
Demo přístupy (prototyp)
|
||||
</p>
|
||||
<ul className="mt-2.5 space-y-1 font-mono text-xs text-white/50">
|
||||
<li>admin@automia.cz / demo1234</li>
|
||||
<li>klient@firma.cz / demo1234</li>
|
||||
<p className="mt-1 text-xs text-white/35">
|
||||
Formulář je předvyplněný správcem platformy. Kliknutím se přepne účet.
|
||||
</p>
|
||||
<ul className="mt-2.5 space-y-1">
|
||||
{demoAccounts.map((account) => (
|
||||
<li key={account.email}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillAccount(account)}
|
||||
className="w-full rounded-lg px-2 py-1 text-left font-mono text-xs text-white/50 transition-colors hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
{account.email}
|
||||
<span className="ml-2 font-sans text-white/30">{account.note}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
AutomationDetail as Detail,
|
||||
AutomationFlow,
|
||||
ConnectorCatalog,
|
||||
ServiceCatalog,
|
||||
TriggerField,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function AutomationDetail() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const automation = useApiQuery<Detail>(`/api/dashboard/automations/${id}`);
|
||||
const catalog = useApiQuery<ConnectorCatalog>('/api/dashboard/connectors');
|
||||
const catalog = useApiQuery<ServiceCatalog>('/api/dashboard/services');
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [flow, setFlow] = useState<AutomationFlow>({ trigger: null, steps: [] });
|
||||
@@ -79,14 +79,14 @@ export default function AutomationDetail() {
|
||||
}, []);
|
||||
|
||||
/** Parametry, ktere ma mit spoustec po jeho zmene. */
|
||||
function fieldsForTrigger(connectorId: string, operationId: string): TriggerField[] {
|
||||
const provided = findTrigger(connectorId, operationId)?.providedFields;
|
||||
function fieldsForTrigger(serviceId: string, operationId: string): TriggerField[] {
|
||||
const provided = findTrigger(serviceId, operationId)?.providedFields;
|
||||
// Sluzba si data urcuje sama. Server je pri ulozeni stejne dosadi z katalogu,
|
||||
// tady je nastavime hned, aby slo rovnou stavet podminky.
|
||||
if (provided) return provided;
|
||||
|
||||
const previous = flow.trigger
|
||||
? findTrigger(flow.trigger.connectorId, flow.trigger.operationId)
|
||||
? findTrigger(flow.trigger.serviceId, flow.trigger.operationId)
|
||||
: undefined;
|
||||
// Prechod z katalogoveho spoustece na vlastni - cizi parametry by tu neplatily.
|
||||
if (previous?.providedFields) return [];
|
||||
@@ -95,13 +95,13 @@ export default function AutomationDetail() {
|
||||
return flow.trigger?.fields ?? [];
|
||||
}
|
||||
|
||||
function findTrigger(connectorId: string, operationId: string) {
|
||||
function findTrigger(serviceId: string, operationId: string) {
|
||||
return connectors
|
||||
.find((connector) => connector.id === connectorId)
|
||||
.find((connector) => connector.id === serviceId)
|
||||
?.triggers.find((operation) => operation.id === operationId);
|
||||
}
|
||||
|
||||
function handlePick(connectorId: string, operationId: string) {
|
||||
function handlePick(serviceId: string, operationId: string) {
|
||||
if (!picker) {
|
||||
console.warn('[builder] vyber potvrzen bez otevreneho cile');
|
||||
return;
|
||||
@@ -111,9 +111,9 @@ export default function AutomationDetail() {
|
||||
changeFlow({
|
||||
...flow,
|
||||
trigger: {
|
||||
connectorId,
|
||||
serviceId,
|
||||
operationId,
|
||||
fields: fieldsForTrigger(connectorId, operationId),
|
||||
fields: fieldsForTrigger(serviceId, operationId),
|
||||
webhookToken: flow.trigger?.webhookToken,
|
||||
},
|
||||
});
|
||||
@@ -124,7 +124,7 @@ export default function AutomationDetail() {
|
||||
flow.steps,
|
||||
picker.path,
|
||||
picker.index,
|
||||
createActionStep(connectorId, operationId),
|
||||
createActionStep(serviceId, operationId),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,147 +1,130 @@
|
||||
import { CheckCircle2, Clock, Plug, Search, Terminal, Zap } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Plus, Star, Trash2, Wifi } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { brand } from '@/config/brand';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { apiFetch, ApiError } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { connectorIcon } from '@/lib/connectorIcons';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { serviceIcon } from '@/lib/serviceIcons';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
Connector,
|
||||
ConnectorCatalog,
|
||||
ConnectorCategory,
|
||||
ConnectorOperation,
|
||||
ConnectorStatus,
|
||||
ConnectorTestResult,
|
||||
Service,
|
||||
ServiceCredentialField,
|
||||
ServiceOverview,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
const statusMeta: Record<ConnectorStatus, { label: string; tone: 'ok' | 'neutral' | 'warn' }> = {
|
||||
connected: { label: 'Napojeno', tone: 'ok' },
|
||||
available: { label: 'Umíme napojit', tone: 'neutral' },
|
||||
planned: { label: 'Na roadmapě', tone: 'warn' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Konektory: napojeni teto firmy na sluzby.
|
||||
*
|
||||
* Pristupove udaje se zadavaji tady, ne v prostredi aplikace - kazda firma ma
|
||||
* svoje. Tajne hodnoty server nikdy nevraci, takze u vyplneneho pole je videt
|
||||
* jen to, ze vyplnene je. Viz documentation/12-sluzby-a-konektory.md.
|
||||
*/
|
||||
export default function Connectors() {
|
||||
usePageMeta({ title: 'Konektory — portál Automia' });
|
||||
usePageMeta({ title: 'Konektory - portál Automia' });
|
||||
|
||||
const { data, loading, error, reload } = useApiQuery<ConnectorCatalog>(
|
||||
'/api/dashboard/connectors',
|
||||
const [params, setParams] = useSearchParams();
|
||||
const preselected = params.get('service');
|
||||
|
||||
const [services, setServices] = useState<ServiceOverview | null>(null);
|
||||
const [connectors, setConnectors] = useState<Connector[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creatingFor, setCreatingFor] = useState<string | null>(preselected);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([
|
||||
apiFetch<ServiceOverview>('/api/dashboard/connectors/services'),
|
||||
apiFetch<{ items: Connector[] }>('/api/dashboard/connectors'),
|
||||
])
|
||||
.then(([overview, list]) => {
|
||||
setServices(overview);
|
||||
setConnectors(list.items);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : 'Konektory se nepodařilo načíst.');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
const servicesById = useMemo(() => {
|
||||
const map = new Map<string, Service>();
|
||||
for (const service of services?.items ?? []) map.set(service.id, service);
|
||||
return map;
|
||||
}, [services]);
|
||||
|
||||
/** Sluzby, na ktere jde konektor zalozit. Obecne se nenabizeji, nepotrebuji ho. */
|
||||
const connectable = useMemo(
|
||||
() =>
|
||||
(services?.items ?? [])
|
||||
.filter((service) => !service.general && service.status === 'available')
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs')),
|
||||
[services],
|
||||
);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<ConnectorCategory | 'all'>('all');
|
||||
const [status, setStatus] = useState<ConnectorStatus | 'all'>('all');
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const categories = data?.categories ?? [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return items.filter((connector) => {
|
||||
if (category !== 'all' && connector.category !== category) return false;
|
||||
if (status !== 'all' && connector.status !== status) return false;
|
||||
if (needle.length === 0) return true;
|
||||
const haystack = [
|
||||
connector.name,
|
||||
connector.description,
|
||||
...connector.triggers.map((t) => t.name),
|
||||
...connector.actions.map((a) => a.name),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(needle);
|
||||
});
|
||||
}, [items, query, category, status]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
connected: items.filter((c) => c.status === 'connected').length,
|
||||
available: items.filter((c) => c.status === 'available').length,
|
||||
planned: items.filter((c) => c.status === 'planned').length,
|
||||
}),
|
||||
[items],
|
||||
);
|
||||
function closeForm() {
|
||||
setCreatingFor(null);
|
||||
if (preselected) setParams({});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Konektory</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Služby, které jdou použít v automatizacích. Každý konektor nabízí spouštěče
|
||||
(čím běh začne) a akce (co se má stát).
|
||||
</p>
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Konektory</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-white/50">
|
||||
Napojení této firmy na jednotlivé služby. Přístupové údaje zadáváte tady a
|
||||
zůstávají na serveru, prohlížeč je nikdy nedostane zpátky.
|
||||
</p>
|
||||
</div>
|
||||
{connectable.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCreatingFor(connectable[0].id)}
|
||||
disabled={creatingFor !== null}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Nový konektor
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<DataState loading={loading} error={error} onRetry={reload} empty={items.length === 0}>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<SummaryTile
|
||||
icon={CheckCircle2}
|
||||
value={counts.connected}
|
||||
label="napojeno a připraveno k použití"
|
||||
tone="ok"
|
||||
<DataState loading={loading} error={error} onRetry={load}>
|
||||
<div className="space-y-5">
|
||||
{creatingFor && (
|
||||
<CreateForm
|
||||
services={connectable}
|
||||
initialServiceId={creatingFor}
|
||||
onCancel={closeForm}
|
||||
onCreated={() => {
|
||||
closeForm();
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
<SummaryTile
|
||||
icon={Plug}
|
||||
value={counts.available}
|
||||
label="umíme napojit na požádání"
|
||||
tone="brand"
|
||||
/>
|
||||
<SummaryTile icon={Clock} value={counts.planned} label="na roadmapě" tone="warn" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="glass rounded-card p-4 sm:p-5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Hledat službu, spouštěč nebo akci…"
|
||||
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Chip active={category === 'all'} onClick={() => setCategory('all')}>
|
||||
Všechny kategorie
|
||||
</Chip>
|
||||
{categories.map((cat) => (
|
||||
<Chip
|
||||
key={cat.id}
|
||||
active={category === cat.id}
|
||||
onClick={() => setCategory(cat.id)}
|
||||
>
|
||||
{cat.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Chip active={status === 'all'} onClick={() => setStatus('all')}>
|
||||
Vše
|
||||
</Chip>
|
||||
{(['connected', 'available', 'planned'] as const).map((value) => (
|
||||
<Chip key={value} active={status === value} onClick={() => setStatus(value)}>
|
||||
{statusMeta[value].label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
{(connectors?.length ?? 0) === 0 && !creatingFor ? (
|
||||
<p className="py-10 text-center text-sm text-white/45">
|
||||
Nic neodpovídá filtru. Chybí vám konektor?{' '}
|
||||
<a href={`mailto:${brand.email}`} className="text-brand-300 hover:text-brand-200">
|
||||
Napište nám
|
||||
</a>
|
||||
, většinu služeb umíme napojit přes HTTP požadavek.
|
||||
Zatím žádný konektor. Vyberte službu na stránce Služby, nebo použijte
|
||||
tlačítko Nový konektor.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{filtered.map((connector) => (
|
||||
<ConnectorCard key={connector.id} connector={connector} />
|
||||
<div className="space-y-4">
|
||||
{(connectors ?? []).map((connector) => (
|
||||
<ConnectorCard
|
||||
key={connector.id}
|
||||
connector={connector}
|
||||
service={servicesById.get(connector.serviceId)}
|
||||
onChanged={load}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -151,133 +134,383 @@ export default function Connectors() {
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryTile({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
tone,
|
||||
// ------------------------------------------------------------------ zakladani
|
||||
|
||||
function CreateForm({
|
||||
services,
|
||||
initialServiceId,
|
||||
onCancel,
|
||||
onCreated,
|
||||
}: {
|
||||
icon: typeof Plug;
|
||||
value: number;
|
||||
label: string;
|
||||
tone: 'ok' | 'brand' | 'warn';
|
||||
services: Service[];
|
||||
initialServiceId: string;
|
||||
onCancel: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const tones = {
|
||||
ok: 'bg-ok-500/12 text-ok-400',
|
||||
brand: 'bg-brand-500/12 text-brand-300',
|
||||
warn: 'bg-warn-500/12 text-warn-400',
|
||||
} as const;
|
||||
const [serviceId, setServiceId] = useState(
|
||||
services.some((service) => service.id === initialServiceId)
|
||||
? initialServiceId
|
||||
: (services[0]?.id ?? ''),
|
||||
);
|
||||
const service = services.find((item) => item.id === serviceId);
|
||||
const [name, setName] = useState('');
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Nazev se predvyplni podle sluzby, at ho nikdo nemusi vymyslet.
|
||||
useEffect(() => {
|
||||
setName(service ? service.name : '');
|
||||
setValues({});
|
||||
}, [service]);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!service) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch('/api/dashboard/connectors', {
|
||||
method: 'POST',
|
||||
body: { serviceId: service.id, name: name.trim() || service.name, values },
|
||||
});
|
||||
onCreated();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Konektor se nepodařilo založit.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass flex items-center gap-4 rounded-card p-5">
|
||||
<span className={cn('grid size-10 shrink-0 place-items-center rounded-xl', tones[tone])}>
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-2xl font-extrabold text-white tabular-nums">{value}</p>
|
||||
<p className="text-xs text-white/45">{label}</p>
|
||||
<div className="glass rounded-card border border-brand-400/30 p-5">
|
||||
<p className="mb-4 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
Nový konektor
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Služba">
|
||||
<select
|
||||
value={serviceId}
|
||||
onChange={(event) => setServiceId(event.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{services.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Název konektoru" hint="Firma může mít dva účty téže služby.">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{service && service.credentials.length > 0 && (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
{service.credentials.map((field) => (
|
||||
<CredentialInput
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={values[field.id] ?? ''}
|
||||
filled={false}
|
||||
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{service && service.credentials.length === 0 && (
|
||||
<p className="mt-4 text-sm text-white/45">
|
||||
U služby {service.name} ještě nejsou popsané přístupové údaje. Konektor půjde
|
||||
založit, ale volat se bude umět až spolu se službou.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
|
||||
|
||||
<div className="mt-5 flex gap-3">
|
||||
<Button size="sm" onClick={handleCreate} disabled={saving || !service}>
|
||||
{saving ? 'Zakládám...' : 'Založit'}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={onCancel} disabled={saving}>
|
||||
Zrušit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
// --------------------------------------------------------------------- detail
|
||||
|
||||
function ConnectorCard({
|
||||
connector,
|
||||
service,
|
||||
onChanged,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
connector: Connector;
|
||||
service: Service | undefined;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
|
||||
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const Icon = serviceIcon(service?.icon ?? 'Plug');
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [name, setName] = useState(connector.name);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [test, setTest] = useState<ConnectorTestResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function ConnectorCard({ connector }: { connector: Connector }) {
|
||||
const Icon = connectorIcon(connector.icon);
|
||||
const meta = statusMeta[connector.status];
|
||||
const dirty = Object.keys(values).length > 0 || name !== connector.name;
|
||||
|
||||
async function patch(body: Record<string, unknown>) {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/dashboard/connectors/${connector.id}`, { method: 'PATCH', body });
|
||||
setValues({});
|
||||
setTest(null);
|
||||
onChanged();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiError ? err.message : 'Uložení selhalo.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
try {
|
||||
setTest(
|
||||
await apiFetch<ConnectorTestResult>(`/api/dashboard/connectors/${connector.id}/test`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
onChanged();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Ověření se nepodařilo odeslat.');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiFetch(`/api/dashboard/connectors/${connector.id}`, { method: 'DELETE' });
|
||||
onChanged();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Smazání selhalo.');
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="glass flex h-full flex-col rounded-card p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<article className="glass rounded-card p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-white">{connector.name}</h2>
|
||||
<p className="mt-0.5 text-sm text-white/55">{connector.description}</p>
|
||||
<h2 className="flex flex-wrap items-center gap-2 font-semibold text-white">
|
||||
{connector.name}
|
||||
{connector.isDefault && (
|
||||
<Star className="size-3.5 text-brand-300" aria-label="Výchozí konektor" />
|
||||
)}
|
||||
{!connector.enabled && <Badge tone="warn">vypnutý</Badge>}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-white/45">
|
||||
{service?.name ?? connector.serviceId}
|
||||
{connector.baseUrl ? ` - ${connector.baseUrl}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={meta.tone}>{meta.label}</Badge>
|
||||
|
||||
<StatusBadge connector={connector} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid flex-1 gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
|
||||
<OperationList
|
||||
title="Spouštěče"
|
||||
icon={<Zap className="size-3 text-brand-300" />}
|
||||
operations={connector.triggers}
|
||||
emptyLabel="Nelze použít jako spouštěč"
|
||||
/>
|
||||
<OperationList
|
||||
title="Akce"
|
||||
icon={<Plug className="size-3 text-accent-300" />}
|
||||
operations={connector.actions}
|
||||
emptyLabel="Žádné akce"
|
||||
/>
|
||||
{connector.missing.length > 0 && service && (
|
||||
<p className="mt-3 text-sm text-warn-400">
|
||||
Chybí vyplnit:{' '}
|
||||
{connector.missing
|
||||
.map((id) => service.credentials.find((field) => field.id === id)?.label ?? id)
|
||||
.join(', ')}
|
||||
. Dokud to nevyplníte, kroky s touhle službou nepoběží.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{connector.lastError && (
|
||||
<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" />
|
||||
{connector.lastError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 grid gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
|
||||
<Field label="Název">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{(service?.credentials ?? []).map((field) => (
|
||||
<CredentialInput
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={values[field.id] ?? ''}
|
||||
filled={connector.filled.includes(field.id)}
|
||||
existing={connector.config[field.id]}
|
||||
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
|
||||
|
||||
{test && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-3 flex items-start gap-2 rounded-lg p-3 text-sm',
|
||||
test.ok
|
||||
? 'border border-ok-500/40 bg-ok-500/8 text-ok-400'
|
||||
: 'border border-danger-500/40 bg-danger-500/8 text-danger-400',
|
||||
)}
|
||||
>
|
||||
{test.ok ? (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
)}
|
||||
<span>
|
||||
{test.message}
|
||||
<span className="mt-0.5 block text-xs text-white/40">Ověřeno: {test.checked}</span>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => patch({ name: name.trim(), values })}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving ? 'Ukládám...' : 'Uložit'}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleTest} disabled={testing || saving}>
|
||||
<Wifi className="size-4" />
|
||||
{testing ? 'Ověřuji...' : 'Ověřit napojení'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => patch({ enabled: !connector.enabled })}
|
||||
disabled={saving}
|
||||
>
|
||||
{connector.enabled ? 'Vypnout' : 'Zapnout'}
|
||||
</Button>
|
||||
{!connector.isDefault && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => patch({ isDefault: true })}
|
||||
disabled={saving}
|
||||
>
|
||||
<Star className="size-4" />
|
||||
Nastavit jako výchozí
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={handleDelete} disabled={saving}>
|
||||
<Trash2 className="size-4" />
|
||||
Smazat
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationList({
|
||||
title,
|
||||
icon,
|
||||
operations,
|
||||
emptyLabel,
|
||||
function StatusBadge({ connector }: { connector: Connector }) {
|
||||
if (!connector.ready) return <Badge tone="warn">Chybí údaje</Badge>;
|
||||
if (connector.status === 'ok') return <Badge tone="ok">Ověřeno</Badge>;
|
||||
if (connector.status === 'error') return <Badge tone="danger">Ověření selhalo</Badge>;
|
||||
return <Badge tone="neutral">Neověřeno</Badge>;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ formulare
|
||||
|
||||
const inputClass =
|
||||
'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';
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
operations: ConnectorOperation[];
|
||||
emptyLabel: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
{icon}
|
||||
{title}
|
||||
</p>
|
||||
{operations.length === 0 ? (
|
||||
<p className="text-xs text-white/25">{emptyLabel}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-white/70">{label}</span>
|
||||
{children}
|
||||
{hint && <span className="mt-1 block text-xs text-white/35">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pole pristupoveho udaje.
|
||||
*
|
||||
* U tajneho pole server hodnotu nevraci, takze se ukazuje jen to, ze vyplnene
|
||||
* je. Prazdne pole hodnotu nemeni - ulozeni formulare tedy nic nepresepe.
|
||||
*/
|
||||
function CredentialInput({
|
||||
field,
|
||||
value,
|
||||
filled,
|
||||
existing,
|
||||
onChange,
|
||||
}: {
|
||||
field: ServiceCredentialField;
|
||||
value: string;
|
||||
filled: boolean;
|
||||
existing?: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const placeholder = field.secret
|
||||
? filled
|
||||
? 'Vyplněno, prázdné pole hodnotu nezmění'
|
||||
: 'Nevyplněno'
|
||||
: (existing ?? '');
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 flex flex-wrap items-center gap-2 text-sm text-white/70">
|
||||
{field.label}
|
||||
{field.required && <span className="text-warn-400">*</span>}
|
||||
{field.secret && <Badge tone="neutral">tajné</Badge>}
|
||||
{filled && <Badge tone="ok">vyplněno</Badge>}
|
||||
</span>
|
||||
<input
|
||||
type={field.secret ? 'password' : 'text'}
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
<span className="mt-1 block text-xs text-white/35">
|
||||
{field.hint ??
|
||||
`Posílá se jako ${field.target === 'header' ? 'hlavička' : 'nastavení'} ${field.name}.`}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { cn } from '@/lib/cn';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
Connector,
|
||||
ScriptCatalog,
|
||||
ScriptDetail,
|
||||
ScriptField,
|
||||
@@ -48,20 +48,14 @@ export default function Scripts() {
|
||||
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 connectorsByService = catalog.data?.connectors ?? {};
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, ScriptCatalog['items']>();
|
||||
for (const item of items) {
|
||||
const list = map.get(item.connectorId) ?? [];
|
||||
const list = map.get(item.serviceId) ?? [];
|
||||
list.push(item);
|
||||
map.set(item.connectorId, list);
|
||||
map.set(item.serviceId, list);
|
||||
}
|
||||
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}, [items]);
|
||||
@@ -89,11 +83,11 @@ export default function Scripts() {
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)]">
|
||||
<aside className="space-y-5">
|
||||
{grouped.map(([connectorId, scripts]) => (
|
||||
{grouped.map(([serviceId, scripts]) => (
|
||||
<ConnectorGroup
|
||||
key={connectorId}
|
||||
connectorId={connectorId}
|
||||
connection={connectionsById.get(connectorId)}
|
||||
key={serviceId}
|
||||
serviceId={serviceId}
|
||||
connection={connectorsByService[serviceId]}
|
||||
scripts={scripts}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
@@ -127,14 +121,14 @@ export default function Scripts() {
|
||||
// ------------------------------------------------------------------- seznam
|
||||
|
||||
function ConnectorGroup({
|
||||
connectorId,
|
||||
serviceId,
|
||||
connection,
|
||||
scripts,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
connectorId: string;
|
||||
connection: ConnectionStatus | undefined;
|
||||
serviceId: string;
|
||||
connection: Connector | undefined;
|
||||
scripts: ScriptCatalog['items'];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
@@ -142,7 +136,7 @@ function ConnectorGroup({
|
||||
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>
|
||||
<p className="font-semibold text-white">{serviceId}</p>
|
||||
{connection && (
|
||||
<Badge tone={connection.ready ? 'ok' : 'warn'}>
|
||||
{connection.ready ? 'Napojeno' : 'Chybí údaje'}
|
||||
@@ -242,7 +236,7 @@ function ScriptPanel({
|
||||
<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} />
|
||||
<ConnectorBadge connector={detail.connector} />
|
||||
</div>
|
||||
|
||||
{detail.manifest && (
|
||||
@@ -256,12 +250,19 @@ function ScriptPanel({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!detail.connection.ready && (
|
||||
{detail.connector === null ? (
|
||||
<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.
|
||||
Služba {detail.serviceName} nemá v této firmě konektor, skript nepůjde
|
||||
spustit. Vytvořte ho na stránce Konektory.
|
||||
</p>
|
||||
) : (
|
||||
!detail.connector.ready && (
|
||||
<p className="mt-3 text-sm text-warn-400">
|
||||
Konektor {detail.connector.name} nemá vyplněné všechny přístupové údaje,
|
||||
chybí <code>{detail.connector.missing.join(', ')}</code>. Doplňte je na
|
||||
stránce Konektory.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -276,7 +277,7 @@ function ScriptPanel({
|
||||
<TestPanel
|
||||
scriptId={detail.id}
|
||||
fields={detail.manifest.inputs}
|
||||
ready={detail.connection.ready}
|
||||
ready={detail.connector?.ready === true}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -295,13 +296,15 @@ function ScriptPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionBadge({ connection }: { connection: ConnectionStatus }) {
|
||||
function ConnectorBadge({ connector }: { connector: Connector | null }) {
|
||||
if (!connector) return <Badge tone="warn">Bez konektoru</Badge>;
|
||||
|
||||
return (
|
||||
<div className="text-right">
|
||||
<Badge tone={connection.ready ? 'ok' : 'warn'}>
|
||||
{connection.ready ? 'Napojení připravené' : 'Napojení nenastavené'}
|
||||
<Badge tone={connector.ready ? 'ok' : 'warn'}>
|
||||
{connector.ready ? 'Konektor připravený' : 'Konektor bez údajů'}
|
||||
</Badge>
|
||||
<p className="mt-1 font-mono text-xs break-all text-white/30">{connection.baseUrl}</p>
|
||||
<p className="mt-1 text-xs text-white/30">{connector.name}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { CheckCircle2, Clock, Lock, Plug, Search, Terminal, Zap } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { brand } from '@/config/brand';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { serviceIcon } from '@/lib/serviceIcons';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
ServiceCategory,
|
||||
ServiceOperation,
|
||||
ServiceOverview,
|
||||
ServiceWithUsage,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Katalog sluzeb: co umime.
|
||||
*
|
||||
* Sluzba neni to same jako konektor. Sluzba je to, co umime; konektor je
|
||||
* napojeni teto firmy na ni vcetne jejich pristupovych udaju. Proto se stav
|
||||
* "napojeno" pocita z toho, jestli firma konektor ma, a neni to vlastnost
|
||||
* sluzby - viz documentation/12-sluzby-a-konektory.md.
|
||||
*
|
||||
* Sluzba, kterou uzivatel nema videt, se ze serveru vubec nevraci.
|
||||
*/
|
||||
|
||||
/** Stav sluzby ocima firmy. Pocita se z konektoru, necte se z katalogu. */
|
||||
type ServiceView = 'connected' | 'available' | 'planned';
|
||||
|
||||
const viewMeta: Record<ServiceView, { label: string; tone: 'ok' | 'neutral' | 'warn' }> = {
|
||||
connected: { label: 'Napojeno', tone: 'ok' },
|
||||
available: { label: 'Můžete napojit', tone: 'neutral' },
|
||||
planned: { label: 'Na roadmapě', tone: 'warn' },
|
||||
};
|
||||
|
||||
function viewOf(service: ServiceWithUsage): ServiceView {
|
||||
if (service.status === 'planned') return 'planned';
|
||||
// Obecna sluzba konektor nepotrebuje, je pripravena rovnou.
|
||||
if (service.general || service.connectorCount > 0) return 'connected';
|
||||
return 'available';
|
||||
}
|
||||
|
||||
export default function Services() {
|
||||
usePageMeta({ title: 'Služby - portál Automia' });
|
||||
|
||||
const { data, loading, error, reload } = useApiQuery<ServiceOverview>(
|
||||
'/api/dashboard/connectors/services',
|
||||
);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<ServiceCategory | 'all'>('all');
|
||||
const [view, setView] = useState<ServiceView | 'all'>('all');
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const categories = data?.categories ?? [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return items.filter((service) => {
|
||||
if (category !== 'all' && service.category !== category) return false;
|
||||
if (view !== 'all' && viewOf(service) !== view) return false;
|
||||
if (needle.length === 0) return true;
|
||||
const haystack = [
|
||||
service.name,
|
||||
service.description,
|
||||
...service.triggers.map((t) => t.name),
|
||||
...service.actions.map((a) => a.name),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(needle);
|
||||
});
|
||||
}, [items, query, category, view]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
connected: items.filter((s) => viewOf(s) === 'connected').length,
|
||||
available: items.filter((s) => viewOf(s) === 'available').length,
|
||||
planned: items.filter((s) => viewOf(s) === 'planned').length,
|
||||
}),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Služby</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Co umíme napojit. Každá služba nabízí spouštěče (čím běh začne) a akce (co se
|
||||
má stát). Obecné služby fungují rovnou, u ostatních si vytvoříte{' '}
|
||||
<Link to="/dashboard/konektory" className="text-brand-300 hover:text-brand-200">
|
||||
konektor
|
||||
</Link>{' '}
|
||||
se svými přístupovými údaji.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DataState loading={loading} error={error} onRetry={reload} empty={items.length === 0}>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<SummaryTile
|
||||
icon={CheckCircle2}
|
||||
value={counts.connected}
|
||||
label="připraveno k použití"
|
||||
tone="ok"
|
||||
/>
|
||||
<SummaryTile
|
||||
icon={Plug}
|
||||
value={counts.available}
|
||||
label="můžete si napojit"
|
||||
tone="brand"
|
||||
/>
|
||||
<SummaryTile icon={Clock} value={counts.planned} label="na roadmapě" tone="warn" />
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-card p-4 sm:p-5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Hledat službu, spouštěč nebo akci"
|
||||
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Chip active={category === 'all'} onClick={() => setCategory('all')}>
|
||||
Všechny kategorie
|
||||
</Chip>
|
||||
{categories.map((cat) => (
|
||||
<Chip
|
||||
key={cat.id}
|
||||
active={category === cat.id}
|
||||
onClick={() => setCategory(cat.id)}
|
||||
>
|
||||
{cat.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Chip active={view === 'all'} onClick={() => setView('all')}>
|
||||
Vše
|
||||
</Chip>
|
||||
{(['connected', 'available', 'planned'] as const).map((value) => (
|
||||
<Chip key={value} active={view === value} onClick={() => setView(value)}>
|
||||
{viewMeta[value].label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-white/45">
|
||||
Nic neodpovídá filtru. Chybí vám služba?{' '}
|
||||
<a href={`mailto:${brand.email}`} className="text-brand-300 hover:text-brand-200">
|
||||
Napište nám
|
||||
</a>
|
||||
, většinu API umíme napojit přes HTTP požadavek.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{filtered.map((service) => (
|
||||
<ServiceCard key={service.id} service={service} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryTile({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
tone,
|
||||
}: {
|
||||
icon: typeof Plug;
|
||||
value: number;
|
||||
label: string;
|
||||
tone: 'ok' | 'brand' | 'warn';
|
||||
}) {
|
||||
const tones = {
|
||||
ok: 'bg-ok-500/12 text-ok-400',
|
||||
brand: 'bg-brand-500/12 text-brand-300',
|
||||
warn: 'bg-warn-500/12 text-warn-400',
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div className="glass flex items-center gap-4 rounded-card p-5">
|
||||
<span className={cn('grid size-10 shrink-0 place-items-center rounded-xl', tones[tone])}>
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-2xl font-extrabold text-white tabular-nums">{value}</p>
|
||||
<p className="text-xs text-white/45">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
|
||||
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceCard({ service }: { service: ServiceWithUsage }) {
|
||||
const Icon = serviceIcon(service.icon);
|
||||
const meta = viewMeta[viewOf(service)];
|
||||
|
||||
return (
|
||||
<article className="glass flex h-full flex-col rounded-card p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex flex-wrap items-center gap-2 font-semibold text-white">
|
||||
{service.name}
|
||||
{service.general && <Badge tone="neutral">obecná</Badge>}
|
||||
{service.visibility.mode === 'restricted' && (
|
||||
<Lock className="size-3.5 text-warn-400" aria-label="Omezená viditelnost" />
|
||||
)}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-white/55">{service.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={meta.tone}>{meta.label}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid flex-1 gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
|
||||
<OperationList
|
||||
title="Spouštěče"
|
||||
icon={<Zap className="size-3 text-brand-300" />}
|
||||
operations={service.triggers}
|
||||
emptyLabel="Nelze použít jako spouštěč"
|
||||
/>
|
||||
<OperationList
|
||||
title="Akce"
|
||||
icon={<Plug className="size-3 text-accent-300" />}
|
||||
operations={service.actions}
|
||||
emptyLabel="Žádné akce"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!service.general && (
|
||||
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-ink-600/50 pt-3 text-xs">
|
||||
<span className="text-white/40">
|
||||
{service.connectorCount === 0
|
||||
? 'Bez konektoru službu ve stromu použít nelze.'
|
||||
: `${service.connectorCount} ${service.connectorCount === 1 ? 'konektor' : 'konektory'}`}
|
||||
</span>
|
||||
<Link
|
||||
to={`/dashboard/konektory?service=${service.id}`}
|
||||
className="text-brand-300 hover:text-brand-200"
|
||||
>
|
||||
{service.connectorCount === 0 ? 'Vytvořit konektor' : 'Spravovat konektory'}
|
||||
</Link>
|
||||
</footer>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationList({
|
||||
title,
|
||||
icon,
|
||||
operations,
|
||||
emptyLabel,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
operations: ServiceOperation[];
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
{icon}
|
||||
{title}
|
||||
</p>
|
||||
{operations.length === 0 ? (
|
||||
<p className="text-xs text-white/25">{emptyLabel}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import { formatDateTime } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
ConnectorCatalog,
|
||||
ServiceCatalog,
|
||||
PeopleResponse,
|
||||
TicketDetail as Detail,
|
||||
TicketStatus,
|
||||
@@ -48,7 +48,7 @@ export default function TicketDetail() {
|
||||
refetchOn: ['ticket.updated', 'ticket.assigned', 'ticket.resolved'],
|
||||
});
|
||||
const people = useApiQuery<PeopleResponse>('/api/dashboard/people');
|
||||
const catalog = useApiQuery<ConnectorCatalog>('/api/dashboard/connectors');
|
||||
const catalog = useApiQuery<ServiceCatalog>('/api/dashboard/services');
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
@@ -168,7 +168,7 @@ export default function TicketDetail() {
|
||||
|
||||
<TicketTrace
|
||||
entries={ticket.data.trace}
|
||||
connectors={catalog.data?.items ?? []}
|
||||
services={catalog.data?.items ?? []}
|
||||
/>
|
||||
|
||||
<form
|
||||
|
||||
+101
-33
@@ -82,7 +82,7 @@ export interface TicketTraceEntry {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
kind: TraceKind;
|
||||
connectorId: string | null;
|
||||
serviceId: string | null;
|
||||
operationId: string | null;
|
||||
label: string;
|
||||
status: TraceStatus;
|
||||
@@ -158,20 +158,38 @@ export interface Automation {
|
||||
|
||||
// ----------------------------------------------------------------- konektory
|
||||
|
||||
export type ConnectorCategory =
|
||||
| 'spoustece'
|
||||
| 'servicedesk'
|
||||
export type ServiceCategory =
|
||||
/** Obecne veci, ktere ma kazdy. Nepotrebuji konektor. */
|
||||
| 'obecne'
|
||||
| 'crm'
|
||||
| 'ekonomika'
|
||||
| 'logistika'
|
||||
| 'komunikace'
|
||||
| 'analytika'
|
||||
| 'ai'
|
||||
| 'nastroje';
|
||||
| 'ai';
|
||||
|
||||
export type ConnectorStatus = 'connected' | 'available' | 'planned';
|
||||
/** available = sluzbu umime, planned = je na roadmape. */
|
||||
export type ServiceStatus = 'available' | 'planned';
|
||||
|
||||
export interface ConnectorOperation {
|
||||
export interface ServiceVisibility {
|
||||
mode: 'everyone' | 'restricted' | 'admin';
|
||||
tenantIds: string[];
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
/** Jeden udaj, ktery je potreba vyplnit pri zakladani konektoru. */
|
||||
export interface ServiceCredentialField {
|
||||
id: string;
|
||||
label: string;
|
||||
target: 'header' | 'config';
|
||||
name: string;
|
||||
required: boolean;
|
||||
/** true = hodnota se ze serveru nikdy nevraci. */
|
||||
secret: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ServiceOperation {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -211,21 +229,76 @@ export interface OperationField {
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
category: ConnectorCategory;
|
||||
category: ServiceCategory;
|
||||
description: string;
|
||||
/** Klic ikony - mapuje se v lib/connectorIcons.ts */
|
||||
/** Klic ikony - mapuje se v lib/serviceIcons.ts */
|
||||
icon: string;
|
||||
status: ConnectorStatus;
|
||||
triggers: ConnectorOperation[];
|
||||
actions: ConnectorOperation[];
|
||||
status: ServiceStatus;
|
||||
/** true = funguje bez konektoru (webhook, pauza, transformace dat). */
|
||||
general: boolean;
|
||||
appId: string | null;
|
||||
visibility: ServiceVisibility;
|
||||
credentials: ServiceCredentialField[];
|
||||
verifyPath?: string;
|
||||
triggers: ServiceOperation[];
|
||||
actions: ServiceOperation[];
|
||||
}
|
||||
|
||||
export interface ConnectorCatalog {
|
||||
categories: Array<{ id: ConnectorCategory; label: string }>;
|
||||
items: Connector[];
|
||||
/** Sluzba ocima jedne firmy, tedy uz vcetne toho, co ma napojene. */
|
||||
export interface ServiceWithUsage extends Service {
|
||||
baseUrl: string;
|
||||
/** Kolik konektoru na ni firma ma. 0 = muze si napojit. */
|
||||
connectorCount: number;
|
||||
/** Kolik akci uz opravdu neco dela, tedy ma skript. */
|
||||
implementedActions: number;
|
||||
}
|
||||
|
||||
export interface ServiceOverview {
|
||||
categories: Array<{ id: ServiceCategory; label: string }>;
|
||||
items: ServiceWithUsage[];
|
||||
tenantId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konektor = napojeni firmy na jednu sluzbu.
|
||||
* Hodnoty pristupovych udaju server nikdy nevraci, jen `filled` a `missing`.
|
||||
*/
|
||||
export interface Connector {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
serviceId: string;
|
||||
name: string;
|
||||
baseUrl: string | null;
|
||||
enabled: boolean;
|
||||
status: 'untested' | 'ok' | 'error';
|
||||
lastCheckAt: string | null;
|
||||
lastError: string | null;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** ID poli, ktera jsou vyplnena. */
|
||||
filled: string[];
|
||||
/** ID povinnych poli, ktera chybi. */
|
||||
missing: string[];
|
||||
/** Necitliva nastaveni. Tajna pole tu nejsou vubec. */
|
||||
config: Record<string, string>;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectorTestResult {
|
||||
ok: boolean;
|
||||
/** Co se vlastne overilo: pristupove udaje, nebo jen dostupnost sluzby. */
|
||||
checked: string;
|
||||
status?: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ServiceCatalog {
|
||||
categories: Array<{ id: ServiceCategory; label: string }>;
|
||||
items: Service[];
|
||||
/** Ktere operatory server povoli pro ktery typ parametru. */
|
||||
operatorsByType: Record<FieldType, ConditionOperator[]>;
|
||||
/** Zaklad adresy webhooku, napr. "https://api.automia.cz/webhook". */
|
||||
@@ -262,7 +335,7 @@ export interface TriggerField {
|
||||
// ----------------------------------------------------------- strom automatizace
|
||||
|
||||
export interface FlowTrigger {
|
||||
connectorId: string;
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
fields: TriggerField[];
|
||||
/** Generuje vyhradne server, klient ho jen zobrazuje. */
|
||||
@@ -274,7 +347,7 @@ export type FlowStep =
|
||||
| {
|
||||
id: string;
|
||||
kind: 'action';
|
||||
connectorId: string;
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
/** Klic je `OperationField.id`, hodnota sablona s `{{parametry}}`. */
|
||||
inputs?: Record<string, string>;
|
||||
@@ -380,7 +453,8 @@ export interface ScriptField {
|
||||
|
||||
export interface ScriptManifest {
|
||||
id: string;
|
||||
connectorId: string;
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
operationId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -396,32 +470,26 @@ export interface ScriptProblem {
|
||||
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[];
|
||||
/** Vychozi konektor firmy podle ID sluzby. Chybi = sluzba neni napojena. */
|
||||
connectors: Record<string, Connector>;
|
||||
tenantId: string | null;
|
||||
directory: string;
|
||||
}
|
||||
|
||||
export interface ScriptDetail {
|
||||
id: string;
|
||||
connectorId: string;
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
serviceName: string;
|
||||
/** null = soubor je rozbity, manifest se nepodarilo precist. */
|
||||
manifest: ScriptManifest | null;
|
||||
code: string;
|
||||
problem: ScriptProblem | null;
|
||||
connection: ConnectionStatus;
|
||||
/** Vychozi konektor firmy, pod kterym se skript da vyzkouset. */
|
||||
connector: Connector | null;
|
||||
}
|
||||
|
||||
export type ScriptErrorKind =
|
||||
|
||||
Reference in New Issue
Block a user