diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index d38a0a6..f58e08d 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -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 | diff --git a/documentation/04-api.md b/documentation/04-api.md index c2c7709..58cbb27 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -36,7 +36,14 @@ Vyzaduji `Authorization: Bearer `: | 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. diff --git a/documentation/11-skripty-konektoru.md b/documentation/11-skripty-konektoru.md index 91d90ed..6c4d365 100644 --- a/documentation/11-skripty-konektoru.md +++ b/documentation/11-skripty-konektoru.md @@ -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 `..js` a `manifest.id` musi byt stejne. +Soubor se jmenuje `..js` a `manifest.id` musi byt stejne. Nesoulad je chyba, ne varovani - jinak by se skript ulozil pod jednim jmenem a nacetl pod druhym. ``` scripts/idoklad.get-issued-invoice.js \_____/ \________________/ - konektor operace + 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` | -| `_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 | +| `_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 `..js`. +1. Zkopirovat `scripts/_sablona.js` na `..js`. 2. Srovnat `manifest.id` s nazvem souboru. 3. Vyplnit `inputs` a `outputs`. 4. Napsat `run`. -5. Kdyz konektor jeste neni v `src/data/connectors.ts`, pridat ho. -6. Kdyz potrebuje pristupove udaje, pridat zaznam do `authSpecs` - v `src/scripts/connections.ts`. +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 | diff --git a/documentation/12-sluzby-a-konektory.md b/documentation/12-sluzby-a-konektory.md new file mode 100644 index 0000000..0db2917 --- /dev/null +++ b/documentation/12-sluzby-a-konektory.md @@ -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/..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 | diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 085c4c6..e1fe8ac 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -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 diff --git a/scripts/_sablona.js b/scripts/_sablona.js index ae874c0..a87804a 100644 --- a/scripts/_sablona.js +++ b/scripts/_sablona.js @@ -3,7 +3,7 @@ * nikde nevznikne jako operace - je tu jen ke zkopirovani. * * Postup: - * 1. zkopirovat na `..js`, napriklad `idoklad.get-contact.js`, + * 1. zkopirovat na `..js`, napriklad `idoklad.get-contact.js`, * 2. srovnat `manifest.id` s nazvem souboru, musi byt stejne, * 3. vyplnit vstupy a vystupy, * 4. napsat `run`. @@ -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.', diff --git a/src/data/automationStore.ts b/src/data/automationStore.ts index 3f2cd56..78b688f 100644 --- a/src/data/automationStore.ts +++ b/src/data/automationStore.ts @@ -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; } diff --git a/src/data/connectorStore.ts b/src/data/connectorStore.ts new file mode 100644 index 0000000..28fc21e --- /dev/null +++ b/src/data/connectorStore.ts @@ -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; + 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; + /** 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[] { + 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 = {}; + 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 { + const counts = new Map(); + 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, +): ConnectorIssue[] { + const known = new Map( + 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; + 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; + 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', +}); diff --git a/src/data/flowScope.ts b/src/data/flowScope.ts index ee1df79..4d7ba47 100644 --- a/src/data/flowScope.ts +++ b/src/data/flowScope.ts @@ -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): 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, diff --git a/src/data/connectors.ts b/src/data/services.ts similarity index 70% rename from src/data/connectors.ts rename to src/data/services.ts index 26bb742..be99b31 100644 --- a/src/data/connectors.ts +++ b/src/data/services.ts @@ -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(); +const scriptActions = new Map(); /** Nahradi cely prekryv. Volani je idempotentni, poradi nezalezi. */ -export function setScriptActions(byConnector: Map): void { +export function setScriptActions(byService: Map): 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 ?? []; } diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 8761520..4431e71 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -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', diff --git a/src/openapi.ts b/src/openapi.ts index a31066f..d0e2c50 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -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 .js.', + description: 'Tvar sluzba.operace. Nazev souboru musi byt .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': { diff --git a/src/routes/connectors.ts b/src/routes/connectors.ts new file mode 100644 index 0000000..a4c86b8 --- /dev/null +++ b/src/routes/connectors.ts @@ -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(); + + 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); + } +}); diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index d7fe115..0f41f21 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -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 = 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): z.infer { 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): string[] { +function validateFlowReferences( + flow: z.infer, + 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): 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): 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. */ diff --git a/src/routes/scripts.ts b/src/routes/scripts.ts index 0e535c1..6470fe3 100644 --- a/src/routes/scripts.ts +++ b/src/routes/scripts.ts @@ -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] => 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. diff --git a/src/routes/simulate.ts b/src/routes/simulate.ts index 4edc3a0..e701051 100644 --- a/src/routes/simulate.ts +++ b/src/routes/simulate.ts @@ -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', diff --git a/src/scripts/connections.ts b/src/scripts/connections.ts index ddc37d3..5448b52 100644 --- a/src/scripts/connections.ts +++ b/src/scripts/connections.ts @@ -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; - /** Ktere hlavicky musi byt vyplnene, aby se dalo volat. */ - required: string[]; - /** Necitliva nastaveni pristupna skriptu jako `ctx.config`. */ - config?: Record; -} - -/** - * Autorizace jednotlivych konektoru. - * - * iDoklad podle https://services.csbot.cz/apps/idoklad/docs: sluzba prijima - * `X-ClientId` a `X-ClientSecret`, `X-ApplicationId` jen partnerske aplikace. - * OAuth tok resi ta sluzba, my posilame jen tyto hlavicky. - */ -const authSpecs: Record = { - idoklad: { - headers: { - 'X-ClientId': 'IDOKLAD_CLIENT_ID', - 'X-ClientSecret': 'IDOKLAD_CLIENT_SECRET', - 'X-ApplicationId': 'IDOKLAD_APPLICATION_ID', - }, - required: ['X-ClientId', 'X-ClientSecret'], - config: { language: 'IDOKLAD_LANGUAGE' }, - }, -}; - -export interface ResolvedConnection { - connectorId: string; - name: string; +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; /** Necitliva cast, skript ji vidi jako `ctx.config`. */ - config: Record; - /** false = chybi pristupove udaje, volat nema smysl. */ + serviceConfig: Record; + /** 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 `_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 = {}; - const scriptConfig: Record = {}; - 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 = {}; + const serviceConfig: Record = {}; + 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); } diff --git a/src/scripts/http.ts b/src/scripts/http.ts index 355235b..a11d45d 100644 --- a/src/scripts/http.ts +++ b/src/scripts/http.ts @@ -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( method: string, @@ -126,7 +136,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp { body: unknown, httpOptions?: ScriptHttpOptions, ): Promise> { - 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, }, diff --git a/src/scripts/manifest.ts b/src/scripts/manifest.ts index 8123d4e..ee7f8b1 100644 --- a/src/scripts/manifest.ts +++ b/src/scripts/manifest.ts @@ -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, diff --git a/src/scripts/registry.ts b/src/scripts/registry.ts index 54978ab..0cf5fe5 100644 --- a/src/scripts/registry.ts +++ b/src/scripts/registry.ts @@ -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 { 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 { } /** - * 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(); + const byService = new Map(); 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 { 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ý.' }; diff --git a/src/scripts/runner.ts b/src/scripts/runner.ts index 1f7e123..1fec37c 100644 --- a/src/scripts/runner.ts +++ b/src/scripts/runner.ts @@ -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) }); diff --git a/src/scripts/types.ts b/src/scripts/types.ts index ea1b5a1..f9ce48c 100644 --- a/src/scripts/types.ts +++ b/src/scripts/types.ts @@ -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ý.'), diff --git a/web/src/App.tsx b/web/src/App.tsx index 4dfa204..07f1832 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/dashboard/DashboardLayout.tsx b/web/src/components/dashboard/DashboardLayout.tsx index 1d72f65..f27067b 100644 --- a/web/src/components/dashboard/DashboardLayout.tsx +++ b/web/src/components/dashboard/DashboardLayout.tsx @@ -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 }, diff --git a/web/src/components/dashboard/TicketTrace.tsx b/web/src/components/dashboard/TicketTrace.tsx index a7d6764..a3be47b 100644 --- a/web/src/components/dashboard/TicketTrace.tsx +++ b/web/src/components/dashboard/TicketTrace.tsx @@ -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 (
    {tree.map((node) => ( - + ))}
); } -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 (
  • @@ -124,7 +124,7 @@ function TraceRow({ node, connectors }: { node: TraceNode; connectors: Connector {node.children.length > 0 && (
      {node.children.map((child) => ( - + ))}
    )} @@ -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 (

    @@ -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; } diff --git a/web/src/components/dashboard/flow/FlowCanvas.tsx b/web/src/components/dashboard/flow/FlowCanvas.tsx index ce8235f..f6696a7 100644 --- a/web/src/components/dashboard/flow/FlowCanvas.tsx +++ b/web/src/components/dashboard/flow/FlowCanvas.tsx @@ -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 ( ); } - const Icon = connectorIcon(resolved.connector.icon); + const Icon = serviceIcon(resolved.connector.icon); return (

    @@ -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; /** Pouzije se, kdyz krok ve scope jeste neni (prave pridany). */ @@ -279,7 +279,7 @@ function ActionCard({ onUpdateInputs, }: { step: Extract; - 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) => void; }) { - const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action'); + const resolved = resolveOperation(connectors, step.serviceId, step.operationId, 'action'); if (!resolved) { return ( ); } - 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; path: FlowPath; - connectors: Connector[]; + connectors: Service[]; scopes: Map; fallbackFields: TriggerField[]; available: TriggerField[]; diff --git a/web/src/components/dashboard/flow/StepPicker.tsx b/web/src/components/dashboard/flow/StepPicker.tsx index 1066cf5..2e950e6 100644 --- a/web/src/components/dashboard/flow/StepPicker.tsx +++ b/web/src/components/dashboard/flow/StepPicker.tsx @@ -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(null); + const [selected, setSelected] = useState(null); const [query, setQuery] = useState(''); - const [category, setCategory] = useState('all'); + const [category, setCategory] = useState('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 ( @@ -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; }) { diff --git a/web/src/components/dashboard/flow/TriggerConfig.tsx b/web/src/components/dashboard/flow/TriggerConfig.tsx index 1d6cd34..a9461e8 100644 --- a/web/src/components/dashboard/flow/TriggerConfig.tsx +++ b/web/src/components/dashboard/flow/TriggerConfig.tsx @@ -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; /** diff --git a/web/src/lib/flow.ts b/web/src/lib/flow.ts index 29e1403..34af575 100644 --- a/web/src/lib/flow.ts +++ b/web/src/lib/flow.ts @@ -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 { const byStep = new Map(); const triggerFields = flow.trigger?.fields ?? []; const outputsOf = (step: Extract): 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; } diff --git a/web/src/lib/connectorIcons.ts b/web/src/lib/serviceIcons.ts similarity index 80% rename from web/src/lib/connectorIcons.ts rename to web/src/lib/serviceIcons.ts index 8afb8f7..f2fafff 100644 --- a/web/src/lib/connectorIcons.ts +++ b/web/src/lib/serviceIcons.ts @@ -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 = { BarChart3, + Boxes, Building2, Clock, Facebook, @@ -67,10 +69,10 @@ const icons: Record = { 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; diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 08cd012..9b13a36 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -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(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('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() { Demo přístupy (prototyp)

    -
      -
    • admin@automia.cz / demo1234
    • -
    • klient@firma.cz / demo1234
    • +

      + Formulář je předvyplněný správcem platformy. Kliknutím se přepne účet. +

      +
        + {demoAccounts.map((account) => ( +
      • + +
      • + ))}
    diff --git a/web/src/pages/dashboard/AutomationDetail.tsx b/web/src/pages/dashboard/AutomationDetail.tsx index 96689af..1cb6a8f 100644 --- a/web/src/pages/dashboard/AutomationDetail.tsx +++ b/web/src/pages/dashboard/AutomationDetail.tsx @@ -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(`/api/dashboard/automations/${id}`); - const catalog = useApiQuery('/api/dashboard/connectors'); + const catalog = useApiQuery('/api/dashboard/services'); const [name, setName] = useState(''); const [flow, setFlow] = useState({ 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), ), }); } diff --git a/web/src/pages/dashboard/Connectors.tsx b/web/src/pages/dashboard/Connectors.tsx index 8301382..de744a0 100644 --- a/web/src/pages/dashboard/Connectors.tsx +++ b/web/src/pages/dashboard/Connectors.tsx @@ -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 = { - 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( - '/api/dashboard/connectors', + const [params, setParams] = useSearchParams(); + const preselected = params.get('service'); + + const [services, setServices] = useState(null); + const [connectors, setConnectors] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [creatingFor, setCreatingFor] = useState(preselected); + + const load = useCallback(() => { + setLoading(true); + setError(null); + Promise.all([ + apiFetch('/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(); + 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('all'); - const [status, setStatus] = useState('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 (
    -
    -

    Konektory

    -

    - 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). -

    +
    +
    +

    Konektory

    +

    + 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. +

    +
    + {connectable.length > 0 && ( + + )}
    - -
    -
    - +
    + {creatingFor && ( + { + closeForm(); + load(); + }} /> - - -
    + )} -
    -
    - - 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" - /> -
    - -
    - setCategory('all')}> - Všechny kategorie - - {categories.map((cat) => ( - setCategory(cat.id)} - > - {cat.label} - - ))} -
    - -
    - setStatus('all')}> - Vše - - {(['connected', 'available', 'planned'] as const).map((value) => ( - setStatus(value)}> - {statusMeta[value].label} - - ))} -
    -
    - - {filtered.length === 0 ? ( + {(connectors?.length ?? 0) === 0 && !creatingFor ? (

    - Nic neodpovídá filtru. Chybí vám konektor?{' '} - - Napište nám - - , 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.

    ) : ( -
    - {filtered.map((connector) => ( - +
    + {(connectors ?? []).map((connector) => ( + ))}
    )} @@ -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>({}); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 ( -
    - - - -
    -

    {value}

    -

    {label}

    +
    +

    + Nový konektor +

    + +
    + + + + + + setName(event.target.value)} + className={inputClass} + /> + +
    + + {service && service.credentials.length > 0 && ( +
    + {service.credentials.map((field) => ( + setValues((prev) => ({ ...prev, [field.id]: value }))} + /> + ))} +
    + )} + + {service && service.credentials.length === 0 && ( +

    + 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. +

    + )} + + {error &&

    {error}

    } + +
    + +
    ); } -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 ( - - ); -} + const Icon = serviceIcon(service?.icon ?? 'Plug'); + const [values, setValues] = useState>({}); + const [name, setName] = useState(connector.name); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [test, setTest] = useState(null); + const [error, setError] = useState(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) { + 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(`/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 ( -
    -
    +
    +
    -

    {connector.name}

    -

    {connector.description}

    +

    + {connector.name} + {connector.isDefault && ( + + )} + {!connector.enabled && vypnutý} +

    +

    + {service?.name ?? connector.serviceId} + {connector.baseUrl ? ` - ${connector.baseUrl}` : ''} +

    - {meta.label} + +
    -
    - } - operations={connector.triggers} - emptyLabel="Nelze použít jako spouštěč" - /> - } - operations={connector.actions} - emptyLabel="Žádné akce" - /> + {connector.missing.length > 0 && service && ( +

    + 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ěží. +

    + )} + + {connector.lastError && ( +

    + + {connector.lastError} +

    + )} + +
    + + setName(event.target.value)} + className={inputClass} + /> + + + {(service?.credentials ?? []).map((field) => ( + setValues((prev) => ({ ...prev, [field.id]: value }))} + /> + ))} +
    + + {error &&

    {error}

    } + + {test && ( +

    + {test.ok ? ( + + ) : ( + + )} + + {test.message} + Ověřeno: {test.checked} + +

    + )} + +
    + + + + {!connector.isDefault && ( + + )} +
    ); } -function OperationList({ - title, - icon, - operations, - emptyLabel, +function StatusBadge({ connector }: { connector: Connector }) { + if (!connector.ready) return Chybí údaje; + if (connector.status === 'ok') return Ověřeno; + if (connector.status === 'error') return Ověření selhalo; + return Neověřeno; +} + +// ------------------------------------------------------------------ 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 ( -
    -

    - {icon} - {title} -

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

    {emptyLabel}

    - ) : ( -
      - {operations.map((operation) => ( -
    • - {operation.name} - {/* Operace se skriptem se opravdu vykona, ostatni jsou zatim popis. */} - {operation.implementation === 'script' && ( - - )} -
    • - ))} -
    - )} -
    + + ); +} + +/** + * 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 ( + ); } diff --git a/web/src/pages/dashboard/Scripts.tsx b/web/src/pages/dashboard/Scripts.tsx index 4a104dd..d633a09 100644 --- a/web/src/pages/dashboard/Scripts.tsx +++ b/web/src/pages/dashboard/Scripts.tsx @@ -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(); - 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(); 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() {
    @@ -276,7 +277,7 @@ function ScriptPanel({ )} @@ -295,13 +296,15 @@ function ScriptPanel({ ); } -function ConnectionBadge({ connection }: { connection: ConnectionStatus }) { +function ConnectorBadge({ connector }: { connector: Connector | null }) { + if (!connector) return Bez konektoru; + return (
    - - {connection.ready ? 'Napojení připravené' : 'Napojení nenastavené'} + + {connector.ready ? 'Konektor připravený' : 'Konektor bez údajů'} -

    {connection.baseUrl}

    +

    {connector.name}

    ); } diff --git a/web/src/pages/dashboard/Services.tsx b/web/src/pages/dashboard/Services.tsx new file mode 100644 index 0000000..62eae2a --- /dev/null +++ b/web/src/pages/dashboard/Services.tsx @@ -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 = { + 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( + '/api/dashboard/connectors/services', + ); + + const [query, setQuery] = useState(''); + const [category, setCategory] = useState('all'); + const [view, setView] = useState('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 ( +
    +
    +

    Služby

    +

    + 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{' '} + + konektor + {' '} + se svými přístupovými údaji. +

    +
    + + +
    +
    + + + +
    + +
    +
    + + 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" + /> +
    + +
    + setCategory('all')}> + Všechny kategorie + + {categories.map((cat) => ( + setCategory(cat.id)} + > + {cat.label} + + ))} +
    + +
    + setView('all')}> + Vše + + {(['connected', 'available', 'planned'] as const).map((value) => ( + setView(value)}> + {viewMeta[value].label} + + ))} +
    +
    + + {filtered.length === 0 ? ( +

    + Nic neodpovídá filtru. Chybí vám služba?{' '} + + Napište nám + + , většinu API umíme napojit přes HTTP požadavek. +

    + ) : ( +
    + {filtered.map((service) => ( + + ))} +
    + )} +
    +
    +
    + ); +} + +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 ( +
    + + + +
    +

    {value}

    +

    {label}

    +
    +
    + ); +} + +function Chip({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function ServiceCard({ service }: { service: ServiceWithUsage }) { + const Icon = serviceIcon(service.icon); + const meta = viewMeta[viewOf(service)]; + + return ( +
    +
    +
    + + + +
    +

    + {service.name} + {service.general && obecná} + {service.visibility.mode === 'restricted' && ( + + )} +

    +

    {service.description}

    +
    +
    + {meta.label} +
    + +
    + } + operations={service.triggers} + emptyLabel="Nelze použít jako spouštěč" + /> + } + operations={service.actions} + emptyLabel="Žádné akce" + /> +
    + + {!service.general && ( +
    + + {service.connectorCount === 0 + ? 'Bez konektoru službu ve stromu použít nelze.' + : `${service.connectorCount} ${service.connectorCount === 1 ? 'konektor' : 'konektory'}`} + + + {service.connectorCount === 0 ? 'Vytvořit konektor' : 'Spravovat konektory'} + +
    + )} +
    + ); +} + +function OperationList({ + title, + icon, + operations, + emptyLabel, +}: { + title: string; + icon: ReactNode; + operations: ServiceOperation[]; + emptyLabel: string; +}) { + return ( +
    +

    + {icon} + {title} +

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

    {emptyLabel}

    + ) : ( +
      + {operations.map((operation) => ( +
    • + {operation.name} + {/* Operace se skriptem se opravdu vykona, ostatni jsou zatim popis. */} + {operation.implementation === 'script' && ( + + )} +
    • + ))} +
    + )} +
    + ); +} diff --git a/web/src/pages/dashboard/TicketDetail.tsx b/web/src/pages/dashboard/TicketDetail.tsx index 902aa98..24dbfbb 100644 --- a/web/src/pages/dashboard/TicketDetail.tsx +++ b/web/src/pages/dashboard/TicketDetail.tsx @@ -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('/api/dashboard/people'); - const catalog = useApiQuery('/api/dashboard/connectors'); + const catalog = useApiQuery('/api/dashboard/services'); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(null); @@ -168,7 +168,7 @@ export default function TicketDetail() {
    ; - 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; + 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; /** 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; @@ -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; + 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 =