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:
JiriUhlir
2026-08-12 14:11:50 +02:00
co-authored by Claude Opus 5
parent 6f6b287d7e
commit 8ad91a6c28
37 changed files with 3006 additions and 776 deletions
+2 -1
View File
@@ -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 |
+23
View File
@@ -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.
+22 -27
View File
@@ -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 |
+216
View File
@@ -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 |
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+315
View File
@@ -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',
});
+2 -2
View File
@@ -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,
+409 -100
View File
@@ -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
View File
@@ -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
View File
@@ -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': {
+316
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
+4 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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) });
+1 -1
View File
@@ -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ý.'),
+2
View File
@@ -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 },
+17 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+11 -11
View File
@@ -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),
),
});
}
+451 -218
View File
@@ -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>
);
}
+31 -28
View File
@@ -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>
);
}
+330
View File
@@ -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>
);
}
+3 -3
View File
@@ -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
View File
@@ -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 =