Realne sluzby, OpenAI, odesilani e-mailu a helpdesk
Katalog srovnany s tim, co opravdu bezi na services.csbot.cz/apps: trinact sluzeb dostalo pristupove udaje a levne cteci overeni, opravena appId, ktera nikam nevedla (ppl, microsoft365, transcription), a GA4, Search Console, Google Ads i Sklik ted stoji na aplikaci analytics, kazda s vlastnimi udaji. Nove sluzby SAP Business One, Google Workspace a Meta Ads. K tomu 23 skriptu, ktere s nimi opravdu neco delaji. OpenAI jako prvni sluzba, ktera nebezi u nas: Service.baseUrl s absolutni adresou, prepis pres <SLUZBA>_BASE_URL nebo adresu u konektoru, predpona hlavicky u pole udaju (uzivatel vlepi holy klic, Bearer dopise runtime). Dotaz na model, nahrani souboru, otazka nad souborem, prepis zvuku. Skript umi odeslat soubor pres ctx.http.postForm (multipart, obsah Base64). Sluzba E-mail pres SMTP. Neni to skript, ale vnitrni krok - SMTP neni HTTP. Konektor nese schranku firmy, krok ma HTML telo, ve kterem se dosazene hodnoty escapuji (znacky autora sablony jsou zamer, ostre zavorky od zakaznika ne). Overeni konektoru se prihlasi na server a nic neodesle. Helpdesk: Ticket.helpdeskSourceId drzi firmu, ktera pozadavek poslala, vlastnikem zustava ta, ktera ho resi - jinak by ho resitel nemel ve sve fronte. Komu pozadavek pripadne, urcuje Tenant.helpdeskProviderId. Zadavatel vidi jen svoje pozadavky a smi k nim pripsat komentar. Opravy v portalu: - hlasky o ulozisti a odchozi IP vidi jen spravce platformy - typ ticketu se v automatizaci vybira ze seznamu firmy, nebo dosadi z dat - stav ticketu je otevreny naseptavac, ne ciselnik - ticket jde zalozit rucne, zakaznik u nej neni povinny - kanal se prejmenoval a parametry u webhooku jsou oznacene jako nepovinne - srovnane markdown tabulky v cele dokumentaci Co z teto davky jeste neni: prepinac firmy je porad jen stav uvnitr stranky Prehled, takze se prepnuti neprojevi v Lidech ani jinde. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b25a149574
commit
a771834e57
@@ -24,11 +24,11 @@ npm run dev
|
||||
|
||||
## Povinne endpointy
|
||||
|
||||
| Cesta | Ucel |
|
||||
| --------------- | --------------------------------------- |
|
||||
| `/health` | Health check pro AppFactory, vraci 200 |
|
||||
| `/docs` | Swagger UI |
|
||||
| `/openapi.json` | OpenAPI definice |
|
||||
| Cesta | Ucel |
|
||||
| --------------- | -------------------------------------- |
|
||||
| `/health` | Health check pro AppFactory, vraci 200 |
|
||||
| `/docs` | Swagger UI |
|
||||
| `/openapi.json` | OpenAPI definice |
|
||||
|
||||
Verejne pres proxy jako `/apps/<app-id>/health` a `/apps/<app-id>/docs`.
|
||||
|
||||
|
||||
@@ -10,56 +10,61 @@ React aplikaci ze slozky `dist/public`.
|
||||
|
||||
## Stav
|
||||
|
||||
| Oblast | Stav | Poznamka |
|
||||
| --------------------------------- | ------ | ----------------------------------------------------- |
|
||||
| Verejny web | hotovo | homepage, sluzby, o nas, kontakt, 404 |
|
||||
| Prihlaseni | hotovo | JWT, demo ucty |
|
||||
| Dashboard | hotovo | prehled, tickety, incidenty, automatizace, nastaveni |
|
||||
| Zivy dashboard pres SSE | hotovo | zmeny se projevi bez obnoveni stranky |
|
||||
| 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 |
|
||||
| Prijem udalosti do ticketu | hotovo | webhook na firmu, externi ID unikatni za firmu |
|
||||
| Udalosti na ticketu | hotovo | dalsi zprava se navesi na tentyz ticket |
|
||||
| Statistiky resitelu | hotovo | odbaveno, mediany casu, vracene, fronta |
|
||||
| Pohledy tabulka a dlazdice | hotovo | tickety i lide |
|
||||
| Stranka Lide a detail osoby | hotovo | vykon a co ma u sebe |
|
||||
| Log ticketu ve strome | hotovo | vcetne toho, co ktera sluzba vratila |
|
||||
| Kanaly do ticketu | hotovo | WhatsApp, e-mail, hlas a formular jako spoustece |
|
||||
| Parametry od sluzby | hotovo | katalog je deklaruje, server je dosazuje pri ulozeni |
|
||||
| Nastaveni poli akci | castecne | ticket, e-mail a WhatsApp ano, ostatni jen napoveda |
|
||||
| Obsah ticketu a sablony | hotovo | `{{parametr}}` ze spoustece do poli akce |
|
||||
| Vystupy kroku a predvalidace | hotovo | podminka se umi zeptat, co vratil predchozi krok |
|
||||
| Kanaly WhatsApp, FB, Instagram | hotovo | vcetne vzorovych automatizaci na prijem |
|
||||
| Firmy a prava | hotovo | tri pohledy, uzivatel muze byt ve vic firmach |
|
||||
| Nastavitelny dashboard | hotovo | widgety, sirky a poradi, ulozene za uzivatele a firmu |
|
||||
| Skripty konektoru | hotovo | manifest, kontrola parametru, hot reload, iDoklad |
|
||||
| Konektory za firmu | hotovo | pristupove udaje v konektoru, overeni napojeni |
|
||||
| Transformace dat | hotovo | pravidla i sablona JSON, kroky si predavaji struktury |
|
||||
| Prace nad celym modelem | hotovo | ukazka tela, cesty v sablonach, smycka nad seznamem |
|
||||
| Vlastni skripty firmy | hotovo | prevod dat v JS, v logu vstup i vystup |
|
||||
| Sprava clenstvi z portalu | hotovo | firmy a role v Nastaveni, lide a pozvanky v Lidech |
|
||||
| Role a prava jako data | hotovo | 26 prav v katalogu, vlastni role za firmu |
|
||||
| Zalozky a limity za firmu | hotovo | navigace chodi ze serveru, ne z kodu klienta |
|
||||
| Osoby a skupiny resitelu | hotovo | ticket lze prehodit na skupinu, ne jen na cloveka |
|
||||
| Prevzeti ticketu ze skupiny | hotovo | kdo ma cas, si praci vezme sam |
|
||||
| Pozvanky do firmy | hotovo | odkaz s kodem, heslo si nastavi pozvany |
|
||||
| Typy ticketu a vlastni pole | hotovo | typ rozhoduje, ktere akce se na ticketu ukazou |
|
||||
| Vydefinovane akce na ticketu | hotovo | vazba na typ nebo tag, telo je operace, strom, skript |
|
||||
| Vlastni widgety | hotovo | vcetne zdroje z konektoru a vykonu resitelu |
|
||||
| Telo akce jako strom | hotovo | tentyz editor jako automatizace |
|
||||
| Audit a prepnuti na jiny ucet | hotovo | prepnuti je vychozi jen pro cteni, vse v auditu |
|
||||
| Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu |
|
||||
| Beh automatizaci | hotovo | fronta, worker, opakovani, ochrana proti smycce |
|
||||
| Prijem udalosti do fronty | hotovo | webhook odpovi 202, praci dela worker |
|
||||
| Pravidelne dotazovani sluzeb | hotovo | planovac pro postu a zpravy, perioda u spoustece |
|
||||
| Upozorneni na pridelenou praci | hotovo | cislo u zalozky a hlaska v portalu |
|
||||
| Incident z chyby | hotovo | popis pro klienta, podrobnosti pro admina |
|
||||
| Uloziste konektoru | hotovo | Postgres, nebo JSON soubor. Udaje vzdy sifrovane |
|
||||
| Uloziste pro zbytek | hotovo | tickety, automatizace, incidenty, rozlozeni, entity |
|
||||
| Monetizace a cena za krok | navrh | popis v 16-monetizace.md, neni naprogramovane |
|
||||
| Odesilani e-mailu z formulare | chybi | poptavka se zatim jen loguje |
|
||||
| Oblast | Stav | Poznamka |
|
||||
| ------------------------------- | -------- | ------------------------------------------------------- |
|
||||
| Verejny web | hotovo | homepage, sluzby, o nas, kontakt, 404 |
|
||||
| Prihlaseni | hotovo | JWT, demo ucty |
|
||||
| Dashboard | hotovo | prehled, tickety, incidenty, automatizace, nastaveni |
|
||||
| Zivy dashboard pres SSE | hotovo | zmeny se projevi bez obnoveni stranky |
|
||||
| Katalog sluzeb | hotovo | 34 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 |
|
||||
| Prijem udalosti do ticketu | hotovo | webhook na firmu, externi ID unikatni za firmu |
|
||||
| Udalosti na ticketu | hotovo | dalsi zprava se navesi na tentyz ticket |
|
||||
| Statistiky resitelu | hotovo | odbaveno, mediany casu, vracene, fronta |
|
||||
| Pohledy tabulka a dlazdice | hotovo | tickety i lide |
|
||||
| Stranka Lide a detail osoby | hotovo | vykon a co ma u sebe |
|
||||
| Log ticketu ve strome | hotovo | vcetne toho, co ktera sluzba vratila |
|
||||
| Kanaly do ticketu | hotovo | WhatsApp, e-mail, hlas a formular jako spoustece |
|
||||
| Parametry od sluzby | hotovo | katalog je deklaruje, server je dosazuje pri ulozeni |
|
||||
| Nastaveni poli akci | castecne | ticket, e-mail a WhatsApp ano, ostatni jen napoveda |
|
||||
| Obsah ticketu a sablony | hotovo | `{{parametr}}` ze spoustece do poli akce |
|
||||
| Vystupy kroku a predvalidace | hotovo | podminka se umi zeptat, co vratil predchozi krok |
|
||||
| Kanaly WhatsApp, FB, Instagram | hotovo | vcetne vzorovych automatizaci na prijem |
|
||||
| Firmy a prava | hotovo | tri pohledy, uzivatel muze byt ve vic firmach |
|
||||
| Nastavitelny dashboard | hotovo | widgety, sirky a poradi, ulozene za uzivatele a firmu |
|
||||
| Skripty konektoru | hotovo | manifest, kontrola parametru, hot reload, iDoklad |
|
||||
| Napojeni na realne sluzby | hotovo | 13 sluzeb ma bezici aplikaci, udaje a overeni |
|
||||
| Odesilani souboru ze skriptu | hotovo | `ctx.http.postForm`, obsah jako Base64 |
|
||||
| OpenAI pod vlastnim klicem | hotovo | dotaz, soubor, prepis zvuku, seznam modelu |
|
||||
| Konektory za firmu | hotovo | pristupove udaje v konektoru, overeni napojeni |
|
||||
| Transformace dat | hotovo | pravidla i sablona JSON, kroky si predavaji struktury |
|
||||
| Prace nad celym modelem | hotovo | ukazka tela, cesty v sablonach, smycka nad seznamem |
|
||||
| Vlastni skripty firmy | hotovo | prevod dat v JS, v logu vstup i vystup |
|
||||
| Sprava clenstvi z portalu | hotovo | firmy a role v Nastaveni, lide a pozvanky v Lidech |
|
||||
| Role a prava jako data | hotovo | 26 prav v katalogu, vlastni role za firmu |
|
||||
| Zalozky a limity za firmu | hotovo | navigace chodi ze serveru, ne z kodu klienta |
|
||||
| Osoby a skupiny resitelu | hotovo | ticket lze prehodit na skupinu, ne jen na cloveka |
|
||||
| Prevzeti ticketu ze skupiny | hotovo | kdo ma cas, si praci vezme sam |
|
||||
| Pozvanky do firmy | hotovo | odkaz s kodem, heslo si nastavi pozvany |
|
||||
| Typy ticketu a vlastni pole | hotovo | typ rozhoduje, ktere akce se na ticketu ukazou |
|
||||
| Vydefinovane akce na ticketu | hotovo | vazba na typ nebo tag, telo je operace, strom, skript |
|
||||
| Vlastni widgety | hotovo | vcetne zdroje z konektoru a vykonu resitelu |
|
||||
| Telo akce jako strom | hotovo | tentyz editor jako automatizace |
|
||||
| Audit a prepnuti na jiny ucet | hotovo | prepnuti je vychozi jen pro cteni, vse v auditu |
|
||||
| Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu |
|
||||
| Beh automatizaci | hotovo | fronta, worker, opakovani, ochrana proti smycce |
|
||||
| Prijem udalosti do fronty | hotovo | webhook odpovi 202, praci dela worker |
|
||||
| Pravidelne dotazovani sluzeb | hotovo | planovac pro postu a zpravy, perioda u spoustece |
|
||||
| Upozorneni na pridelenou praci | hotovo | cislo u zalozky a hlaska v portalu |
|
||||
| Incident z chyby | hotovo | popis pro klienta, podrobnosti pro admina |
|
||||
| Uloziste konektoru | hotovo | Postgres, nebo JSON soubor. Udaje vzdy sifrovane |
|
||||
| Uloziste pro zbytek | hotovo | tickety, automatizace, incidenty, rozlozeni, entity |
|
||||
| Monetizace a cena za krok | navrh | popis v 16-monetizace.md, neni naprogramovane |
|
||||
| Helpdesk pro zadavatele | hotovo | pozadavek vidi zadavatel i resitel, kazdy ze sve strany |
|
||||
| Odesilani e-mailu pres SMTP | hotovo | konektor se schrankou firmy, HTML telo s promennymi |
|
||||
| Odesilani e-mailu z formulare | chybi | poptavka se zatim jen loguje |
|
||||
|
||||
## Znama omezeni
|
||||
|
||||
@@ -67,11 +72,11 @@ Data prezijou restart, ale ne redeploy, kdyz neni databaze. Rezim se pozna
|
||||
v portalu i v `/health/ready` a rozhoduje o nem jedno misto, viz
|
||||
[14-databaze.md](14-databaze.md):
|
||||
|
||||
| Rezim | Kdy | Nasledek |
|
||||
| --- | --- | --- |
|
||||
| `postgres` | je `DATABASE_URL` a migrace prosly | data se neztraci |
|
||||
| `file` | neni databaze, je `DATA_DIR` | prezije restart, ne redeploy |
|
||||
| `memory` | neni ani `DATA_DIR` | ztrati se pri restartu |
|
||||
| Rezim | Kdy | Nasledek |
|
||||
| ---------- | ---------------------------------- | ---------------------------- |
|
||||
| `postgres` | je `DATABASE_URL` a migrace prosly | data se neztraci |
|
||||
| `file` | neni databaze, je `DATA_DIR` | prezije restart, ne redeploy |
|
||||
| `memory` | neni ani `DATA_DIR` | ztrati se pri restartu |
|
||||
|
||||
Beh automatizaci uz existuje, ale je **synchronni v requestu**: webhook ceka,
|
||||
nez cely strom dobehne, a pri padu procesu se rozdelany beh ztrati. Neni fronta
|
||||
@@ -115,25 +120,25 @@ pro frontu, beh kroku a rozpocet na 150 klientu, a
|
||||
|
||||
## Dokumentace
|
||||
|
||||
| Soubor | O cem |
|
||||
| --- | --- |
|
||||
| [02-appfactory-proxy.md](02-appfactory-proxy.md) | beh za reverse proxy, ROOT_PATH, health |
|
||||
| [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md) | kde co je |
|
||||
| [04-api.md](04-api.md) | endpointy a to, co ze Swaggeru neni videt |
|
||||
| [05-dashboard-a-builder.md](05-dashboard-a-builder.md) | editor automatizaci |
|
||||
| [06-tickety.md](06-tickety.md) | model ticketu a log prubehu |
|
||||
| [07-firmy-a-prava.md](07-firmy-a-prava.md) | firmy, pohledy, kdo co vidi |
|
||||
| [08-dashboard-widgety.md](08-dashboard-widgety.md) | nastavitelny prehled |
|
||||
| [09-navrh-rozsireni.md](09-navrh-rozsireni.md) | puvodni navrh rozsireni |
|
||||
| [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md) | **navrh**: fronta, beh kroku, kapacita |
|
||||
| [11-skripty-konektoru.md](11-skripty-konektoru.md) | vykonna cast sluzeb |
|
||||
| [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md) | sluzba, konektor, viditelnost |
|
||||
| [13-transformace-dat.md](13-transformace-dat.md) | pole na pole a JSON na JSON |
|
||||
| [14-databaze.md](14-databaze.md) | tri rezimy uloziste, migrace, sifrovani |
|
||||
| [15-rejstrik-funkci.md](15-rejstrik-funkci.md) | k cemu je jaka funkce a komponenta |
|
||||
| [16-monetizace.md](16-monetizace.md) | **navrh**: cena za krok a balicky |
|
||||
| [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md) | prava, typy, akce, widgety, prepnuti uctu |
|
||||
| [18-ticketovaci-system.md](18-ticketovaci-system.md) | udalosti, externi ID, statistiky, pohledy |
|
||||
| [19-kapacita-200-firem.md](19-kapacita-200-firem.md) | zmereno, co zvladne soucasny stav |
|
||||
| [20-fronta-a-runtime.md](20-fronta-a-runtime.md) | fronta, worker, spoustece, ochrana proti smycce |
|
||||
| [99-zmeny.md](99-zmeny.md) | zaznam zmen, nejnovejsi nahore |
|
||||
| Soubor | O cem |
|
||||
| ---------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| [02-appfactory-proxy.md](02-appfactory-proxy.md) | beh za reverse proxy, ROOT_PATH, health |
|
||||
| [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md) | kde co je |
|
||||
| [04-api.md](04-api.md) | endpointy a to, co ze Swaggeru neni videt |
|
||||
| [05-dashboard-a-builder.md](05-dashboard-a-builder.md) | editor automatizaci |
|
||||
| [06-tickety.md](06-tickety.md) | model ticketu a log prubehu |
|
||||
| [07-firmy-a-prava.md](07-firmy-a-prava.md) | firmy, pohledy, kdo co vidi |
|
||||
| [08-dashboard-widgety.md](08-dashboard-widgety.md) | nastavitelny prehled |
|
||||
| [09-navrh-rozsireni.md](09-navrh-rozsireni.md) | puvodni navrh rozsireni |
|
||||
| [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md) | **navrh**: fronta, beh kroku, kapacita |
|
||||
| [11-skripty-konektoru.md](11-skripty-konektoru.md) | vykonna cast sluzeb |
|
||||
| [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md) | sluzba, konektor, viditelnost |
|
||||
| [13-transformace-dat.md](13-transformace-dat.md) | pole na pole a JSON na JSON |
|
||||
| [14-databaze.md](14-databaze.md) | tri rezimy uloziste, migrace, sifrovani |
|
||||
| [15-rejstrik-funkci.md](15-rejstrik-funkci.md) | k cemu je jaka funkce a komponenta |
|
||||
| [16-monetizace.md](16-monetizace.md) | **navrh**: cena za krok a balicky |
|
||||
| [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md) | prava, typy, akce, widgety, prepnuti uctu |
|
||||
| [18-ticketovaci-system.md](18-ticketovaci-system.md) | udalosti, externi ID, statistiky, pohledy |
|
||||
| [19-kapacita-200-firem.md](19-kapacita-200-firem.md) | zmereno, co zvladne soucasny stav |
|
||||
| [20-fronta-a-runtime.md](20-fronta-a-runtime.md) | fronta, worker, spoustece, ochrana proti smycce |
|
||||
| [99-zmeny.md](99-zmeny.md) | zaznam zmen, nejnovejsi nahore |
|
||||
|
||||
@@ -77,12 +77,12 @@ AppFactory. Z aplikacniho repozitare se infrastruktura nemeni, viz AGENTS.md.
|
||||
|
||||
## Povinne endpointy
|
||||
|
||||
| Verejna cesta | Vraci |
|
||||
| ------------------------------ | -------------------------------------- |
|
||||
| `/apps/<app-id>/health` | `{"status":"ok","uptimeSec":N}` |
|
||||
| `/apps/<app-id>/docs` | presmeruje na `/docs/` |
|
||||
| `/apps/<app-id>/docs/` | Swagger UI |
|
||||
| `/apps/<app-id>/openapi.json` | OpenAPI definice |
|
||||
| Verejna cesta | Vraci |
|
||||
| ----------------------------- | ------------------------------- |
|
||||
| `/apps/<app-id>/health` | `{"status":"ok","uptimeSec":N}` |
|
||||
| `/apps/<app-id>/docs` | presmeruje na `/docs/` |
|
||||
| `/apps/<app-id>/docs/` | Swagger UI |
|
||||
| `/apps/<app-id>/openapi.json` | OpenAPI definice |
|
||||
|
||||
Presmerovani z `/docs` na `/docs/` je nutne. Bez koncoveho lomitka by se
|
||||
relativni odkazy Swagger UI na CSS a JS skladaly o uroven vys a nenacetly by se.
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
## Technologie
|
||||
|
||||
| Vrstva | Technologie |
|
||||
| ------- | ---------------------------------------------------- |
|
||||
| Server | Node.js 20, Express 4, TypeScript, ESM |
|
||||
| Web | React 18, Vite 6, TypeScript, Tailwind 4, React Router 6 |
|
||||
| Auth | JWT (jsonwebtoken), hesla bcrypt |
|
||||
| Validace| zod |
|
||||
| Docs | swagger-ui-express nad rucne psanou OpenAPI definici |
|
||||
| Vrstva | Technologie |
|
||||
| -------- | -------------------------------------------------------- |
|
||||
| Server | Node.js 20, Express 4, TypeScript, ESM |
|
||||
| Web | React 18, Vite 6, TypeScript, Tailwind 4, React Router 6 |
|
||||
| Auth | JWT (jsonwebtoken), hesla bcrypt |
|
||||
| Validace | zod |
|
||||
| Docs | swagger-ui-express nad rucne psanou OpenAPI definici |
|
||||
|
||||
Jeden `package.json`. Runtime zavislosti jsou v `dependencies`, nastroje pro build
|
||||
webu v `devDependencies` - runtime image je pak instaluje pres `--omit=dev`.
|
||||
@@ -25,53 +25,53 @@ image jen `dist`, takze staci jedna slozka.
|
||||
|
||||
## Mapa kodu - server
|
||||
|
||||
| Cesta | K cemu je |
|
||||
| --------------------------- | -------------------------------------------------------- |
|
||||
| `src/index.ts` | vstupni bod: middleware, mount routeru, statika, SPA, Swagger |
|
||||
| `src/config.ts` | cteni environment variables, normalizace `ROOT_PATH` |
|
||||
| `src/openapi.ts` | OpenAPI definice vcetne `servers` s prefixem proxy |
|
||||
| `src/types.ts` | typy uzivatele a JWT payloadu |
|
||||
| `src/middleware/auth.ts` | `requireAuth`, `requireRole` |
|
||||
| `src/events/bus.ts` | sbernice udalosti, ze ktere cerpa SSE stream |
|
||||
| `src/routes/auth.ts` | prihlaseni, odhlaseni, kdo jsem |
|
||||
| `src/routes/dashboard.ts` | data portalu, katalog konektoru, CRUD automatizaci |
|
||||
| `src/routes/stream.ts` | SSE stream zmen |
|
||||
| `src/routes/simulate.ts` | vyvolani provoznich udalosti |
|
||||
| `src/routes/webhook.ts` | verejny prijem dat do automatizace |
|
||||
| `src/routes/contact.ts` | poptavkovy formular z webu |
|
||||
| `src/data/ticketStore.ts` | tickety, jejich resitele, log prubehu, prehled vytizeni |
|
||||
| `src/data/people.ts` | resitele ticketu - oddeleni od uzivatelu portalu |
|
||||
| `src/data/tenants.ts` | firmy, ktere portal pouzivaji |
|
||||
| `src/data/access.ts` | kdo co vidi - jedno misto pro cely portal |
|
||||
| `src/data/widgets.ts` | katalog widgetu prehledu |
|
||||
| `src/data/dashboardLayouts.ts` | rozlozeni dashboardu za dvojici uzivatel a firma |
|
||||
| `src/data/incidentStore.ts` | incidenty vcetne zmen a udalosti |
|
||||
| `src/data/automationStore.ts` | automatizace, strom akci, tokeny webhooku |
|
||||
| `src/data/connectors.ts` | katalog konektoru, jejich spousteču a akci |
|
||||
| `src/data/conditions.ts` | typy parametru a operatory podminek |
|
||||
| `src/data/templates.ts` | sablony `{{parametr}}` v nastaveni kroku |
|
||||
| `src/data/flowScope.ts` | co je videt v kterem miste stromu |
|
||||
| `src/data/users.ts` | demo uzivatele |
|
||||
| `src/data/mock.ts` | souhrn pro prehled a casova rada grafu |
|
||||
| Cesta | K cemu je |
|
||||
| ------------------------------ | ------------------------------------------------------------- |
|
||||
| `src/index.ts` | vstupni bod: middleware, mount routeru, statika, SPA, Swagger |
|
||||
| `src/config.ts` | cteni environment variables, normalizace `ROOT_PATH` |
|
||||
| `src/openapi.ts` | OpenAPI definice vcetne `servers` s prefixem proxy |
|
||||
| `src/types.ts` | typy uzivatele a JWT payloadu |
|
||||
| `src/middleware/auth.ts` | `requireAuth`, `requireRole` |
|
||||
| `src/events/bus.ts` | sbernice udalosti, ze ktere cerpa SSE stream |
|
||||
| `src/routes/auth.ts` | prihlaseni, odhlaseni, kdo jsem |
|
||||
| `src/routes/dashboard.ts` | data portalu, katalog konektoru, CRUD automatizaci |
|
||||
| `src/routes/stream.ts` | SSE stream zmen |
|
||||
| `src/routes/simulate.ts` | vyvolani provoznich udalosti |
|
||||
| `src/routes/webhook.ts` | verejny prijem dat do automatizace |
|
||||
| `src/routes/contact.ts` | poptavkovy formular z webu |
|
||||
| `src/data/ticketStore.ts` | tickety, jejich resitele, log prubehu, prehled vytizeni |
|
||||
| `src/data/people.ts` | resitele ticketu - oddeleni od uzivatelu portalu |
|
||||
| `src/data/tenants.ts` | firmy, ktere portal pouzivaji |
|
||||
| `src/data/access.ts` | kdo co vidi - jedno misto pro cely portal |
|
||||
| `src/data/widgets.ts` | katalog widgetu prehledu |
|
||||
| `src/data/dashboardLayouts.ts` | rozlozeni dashboardu za dvojici uzivatel a firma |
|
||||
| `src/data/incidentStore.ts` | incidenty vcetne zmen a udalosti |
|
||||
| `src/data/automationStore.ts` | automatizace, strom akci, tokeny webhooku |
|
||||
| `src/data/connectors.ts` | katalog konektoru, jejich spousteču a akci |
|
||||
| `src/data/conditions.ts` | typy parametru a operatory podminek |
|
||||
| `src/data/templates.ts` | sablony `{{parametr}}` v nastaveni kroku |
|
||||
| `src/data/flowScope.ts` | co je videt v kterem miste stromu |
|
||||
| `src/data/users.ts` | demo uzivatele |
|
||||
| `src/data/mock.ts` | souhrn pro prehled a casova rada grafu |
|
||||
|
||||
## Mapa kodu - web
|
||||
|
||||
| Cesta | K cemu je |
|
||||
| ---------------------------------- | -------------------------------------------------- |
|
||||
| `web/src/main.tsx` | vstupni bod, `basename` routeru podle prefixu proxy |
|
||||
| `web/src/App.tsx` | routovani, portal se nacita lazy |
|
||||
| `web/src/index.css` | design tokeny a vlastni utility Tailwindu |
|
||||
| `web/src/config/brand.ts` | vsechny firemni udaje na jednom miste |
|
||||
| `web/src/lib/api.ts` | fetch wrapper, sprava tokenu, skladani adres |
|
||||
| `web/src/lib/eventStream.ts` | cteni SSE streamu pres fetch |
|
||||
| `web/src/lib/useApiQuery.ts` | nacitani dat vcetne obnoveni pri udalosti |
|
||||
| `web/src/lib/flow.ts` | ciste funkce nad stromem automatizace |
|
||||
| `web/src/components/dashboard/` | shell portalu, dlazdice, graf, stream, simulace |
|
||||
| `web/src/components/dashboard/flow/` | strom akci, vyber kroku, nastaveni poli akce |
|
||||
| `web/src/components/dashboard/TicketTrace.tsx` | log ticketu jako strom |
|
||||
| `web/src/components/dashboard/TicketWorkload.tsx` | prehled, kdo co ma u sebe |
|
||||
| `web/src/components/home/` | sekce homepage |
|
||||
| `web/src/pages/` | jedna stranka je jeden soubor |
|
||||
| Cesta | K cemu je |
|
||||
| ------------------------------------------------- | --------------------------------------------------- |
|
||||
| `web/src/main.tsx` | vstupni bod, `basename` routeru podle prefixu proxy |
|
||||
| `web/src/App.tsx` | routovani, portal se nacita lazy |
|
||||
| `web/src/index.css` | design tokeny a vlastni utility Tailwindu |
|
||||
| `web/src/config/brand.ts` | vsechny firemni udaje na jednom miste |
|
||||
| `web/src/lib/api.ts` | fetch wrapper, sprava tokenu, skladani adres |
|
||||
| `web/src/lib/eventStream.ts` | cteni SSE streamu pres fetch |
|
||||
| `web/src/lib/useApiQuery.ts` | nacitani dat vcetne obnoveni pri udalosti |
|
||||
| `web/src/lib/flow.ts` | ciste funkce nad stromem automatizace |
|
||||
| `web/src/components/dashboard/` | shell portalu, dlazdice, graf, stream, simulace |
|
||||
| `web/src/components/dashboard/flow/` | strom akci, vyber kroku, nastaveni poli akce |
|
||||
| `web/src/components/dashboard/TicketTrace.tsx` | log ticketu jako strom |
|
||||
| `web/src/components/dashboard/TicketWorkload.tsx` | prehled, kdo co ma u sebe |
|
||||
| `web/src/components/home/` | sekce homepage |
|
||||
| `web/src/pages/` | jedna stranka je jeden soubor |
|
||||
|
||||
## Klicova rozhodnuti
|
||||
|
||||
|
||||
+32
-32
@@ -7,17 +7,17 @@ co ze Swaggeru neni videt.
|
||||
|
||||
Verejne:
|
||||
|
||||
| Metoda | Cesta | Popis |
|
||||
| ------ | ------------------- | --------------------------------------- |
|
||||
| GET | `/health` | liveness, nezavisi na databazi |
|
||||
| GET | `/health/ready` | readiness, 503 pri nedostupne databazi |
|
||||
| GET | `/docs` | Swagger UI |
|
||||
| GET | `/openapi.json` | OpenAPI definice |
|
||||
| POST | `/api/auth/login` | prihlaseni, vraci JWT |
|
||||
| POST | `/api/contact` | poptavka z webu |
|
||||
| POST | `/webhook/:token` | prijem dat do automatizace |
|
||||
| POST | `/webhook/ticket/:token` | prijem udalosti do ticketu |
|
||||
| GET | `/webhook/ticket/:token` | napoveda k prijmu |
|
||||
| Metoda | Cesta | Popis |
|
||||
| ------ | ------------------------ | -------------------------------------- |
|
||||
| GET | `/health` | liveness, nezavisi na databazi |
|
||||
| GET | `/health/ready` | readiness, 503 pri nedostupne databazi |
|
||||
| GET | `/docs` | Swagger UI |
|
||||
| GET | `/openapi.json` | OpenAPI definice |
|
||||
| POST | `/api/auth/login` | prihlaseni, vraci JWT |
|
||||
| POST | `/api/contact` | poptavka z webu |
|
||||
| POST | `/webhook/:token` | prijem dat do automatizace |
|
||||
| POST | `/webhook/ticket/:token` | prijem udalosti do ticketu |
|
||||
| GET | `/webhook/ticket/:token` | napoveda k prijmu |
|
||||
|
||||
Vyzaduji `Authorization: Bearer <token>`:
|
||||
|
||||
@@ -105,14 +105,14 @@ Jednotny pro cele API:
|
||||
{ "error": "validation_error", "message": "Zadejte platny e-mail." }
|
||||
```
|
||||
|
||||
| HTTP | `error` | Kdy |
|
||||
| ---- | --------------------- | --------------------------------------- |
|
||||
| 400 | `validation_error` | vstup neprosel schematem |
|
||||
| 401 | `unauthorized` | chybi nebo neplatny token |
|
||||
| 401 | `invalid_credentials` | spatny e-mail nebo heslo |
|
||||
| 403 | `forbidden` | nedostatecna role |
|
||||
| 404 | `not_found` | zaznam nebo endpoint neexistuje |
|
||||
| 409 | ruzne | operace nedava v danem stavu smysl |
|
||||
| HTTP | `error` | Kdy |
|
||||
| ---- | --------------------- | ------------------------------------------- |
|
||||
| 400 | `validation_error` | vstup neprosel schematem |
|
||||
| 401 | `unauthorized` | chybi nebo neplatny token |
|
||||
| 401 | `invalid_credentials` | spatny e-mail nebo heslo |
|
||||
| 403 | `forbidden` | nedostatecna role |
|
||||
| 404 | `not_found` | zaznam nebo endpoint neexistuje |
|
||||
| 409 | ruzne | operace nedava v danem stavu smysl |
|
||||
| 500 | `internal_error` | neodchycena chyba, detail jen mimo produkci |
|
||||
|
||||
`message` je vzdy cesky a je urcena k zobrazeni uzivateli.
|
||||
@@ -150,12 +150,12 @@ Verejny endpoint bez prihlaseni. Autorizuje neuhodnutelny token v adrese,
|
||||
Token generuje **vyhradne server**. Hodnota `webhookToken` poslana klientem se
|
||||
ignoruje, jinak by si sel nastavit predvidatelnou adresu.
|
||||
|
||||
| Situace | Odpoved |
|
||||
| ----------------------------------- | ------- |
|
||||
| vse v poradku | 202 |
|
||||
| neznamy token | 404 |
|
||||
| automatizace je pozastavena | 409 |
|
||||
| chybi povinny parametr, spatny typ | 400 |
|
||||
| Situace | Odpoved |
|
||||
| ---------------------------------- | ------- |
|
||||
| vse v poradku | 202 |
|
||||
| neznamy token | 404 |
|
||||
| automatizace je pozastavena | 409 |
|
||||
| chybi povinny parametr, spatny typ | 400 |
|
||||
|
||||
Parametry navic se neodmitaji, jen loguji. Odesilatele bezne posilaji i vlastni
|
||||
data a odmitat je by rozbijelo integrace.
|
||||
@@ -217,13 +217,13 @@ uz adresu zname, tak ji, at ji clovek nemusi psat. Nic o tom, kdo ve firme je.
|
||||
|
||||
`POST /api/invites/:kod/accept` s telem `{"name", "email", "password"}`:
|
||||
|
||||
| Situace | Co se stane |
|
||||
| --- | --- |
|
||||
| ucet neexistuje | zalozi se a pripoji k firme |
|
||||
| ucet existuje, heslo sedi | jen se pripoji k firme |
|
||||
| ucet existuje, heslo nesedi | 401 |
|
||||
| pozvanka je na jinou adresu | 403 |
|
||||
| pozvanka uz byla pouzita nebo vyprsela | 409 s konkretnim duvodem |
|
||||
| Situace | Co se stane |
|
||||
| -------------------------------------- | --------------------------- |
|
||||
| ucet neexistuje | zalozi se a pripoji k firme |
|
||||
| ucet existuje, heslo sedi | jen se pripoji k firme |
|
||||
| ucet existuje, heslo nesedi | 401 |
|
||||
| pozvanka je na jinou adresu | 403 |
|
||||
| pozvanka uz byla pouzita nebo vyprsela | 409 s konkretnim duvodem |
|
||||
|
||||
Overeni hesla u existujiciho uctu neni formalita: bez nej by kdokoliv s odkazem
|
||||
pripojil cizi adresu ke sve firme a videl by jeji data.
|
||||
|
||||
@@ -170,14 +170,14 @@ Builder i katalog ji vezmou automaticky.
|
||||
|
||||
## Co chybi
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| --------------------------- | -------------------------------------------------------- |
|
||||
| `inputs` u zbylych konektoru| zatim ticket, kanaly, CRM a AI, ostatni maji jen `fields` |
|
||||
| Vazba logu ticketu na beh | log plni simulace, ne vykonany strom |
|
||||
| Kombinovane podminky | jedna podminka je jedno porovnani, AND a OR jen vnorenim |
|
||||
| Beh automatizaci | ulozeny strom se nevykonava |
|
||||
| Historie behu a logy | prazdne, chybi runtime |
|
||||
| Drag and drop | presouvani je zatim tlacitky nahoru a dolu |
|
||||
| Chybi | Poznamka |
|
||||
| ---------------------------- | --------------------------------------------------------- |
|
||||
| `inputs` u zbylych konektoru | zatim ticket, kanaly, CRM a AI, ostatni maji jen `fields` |
|
||||
| Vazba logu ticketu na beh | log plni simulace, ne vykonany strom |
|
||||
| Kombinovane podminky | jedna podminka je jedno porovnani, AND a OR jen vnorenim |
|
||||
| Beh automatizaci | ulozeny strom se nevykonava |
|
||||
| Historie behu a logy | prazdne, chybi runtime |
|
||||
| Drag and drop | presouvani je zatim tlacitky nahoru a dolu |
|
||||
|
||||
## Co je videt v kterem kroku
|
||||
|
||||
@@ -220,13 +220,13 @@ parametry. Podminka se totiz pta na parametr, ne na cestu.
|
||||
|
||||
### Odkaz muze byt cesta
|
||||
|
||||
| Odkaz | Co vrati |
|
||||
| --- | --- |
|
||||
| `{{callSid}}` | deklarovany parametr, jako driv |
|
||||
| `{{data.order.code}}` | hodnotu z prijateho tela |
|
||||
| `{{data.order.items[0].name}}` | prvni polozku seznamu |
|
||||
| `{{st_faktura.invoiceId}}` | vystup kroku `st_faktura` |
|
||||
| `{{item.amount}}` | polozku uvnitr smycky |
|
||||
| Odkaz | Co vrati |
|
||||
| ------------------------------ | ------------------------------- |
|
||||
| `{{callSid}}` | deklarovany parametr, jako driv |
|
||||
| `{{data.order.code}}` | hodnotu z prijateho tela |
|
||||
| `{{data.order.items[0].name}}` | prvni polozku seznamu |
|
||||
| `{{st_faktura.invoiceId}}` | vystup kroku `st_faktura` |
|
||||
| `{{item.amount}}` | polozku uvnitr smycky |
|
||||
|
||||
Overuje se jen **prvni cast** odkazu. Zbytek je cesta a tu predem overit nejde -
|
||||
co presne prijde v tele, vime az pri behu. Diky tomu ploche odkazy funguji dal
|
||||
|
||||
+46
-46
@@ -88,10 +88,10 @@ prace se nerozdava shora.
|
||||
|
||||
Proc obojí vedle sebe:
|
||||
|
||||
| Situace | Co se hodi |
|
||||
| --- | --- |
|
||||
| Situace | Co se hodi |
|
||||
| --------------------------------- | ----------------------------- |
|
||||
| havarie, musi to nekdo hned resit | automat prideli nejvolnejsimu |
|
||||
| bezny dotaz, lidi maji ruzne dny | necha se ve fronte skupiny |
|
||||
| bezny dotaz, lidi maji ruzne dny | necha se ve fronte skupiny |
|
||||
|
||||
Proto je prepinac **Priradit rovnou nejvolnejsimu** na kroku automatizace
|
||||
(`ticket/assign-group`), ne na skupine: tataz skupina potrebuje obe chovani,
|
||||
@@ -170,12 +170,12 @@ Odesilatele umi poslat stejnou zpravu i osmdesatkrat za minutu. Kdyz prijde
|
||||
**presne totez co posledne** (stejny typ, zdroj, popisek a stejna data), nezaklada
|
||||
se dalsi radek: pricte se k pocitadlu u te predchozi.
|
||||
|
||||
| Co se stane | Proc |
|
||||
| --- | --- |
|
||||
| `repeats` +1, `lastAt` = ted | osmdesat radku znamena, ze v historii nikdo nic nenajde |
|
||||
| ticket se **nemeni** | jinak by duplikat rozblikal dashboard a spustil automatizaci na zmenu ticketu |
|
||||
| do logu se nezapisuje nic | log ma ukazovat zmeny, ne to, ze se nezmenilo nic |
|
||||
| udalost se **nezahazuje** | bez pocitadla by nikdo nezjistil, ze proti nam neco tluce ve smycce |
|
||||
| Co se stane | Proc |
|
||||
| ---------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `repeats` +1, `lastAt` = ted | osmdesat radku znamena, ze v historii nikdo nic nenajde |
|
||||
| ticket se **nemeni** | jinak by duplikat rozblikal dashboard a spustil automatizaci na zmenu ticketu |
|
||||
| do logu se nezapisuje nic | log ma ukazovat zmeny, ne to, ze se nezmenilo nic |
|
||||
| udalost se **nezahazuje** | bez pocitadla by nikdo nezjistil, ze proti nam neco tluce ve smycce |
|
||||
|
||||
Porovnava se jen s posledni udalosti. "Objednavka pripravena" muze legitimne
|
||||
prijit znovu za hodinu, kdyz se mezitim stalo neco jineho - to je novy fakt.
|
||||
@@ -204,20 +204,20 @@ ze se neco stalo, a nerekne co.
|
||||
|
||||
Kategorie `servicedesk`, driv byl pod Nastroji. Ma obe strany:
|
||||
|
||||
| Spoustec | Kdy |
|
||||
| ------------------ | ------------------------------------------------------ |
|
||||
| `created` | zalozen ticket, at uz z kanalu nebo rucne |
|
||||
| `unknown-customer` | k ticketu se nepodarilo dohledat firmu |
|
||||
| `assigned` | ticket dostal konkretniho cloveka |
|
||||
| `status-changed` | prechod do jineho stavu vcetne vyreseni |
|
||||
| Spoustec | Kdy |
|
||||
| ------------------ | ----------------------------------------- |
|
||||
| `created` | zalozen ticket, at uz z kanalu nebo rucne |
|
||||
| `unknown-customer` | k ticketu se nepodarilo dohledat firmu |
|
||||
| `assigned` | ticket dostal konkretniho cloveka |
|
||||
| `status-changed` | prechod do jineho stavu vcetne vyreseni |
|
||||
|
||||
| Akce | Co dela |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| `create` | zalozi pozadavek |
|
||||
| `assign` | preda ticket cloveku |
|
||||
| `set-status` | posune stav |
|
||||
| `link-customer` | doplni firmu z CRM |
|
||||
| `comment` | zapise komentar do logu |
|
||||
| Akce | Co dela |
|
||||
| --------------- | ----------------------- |
|
||||
| `create` | zalozi pozadavek |
|
||||
| `assign` | preda ticket cloveku |
|
||||
| `set-status` | posune stav |
|
||||
| `link-customer` | doplni firmu z CRM |
|
||||
| `comment` | zapise komentar do logu |
|
||||
|
||||
Typicky retez, ktery z toho jde postavit:
|
||||
|
||||
@@ -236,15 +236,15 @@ Akce "Zalozit ticket" ma nastavitelna pole. Klikni na krok ve strome a vyplnis
|
||||
je primo tam. Hodnota je **sablona**: `{{nazev}}` se nahradi parametrem
|
||||
spoustece.
|
||||
|
||||
| Pole | Typicka hodnota u WhatsApp |
|
||||
| ------------ | --------------------------------- |
|
||||
| Predmet | `Zprava od {{profileName}}` |
|
||||
| Obsah | `{{text}}` |
|
||||
| Firma | necha se prazdne, doplni CRM krok |
|
||||
| Kontakt | `{{profileName}}` |
|
||||
| Odpoved na | `{{phone}}` |
|
||||
| Priorita | vyber ze seznamu |
|
||||
| Resitel | vyber ze seznamu lidi |
|
||||
| Pole | Typicka hodnota u WhatsApp |
|
||||
| ---------- | --------------------------------- |
|
||||
| Predmet | `Zprava od {{profileName}}` |
|
||||
| Obsah | `{{text}}` |
|
||||
| Firma | necha se prazdne, doplni CRM krok |
|
||||
| Kontakt | `{{profileName}}` |
|
||||
| Odpoved na | `{{phone}}` |
|
||||
| Priorita | vyber ze seznamu |
|
||||
| Resitel | vyber ze seznamu lidi |
|
||||
|
||||
Nabidka parametru je pod poli. Kliknuti vlozi `{{nazev}}` na pozici kurzoru,
|
||||
takze se nemusi psat rucne a neudela se preklep.
|
||||
@@ -317,12 +317,12 @@ automatizace na kanal, smerovani je jedna spolecna nad vsemi tickety.
|
||||
|
||||
V `automationStore.ts` jsou nasazene presne v tomhle rozdeleni:
|
||||
|
||||
| Automatizace | Co ukazuje |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| WhatsApp: zprava do ticketu | predvalidace v CRM a vetveni podle vysledku |
|
||||
| Facebook: zprava do ticketu | prijem bez predvalidace, nemame podle ceho hledat |
|
||||
| E-mail: pozadavky do ticketu | dohledani podle `{{from}}`, plus odpoved zadavateli |
|
||||
| Smerovani ticketu na resitele | jedna spolecna logika nad vsemi tickety |
|
||||
| Automatizace | Co ukazuje |
|
||||
| ----------------------------- | --------------------------------------------------- |
|
||||
| WhatsApp: zprava do ticketu | predvalidace v CRM a vetveni podle vysledku |
|
||||
| Facebook: zprava do ticketu | prijem bez predvalidace, nemame podle ceho hledat |
|
||||
| E-mail: pozadavky do ticketu | dohledani podle `{{from}}`, plus odpoved zadavateli |
|
||||
| Smerovani ticketu na resitele | jedna spolecna logika nad vsemi tickety |
|
||||
|
||||
Prijmove automatizace zamerne **neprirazuji resitele**. Nechavaji ticket ve
|
||||
fronte a smerovani si ho prevezme. Podminka `assigned neni splneno` na zacatku
|
||||
@@ -402,12 +402,12 @@ to rozhodnout vedome, ne omylem. Varianty od nejlevnejsi:
|
||||
|
||||
## Co chybi
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| ---------------------------- | ------------------------------------------------------- |
|
||||
| Bugs a wishes | vyvojarska agenda, samostatna evidence |
|
||||
| Skutecny beh automatizaci | sablony se ukladaji, ale nikdo je nevyhodnocuje |
|
||||
| `inputs` u zbylych konektoru | zatim ticket, kanaly, CRM a AI, ostatni maji jen napovedu |
|
||||
| Napojeni logu na beh | `automationId` je odkaz, historie behu ale neexistuje |
|
||||
| Odpoved zakaznikovi z detailu| akce `send` u kanalu se z portalu nevola |
|
||||
| SLA a eskalace | zadne lhuty, `capacity` je jen orientacni |
|
||||
| Databaze | data v pameti, restart je vrati na vychozi sadu |
|
||||
| Chybi | Poznamka |
|
||||
| ----------------------------- | --------------------------------------------------------- |
|
||||
| Bugs a wishes | vyvojarska agenda, samostatna evidence |
|
||||
| Skutecny beh automatizaci | sablony se ukladaji, ale nikdo je nevyhodnocuje |
|
||||
| `inputs` u zbylych konektoru | zatim ticket, kanaly, CRM a AI, ostatni maji jen napovedu |
|
||||
| Napojeni logu na beh | `automationId` je odkaz, historie behu ale neexistuje |
|
||||
| Odpoved zakaznikovi z detailu | akce `send` u kanalu se z portalu nevola |
|
||||
| SLA a eskalace | zadne lhuty, `capacity` je jen orientacni |
|
||||
| Databaze | data v pameti, restart je vrati na vychozi sadu |
|
||||
|
||||
@@ -34,11 +34,11 @@ globalni, nesla by tahle situace vubec zapsat.
|
||||
|
||||
## Tri pohledy na tickety
|
||||
|
||||
| Pohled | Co ukazuje | Kdo smi |
|
||||
| -------- | ------------------------- | ------------------------------ |
|
||||
| `all` | napric vsemi firmami | jen `platformAdmin` |
|
||||
| `tenant` | cela jedna firma | kdokoliv, kdo do ni patri |
|
||||
| `mine` | jen tickety prihlaseneho | kdo ma navazaneho resitele |
|
||||
| Pohled | Co ukazuje | Kdo smi |
|
||||
| -------- | ------------------------ | -------------------------- |
|
||||
| `all` | napric vsemi firmami | jen `platformAdmin` |
|
||||
| `tenant` | cela jedna firma | kdokoliv, kdo do ni patri |
|
||||
| `mine` | jen tickety prihlaseneho | kdo ma navazaneho resitele |
|
||||
|
||||
Posilaji se jako query: `?scope=tenant&tenantId=tnt_automia`.
|
||||
|
||||
@@ -70,12 +70,12 @@ prava pocitala na dvou mistech a jednou se rozejdou.
|
||||
Pozadavek na pohled nebo firmu, na kterou uzivatel nema pravo, vraci **chybu**,
|
||||
ne potichu zuzeny vysledek:
|
||||
|
||||
| Situace | Odpoved |
|
||||
| -------------------------------- | ------- |
|
||||
| pohled bez opravneni | 403 |
|
||||
| firma, do ktere nepatri | 404 |
|
||||
| ucet bez firmy | 403 |
|
||||
| prirazeni ostatnim bez prava | 403 |
|
||||
| Situace | Odpoved |
|
||||
| ---------------------------- | ------- |
|
||||
| pohled bez opravneni | 403 |
|
||||
| firma, do ktere nepatri | 404 |
|
||||
| ucet bez firmy | 403 |
|
||||
| prirazeni ostatnim bez prava | 403 |
|
||||
|
||||
Duvod: kdyby se pozadavek na cizi firmu jen prepnul na vlastni, uzivatel by
|
||||
koukal na cizi cisla v domneni, ze jsou spravna. To je horsi nez chyba.
|
||||
@@ -92,6 +92,16 @@ tedy 404, ne 403 - z odpovedi nemá jit poznat, ze takove ID vubec existuje.
|
||||
Vyjimka je verejny webhook. Ten se autorizuje tokenem v adrese, ne prihlasenim,
|
||||
takze si automatizaci najde pres vsechny firmy.
|
||||
|
||||
## Jedina vyjimka: helpdesk
|
||||
|
||||
Ticket patri jedne firme a to plati dal. U pozadavku z helpdesku ale figuruji
|
||||
dve: vlastnikem je ta, ktera ho resi, a v `helpdeskSourceId` je ta, ktera ho
|
||||
poslala. Zadavatel se k nemu dostane **jen pres helpdesk** a jen ke svym
|
||||
pozadavkum; bezny seznam ticketu zustava vlastnikovi.
|
||||
|
||||
Neni to obchazeni hranice, je to druha cesta dovnitr s vlastnim scopem. Popis
|
||||
je v [18-ticketovaci-system.md](18-ticketovaci-system.md).
|
||||
|
||||
## Prirazeni jen v ramci firmy
|
||||
|
||||
`assignTicket` odmitne resitele z jine firmy. Jinak by ticket zmizel z prehledu
|
||||
@@ -104,21 +114,21 @@ ale nemuze ho poslat kolegovi - to hlida `canAssignOthers`.
|
||||
|
||||
Heslo je u vsech `demo1234`.
|
||||
|
||||
| E-mail | Kdo je |
|
||||
| ------------------------- | ----------------------------------------------- |
|
||||
| `admin@automia.cz` | spravce platformy, vidi vsechny tri firmy |
|
||||
| `karel.vomacka@automia.cz`| agent v Automii, admin u Nordisu - dve firmy |
|
||||
| `martin.kriz@automia.cz` | bezny resitel jedne firmy |
|
||||
| E-mail | Kdo je |
|
||||
| -------------------------- | -------------------------------------------- |
|
||||
| `admin@automia.cz` | spravce platformy, vidi vsechny tri firmy |
|
||||
| `karel.vomacka@automia.cz` | agent v Automii, admin u Nordisu - dve firmy |
|
||||
| `martin.kriz@automia.cz` | bezny resitel jedne firmy |
|
||||
|
||||
Druhy ucet je ten zajimavy: ukazuje prepinac firem i to, ze prava se lisi
|
||||
podle toho, ktera firma je prave zvolena.
|
||||
|
||||
## Co chybi
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| ------------------------ | ---------------------------------------------------- |
|
||||
| Sprava clenstvi z portalu| memberships jdou zmenit jen v kodu |
|
||||
| Pozvanky uzivatelu | zadny onboarding |
|
||||
| Tenant u incidentu | incidenty jsou zatim spolecne, nefiltruji se |
|
||||
| Tenant u konektoru | katalog je spolecny, napojeni se zatim neeviduje |
|
||||
| Audit pristupu | odepreni se jen loguje, nikde se neuklada |
|
||||
| Chybi | Poznamka |
|
||||
| ------------------------- | ------------------------------------------------ |
|
||||
| Sprava clenstvi z portalu | memberships jdou zmenit jen v kodu |
|
||||
| Pozvanky uzivatelu | zadny onboarding |
|
||||
| Tenant u incidentu | incidenty jsou zatim spolecne, nefiltruji se |
|
||||
| Tenant u konektoru | katalog je spolecny, napojeni se zatim neeviduje |
|
||||
| Audit pristupu | odepreni se jen loguje, nikde se neuklada |
|
||||
|
||||
@@ -67,12 +67,12 @@ Duvod je stejny jako u stromu automatizaci, viz
|
||||
|
||||
Server overuje ulozene rozlozeni proti katalogu:
|
||||
|
||||
| Situace | Vysledek |
|
||||
| ----------------------------------- | -------- |
|
||||
| neznamy widget | 400 |
|
||||
| sirka, kterou widget nepodporuje | 400 |
|
||||
| duplicitni ID instance | 400 |
|
||||
| vic nez 12 widgetu | 400 |
|
||||
| Situace | Vysledek |
|
||||
| -------------------------------- | -------- |
|
||||
| neznamy widget | 400 |
|
||||
| sirka, kterou widget nepodporuje | 400 |
|
||||
| duplicitni ID instance | 400 |
|
||||
| vic nez 12 widgetu | 400 |
|
||||
|
||||
Neulozit je tady spravne. Klient by dostal zpatky neco, co neumi vykreslit.
|
||||
|
||||
@@ -90,10 +90,10 @@ Nabidka i rozlozeni ho vezmou automaticky.
|
||||
|
||||
## Co chybi
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| -------------------------- | ------------------------------------------------- |
|
||||
| Drag and drop | poradi se meni sipkami, stejne jako ve strome |
|
||||
| Nastaveni jednotlivych widgetu | napr. kolik radku ukazat, za jake obdobi |
|
||||
| Vlastni metriky | katalog je pevny, nejde si nadefinovat vlastni |
|
||||
| Sdilene rozlozeni pro firmu| kazdy si upravuje jen to svoje |
|
||||
| Databaze | rozlozeni je v pameti, restart je vrati na vychozi |
|
||||
| Chybi | Poznamka |
|
||||
| ------------------------------ | -------------------------------------------------- |
|
||||
| Drag and drop | poradi se meni sipkami, stejne jako ve strome |
|
||||
| Nastaveni jednotlivych widgetu | napr. kolik radku ukazat, za jake obdobi |
|
||||
| Vlastni metriky | katalog je pevny, nejde si nadefinovat vlastni |
|
||||
| Sdilene rozlozeni pro firmu | kazdy si upravuje jen to svoje |
|
||||
| Databaze | rozlozeni je v pameti, restart je vrati na vychozi |
|
||||
|
||||
@@ -28,10 +28,10 @@ Stejne pravidlo plati na widgety (katalog je zdroj pravdy) a na prava
|
||||
Automatizacni stromy a akce jsou **dve samostatne veci** a nemaji spolecnou
|
||||
vrstvu mezi sebou:
|
||||
|
||||
| Vec | Spousti | Kde je definovana |
|
||||
| -------------------- | --------------------------- | ------------------------ |
|
||||
| Automatizacni strom | udalost, spoustec | seznam automatizaci |
|
||||
| Akce | clovek kliknutim na ticketu | seznam akci za firmu |
|
||||
| Vec | Spousti | Kde je definovana |
|
||||
| ------------------- | --------------------------- | -------------------- |
|
||||
| Automatizacni strom | udalost, spoustec | seznam automatizaci |
|
||||
| Akce | clovek kliknutim na ticketu | seznam akci za firmu |
|
||||
|
||||
**Akce se vaze na typ nebo tag ticketu.** Priklad: "Odeslat do iDokladu" pro
|
||||
objednavku. Na ticketu se pak vykresli CTA vsech akci, ktere na jeho typ nebo tag
|
||||
@@ -110,10 +110,10 @@ precetl.
|
||||
V zadani je otazka, jestli je "objednavka" tag. Odpoved je, ze jsou potreba
|
||||
**oba a jsou to jine veci**:
|
||||
|
||||
| Vec | Kolik na ticket | K cemu |
|
||||
| ---- | --------------- | --------------------------------------------- |
|
||||
| Typ | prave jeden | vlastni pole a vlastni workflow stavu |
|
||||
| Tag | libovolne mnoho | volne oznaceni, filtry a widgety |
|
||||
| Vec | Kolik na ticket | K cemu |
|
||||
| --- | --------------- | ------------------------------------- |
|
||||
| Typ | prave jeden | vlastni pole a vlastni workflow stavu |
|
||||
| Tag | libovolne mnoho | volne oznaceni, filtry a widgety |
|
||||
|
||||
**Akce se muze vazat na oboji**, ale nasledek se lisi:
|
||||
|
||||
@@ -180,11 +180,11 @@ se neukaze.
|
||||
Akce neni jen jeden krok. `ActionBody` je proto union a **kazdy druh je jina
|
||||
uroven slozitosti pro tehoz cloveka**:
|
||||
|
||||
| Druh | Kdy | UI |
|
||||
| ----------- | ---------------------------------------------- | --------------- |
|
||||
| `operation` | odesli tenhle doklad tam | formular |
|
||||
| `tree` | zkus to, a kdyz se to nepovede, dej to Karlovi | builder |
|
||||
| `script` | poskladej text v nasem formatu | editor skriptu |
|
||||
| Druh | Kdy | UI |
|
||||
| ----------- | ---------------------------------------------- | -------------- |
|
||||
| `operation` | odesli tenhle doklad tam | formular |
|
||||
| `tree` | zkus to, a kdyz se to nepovede, dej to Karlovi | builder |
|
||||
| `script` | poskladej text v nasem formatu | editor skriptu |
|
||||
|
||||
`tree` pouziva **tentyz model kroku, tentyz builder a tutez validaci** jako
|
||||
automatizace. Neni to druhy strom, je to ten samy strom na jinem miste. Kdyby to
|
||||
@@ -203,11 +203,11 @@ vsechna omezeni z bodu 9: nema sit, nema tajemstvi, vystupy deklaruje dopredu.
|
||||
Akce spustena z ticketu dostane vstupy ve tri skupinach. Pro builder je to totez
|
||||
jako `providedFields` u spoustece, takze se menit nemusi:
|
||||
|
||||
| Zdroj | Priklad ID | Priklad v sablone |
|
||||
| ---------------------- | ----------------------- | --------------------- |
|
||||
| vestavena pole ticketu | `ticket.subject` | `{{subject}}` |
|
||||
| vlastni pole typu | `ticket.fld_order_no` | `{{orderNumber}}` |
|
||||
| doptavaci formular | `form.reason` | `{{reason}}` |
|
||||
| Zdroj | Priklad ID | Priklad v sablone |
|
||||
| ---------------------- | --------------------- | ----------------- |
|
||||
| vestavena pole ticketu | `ticket.subject` | `{{subject}}` |
|
||||
| vlastni pole typu | `ticket.fld_order_no` | `{{orderNumber}}` |
|
||||
| doptavaci formular | `form.reason` | `{{reason}}` |
|
||||
|
||||
Nabidka vstupu se pocita **za typ ticketu**, ne staticky z katalogu. To je jediny
|
||||
novy pripad: katalog dnes vraci pevny seznam.
|
||||
@@ -285,15 +285,15 @@ prava hlida `canAssignOthers`, v detailu ticketu jsou dva selecty.
|
||||
Navrh je **postavit vestavene akce do stejneho seznamu jako vlastni**. Ne jako
|
||||
zvlastni kus UI vedle nich.
|
||||
|
||||
| ID | Co dela | Pravo |
|
||||
| ---------------------- | ------------------------------ | ------------------------- |
|
||||
| `builtin.assign.self` | vzit ticket na sebe | `ticket.assign.self` |
|
||||
| `builtin.assign.other` | prehodit na kolegu | `ticket.assign.others` |
|
||||
| `builtin.assign.group` | prehodit na skupinu | `ticket.assign.group` |
|
||||
| `builtin.status` | zmenit stav | `ticket.status.change` |
|
||||
| `builtin.priority` | zmenit prioritu | `ticket.priority.change` |
|
||||
| `builtin.type` | zmenit typ ticketu | `ticket.type.change` |
|
||||
| `builtin.reopen` | otevrit vyreseny | `ticket.reopen` |
|
||||
| ID | Co dela | Pravo |
|
||||
| ---------------------- | ------------------- | ------------------------ |
|
||||
| `builtin.assign.self` | vzit ticket na sebe | `ticket.assign.self` |
|
||||
| `builtin.assign.other` | prehodit na kolegu | `ticket.assign.others` |
|
||||
| `builtin.assign.group` | prehodit na skupinu | `ticket.assign.group` |
|
||||
| `builtin.status` | zmenit stav | `ticket.status.change` |
|
||||
| `builtin.priority` | zmenit prioritu | `ticket.priority.change` |
|
||||
| `builtin.type` | zmenit typ ticketu | `ticket.type.change` |
|
||||
| `builtin.reopen` | otevrit vyreseny | `ticket.reopen` |
|
||||
|
||||
Dva prinosy: pravo se resi jednim mechanismem pro vestavene i vlastni akce,
|
||||
a admin muze vestavenou akci pro nekoho vypnout, aniz by se menil kod.
|
||||
@@ -416,10 +416,10 @@ interface TenantFeatures {
|
||||
|
||||
**Dve vrstvy s jinym vlastnikem, ktere se nesmi michat:**
|
||||
|
||||
| Vrstva | Kdo nastavuje | Znamena |
|
||||
| --------------- | ------------------- | ------------------------------------ |
|
||||
| `TenantFeatures`| my, provozovatel | co ma firma zaplacene a zapnute |
|
||||
| `Role` | admin te firmy | kdo z jejich lidi to smi |
|
||||
| Vrstva | Kdo nastavuje | Znamena |
|
||||
| ---------------- | ---------------- | ------------------------------- |
|
||||
| `TenantFeatures` | my, provozovatel | co ma firma zaplacene a zapnute |
|
||||
| `Role` | admin te firmy | kdo z jejich lidi to smi |
|
||||
|
||||
Efektivni viditelnost je prunik. Vypnuty modul neexistuje ani pro admina te
|
||||
firmy - nema si ho jak zapnout, protoze ho nema. Kdyby to byla jedna vrstva,
|
||||
@@ -611,13 +611,13 @@ funkci ulozist** a prepsat jim vnitrek. Routy se menit nemaji.
|
||||
|
||||
### Volby
|
||||
|
||||
| Vec | Navrh | Proc |
|
||||
| ------------- | ------------------------------------ | ------------------------------------------- |
|
||||
| Databaze | PostgreSQL 16 | JSONB, `SKIP LOCKED`, `LISTEN/NOTIFY` |
|
||||
| Driver | `pg` | bez nadstavby, pool |
|
||||
| Dotazy | Drizzle (nebo Kysely) | typovane SQL, ne skryty ORM |
|
||||
| Migrace | `drizzle-kit`, soubory v repu | deterministicke, dohledatelne v gitu |
|
||||
| Pripojeni | `DATABASE_URL` jako AppFactory secret| nikdy v kodu, nikdy v logu |
|
||||
| Vec | Navrh | Proc |
|
||||
| --------- | ------------------------------------- | ------------------------------------- |
|
||||
| Databaze | PostgreSQL 16 | JSONB, `SKIP LOCKED`, `LISTEN/NOTIFY` |
|
||||
| Driver | `pg` | bez nadstavby, pool |
|
||||
| Dotazy | Drizzle (nebo Kysely) | typovane SQL, ne skryty ORM |
|
||||
| Migrace | `drizzle-kit`, soubory v repu | deterministicke, dohledatelne v gitu |
|
||||
| Pripojeni | `DATABASE_URL` jako AppFactory secret | nikdy v kodu, nikdy v logu |
|
||||
|
||||
Prisny ORM se nedoporucuje. Cely projekt je psany tak, ze server je autorita
|
||||
a filtr na firmu je povinny argument - to se hlida lip nad viditelnym SQL.
|
||||
@@ -777,11 +777,11 @@ a `07-firmy-a-prava.md` vede "Tenant u konektoru" jako chybejici.
|
||||
Priklad ze zadani (vsichni mohou iDoklad, jen firma C vidi Polstryn SAP, firma B
|
||||
iDoklad vidi ale nema napojeni) nejde zapsat mene nez tremi vrstvami:
|
||||
|
||||
| Vrstva | Co to je | Kdo to vlastni |
|
||||
| --------------- | ------------------------------------ | -------------------- |
|
||||
| **Definice** | ze iDoklad existuje a co umi | my, nebo firma |
|
||||
| **Zpristupneni**| kdo ho vubec smi videt | my |
|
||||
| **Napojeni** | ucet firmy s jejimi pristupy | firma |
|
||||
| Vrstva | Co to je | Kdo to vlastni |
|
||||
| ---------------- | ---------------------------- | -------------- |
|
||||
| **Definice** | ze iDoklad existuje a co umi | my, nebo firma |
|
||||
| **Zpristupneni** | kdo ho vubec smi videt | my |
|
||||
| **Napojeni** | ucet firmy s jejimi pristupy | firma |
|
||||
|
||||
```ts
|
||||
interface ConnectorDefinition {
|
||||
@@ -834,12 +834,12 @@ Dnes je `ConnectorStatus = 'connected' | 'available' | 'planned'` pevne pole
|
||||
v katalogu. Ty tri hodnoty jsou ale presne to, co zadani popisuje, takze staci
|
||||
je **pocitat za firmu**:
|
||||
|
||||
| Stav | Kdy |
|
||||
| ----------- | ------------------------------------------------------- |
|
||||
| `connected` | firma ma aktivni napojeni |
|
||||
| `available` | vidi definici, napojeni nema, muze si ho udelat |
|
||||
| `planned` | definice je na roadmape |
|
||||
| neviditelny | `restricted` bez zpristupneni - v odpovedi vubec neni |
|
||||
| Stav | Kdy |
|
||||
| ----------- | ----------------------------------------------------- |
|
||||
| `connected` | firma ma aktivni napojeni |
|
||||
| `available` | vidi definici, napojeni nema, muze si ho udelat |
|
||||
| `planned` | definice je na roadmape |
|
||||
| neviditelny | `restricted` bez zpristupneni - v odpovedi vubec neni |
|
||||
|
||||
`GET /api/dashboard/connectors` tim prestava byt spolecny a zacina byt za firmu.
|
||||
Neviditelna definice se **nevraci se stavem, ale nevraci se vubec**. Firma A nesmi
|
||||
@@ -902,11 +902,11 @@ Runtime v obou pripadech cte z DB, takze se kod nemusi rozdvojovat.
|
||||
|
||||
Definice ma u kazde operace **implementaci**, a jsou tri druhy:
|
||||
|
||||
| Druh | Kde je kod | Pro co |
|
||||
| --------- | ----------------- | ----------------------------------------- |
|
||||
| `builtin` | v repu, TypeScript| nase konektory, kde potrebujeme plnou moc |
|
||||
| `http` | zadny kod | vetsina REST API, i to, co si udela firma |
|
||||
| `script` | sandbox | prevod dat a divne protokoly |
|
||||
| Druh | Kde je kod | Pro co |
|
||||
| --------- | ------------------ | ----------------------------------------- |
|
||||
| `builtin` | v repu, TypeScript | nase konektory, kde potrebujeme plnou moc |
|
||||
| `http` | zadny kod | vetsina REST API, i to, co si udela firma |
|
||||
| `script` | sandbox | prevod dat a divne protokoly |
|
||||
|
||||
**`http` ma byt vychozi**, i pro nase konektory. Operace je pak zaznam:
|
||||
|
||||
@@ -975,13 +975,13 @@ Tim se z vyjimky stava normalni konektor.
|
||||
|
||||
U konektoru i skriptu se musi rozlisit, a kazda ma jineho vlastnika:
|
||||
|
||||
| Otazka | Mechanismus | Nastavuje |
|
||||
| --------------------------------- | -------------------------- | --------------- |
|
||||
| Kdo to **vidi** | `visibility` plus grant | my |
|
||||
| Kdo si smi udelat **napojeni** | pravo `connector.manage` | admin firmy |
|
||||
| Kdo to smi **pouzit ve strome** | pravo `automation.edit` | admin firmy |
|
||||
| Kdo smi **spustit** rucni akci | pravo `action:<id>` | admin firmy |
|
||||
| Kdo smi **upravit skript** | pravo `script.edit` | my |
|
||||
| Otazka | Mechanismus | Nastavuje |
|
||||
| ------------------------------- | ------------------------ | ----------- |
|
||||
| Kdo to **vidi** | `visibility` plus grant | my |
|
||||
| Kdo si smi udelat **napojeni** | pravo `connector.manage` | admin firmy |
|
||||
| Kdo to smi **pouzit ve strome** | pravo `automation.edit` | admin firmy |
|
||||
| Kdo smi **spustit** rucni akci | pravo `action:<id>` | admin firmy |
|
||||
| Kdo smi **upravit skript** | pravo `script.edit` | my |
|
||||
|
||||
Zvlast posledni radek: uprava skriptu zmeni chovani vseho, co ho pouziva. To neni
|
||||
pravo, ktere se dava vedle prava zakladat tickety.
|
||||
@@ -1067,13 +1067,13 @@ Je to jediny bod celeho navrhu, ktery si rika o dalsi sluzbu vedle Postgresu.
|
||||
|
||||
### Widgety z prikladu
|
||||
|
||||
| Widget | Zdroj |
|
||||
| ---------------------------------------- | -------------------------------------------------- |
|
||||
| Pocet objednavek od-do | `connectorMetric` nad `cn_1`, metrika a obdobi |
|
||||
| Pocet novych klientu od-do | `connectorMetric` nad `cn_1` |
|
||||
| Pocet ticketu typu Objednavka | `ticketCount`, filtr `typeIds: [tt_order]` |
|
||||
| Pocet padlych behu | `runCount`, filtr `status: failed` |
|
||||
| Padle tickety vuci lidem | `ticketCount` plus `groupBy: 'assignee'` |
|
||||
| Widget | Zdroj |
|
||||
| ----------------------------- | ---------------------------------------------- |
|
||||
| Pocet objednavek od-do | `connectorMetric` nad `cn_1`, metrika a obdobi |
|
||||
| Pocet novych klientu od-do | `connectorMetric` nad `cn_1` |
|
||||
| Pocet ticketu typu Objednavka | `ticketCount`, filtr `typeIds: [tt_order]` |
|
||||
| Pocet padlych behu | `runCount`, filtr `status: failed` |
|
||||
| Padle tickety vuci lidem | `ticketCount` plus `groupBy: 'assignee'` |
|
||||
|
||||
Prvni dva se ve firme Delo postavit nedaji, protoze `cn_1` do ni nepatri.
|
||||
Az bude mit Delo svuj iDoklad, postavi si je nad svym napojenim a cisla budou
|
||||
@@ -1088,17 +1088,17 @@ je cekaci krok z bodu 8 skoro zdarma.
|
||||
|
||||
## Poradi prace
|
||||
|
||||
| Vlna | Co | Zavisi na |
|
||||
| ---- | ----------------------------------------------------- | --------- |
|
||||
| 0 | Postgres, prevod ulozist, LISTEN/NOTIFY (bod 7) | - |
|
||||
| 1 | Konektory do DB: definice, zpristupneni, napojeni, sifrovani udaju (bod 9) | 0 |
|
||||
| 2 | Definice akci: telo jako operace, strom nebo skript, verzovani | 0, 1 |
|
||||
| 3 | Typy ticketu, tagy, vlastni pole, rucni akce, vestavene akce (1, 2, 3) | 0, 2 |
|
||||
| 4 | Role a prava jako data, zalozky ze serveru, Nastaveni klienta, audit, impersonace (5, 6) | 0, 1 |
|
||||
| 5 | Runtime: fronta, retry, idempotence, fairness, krok `wait` (bod 8) | 0, 2 |
|
||||
| 6 | Sablony zprav a odesilani e-mailu (bod 8.1) | 0, 5 |
|
||||
| 7 | Vlastni widgety, seskupovani, sdilene rozlozeni (bod 4) | 0, 1 |
|
||||
| 8 | Skripty v sandboxu | 1, 5 |
|
||||
| Vlna | Co | Zavisi na |
|
||||
| ---- | ---------------------------------------------------------------------------------------- | --------- |
|
||||
| 0 | Postgres, prevod ulozist, LISTEN/NOTIFY (bod 7) | - |
|
||||
| 1 | Konektory do DB: definice, zpristupneni, napojeni, sifrovani udaju (bod 9) | 0 |
|
||||
| 2 | Definice akci: telo jako operace, strom nebo skript, verzovani | 0, 1 |
|
||||
| 3 | Typy ticketu, tagy, vlastni pole, rucni akce, vestavene akce (1, 2, 3) | 0, 2 |
|
||||
| 4 | Role a prava jako data, zalozky ze serveru, Nastaveni klienta, audit, impersonace (5, 6) | 0, 1 |
|
||||
| 5 | Runtime: fronta, retry, idempotence, fairness, krok `wait` (bod 8) | 0, 2 |
|
||||
| 6 | Sablony zprav a odesilani e-mailu (bod 8.1) | 0, 5 |
|
||||
| 7 | Vlastni widgety, seskupovani, sdilene rozlozeni (bod 4) | 0, 1 |
|
||||
| 8 | Skripty v sandboxu | 1, 5 |
|
||||
|
||||
Zmena proti prvni verzi: **konektory se posunuly na zacatek**. Bez rozdeleni na
|
||||
definici, zpristupneni a napojeni nema smysl delat typy ticketu ani widgety,
|
||||
@@ -1116,21 +1116,21 @@ casto se ukaze, ze skripty nikdo nepotrebuje.
|
||||
|
||||
Seznam mist, ktera navrh meni a je potreba je hlidat.
|
||||
|
||||
| Zmena | Dotkne se |
|
||||
| -------------------------------------------- | ------------------------------------------------------ |
|
||||
| `accessFor(user)` -> `accessFor(user, tenantId)` | vsechny routy dashboardu, prava jsou az uvnitr firmy |
|
||||
| `Membership.role` -> `roleIds` | `types.ts`, `users.ts`, `access.ts`, `middleware/auth.ts` |
|
||||
| `requireRole` -> `requirePermission` | `src/middleware/auth.ts` a vsechna jeho pouziti |
|
||||
| `Ticket` dostane `typeId` a `fields` | `ticketStore.ts`, `openapi.ts`, `web/src/types/dashboard.ts`, seznam, detail, simulace |
|
||||
| Katalog konektoru prestane byt spolecny | `connectors.ts`, `GET /connectors`, `Connectors.tsx` - vraci se za firmu |
|
||||
| `ConnectorStatus` se prestane cist z katalogu | pocita se z napojeni, dnes je to pevne pole |
|
||||
| `FlowStep` dostane `connectionId` | `automationStore.ts`, `flow.ts`, validace stromu, builder |
|
||||
| Zalozky ze serveru | `web/src/components/dashboard/DashboardLayout.tsx`, dnes konstanta |
|
||||
| `WidgetKind` -> `render` plus `source` | `widgets.ts`, `WidgetCard.tsx`, ulozena rozlozeni potrebuji prevod ID |
|
||||
| Novy druh kroku `wait` a `call` | `flow.ts`, `flowScope.ts`, `FlowCanvas.tsx`, validace |
|
||||
| `visibleWhen` potrebuje AND vice podminek | model podminek dnes umi jedno porovnani, viz `conditions.ts` |
|
||||
| Log ticketu potrebuje redakci tajemstvi | `ticketStore.ts`, zapis `response` do trace |
|
||||
| Data z pameti do Postgresu | cele `src/data/`, routy zustavaji |
|
||||
| Zmena | Dotkne se |
|
||||
| ------------------------------------------------ | -------------------------------------------------------------------------------------- |
|
||||
| `accessFor(user)` -> `accessFor(user, tenantId)` | vsechny routy dashboardu, prava jsou az uvnitr firmy |
|
||||
| `Membership.role` -> `roleIds` | `types.ts`, `users.ts`, `access.ts`, `middleware/auth.ts` |
|
||||
| `requireRole` -> `requirePermission` | `src/middleware/auth.ts` a vsechna jeho pouziti |
|
||||
| `Ticket` dostane `typeId` a `fields` | `ticketStore.ts`, `openapi.ts`, `web/src/types/dashboard.ts`, seznam, detail, simulace |
|
||||
| Katalog konektoru prestane byt spolecny | `connectors.ts`, `GET /connectors`, `Connectors.tsx` - vraci se za firmu |
|
||||
| `ConnectorStatus` se prestane cist z katalogu | pocita se z napojeni, dnes je to pevne pole |
|
||||
| `FlowStep` dostane `connectionId` | `automationStore.ts`, `flow.ts`, validace stromu, builder |
|
||||
| Zalozky ze serveru | `web/src/components/dashboard/DashboardLayout.tsx`, dnes konstanta |
|
||||
| `WidgetKind` -> `render` plus `source` | `widgets.ts`, `WidgetCard.tsx`, ulozena rozlozeni potrebuji prevod ID |
|
||||
| Novy druh kroku `wait` a `call` | `flow.ts`, `flowScope.ts`, `FlowCanvas.tsx`, validace |
|
||||
| `visibleWhen` potrebuje AND vice podminek | model podminek dnes umi jedno porovnani, viz `conditions.ts` |
|
||||
| Log ticketu potrebuje redakci tajemstvi | `ticketStore.ts`, zapis `response` do trace |
|
||||
| Data z pameti do Postgresu | cele `src/data/`, routy zustavaji |
|
||||
|
||||
Dve veci k modelu podminek. `visibleWhen` u akce potrebuje spojit vic porovnani,
|
||||
zatim to jde jen vnorenim ve strome. Bud model podminek rozsirit o seznam
|
||||
|
||||
@@ -12,10 +12,10 @@ Zadani: 150 klientu, kazdy asi 5 systemu, z nich chodi radove desitky udalosti.
|
||||
To je 750 napojeni. "Desitky udalosti" ma dve cteni a **odpoved se mezi nimi
|
||||
podstatne lisi**, takze obe:
|
||||
|
||||
| Scenar | Desitky udalosti za | Udalosti/den | Kroku/den | Prumer | Spicka |
|
||||
| ------ | ------------------- | ------------ | ---------- | ------- | -------- |
|
||||
| A | den a system | 37 tisic | 375 tisic | 4 kr/s | 30-60/s |
|
||||
| B | hodinu a system | 450 tisic | 4,5 mil | 52 kr/s | 200-400/s|
|
||||
| Scenar | Desitky udalosti za | Udalosti/den | Kroku/den | Prumer | Spicka |
|
||||
| ------ | ------------------- | ------------ | --------- | ------- | --------- |
|
||||
| A | den a system | 37 tisic | 375 tisic | 4 kr/s | 30-60/s |
|
||||
| B | hodinu a system | 450 tisic | 4,5 mil | 52 kr/s | 200-400/s |
|
||||
|
||||
Pocitano s 10 kroky na udalost, coz je stredni automatizace. Spicka vychazi
|
||||
z toho, ze provoz je v osmihodinovem okne a uvnitr nerovnomerny, tedy radove
|
||||
@@ -27,12 +27,12 @@ je metrika kroku za sekundu ta jedina, podle ktere se da neco rict.
|
||||
|
||||
### Co je a co neni uzke misto
|
||||
|
||||
| Vec | Scenar A | Scenar B |
|
||||
| ----------------------- | --------------- | ------------------------------ |
|
||||
| Fronta v Postgresu | par procent | zvladne, ale s davkovym odberem|
|
||||
| Soubezne HTTP volani | 15 soubezne | 90 soubezne, Node se nezapoti |
|
||||
| **Zapis `run_step`** | 22 GB/mesic | **270 GB/mesic, nutne zkratit**|
|
||||
| Limity cizich API | uzke misto | uzke misto |
|
||||
| Vec | Scenar A | Scenar B |
|
||||
| -------------------- | ----------- | ------------------------------- |
|
||||
| Fronta v Postgresu | par procent | zvladne, ale s davkovym odberem |
|
||||
| Soubezne HTTP volani | 15 soubezne | 90 soubezne, Node se nezapoti |
|
||||
| **Zapis `run_step`** | 22 GB/mesic | **270 GB/mesic, nutne zkratit** |
|
||||
| Limity cizich API | uzke misto | uzke misto |
|
||||
|
||||
Fronta nad Postgresem s `FOR UPDATE SKIP LOCKED` uklidne obslouzi radove
|
||||
200 az 500 uloh za sekundu na jednom uzlu, kdyz se odebira davkove. Scenar A
|
||||
@@ -56,20 +56,20 @@ ne do infrastruktury.
|
||||
|
||||
Odhad velikosti:
|
||||
|
||||
| Scenar | Postgres | Workeri | Kde to bezi |
|
||||
| ------ | ------------------ | --------------------------- | ----------- |
|
||||
| A | 4 vCPU, 16 GB | 2 procesy, 50 soubezne | jeden stroj |
|
||||
| Scenar | Postgres | Workeri | Kde to bezi |
|
||||
| ------ | ---------------------- | ------------------------- | ----------- |
|
||||
| A | 4 vCPU, 16 GB | 2 procesy, 50 soubezne | jeden stroj |
|
||||
| B | 8-16 vCPU, 32 GB, NVMe | 4-6 procesu, 100 soubezne | dva stroje |
|
||||
|
||||
## Moznosti u databaze
|
||||
|
||||
| Varianta | Verdikt pri 150 klientech |
|
||||
| -------------------------------- | ------------------------------------------------ |
|
||||
| Jedno DB, `tenant_id` ve sloupci | **ano, tohle** |
|
||||
| Varianta | Verdikt pri 150 klientech |
|
||||
| -------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Jedno DB, `tenant_id` ve sloupci | **ano, tohle** |
|
||||
| Schema na klienta | ne: 150 x 20 tabulek je 3000 tabulek, migrace se stanou nespolehlivymi |
|
||||
| Databaze na klienta | ne, ale nechat si dvere otevrene |
|
||||
| Partitionovani podle klienta | ne, oddily by byly velikostne nesouvisle |
|
||||
| Partitionovani podle casu | **ano, u pripisovacich tabulek** |
|
||||
| Databaze na klienta | ne, ale nechat si dvere otevrene |
|
||||
| Partitionovani podle klienta | ne, oddily by byly velikostne nesouvisle |
|
||||
| Partitionovani podle casu | **ano, u pripisovacich tabulek** |
|
||||
|
||||
### Dvere k oddelene databazi za par korun
|
||||
|
||||
@@ -340,12 +340,12 @@ co by nekdo cetl - do uspesneho kroku se nikdo nechodi divat.
|
||||
|
||||
**Retence** podle toho, kdo to cte:
|
||||
|
||||
| Data | Jak dlouho |
|
||||
| --------------------- | ----------------- |
|
||||
| Vstupy a vystupy kroku| 30 dni |
|
||||
| Souhrn behu | 12 mesicu |
|
||||
| Log ticketu | 90 dni |
|
||||
| Audit | dele, dane pravni potrebou |
|
||||
| Data | Jak dlouho |
|
||||
| ---------------------- | -------------------------- |
|
||||
| Vstupy a vystupy kroku | 30 dni |
|
||||
| Souhrn behu | 12 mesicu |
|
||||
| Log ticketu | 90 dni |
|
||||
| Audit | dele, dane pravni potrebou |
|
||||
|
||||
Cisla patri do nastaveni za klienta, protoze delsi retence je dobry duvod
|
||||
pro drazsi tarif.
|
||||
@@ -354,13 +354,13 @@ pro drazsi tarif.
|
||||
|
||||
Ne CPU. Ctyri veci, a kazda odpovida na jinou otazku:
|
||||
|
||||
| Metrika | Odpovida na |
|
||||
| ----------------------------------- | --------------------------------- |
|
||||
| Hloubka fronty | stiha se to |
|
||||
| **Vek nejstarsi pripravene ulohy** | je to zahlcene, nebo zaseknute |
|
||||
| Kroku za sekundu, p95 za konektor | kde to drhne |
|
||||
| Padle behy za hodinu, podil opakovani| co je rozbite |
|
||||
| Podil kroku za klienta | kdo je hlucny soused |
|
||||
| Metrika | Odpovida na |
|
||||
| -------------------------------------- | ------------------------------ |
|
||||
| Hloubka fronty | stiha se to |
|
||||
| **Vek nejstarsi pripravene ulohy** | je to zahlcene, nebo zaseknute |
|
||||
| Kroku za sekundu, p95 za konektor | kde to drhne |
|
||||
| Padle behy za hodinu, podil opakovani | co je rozbite |
|
||||
| Podil kroku za klienta | kdo je hlucny soused |
|
||||
| Zpozdeni inboxu (prijato az rozeslano) | stiha dispatcher |
|
||||
|
||||
Bez veku nejstarsi ulohy se neda odlisit "je hodne prace" od "nic se nedeje",
|
||||
@@ -370,14 +370,14 @@ a to jsou dva uplne jine problemy se stejnou hloubkou fronty.
|
||||
|
||||
Aby se to nemuselo rozhodovat dopredu. Do te doby plati navrh vyse.
|
||||
|
||||
| Signal | Co udelat |
|
||||
| ---------------------------------------- | ------------------------------------- |
|
||||
| Fronta zere nad 30 % CPU databaze | vetsi davky, pak fronta v Redisu (BullMQ) |
|
||||
| Zapisy `run_step` prevalcuji IO | zkratit obsah, vzorkovat, velka tela do objektoveho uloziste |
|
||||
| Jeden klient dela nad 30 % provozu | vlastni bazen workeru, pak vlastni databaze |
|
||||
| Fronta roste kazdy den ve spicce | pridat workery, jsou bezstavove |
|
||||
| Prevazuji chyby 429 z cizich API | limity za napojeni, pak vyjednat kvoty|
|
||||
| Cekajici behy jdou do stovek tisic | oddelena fronta pro dlouha cekani, aby nezdrzovala bezny odber |
|
||||
| Signal | Co udelat |
|
||||
| ---------------------------------- | -------------------------------------------------------------- |
|
||||
| Fronta zere nad 30 % CPU databaze | vetsi davky, pak fronta v Redisu (BullMQ) |
|
||||
| Zapisy `run_step` prevalcuji IO | zkratit obsah, vzorkovat, velka tela do objektoveho uloziste |
|
||||
| Jeden klient dela nad 30 % provozu | vlastni bazen workeru, pak vlastni databaze |
|
||||
| Fronta roste kazdy den ve spicce | pridat workery, jsou bezstavove |
|
||||
| Prevazuji chyby 429 z cizich API | limity za napojeni, pak vyjednat kvoty |
|
||||
| Cekajici behy jdou do stovek tisic | oddelena fronta pro dlouha cekani, aby nezdrzovala bezny odber |
|
||||
|
||||
## Jeden container, dve role
|
||||
|
||||
|
||||
@@ -43,11 +43,11 @@ nejvyse jednou za sekundu, takze cteni katalogu neznamena stat na kazdy dotaz.
|
||||
|
||||
Uprava tedy funguje trema cestami a vzdy stejne:
|
||||
|
||||
| Kudy | Co se stane |
|
||||
| -------------------------------- | -------------------------------------------- |
|
||||
| Editor v portalu | ulozi soubor, registr ho nacte hned |
|
||||
| Rucni uprava souboru na serveru | registr si zmeny vsimne pri dalsim dotazu |
|
||||
| Novy soubor ve slozce | objevi se jako nova operace v katalogu |
|
||||
| Kudy | Co se stane |
|
||||
| ------------------------------- | ----------------------------------------- |
|
||||
| Editor v portalu | ulozi soubor, registr ho nacte hned |
|
||||
| Rucni uprava souboru na serveru | registr si zmeny vsimne pri dalsim dotazu |
|
||||
| Novy soubor ve slozce | objevi se jako nova operace v katalogu |
|
||||
|
||||
`POST /api/dashboard/scripts/reload` to jen vynuti hned, bez cekani.
|
||||
|
||||
@@ -83,17 +83,17 @@ export const manifest = {
|
||||
Parametr je pro vstup i vystup **tentyz tvar**. Kontrola je pak jedna funkce,
|
||||
ne dve skoro stejne, ktere by se casem rozesly.
|
||||
|
||||
| Klic | K cemu |
|
||||
| ----------- | ------------------------------------------------------------- |
|
||||
| `id` | pouziva se v sablonach jako `{{id}}`, jen pismena a podtrzitka |
|
||||
| `label` | co vidi uzivatel v builderu |
|
||||
| `type` | `string`, `number`, `boolean`, `date` |
|
||||
| Klic | K cemu |
|
||||
| ----------- | ------------------------------------------------------------------- |
|
||||
| `id` | pouziva se v sablonach jako `{{id}}`, jen pismena a podtrzitka |
|
||||
| `label` | co vidi uzivatel v builderu |
|
||||
| `type` | `string`, `number`, `boolean`, `date` |
|
||||
| `required` | u vstupu: bez hodnoty se skript nespusti. U vystupu: musi ho vratit |
|
||||
| `hint` | napoveda pod polem |
|
||||
| `options` | vyber z hodnot, jina neprojde |
|
||||
| `pattern` | dalsi kontrola regularnim vyrazem (jen `string`) |
|
||||
| `multiline` | pole na vic radku (jen `string`) |
|
||||
| `default` | dosadi se, kdyz hodnota chybi a parametr neni povinny |
|
||||
| `hint` | napoveda pod polem |
|
||||
| `options` | vyber z hodnot, jina neprojde |
|
||||
| `pattern` | dalsi kontrola regularnim vyrazem (jen `string`) |
|
||||
| `multiline` | pole na vic radku (jen `string`) |
|
||||
| `default` | dosadi se, kdyz hodnota chybi a parametr neni povinny |
|
||||
|
||||
Schema manifestu je `.strict()`. Preklep v nazvu klice (`outputFileds`) se ohlasi,
|
||||
ne tise ignoruje.
|
||||
@@ -127,50 +127,70 @@ a **zadne pristupove udaje**.
|
||||
export async function run(inputs, ctx) { /* ... */ }
|
||||
```
|
||||
|
||||
| Na kontextu | K cemu |
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| `ctx.http` | `get`, `post`, `patch`, `put`, `del` nad adresou napojeni |
|
||||
| `ctx.util` | pomocne funkce, viz nize |
|
||||
| `ctx.log` | radek do logu behu, vzdy zredigovany a zkraceny |
|
||||
| `ctx.config` | necitliva cast nastaveni napojeni |
|
||||
| `ctx.idempotencyKey` | stabilni pres vsechny pokusy tehoz kroku |
|
||||
| `ctx.fail` | koncova chyba, neopakuje se |
|
||||
| `ctx.retry` | docasna chyba, ma smysl zkusit znovu |
|
||||
| Na kontextu | K cemu |
|
||||
| -------------------- | --------------------------------------------------------------------- |
|
||||
| `ctx.http` | `get`, `post`, `patch`, `put`, `del`, `postForm` nad adresou napojeni |
|
||||
| `ctx.util` | pomocne funkce, viz nize |
|
||||
| `ctx.log` | radek do logu behu, vzdy zredigovany a zkraceny |
|
||||
| `ctx.config` | necitliva cast nastaveni napojeni |
|
||||
| `ctx.idempotencyKey` | stabilni pres vsechny pokusy tehoz kroku |
|
||||
| `ctx.fail` | koncova chyba, neopakuje se |
|
||||
| `ctx.retry` | docasna chyba, ma smysl zkusit znovu |
|
||||
|
||||
Adresu i autorizacni hlavicky doplnuje runtime podle napojeni. Skript rika
|
||||
`GET /issued-invoices/12` a nic vic. Duvod je v bodu 9 navrhu: kdyby skript
|
||||
znal tajemstvi, staci jeden `ctx.log` a je v logu, ktery vidi klient.
|
||||
|
||||
### Odeslani souboru
|
||||
|
||||
`ctx.http.postForm` slozi `multipart/form-data`. Obsah souboru prichazi jako
|
||||
**Base64 retezec**, protoze parametr skriptu je vzdy hodnota zapsatelna do
|
||||
JSONu - uklada se do zaznamu behu a binarni data by se tam nevesla.
|
||||
|
||||
```js
|
||||
await ctx.http.postForm('/files', {
|
||||
purpose: 'user_data',
|
||||
file: { filename: 'faktura.pdf', base64: inputs.obsah, contentType: 'application/pdf' },
|
||||
});
|
||||
```
|
||||
|
||||
Prazdne polozky se vynechavaji: `null` prevedeny na text by cizi sluzba
|
||||
dostala jako retezec "null". Hranici (boundary) dopisuje az `fetch` - kdyby si
|
||||
ji skript nastavoval sam, chybela by v hlavicce a sluzba by telo neprecetla.
|
||||
|
||||
Strop je `SCRIPT_MAX_UPLOAD_BYTES`, vychozi 10 MB. Zamerne nizsi nez u cizich
|
||||
sluzeb: OpenAI zvladne stovky megabajtu, nas zaznam behu ne.
|
||||
|
||||
### Pomocne funkce
|
||||
|
||||
Cizi API vraci pokazde jinak. iDoklad pouziva velka pocatecni pismena a nekde
|
||||
obaluje odpoved do `Data`. Bez tehle sady by to kazdy skript resil znovu a jeden
|
||||
z nich by to resil spatne.
|
||||
|
||||
| Funkce | Co dela |
|
||||
| -------------------------- | -------------------------------------------------- |
|
||||
| `unwrap(body)` | rozbali `{ Data: x }` i `{ data: x }` |
|
||||
| `pick(obj, ...names)` | prvni existujici pole bez ohledu na velikost pismen |
|
||||
| `first(value)` | prvni prvek pole, nebo null |
|
||||
| `text`, `num`, `bool`, `date` | prevody s fallbackem |
|
||||
| `round(value, decimals)` | zaokrouhleni, uctuje se v halerich |
|
||||
| `need(value, label)` | vrati hodnotu, nebo skonci citelnou chybou |
|
||||
| Funkce | Co dela |
|
||||
| ----------------------------- | --------------------------------------------------- |
|
||||
| `unwrap(body)` | rozbali `{ Data: x }` i `{ data: x }` |
|
||||
| `pick(obj, ...names)` | prvni existujici pole bez ohledu na velikost pismen |
|
||||
| `first(value)` | prvni prvek pole, nebo null |
|
||||
| `text`, `num`, `bool`, `date` | prevody s fallbackem |
|
||||
| `round(value, decimals)` | zaokrouhleni, uctuje se v halerich |
|
||||
| `need(value, label)` | vrati hodnotu, nebo skonci citelnou chybou |
|
||||
|
||||
## Chyby: opakovatelne a koncove
|
||||
|
||||
Rozdeleni je to podstatne. Timeout nebo 503 ma smysl zkusit znovu, spatny vstup
|
||||
nebo 403 ne - opakovat koncovou chybu jen vypali kvotu u cizi sluzby.
|
||||
|
||||
| Druh | Kdy | Opakovat |
|
||||
| ------------ | ------------------------------------------ | -------- |
|
||||
| `not_found` | skript neexistuje | ne |
|
||||
| `config` | chybi pristupove udaje, 401, 403 | ne |
|
||||
| `validation` | vstup neprosel kontrolou | ne |
|
||||
| `output` | skript nevratil deklarovany vystup | ne |
|
||||
| `terminal` | 400, 404, jina koncova odpoved sluzby | ne |
|
||||
| `retryable` | 408, 429, 5xx, chyba spojeni | ano |
|
||||
| `timeout` | skript nedobehl v limitu | ano |
|
||||
| `internal` | neocekavana vyjimka ve skriptu | ne |
|
||||
| Druh | Kdy | Opakovat |
|
||||
| ------------ | ------------------------------------- | -------- |
|
||||
| `not_found` | skript neexistuje | ne |
|
||||
| `config` | chybi pristupove udaje, 401, 403 | ne |
|
||||
| `validation` | vstup neprosel kontrolou | ne |
|
||||
| `output` | skript nevratil deklarovany vystup | ne |
|
||||
| `terminal` | 400, 404, jina koncova odpoved sluzby | ne |
|
||||
| `retryable` | 408, 429, 5xx, chyba spojeni | ano |
|
||||
| `timeout` | skript nedobehl v limitu | ano |
|
||||
| `internal` | neocekavana vyjimka ve skriptu | ne |
|
||||
|
||||
Runner **nikdy nevyhodi vyjimku**. Vzdy vrati vysledek s `ok`, `outputs`, `logs`,
|
||||
`durationMs`, `httpCalls` a pripadne `error` vcetne `retryable`. Az bude
|
||||
@@ -184,14 +204,15 @@ podle **konektoru** firmy, popis je v [12-sluzby-a-konektory.md](12-sluzby-a-kon
|
||||
Z environment variables uz nechodi zadne pristupove udaje, jen provozni
|
||||
nastaveni:
|
||||
|
||||
| Promenna | K cemu |
|
||||
| --------------------------- | --------------------------------------------------- |
|
||||
| Promenna | K cemu |
|
||||
| --------------------------- | ------------------------------------------------------ |
|
||||
| `SERVICES_BASE_URL` | zaklad adres, vychozi `https://services.csbot.cz/apps` |
|
||||
| `<SLUZBA>_BASE_URL` | presmerovani jedne sluzby |
|
||||
| `SCRIPTS_DIR` | jina slozka se skripty |
|
||||
| `SCRIPT_TIMEOUT_MS` | vychozi strop na beh, 15000 |
|
||||
| `SCRIPT_MAX_RESPONSE_BYTES` | strop na velikost odpovedi, 1000000 |
|
||||
| `ALLOW_PRIVATE_TARGETS` | povoli volani na localhost, **jen pro lokalni vyvoj** |
|
||||
| `<SLUZBA>_BASE_URL` | presmerovani jedne sluzby, napr. `OPENAI_BASE_URL` |
|
||||
| `SCRIPTS_DIR` | jina slozka se skripty |
|
||||
| `SCRIPT_TIMEOUT_MS` | vychozi strop na beh, 15000 |
|
||||
| `SCRIPT_MAX_RESPONSE_BYTES` | strop na velikost odpovedi, 1000000 |
|
||||
| `SCRIPT_MAX_UPLOAD_BYTES` | strop na odeslany soubor, 10000000 |
|
||||
| `ALLOW_PRIVATE_TARGETS` | povoli volani na localhost, **jen pro lokalni vyvoj** |
|
||||
|
||||
Co ktera sluzba vyzaduje, je v `credentials` u sluzby v `src/data/services.ts`.
|
||||
Hodnoty patri konektoru a zadavaji se v portalu.
|
||||
@@ -235,13 +256,13 @@ V portalu jsou operace se skriptem oznacene ikonou v katalogu sluzeb.
|
||||
|
||||
## API
|
||||
|
||||
| Metoda | Cesta | Kdo smi |
|
||||
| ------ | ----------------------------------------- | ---------------- |
|
||||
| GET | `/api/dashboard/scripts` | prihlaseny |
|
||||
| GET | `/api/dashboard/scripts/:id` | prihlaseny |
|
||||
| PUT | `/api/dashboard/scripts/:id` | spravce platformy |
|
||||
| POST | `/api/dashboard/scripts/:id/test` | spravce platformy |
|
||||
| POST | `/api/dashboard/scripts/reload` | spravce platformy |
|
||||
| Metoda | Cesta | Kdo smi |
|
||||
| ------ | --------------------------------- | ----------------- |
|
||||
| GET | `/api/dashboard/scripts` | prihlaseny |
|
||||
| GET | `/api/dashboard/scripts/:id` | prihlaseny |
|
||||
| PUT | `/api/dashboard/scripts/:id` | spravce platformy |
|
||||
| POST | `/api/dashboard/scripts/:id/test` | spravce platformy |
|
||||
| POST | `/api/dashboard/scripts/reload` | spravce platformy |
|
||||
|
||||
Cteni smi kazdy prihlaseny - builder potrebuje vedet, co skript umi. Uprava meni
|
||||
chovani vseho, co skript pouziva, takze to neni pravo vedle prava zakladat tickety.
|
||||
@@ -255,19 +276,26 @@ v `issues`.
|
||||
Zamerne: test, ktery volani predstira, nerekne nic o tom, jestli skript funguje.
|
||||
Portal na to upozornuje nad tlacitkem.
|
||||
|
||||
## Skripty pro dalsi realne sluzby
|
||||
|
||||
RAYNET, CSOB, SAP Business One, PPL, Microsoft 365, Google Workspace, GA4,
|
||||
Search Console, Google Ads, Sklik, Meta Ads, Prepis hovoru a OpenAI maji svoje
|
||||
skripty taky. Co ktera sluzba potrebuje a co s ni umime, je
|
||||
v [21-realne-sluzby.md](21-realne-sluzby.md).
|
||||
|
||||
## Ukazkove skripty pro iDoklad
|
||||
|
||||
Postavene proti skutecnemu API sluzby na `https://services.csbot.cz/apps/idoklad`.
|
||||
Kazdy ukazuje jiny vzor, at je z ceho vychazet.
|
||||
|
||||
| Skript | Vzor |
|
||||
| --------------------------------- | --------------------------------------------- |
|
||||
| `idoklad.get-issued-invoice` | jedno volani a prevod odpovedi |
|
||||
| `idoklad.find-issued-invoice` | predvalidace: nenalezeno **neni** chyba |
|
||||
| `idoklad.find-contact` | vlastni kontrola vstupu (aspon jedno z dvojice) |
|
||||
| `idoklad.create-issued-invoice` | dve volani, vzor z `/default` a prepis jen znamych poli |
|
||||
| `idoklad.register-payment` | akce, ktera meni stav, plus idempotence |
|
||||
| `idoklad.send-invoice-email` | odpoved nic nevraci, vystup se sklada ze vstupu |
|
||||
| Skript | Vzor |
|
||||
| ------------------------------- | ------------------------------------------------------- |
|
||||
| `idoklad.get-issued-invoice` | jedno volani a prevod odpovedi |
|
||||
| `idoklad.find-issued-invoice` | predvalidace: nenalezeno **neni** chyba |
|
||||
| `idoklad.find-contact` | vlastni kontrola vstupu (aspon jedno z dvojice) |
|
||||
| `idoklad.create-issued-invoice` | dve volani, vzor z `/default` a prepis jen znamych poli |
|
||||
| `idoklad.register-payment` | akce, ktera meni stav, plus idempotence |
|
||||
| `idoklad.send-invoice-email` | odpoved nic nevraci, vystup se sklada ze vstupu |
|
||||
|
||||
### Proc se u zakladani bere vzor z `/default`
|
||||
|
||||
@@ -300,11 +328,11 @@ Katalog, builder i stranka skriptu si ho vezmou samy. Nic se nerestartuje.
|
||||
|
||||
## Co chybi
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| ---------------------------- | --------------------------------------------------- |
|
||||
| Skripty od zakazniku | potrebuji sandbox a vlastni vlakno, viz vyse |
|
||||
| Verzovani skriptu | uprava prepise soubor, historie je jen v gitu |
|
||||
| Vykonavani ze stromu | runner je hotovy, ale runtime automatizaci neni |
|
||||
| Skripty jako spoustece | zatim jen akce, spoustec potrebuje runtime |
|
||||
| Metriky pro widgety | manifest to zatim nezna, viz bod 4 navrhu |
|
||||
| Ulozeni uprav mimo git | portal zapisuje do souboru v containeru, redeploy je vrati |
|
||||
| Chybi | Poznamka |
|
||||
| ---------------------- | ---------------------------------------------------------- |
|
||||
| Skripty od zakazniku | potrebuji sandbox a vlastni vlakno, viz vyse |
|
||||
| Verzovani skriptu | uprava prepise soubor, historie je jen v gitu |
|
||||
| Vykonavani ze stromu | runner je hotovy, ale runtime automatizaci neni |
|
||||
| Skripty jako spoustece | zatim jen akce, spoustec potrebuje runtime |
|
||||
| Metriky pro widgety | manifest to zatim nezna, viz bod 4 navrhu |
|
||||
| Ulozeni uprav mimo git | portal zapisuje do souboru v containeru, redeploy je vrati |
|
||||
|
||||
@@ -8,11 +8,11 @@ v [11-skripty-konektoru.md](11-skripty-konektoru.md).
|
||||
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 |
|
||||
| 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".
|
||||
@@ -31,6 +31,37 @@ Sluzba iDoklad definujeme my
|
||||
Krok automatizace pak nese oboji: **kterou operaci** (`serviceId` plus
|
||||
`operationId`) a **pod cim ji zavolat** (`connectorId`).
|
||||
|
||||
## Kde sluzba bezi
|
||||
|
||||
Vetsina sluzeb jsou **nase aplikace** za `services.csbot.cz/apps`. Adresa se
|
||||
sklada ze `SERVICES_BASE_URL` a z `appId`, verejna domena se nikdy nehardcoduje
|
||||
do logiky (AGENTS.md).
|
||||
|
||||
Ktera sluzba katalogu stoji na ktere bezici aplikaci, je
|
||||
v [21-realne-sluzby.md](21-realne-sluzby.md).
|
||||
|
||||
Vyjimka je sluzba, ktera **nebezi u nas** - zatim OpenAI. Ta ma misto `appId`
|
||||
nepovinne pole `baseUrl` s absolutni adresou, protoze cizi domenou nehneme
|
||||
a skladat ji ze `SERVICES_BASE_URL` by nedavalo smysl.
|
||||
|
||||
Adresu lze prepsat na dvou urovnich:
|
||||
|
||||
| Kudy | Pro koho plati | K cemu |
|
||||
| ------------------- | -------------- | --------------------------------- |
|
||||
| `<SLUZBA>_BASE_URL` | cela instance | brana, napodobenina pri vyvoji |
|
||||
| adresa u konektoru | jedna firma | vlastni instance nebo brana firmy |
|
||||
|
||||
Nazev promenne vznikne z ID sluzby velkymi pismeny, pomlcka je podtrzitko:
|
||||
`openai` je `OPENAI_BASE_URL`, `sap-bo` je `SAP_BO_BASE_URL`.
|
||||
|
||||
Treti pripad je sluzba, ktera **nejde pres HTTP**: e-mail. Ma
|
||||
`transport: 'smtp'`, adresu serveru nese konektor mezi udaji a operaci nevykona
|
||||
skript, ale vnitrni krok. Podrobnosti v [21-realne-sluzby.md](21-realne-sluzby.md).
|
||||
|
||||
Treti pripad je sluzba, ktera **nejde pres HTTP**: e-mail. Ma
|
||||
`transport: 'smtp'`, adresu serveru nese konektor mezi udaji a operaci nevykona
|
||||
skript, ale vnitrni krok. Podrobnosti v [21-realne-sluzby.md](21-realne-sluzby.md).
|
||||
|
||||
## Pristupove udaje patri konektoru, ne prostredi
|
||||
|
||||
Driv se cetly z environment variables. To bylo spatne: cela instance by mela
|
||||
@@ -50,7 +81,13 @@ Pravidla, ktera se u toho nesmi porusit:
|
||||
- **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.
|
||||
znamych tajnych hodnot za hvezdicky. Redaguje se **cela hlavicka i sama
|
||||
hodnota**: u `Authorization: Bearer <klic>` vraci cizi sluzby v chybe jednou
|
||||
jedno a jednou druhe.
|
||||
- **Predpona hlavicky patri runtime, ne uzivateli.** Pole udaju smi mit
|
||||
`prefix` (typicky `Bearer `). Uzivatel vlepi klic tak, jak ho dostal, a slovo
|
||||
pred nim dopise portal. Kdyby si ho mel psat sam, byl by to zdroj chyb, ktery
|
||||
neni videt ani zpetne - hodnota se z API nevraci.
|
||||
- **Zmena udaju rusi predchozi overeni.** Konektor se vrati na `untested`,
|
||||
jinak by zelena znacka lhala.
|
||||
|
||||
@@ -59,17 +96,17 @@ Pravidla, ktera se u toho nesmi porusit:
|
||||
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 |
|
||||
| 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
|
||||
@@ -85,11 +122,11 @@ interface ServiceVisibility {
|
||||
}
|
||||
```
|
||||
|
||||
| Mode | Kdo vidi |
|
||||
| ------------ | ---------------------------------------------- |
|
||||
| `everyone` | vsichni prihlaseni |
|
||||
| `restricted` | uvedene firmy a jmenovite uvedeni lide |
|
||||
| `admin` | jen spravce platformy |
|
||||
| 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.
|
||||
|
||||
@@ -108,12 +145,12 @@ 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 |
|
||||
| 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`.
|
||||
|
||||
@@ -132,8 +169,8 @@ jineho zmenilo.
|
||||
|
||||
## Validace stromu
|
||||
|
||||
| Situace | Vysledek |
|
||||
| ------------------------------------------- | --------- |
|
||||
| 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 |
|
||||
@@ -151,6 +188,16 @@ 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.
|
||||
Tak je na tom Prepis hovoru: jeho jedine volani je prepis, ktery se uctuje.
|
||||
|
||||
`verifyPath` smi nest i query (`/company?limit=1`), aby overeni nestahovalo
|
||||
cely seznam. Do hlasky se query nedava, odrizne se - muze v ni byt tajemstvi.
|
||||
|
||||
U sluzby s `transport: 'smtp'` se `verifyPath` nepouziva vubec: overeni se
|
||||
**prihlasi na posmovni server** a nic neodesle.
|
||||
|
||||
U sluzby s `transport: 'smtp'` se `verifyPath` nepouziva vubec: overeni se
|
||||
**prihlasi na posmovni server** a nic neodesle.
|
||||
|
||||
Neuspesne overeni **neni chyba API**. Vraci se 200 s `ok: false` a popisem,
|
||||
protoze vysledek "nefunguje to" je platna odpoved na otazku "funguje to?".
|
||||
@@ -246,18 +293,18 @@ Cte se zvlast pres `/connectors/:id/checks`.
|
||||
|
||||
## 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 |
|
||||
| GET | `/api/dashboard/connectors/:id/checks` | poslednich pet overeni |
|
||||
| GET | `/api/dashboard/connectors/egress-ip` | odchozi IP adresa portalu |
|
||||
| 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 |
|
||||
| GET | `/api/dashboard/connectors/:id/checks` | poslednich pet overeni |
|
||||
| GET | `/api/dashboard/connectors/egress-ip` | odchozi IP adresa portalu |
|
||||
|
||||
## Stranky portalu
|
||||
|
||||
@@ -270,7 +317,8 @@ Cte se zvlast pres `/connectors/:id/checks`.
|
||||
## Jak pridat sluzbu
|
||||
|
||||
1. Zaznam do `services` v `src/data/services.ts`: kategorie, ikona, `general`,
|
||||
`appId`, `visibility`, `credentials`, pripadne `verifyPath`.
|
||||
`appId` (nebo `baseUrl` u cizi sluzby), `visibility`, `credentials`,
|
||||
pripadne `verifyPath`.
|
||||
2. Pokud pouziva novou ikonu, doplnit klic do `web/src/lib/serviceIcons.ts`.
|
||||
3. Skripty operaci do `scripts/<sluzba>.<operace>.js`, viz
|
||||
[11-skripty-konektoru.md](11-skripty-konektoru.md).
|
||||
@@ -281,27 +329,27 @@ Katalog, builder, stranka Sluzby i zakladani konektoru si ji vezmou samy.
|
||||
|
||||
Kdo se v kodu orientoval podle stareho pojmenovani:
|
||||
|
||||
| Driv | Ted |
|
||||
| ----------------------------- | ------------------------------ |
|
||||
| `src/data/connectors.ts` | `src/data/services.ts` |
|
||||
| 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 |
|
||||
| `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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -19,10 +19,10 @@ Nova objednavka (e-shop) -> objekt "order"
|
||||
|
||||
Dosud si kroky predavaly jen jednotlive hodnoty. Pribyly dva typy parametru:
|
||||
|
||||
| Typ | Co to je | Do sablony | Podminka |
|
||||
| -------- | --------------------------- | ---------- | ----------------- |
|
||||
| `object` | cely objekt | ne | je / neni prazdny |
|
||||
| `list` | seznam | ne | je / neni prazdny |
|
||||
| Typ | Co to je | Do sablony | Podminka |
|
||||
| -------- | ----------- | ---------- | ----------------- |
|
||||
| `object` | cely objekt | ne | je / neni prazdny |
|
||||
| `list` | seznam | ne | je / neni prazdny |
|
||||
|
||||
**Do sablony se nedosazuji jako celek.** `{{order}}` v textu by znamenalo
|
||||
vlozit do vety kus JSONu, coz nikdo nechce. Struktura se predava **jako celek**
|
||||
@@ -43,11 +43,11 @@ hranice patri do kontroly parametru, ne az do uklidu databaze.
|
||||
|
||||
## Tri zpusoby, jak prevest data
|
||||
|
||||
| Zpusob | Kdy se hodi |
|
||||
| --- | --- |
|
||||
| **Pravidla** (`transform/map-fields`) | par poli, prevody hodnot, klikatelne |
|
||||
| **Sablona JSON** (`transform/to-json`) | hlavni prace je ve **tvaru** vysledku |
|
||||
| **Skript firmy** (`transform/custom`) | slozitejsi prevod, kde je kod citelnejsi nez dvacet pravidel |
|
||||
| Zpusob | Kdy se hodi |
|
||||
| -------------------------------------- | ------------------------------------------------------------ |
|
||||
| **Pravidla** (`transform/map-fields`) | par poli, prevody hodnot, klikatelne |
|
||||
| **Sablona JSON** (`transform/to-json`) | hlavni prace je ve **tvaru** vysledku |
|
||||
| **Skript firmy** (`transform/custom`) | slozitejsi prevod, kde je kod citelnejsi nez dvacet pravidel |
|
||||
|
||||
Skript je JS: dostane `input`, vrati objekt. Nevola nic ven a v logu je u nej
|
||||
**vstup i vystup**, takze kdyz vysledek nesedi, neni potreba hadat, co do
|
||||
@@ -75,10 +75,10 @@ nemuze se v nem udelat preklep v zavorce.
|
||||
Obe moznosti stoji na tom samem enginu v `src/scripts/mapping.ts`.
|
||||
Volba je o tom, cehoz je vic:
|
||||
|
||||
| Rezim | Kdy | Skript |
|
||||
| ------------------------- | -------------------------------------- | ----------------------- |
|
||||
| **Pravidla** (pole na pole) | hlavni prace je v prevodech hodnot | `transform.map-fields` |
|
||||
| **Sablona JSON** | hlavni prace je ve tvaru struktury | `transform.to-json` |
|
||||
| Rezim | Kdy | Skript |
|
||||
| --------------------------- | ---------------------------------- | ---------------------- |
|
||||
| **Pravidla** (pole na pole) | hlavni prace je v prevodech hodnot | `transform.map-fields` |
|
||||
| **Sablona JSON** | hlavni prace je ve tvaru struktury | `transform.to-json` |
|
||||
|
||||
### Rezim 1: pravidla
|
||||
|
||||
@@ -115,15 +115,15 @@ Jedno pravidlo je "vezmi tuhle cestu, projed prevody, uloz sem".
|
||||
Prevod `map` je to, bez ceho by priklad nesel dokoncit. Bez nej by slo prevest
|
||||
hlavicku dokladu, ale ne seznam polozek, a doklad by byl na nulu.
|
||||
|
||||
| Klic | K cemu |
|
||||
| ------------- | --------------------------------------------------------- |
|
||||
| `to` | kam se ulozi. Tecka znamena zanoreni: `partner.id` |
|
||||
| `from` | cesta ve zdroji. `items.0.name` i `items[0].name` |
|
||||
| `value` | pevna hodnota, kdyz `from` chybi |
|
||||
| `transforms` | prevody v uvedenem poradi |
|
||||
| `fallback` | pouzije se, kdyz je vysledek prazdny |
|
||||
| `omitIfEmpty` | prazdny vysledek se do vystupu vubec nezapise |
|
||||
| `required` | prazdny vysledek je chyba |
|
||||
| Klic | K cemu |
|
||||
| ------------- | -------------------------------------------------- |
|
||||
| `to` | kam se ulozi. Tecka znamena zanoreni: `partner.id` |
|
||||
| `from` | cesta ve zdroji. `items.0.name` i `items[0].name` |
|
||||
| `value` | pevna hodnota, kdyz `from` chybi |
|
||||
| `transforms` | prevody v uvedenem poradi |
|
||||
| `fallback` | pouzije se, kdyz je vysledek prazdny |
|
||||
| `omitIfEmpty` | prazdny vysledek se do vystupu vubec nezapise |
|
||||
| `required` | prazdny vysledek je chyba |
|
||||
|
||||
`required` neni formalita. Doklad bez `partnerId` iDoklad odmitne, a je lepsi
|
||||
to poznat na kroku transformace s nazvem pole, nez z odpovedi 400 od iDokladu.
|
||||
@@ -154,20 +154,20 @@ jako text, protoze jinak to nedava smysl.
|
||||
|
||||
## Prevody
|
||||
|
||||
| Prevod | Co dela |
|
||||
| ----------------------------- | ---------------------------------------- |
|
||||
| `trim`, `lower`, `upper` | uprava textu |
|
||||
| `string`, `number`, `boolean` | zmena typu |
|
||||
| `date` (`iso` / `day`) | datum, `day` je jen `YYYY-MM-DD` |
|
||||
| `round` (`decimals`) | zaokrouhleni |
|
||||
| `multiply`, `add` (`by`) | pocty, napriklad prevod na cenu s DPH |
|
||||
| `default` (`value`) | vyplneni prazdne hodnoty |
|
||||
| `replace` (`find`, `with`) | nahrazeni textu |
|
||||
| `slice` (`start`, `end`) | cast textu nebo seznamu |
|
||||
| `split`, `join` (`separator`) | text na seznam a zpatky |
|
||||
| `sum` (`path`) | soucet pres seznam |
|
||||
| `count` | pocet polozek |
|
||||
| `map` (`rules`) | kazdou polozku seznamu podle vlastnich pravidel |
|
||||
| Prevod | Co dela |
|
||||
| ----------------------------- | ----------------------------------------------- |
|
||||
| `trim`, `lower`, `upper` | uprava textu |
|
||||
| `string`, `number`, `boolean` | zmena typu |
|
||||
| `date` (`iso` / `day`) | datum, `day` je jen `YYYY-MM-DD` |
|
||||
| `round` (`decimals`) | zaokrouhleni |
|
||||
| `multiply`, `add` (`by`) | pocty, napriklad prevod na cenu s DPH |
|
||||
| `default` (`value`) | vyplneni prazdne hodnoty |
|
||||
| `replace` (`find`, `with`) | nahrazeni textu |
|
||||
| `slice` (`start`, `end`) | cast textu nebo seznamu |
|
||||
| `split`, `join` (`separator`) | text na seznam a zpatky |
|
||||
| `sum` (`path`) | soucet pres seznam |
|
||||
| `count` | pocet polozek |
|
||||
| `map` (`rules`) | kazdou polozku seznamu podle vlastnich pravidel |
|
||||
|
||||
Sada je zamerne **uzavrena**. Volny vyraz by z transformace udelal dalsi jazyk
|
||||
k ladeni a hlavne by to byl kod bez sandboxu na miste, kde ho nikdo neceka.
|
||||
@@ -175,11 +175,11 @@ Kdo potrebuje vic, napise skript.
|
||||
|
||||
## Kontrola
|
||||
|
||||
| Kdy | Co se overi | Vysledek |
|
||||
| ----------------- | ---------------------------------------------------- | --------- |
|
||||
| Pri psani | JSON se parsuje, chyba se ukaze hned pod polem | jen v UI |
|
||||
| Pri ulozeni stromu| JSON a tvar pravidel (`to`, `from`/`value`, `op`) | nedodelek |
|
||||
| Pri behu | typy, `required`, prazdne hodnoty, hloubka zanoreni | chyba behu|
|
||||
| Kdy | Co se overi | Vysledek |
|
||||
| ------------------ | --------------------------------------------------- | ---------- |
|
||||
| Pri psani | JSON se parsuje, chyba se ukaze hned pod polem | jen v UI |
|
||||
| Pri ulozeni stromu | JSON a tvar pravidel (`to`, `from`/`value`, `op`) | nedodelek |
|
||||
| Pri behu | typy, `required`, prazdne hodnoty, hloubka zanoreni | chyba behu |
|
||||
|
||||
Rozbite pravidlo je **nedodelek**, ne chyba ukladani. Rozdelana prace se
|
||||
nezahazuje, jen automatizace nepujde zapnout. Stejny rezim jako u ostatnich
|
||||
@@ -229,9 +229,9 @@ z transformace ho prepise jen tam, kde neco rika.
|
||||
|
||||
## Co chybi
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| -------------------- | ------------------------------------------- |
|
||||
| Nahled transformace | pravidla jde zkusit jen pres test skriptu |
|
||||
| Chybi | Poznamka |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Nahled transformace | pravidla jde zkusit jen pres test skriptu |
|
||||
|
||||
Hotovo od 2026-08-20: vnorena pravidla se klikaji (prevod **Za kazdou polozku
|
||||
seznamu**), cesty se nabizeji z ukazky skutecneho tela a strom se vykonava pres
|
||||
|
||||
@@ -11,11 +11,11 @@ Rozdil se resi **na jednom miste**, v `src/data/connectorStore.ts`. Nikde jinde
|
||||
se nezjistuje, ktery rezim jede - kdyby se to rozlezlo po kodu, jedno misto by
|
||||
se zapomnelo a chovalo by se pak jinak nez zbytek.
|
||||
|
||||
| Rezim | Kdy | Prezije restart | Prezije redeploy |
|
||||
| ---------- | -------------------------------------- | --------------- | ---------------- |
|
||||
| `postgres` | je `DATABASE_URL` i `SECRETS_KEY` | ano | ano |
|
||||
| `file` | neni databaze, ale je `DATA_DIR` | ano | **ne** |
|
||||
| `memory` | ani jedno, nebo nejde zapsat | ne | ne |
|
||||
| Rezim | Kdy | Prezije restart | Prezije redeploy |
|
||||
| ---------- | --------------------------------- | --------------- | ---------------- |
|
||||
| `postgres` | je `DATABASE_URL` i `SECRETS_KEY` | ano | ano |
|
||||
| `file` | neni databaze, ale je `DATA_DIR` | ano | **ne** |
|
||||
| `memory` | ani jedno, nebo nejde zapsat | ne | ne |
|
||||
|
||||
Rezim `file` je pro mockup. Filesystem containeru je docasny, takze soubor
|
||||
prezije restart procesu i containeru, ale nove nasazeni ho smaze. Je to
|
||||
@@ -37,12 +37,12 @@ Psat do rozbiteho schematu je horsi nez psat do souboru.
|
||||
|
||||
## Promenne
|
||||
|
||||
| Promenna | K cemu |
|
||||
| ------------------- | ---------------------------------------------------------- |
|
||||
| `DATABASE_URL` | `postgres://uzivatel:heslo@host:5432/csbot` |
|
||||
| `SECRETS_KEY` | klic pro sifrovani pristupovych udaju, **secret** |
|
||||
| `DATABASE_POOL_MAX` | kolik spojeni si vezme jedna instance, vychozi 10 |
|
||||
| `DATABASE_SSL` | `true` u spravovanych databazi, ktere vyzaduji TLS |
|
||||
| Promenna | K cemu |
|
||||
| ------------------- | ------------------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | `postgres://uzivatel:heslo@host:5432/csbot` |
|
||||
| `SECRETS_KEY` | klic pro sifrovani pristupovych udaju, **secret** |
|
||||
| `DATABASE_POOL_MAX` | kolik spojeni si vezme jedna instance, vychozi 10 |
|
||||
| `DATABASE_SSL` | `true` u spravovanych databazi, ktere vyzaduji TLS |
|
||||
| `DATA_DIR` | slozka pro JSON mimo databazi, vychozi `./data`. Prazdna hodnota vypne i soubor |
|
||||
|
||||
`SECRETS_KEY` ma byt nahodny retezec, ne heslo:
|
||||
@@ -81,13 +81,13 @@ Bez promennych `npm run dev` funguje dal, jen se uklada do `./data`.
|
||||
Ukladani je **jeden kod pro pamet i soubor**, lisi se jen tim, kam se zapisuje.
|
||||
Kdyby to byly dve implementace, jedna by se casem opravila a druha ne.
|
||||
|
||||
| Vlastnost | Jak a proc |
|
||||
| ---------------- | ----------------------------------------------------------- |
|
||||
| Atomicky zapis | nejdriv `.tmp`, pak prejmenovani. Pad uprostred zapisu jinak nechá polovicni JSON, ktery se pri startu nenacte |
|
||||
| Slucovani zapisu | deset uprav za sebou znamena jeden zapis na disk |
|
||||
| Zapis pri ukonceni | `SIGTERM` dokonci rozepsany zapis, jinak by se posledni zmena ztratila |
|
||||
| Rozbity soubor | prejmenuje se na `.broken`, zaloguje a jede se s prazdnymi daty. Aplikace, ktera nenastartuje, je pro AppFactory nefunkcni sluzba |
|
||||
| Sifrovani | tajne hodnoty jsou v souboru zasifrovane, plaintext nikdy |
|
||||
| Vlastnost | Jak a proc |
|
||||
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Atomicky zapis | nejdriv `.tmp`, pak prejmenovani. Pad uprostred zapisu jinak nechá polovicni JSON, ktery se pri startu nenacte |
|
||||
| Slucovani zapisu | deset uprav za sebou znamena jeden zapis na disk |
|
||||
| Zapis pri ukonceni | `SIGTERM` dokonci rozepsany zapis, jinak by se posledni zmena ztratila |
|
||||
| Rozbity soubor | prejmenuje se na `.broken`, zaloguje a jede se s prazdnymi daty. Aplikace, ktera nenastartuje, je pro AppFactory nefunkcni sluzba |
|
||||
| Sifrovani | tajne hodnoty jsou v souboru zasifrovane, plaintext nikdy |
|
||||
|
||||
### Klic mimo databazi
|
||||
|
||||
@@ -145,12 +145,12 @@ zvlast a nic to nestoji.
|
||||
|
||||
`connectors` (migrace `001_connectors.sql`):
|
||||
|
||||
| Sloupec | Poznamka |
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| `tenant_id` | povinne, index zacina jim |
|
||||
| `service_id` | odkaz do katalogu v kodu, ne do tabulky |
|
||||
| `secrets` | JSONB se sifrovanymi hodnotami, nikdy plaintext |
|
||||
| `is_default` | jediny vychozi na firmu a sluzbu, hlida index |
|
||||
| Sloupec | Poznamka |
|
||||
| ------------ | ----------------------------------------------- |
|
||||
| `tenant_id` | povinne, index zacina jim |
|
||||
| `service_id` | odkaz do katalogu v kodu, ne do tabulky |
|
||||
| `secrets` | JSONB se sifrovanymi hodnotami, nikdy plaintext |
|
||||
| `is_default` | jediny vychozi na firmu a sluzbu, hlida index |
|
||||
|
||||
**Sluzby v databazi nejsou.** Jsou to definice, ktere delame my, a repo je u nich
|
||||
zdroj pravdy kvuli code review a historii v gitu. Rucne upraveny radek v produkci
|
||||
@@ -202,29 +202,29 @@ Ta pak potrebuje prime spojeni mimo PgBouncer.
|
||||
|
||||
Proti Postgresu 16 v kontejneru:
|
||||
|
||||
| Co | Vysledek |
|
||||
| ----------------------------------------------------- | -------- |
|
||||
| Migrace projedou a zapisou se do `schema_migrations` | ano |
|
||||
| Udaje jsou v tabulce sifrovane, plaintext nikde | ano |
|
||||
| Konektor prezije restart procesu | ano |
|
||||
| Se spravnym klicem se udaje rozsifruji | ano |
|
||||
| Se spatnym klicem se chovaji jako nevyplnene a loguje se | ano |
|
||||
| `PATCH` bez tajneho pole tajne pole nesmaze | ano |
|
||||
| Prepnuti vychoziho konektoru | ano |
|
||||
| Smazani vychoziho preda priznak zbylemu | ano |
|
||||
| Bez `DATABASE_URL` jede souborovy rezim a rekne to | ano |
|
||||
| Konektor v souborovem rezimu prezije restart procesu | ano |
|
||||
| Tajne hodnoty jsou v JSONu sifrovane, plaintext nikde | ano |
|
||||
| U databaze se klic vedle dat nevygeneruje | ano |
|
||||
| Co | Vysledek |
|
||||
| -------------------------------------------------------- | -------- |
|
||||
| Migrace projedou a zapisou se do `schema_migrations` | ano |
|
||||
| Udaje jsou v tabulce sifrovane, plaintext nikde | ano |
|
||||
| Konektor prezije restart procesu | ano |
|
||||
| Se spravnym klicem se udaje rozsifruji | ano |
|
||||
| Se spatnym klicem se chovaji jako nevyplnene a loguje se | ano |
|
||||
| `PATCH` bez tajneho pole tajne pole nesmaze | ano |
|
||||
| Prepnuti vychoziho konektoru | ano |
|
||||
| Smazani vychoziho preda priznak zbylemu | ano |
|
||||
| Bez `DATABASE_URL` jede souborovy rezim a rekne to | ano |
|
||||
| Konektor v souborovem rezimu prezije restart procesu | ano |
|
||||
| Tajne hodnoty jsou v JSONu sifrovane, plaintext nikde | ano |
|
||||
| U databaze se klic vedle dat nevygeneruje | ano |
|
||||
|
||||
## Co chybi
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| ---------------------------- | ----------------------------------------------------- |
|
||||
| Automatizace v ulozisti | dalsi na rade, je to to, co si clovek nastavi. Pujde do souboru i do databaze |
|
||||
| Rozlozeni dashboardu | male a samostatne, hned po automatizacich |
|
||||
| Tickety a incidenty | naposled, dnes je generuje simulace |
|
||||
| Uzivatele, firmy, resitele | v prototypu je to spis konfigurace nez data |
|
||||
| Sbernice udalosti pres LISTEN/NOTIFY | dnes `EventEmitter` v pameti jedne instance |
|
||||
| Vymena klice (rotace) | `v` je pripravene, prevod dat napsany neni |
|
||||
| Retence a partitionovani | az u tabulek behu, viz dokument 10 |
|
||||
| Chybi | Poznamka |
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| Automatizace v ulozisti | dalsi na rade, je to to, co si clovek nastavi. Pujde do souboru i do databaze |
|
||||
| Rozlozeni dashboardu | male a samostatne, hned po automatizacich |
|
||||
| Tickety a incidenty | naposled, dnes je generuje simulace |
|
||||
| Uzivatele, firmy, resitele | v prototypu je to spis konfigurace nez data |
|
||||
| Sbernice udalosti pres LISTEN/NOTIFY | dnes `EventEmitter` v pameti jedne instance |
|
||||
| Vymena klice (rotace) | `v` je pripravene, prevod dat napsany neni |
|
||||
| Retence a partitionovani | az u tabulek behu, viz dokument 10 |
|
||||
|
||||
@@ -12,85 +12,92 @@ a kdy to použít.
|
||||
Rozhoduje se na **jednom místě**, viz [14-databaze.md](14-databaze.md).
|
||||
Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět.
|
||||
|
||||
| Co | Kde | K čemu |
|
||||
| --- | --- | --- |
|
||||
| `defineStore<T>(kind)` | `src/data/store/index.ts` | Založí úložiště pro nový druh záznamu. Jeden řádek na entitu. |
|
||||
| `initStores({databaseReady})` | `src/data/store/index.ts` | Vybere režim. Volá se jednou při startu, nikde jinde. |
|
||||
| `flushStores()` | `src/data/store/index.ts` | Dopíše rozepsané zápisy. Jen při ukončení procesu. |
|
||||
| `withCache(store)` | `src/data/store/cached.ts` | Kopie v paměti pro **konfigurační** entity, které se čtou při každém requestu (uživatelé, role, firmy). Čte se synchronně, obnovuje se po zápisu. |
|
||||
| `withMirror(store)` | `src/data/store/mirror.ts` | Opačný směr než `withCache`: data se mění v paměti a po každé změně se celý záznam zapíše. Pro **provozní** data (tickety, automatizace, incidenty, rozložení). |
|
||||
| `isVisible(entity, options)` | `src/data/store/types.ts` | Vidí volající tenhle záznam? Prázdný seznam firem znamená "nic", ne "vše". |
|
||||
| `nowIso()` | `src/data/store/types.ts` | Časová značka. Ať se nepíše `new Date().toISOString()` na třiceti místech. |
|
||||
| `memorySnapshot` / `fileSnapshot` | `src/data/snapshot.ts` | Nižší vrstva pod `createLocalStore`: atomický zápis JSONu s debounce. Přímo se nepoužívá. |
|
||||
| `db()`, `query`, `queryOne`, `transaction` | `src/db/pool.ts` | Postgres. `dbFor(tenantId)` je připravený šev pro rozdělení na víc databází. |
|
||||
| `seal`, `open`, `sealAll`, `openAll` | `src/db/secretBox.ts` | Šifrování přístupových údajů konektorů (AES-256-GCM). Nic tajného se neukládá jinak. |
|
||||
| `runMigrations()` | `src/db/migrate.ts` | Migrace pod zámkem, jeden soubor = jedna transakce. |
|
||||
| Co | Kde | K čemu |
|
||||
| ------------------------------------------ | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `defineStore<T>(kind)` | `src/data/store/index.ts` | Založí úložiště pro nový druh záznamu. Jeden řádek na entitu. |
|
||||
| `initStores({databaseReady})` | `src/data/store/index.ts` | Vybere režim. Volá se jednou při startu, nikde jinde. |
|
||||
| `flushStores()` | `src/data/store/index.ts` | Dopíše rozepsané zápisy. Jen při ukončení procesu. |
|
||||
| `withCache(store)` | `src/data/store/cached.ts` | Kopie v paměti pro **konfigurační** entity, které se čtou při každém requestu (uživatelé, role, firmy). Čte se synchronně, obnovuje se po zápisu. |
|
||||
| `withMirror(store)` | `src/data/store/mirror.ts` | Opačný směr než `withCache`: data se mění v paměti a po každé změně se celý záznam zapíše. Pro **provozní** data (tickety, automatizace, incidenty, rozložení). |
|
||||
| `isVisible(entity, options)` | `src/data/store/types.ts` | Vidí volající tenhle záznam? Prázdný seznam firem znamená "nic", ne "vše". |
|
||||
| `nowIso()` | `src/data/store/types.ts` | Časová značka. Ať se nepíše `new Date().toISOString()` na třiceti místech. |
|
||||
| `memorySnapshot` / `fileSnapshot` | `src/data/snapshot.ts` | Nižší vrstva pod `createLocalStore`: atomický zápis JSONu s debounce. Přímo se nepoužívá. |
|
||||
| `db()`, `query`, `queryOne`, `transaction` | `src/db/pool.ts` | Postgres. `dbFor(tenantId)` je připravený šev pro rozdělení na víc databází. |
|
||||
| `seal`, `open`, `sealAll`, `openAll` | `src/db/secretBox.ts` | Šifrování přístupových údajů konektorů (AES-256-GCM). Nic tajného se neukládá jinak. |
|
||||
| `runMigrations()` | `src/db/migrate.ts` | Migrace pod zámkem, jeden soubor = jedna transakce. |
|
||||
|
||||
## Entity a práva (server)
|
||||
|
||||
| Co | Kde | K čemu |
|
||||
| --- | --- | --- |
|
||||
| `crudRouter(options)` | `src/routes/crud.ts` | Celý CRUD nad jednou entitou: seznam, detail, vytvoření, úprava, mazání, právo, audit. Nová entita v nastavení = jeden `crudRouter`, ne pět handlerů. |
|
||||
| `readScope(req)` | `src/routes/crud.ts` | Ze které firmy smí request číst. Povinný argument všech `list` volání. |
|
||||
| `accessFor(user, tenantId?)` | `src/data/access.ts` | Co uživatel smí: práva, záložky, výchozí firma. Klient si nic nedovozuje sám. |
|
||||
| `permissionsOf(user, tenantId)` | `src/data/permissions.ts` | Efektivní práva z rolí. Pětisekundová cache, `invalidatePermissions()` po zápisu. |
|
||||
| `hasPermission(...)` | `src/data/permissions.ts` | Jedna kontrola. Používá ji `crudRouter` i ruční handlery. |
|
||||
| `navFor(...)` | `src/data/tenantFeatures.ts` | Průnik toho, co firma má, a toho, na co má člověk právo. Navigace chodí ze serveru. |
|
||||
| `recordAudit(input)` | `src/data/audit.ts` | Zápis do auditu. Nevrací chybu a nečeká se - rozbitý audit nesmí rozbít aplikaci. |
|
||||
| `enqueue(input)` | `src/runtime/queue.ts` | Zařadí běh. Klíč proti dvojímu zařazení drží jeden běh na jednu událost. |
|
||||
| `claimBatch(limit)` | `src/runtime/queue.ts` | Vezme další práci, spravedlivě po firmách. Místo, kde nad Postgresem musí být SKIP LOCKED. |
|
||||
| `onTicketEvent(kind, ticket)` | `src/runtime/triggers.ts` | Změna ticketu zařadí navázané automatizace, včetně ochrany proti smyčce. |
|
||||
| `withRun(marker, work)` | `src/runtime/context.ts` | Označí, který běh práci způsobil. Bez toho automatizace spouští sama sebe. |
|
||||
| `findBuiltinStep(...)` | `src/runtime/builtinSteps.ts` | Kroky, které sahají do našeho úložiště, ne ven přes HTTP. |
|
||||
| `findPersonByExternalId(...)` | `src/data/people.ts` | Řešitel podle ID z cizí aplikace, například voicebotId. |
|
||||
| `notify(input)` | `src/data/notifications.ts` | Upozorní člověka. Nečeká se a nevyhazuje chyby, stejně jako audit. |
|
||||
| `runFlow(steps, context, options)` | `src/runtime/executor.ts` | Vykoná strom kroků. Nikdy nevyhodí výjimku, chyba je výsledek. Používá to akce na ticketu i webhook, aby se strom choval všude stejně. |
|
||||
| `widgetCatalog(tenantIds, userId)` | `src/data/widgets.ts` | Jediná definice toho, co jde položit na dashboard. Používá ji nabídka i kontrola ukládaného rozložení. |
|
||||
| `intakeEvent(input)` | `src/data/ticketStore.ts` | Přijme událost zvenku: podle externího ID buď založí ticket, nebo ji navěsí na existující. Jediná cesta, kterou se událost stává ticketem. |
|
||||
| `getAgentStats(...)` | `src/data/ticketStore.ts` | Výkon řešitelů: odbavené, mediány časů, vrácené, fronta. Používá to widget i detail osoby, aby čísla seděla. |
|
||||
| `findByExternalId(...)` | `src/data/ticketStore.ts` | Ticket firmy podle externího ID. Klíč je dvojice firma a ID. |
|
||||
| `findByIntakeToken(token)` | `src/data/tenants.ts` | Firma podle tokenu příjmu. Určuje i to, v jakém rozsahu je externí ID unikátní. |
|
||||
| `refreshCaches()` | `src/data/bootstrap.ts` | Obnoví všechny kopie v paměti. Volá se po zápisu, který je může změnit. |
|
||||
| `bootstrapData({databaseReady})` | `src/data/bootstrap.ts` | Seznam všech entit a provozních dat. **Nová entita se přidává tady**, ne rozesetě po modulech. |
|
||||
| Co | Kde | K čemu |
|
||||
| ---------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `crudRouter(options)` | `src/routes/crud.ts` | Celý CRUD nad jednou entitou: seznam, detail, vytvoření, úprava, mazání, právo, audit. Nová entita v nastavení = jeden `crudRouter`, ne pět handlerů. |
|
||||
| `readScope(req)` | `src/routes/crud.ts` | Ze které firmy smí request číst. Povinný argument všech `list` volání. |
|
||||
| `accessFor(user, tenantId?)` | `src/data/access.ts` | Co uživatel smí: práva, záložky, výchozí firma. Klient si nic nedovozuje sám. |
|
||||
| `permissionsOf(user, tenantId)` | `src/data/permissions.ts` | Efektivní práva z rolí. Pětisekundová cache, `invalidatePermissions()` po zápisu. |
|
||||
| `hasPermission(...)` | `src/data/permissions.ts` | Jedna kontrola. Používá ji `crudRouter` i ruční handlery. |
|
||||
| `navFor(...)` | `src/data/tenantFeatures.ts` | Průnik toho, co firma má, a toho, na co má člověk právo. Navigace chodí ze serveru. |
|
||||
| `recordAudit(input)` | `src/data/audit.ts` | Zápis do auditu. Nevrací chybu a nečeká se - rozbitý audit nesmí rozbít aplikaci. |
|
||||
| `enqueue(input)` | `src/runtime/queue.ts` | Zařadí běh. Klíč proti dvojímu zařazení drží jeden běh na jednu událost. |
|
||||
| `claimBatch(limit)` | `src/runtime/queue.ts` | Vezme další práci, spravedlivě po firmách. Místo, kde nad Postgresem musí být SKIP LOCKED. |
|
||||
| `onTicketEvent(kind, ticket)` | `src/runtime/triggers.ts` | Změna ticketu zařadí navázané automatizace, včetně ochrany proti smyčce. |
|
||||
| `withRun(marker, work)` | `src/runtime/context.ts` | Označí, který běh práci způsobil. Bez toho automatizace spouští sama sebe. |
|
||||
| `findBuiltinStep(...)` | `src/runtime/builtinSteps.ts` | Kroky, které sahají do našeho úložiště, ne ven přes HTTP. |
|
||||
| `findPersonByExternalId(...)` | `src/data/people.ts` | Řešitel podle ID z cizí aplikace, například voicebotId. |
|
||||
| `notify(input)` | `src/data/notifications.ts` | Upozorní člověka. Nečeká se a nevyhazuje chyby, stejně jako audit. |
|
||||
| `runFlow(steps, context, options)` | `src/runtime/executor.ts` | Vykoná strom kroků. Nikdy nevyhodí výjimku, chyba je výsledek. Používá to akce na ticketu i webhook, aby se strom choval všude stejně. |
|
||||
| `widgetCatalog(tenantIds, userId)` | `src/data/widgets.ts` | Jediná definice toho, co jde položit na dashboard. Používá ji nabídka i kontrola ukládaného rozložení. |
|
||||
| `intakeEvent(input)` | `src/data/ticketStore.ts` | Přijme událost zvenku: podle externího ID buď založí ticket, nebo ji navěsí na existující. Jediná cesta, kterou se událost stává ticketem. |
|
||||
| `getAgentStats(...)` | `src/data/ticketStore.ts` | Výkon řešitelů: odbavené, mediány časů, vrácené, fronta. Používá to widget i detail osoby, aby čísla seděla. |
|
||||
| `findByExternalId(...)` | `src/data/ticketStore.ts` | Ticket firmy podle externího ID. Klíč je dvojice firma a ID. |
|
||||
| `findByIntakeToken(token)` | `src/data/tenants.ts` | Firma podle tokenu příjmu. Určuje i to, v jakém rozsahu je externí ID unikátní. |
|
||||
| `refreshCaches()` | `src/data/bootstrap.ts` | Obnoví všechny kopie v paměti. Volá se po zápisu, který je může změnit. |
|
||||
| `bootstrapData({databaseReady})` | `src/data/bootstrap.ts` | Seznam všech entit a provozních dat. **Nová entita se přidává tady**, ne rozesetě po modulech. |
|
||||
|
||||
## Skripty a konektory (server)
|
||||
|
||||
Viz [11-skripty-konektoru.md](11-skripty-konektoru.md).
|
||||
|
||||
| Co | Kde | K čemu |
|
||||
| --- | --- | --- |
|
||||
| `runScript(id, inputs, ctx)` | `src/scripts/runner.ts` | Spustí skript. **Nikdy nevyhodí výjimku**, chybu vrací jako výsledek s celým hlášením. |
|
||||
| `validateValues(...)` | `src/scripts/values.ts` | Jedna kontrola pro vstupy i výstupy skriptu podle manifestu. |
|
||||
| `scriptUtil` | `src/scripts/util.ts` | Nádobíčko pro skripty: `pick`, `first`, `num`, `date`, `need`, `get`, `applyRules`, `fillJson`. Skript nemá sahat na nic jiného. |
|
||||
| `createRedactor(...)` | `src/scripts/util.ts` | Vyškrtá tajemství z textu **před** logováním. Používá se u všeho, co jde do logu. |
|
||||
| `applyRules`, `fillJson` | `src/scripts/mapping.ts` | Transformace dat: pole na pole s převody, nebo objekt na objekt. Viz [13-transformace-dat.md](13-transformace-dat.md). |
|
||||
| `getPath(obj, path)` | `src/scripts/mapping.ts` | Čtení `zakaznik.adresa.mesto` z neznámého objektu. |
|
||||
| `resolveTarget(...)` | `src/scripts/connections.ts` | Z konektoru poskládá adresu a hlavičky. Přístupové údaje nikam jinam nevedou. |
|
||||
| `createHttp(...)` | `src/scripts/http.ts` | HTTP se timeoutem, limitem odpovědi a rozlišením "zkusit znovu" a "marné". |
|
||||
| `scriptIdFor(serviceId, operationId)` | `src/scripts/lookup.ts` | Který skript obsluhuje operaci z katalogu. |
|
||||
| Co | Kde | K čemu |
|
||||
| ------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `runScript(id, inputs, ctx)` | `src/scripts/runner.ts` | Spustí skript. **Nikdy nevyhodí výjimku**, chybu vrací jako výsledek s celým hlášením. |
|
||||
| `validateValues(...)` | `src/scripts/values.ts` | Jedna kontrola pro vstupy i výstupy skriptu podle manifestu. |
|
||||
| `scriptUtil` | `src/scripts/util.ts` | Nádobíčko pro skripty: `pick`, `first`, `num`, `date`, `need`, `get`, `applyRules`, `fillJson`. Skript nemá sahat na nic jiného. |
|
||||
| `createRedactor(...)` | `src/scripts/util.ts` | Vyškrtá tajemství z textu **před** logováním. Používá se u všeho, co jde do logu. |
|
||||
| `applyRules`, `fillJson` | `src/scripts/mapping.ts` | Transformace dat: pole na pole s převody, nebo objekt na objekt. Viz [13-transformace-dat.md](13-transformace-dat.md). |
|
||||
| `getPath(obj, path)` | `src/scripts/mapping.ts` | Čtení `zakaznik.adresa.mesto` z neznámého objektu. |
|
||||
| `resolveTarget(...)` | `src/scripts/connections.ts` | Z konektoru poskládá adresu a hlavičky. Přístupové údaje nikam jinam nevedou. |
|
||||
| `serviceBaseUrl(service)` | `src/scripts/connections.ts` | Adresa služby: naše aplikace ze `SERVICES_BASE_URL`, cizí (OpenAI) z jejího `baseUrl`. Přebít jde přes `<SLUZBA>_BASE_URL`. |
|
||||
| `targetSecrets(target)` | `src/scripts/connections.ts` | Co se musí vyškrtat z logu. Vrací i holý klíč bez předpony `Bearer `, protože v něm ho cizí služby vracejí v chybách. |
|
||||
| `createHttp(...)` | `src/scripts/http.ts` | HTTP se timeoutem, limitem odpovědi a rozlišením "zkusit znovu" a "marné". |
|
||||
| `isPrivateHost(host)` | `src/scripts/http.ts` | Míří jméno do vnitřní sítě? Jedno pravidlo pro HTTP i pro SMTP server z konektoru. |
|
||||
| `sendMail(target, message)` | `src/mail/smtp.ts` | Odešle e-mail přes SMTP z konektoru. Nikdy nevyhodí výjimku, vrací i to, jestli má smysl zkusit znovu. |
|
||||
| `verifySmtp(target)` | `src/mail/smtp.ts` | Přihlásí se na server bez odeslání zprávy. Tím se ověřuje konektor e-mailu. |
|
||||
| `escapeHtml(value)` | `src/data/templates.ts` | Escapuje **dosazenou hodnotu** v HTML šabloně. Značky autora šablony zůstávají, ostré závorky od zákazníka ne. |
|
||||
| `ctx.http.postForm(...)` | `src/scripts/http.ts` | Odeslání souboru (`multipart/form-data`). Obsah přichází jako Base64, hranici dopisuje runtime. |
|
||||
| `scriptIdFor(serviceId, operationId)` | `src/scripts/lookup.ts` | Který skript obsluhuje operaci z katalogu. |
|
||||
|
||||
## Klient
|
||||
|
||||
| Co | Kde | K čemu |
|
||||
| --- | --- | --- |
|
||||
| `EntityAdmin` | `components/dashboard/EntityAdmin.tsx` | Celá správa jedné entity: tabulka, modál, validace, mazání. Nová záložka nastavení = popis sloupců a polí, ne nová stránka. |
|
||||
| `parseJsonField` | `components/dashboard/EntityAdmin.tsx` | Textové pole s JSONem na hodnotu, s hlášením, kde je chyba. |
|
||||
| `TicketActions` | `components/dashboard/TicketActions.tsx` | CTA akcí na ticketu plus typ, tagy a vlastní pole. Seznam akcí chodí ze serveru už vyfiltrovaný. |
|
||||
| `ErrorDetail` | `components/dashboard/ErrorDetail.tsx` | Rozbalovací celé chybové hlášení s kopírováním. Chyba se nikdy nezkracuje. |
|
||||
| `CustomWidgetCard` | `components/dashboard/widgets/CustomWidget.tsx` | Vykreslí widget, jehož data počítá server: číslo, pruhy, tabulka výkonu, časová řada, seznam, data z konektoru. |
|
||||
| `TicketTable` | `components/dashboard/TicketTable.tsx` | Tabulka ticketů pro všechna místa. Na mobilu se místo posouvání do strany kreslí karty. |
|
||||
| `TicketEvents` | `components/dashboard/TicketEvents.tsx` | Příchozí události ticketu včetně celého přijatého JSONu. |
|
||||
| `ViewSwitch` | `components/dashboard/ViewSwitch.tsx` | Přepínač tabulka nebo dlaždice. Používají ho všechny seznamy. |
|
||||
| `FlowCanvas` s `start` | `components/dashboard/flow/FlowCanvas.tsx` | Tentýž strom kroků i bez spouštěče - pro tělo akce, které spouští člověk. |
|
||||
| `MappingEditor` | `components/dashboard/flow/MappingEditor.tsx` | Editor transformací v obou režimech (pole na pole, JSON). |
|
||||
| `DataState` | `components/dashboard/DataState.tsx` | Načítání, chyba, prázdno. Ať to každá stránka nekreslí po svém. |
|
||||
| `apiFetch<T>` | `lib/api.ts` | Jediná cesta na API: base path, token, `ApiError` s celým hlášením ze serveru. |
|
||||
| `useApiQuery<T>` | `lib/useApiQuery.ts` | Načtení dat do stránky včetně `reload`. S `body` pošle POST (dávkové načtení), s `enabled: false` se neptá vůbec. |
|
||||
| `cn(...)` | `lib/cn.ts` | Skládání tříd. Podmíněné třídy nikdy ručně přes šablonu. |
|
||||
| `format*` | `lib/format.ts` | Čísla, procenta, datum, relativní čas, trvání. Formátování se nepíše v komponentě. |
|
||||
| `serviceIcon(key)` | `lib/serviceIcons.ts` | Klíč ikony ze serveru na komponentu. Server neposílá komponenty. |
|
||||
| `usePageMeta` | `lib/usePageMeta.ts` | Titulek stránky. |
|
||||
| `Badge`, `Button`, `Modal`, `Card`, ... | `components/ui/` | Základní prvky. Nový vzhled tlačítka patří sem, ne do stránky. |
|
||||
| Co | Kde | K čemu |
|
||||
| --------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `EntityAdmin` | `components/dashboard/EntityAdmin.tsx` | Celá správa jedné entity: tabulka, modál, validace, mazání. Nová záložka nastavení = popis sloupců a polí, ne nová stránka. |
|
||||
| `parseJsonField` | `components/dashboard/EntityAdmin.tsx` | Textové pole s JSONem na hodnotu, s hlášením, kde je chyba. |
|
||||
| `TicketActions` | `components/dashboard/TicketActions.tsx` | CTA akcí na ticketu plus typ, tagy a vlastní pole. Seznam akcí chodí ze serveru už vyfiltrovaný. |
|
||||
| `ErrorDetail` | `components/dashboard/ErrorDetail.tsx` | Rozbalovací celé chybové hlášení s kopírováním. Chyba se nikdy nezkracuje. |
|
||||
| `CustomWidgetCard` | `components/dashboard/widgets/CustomWidget.tsx` | Vykreslí widget, jehož data počítá server: číslo, pruhy, tabulka výkonu, časová řada, seznam, data z konektoru. |
|
||||
| `TicketTable` | `components/dashboard/TicketTable.tsx` | Tabulka ticketů pro všechna místa. Na mobilu se místo posouvání do strany kreslí karty. |
|
||||
| `TicketEvents` | `components/dashboard/TicketEvents.tsx` | Příchozí události ticketu včetně celého přijatého JSONu. |
|
||||
| `ViewSwitch` | `components/dashboard/ViewSwitch.tsx` | Přepínač tabulka nebo dlaždice. Používají ho všechny seznamy. |
|
||||
| `FlowCanvas` s `start` | `components/dashboard/flow/FlowCanvas.tsx` | Tentýž strom kroků i bez spouštěče - pro tělo akce, které spouští člověk. |
|
||||
| `MappingEditor` | `components/dashboard/flow/MappingEditor.tsx` | Editor transformací v obou režimech (pole na pole, JSON). |
|
||||
| `DataState` | `components/dashboard/DataState.tsx` | Načítání, chyba, prázdno. Ať to každá stránka nekreslí po svém. |
|
||||
| `apiFetch<T>` | `lib/api.ts` | Jediná cesta na API: base path, token, `ApiError` s celým hlášením ze serveru. |
|
||||
| `useApiQuery<T>` | `lib/useApiQuery.ts` | Načtení dat do stránky včetně `reload`. S `body` pošle POST (dávkové načtení), s `enabled: false` se neptá vůbec. |
|
||||
| `cn(...)` | `lib/cn.ts` | Skládání tříd. Podmíněné třídy nikdy ručně přes šablonu. |
|
||||
| `format*` | `lib/format.ts` | Čísla, procenta, datum, relativní čas, trvání. Formátování se nepíše v komponentě. |
|
||||
| `serviceIcon(key)` | `lib/serviceIcons.ts` | Klíč ikony ze serveru na komponentu. Server neposílá komponenty. |
|
||||
| `usePageMeta` | `lib/usePageMeta.ts` | Titulek stránky. |
|
||||
| `Badge`, `Button`, `Modal`, `Card`, ... | `components/ui/` | Základní prvky. Nový vzhled tlačítka patří sem, ne do stránky. |
|
||||
|
||||
## Pravidla, která z toho plynou
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ odeslání objednávky, 0,10 Kč).
|
||||
|
||||
Čtyři věci, každá měří něco jiného:
|
||||
|
||||
| Základ | Co to znamená | Proč / proč ne |
|
||||
| --- | --- | --- |
|
||||
| Za uživatele | Kolik lidí má přístup do portálu | Předvídatelné, ale nesouvisí s tím, co aplikace dělá. U automatizací platí zákazník za lidi, kteří tam nemusí chodit. |
|
||||
| Za automatizaci | Kolik má zapnutých stromů | Trestá to rozdělení jednoho velkého stromu na tři přehledné. Špatná motivace. |
|
||||
| **Za krok** | Kolik kroků se skutečně vykonalo | Odpovídá naší práci: každý krok je jedno volání služby, jeden zápis, jeden běh skriptu. Zákazník vidí, za co platí. |
|
||||
| Za objem dat | Kolik toho proteče | Nesouvisí s náklady, u nás jsou to kilobajty. |
|
||||
| Základ | Co to znamená | Proč / proč ne |
|
||||
| --------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| Za uživatele | Kolik lidí má přístup do portálu | Předvídatelné, ale nesouvisí s tím, co aplikace dělá. U automatizací platí zákazník za lidi, kteří tam nemusí chodit. |
|
||||
| Za automatizaci | Kolik má zapnutých stromů | Trestá to rozdělení jednoho velkého stromu na tři přehledné. Špatná motivace. |
|
||||
| **Za krok** | Kolik kroků se skutečně vykonalo | Odpovídá naší práci: každý krok je jedno volání služby, jeden zápis, jeden běh skriptu. Zákazník vidí, za co platí. |
|
||||
| Za objem dat | Kolik toho proteče | Nesouvisí s náklady, u nás jsou to kilobajty. |
|
||||
|
||||
Doporučení: **paušál plus kroky**. Paušál kryje portál, tickety, úložiště
|
||||
a podporu, kroky kryjí provoz automatizací. Bez paušálu je zákazník, který má
|
||||
@@ -29,11 +29,11 @@ a jeho dotaz na kontakt stojí jinak, protože nás jinak stojí.
|
||||
|
||||
Tři pásma:
|
||||
|
||||
| Pásmo | Příklady | Návrh ceny |
|
||||
| --- | --- | --- |
|
||||
| Obecné | pauza, zápis do logu, podmínka, transformace dat | 0 Kč. Účtovat podmínku je jako účtovat mezeru v textu. |
|
||||
| Naše práce | webhook, plánovač, založení ticketu, přiřazení řešitele, HTTP požadavek | 0,02 Kč |
|
||||
| Cizí služba | iDoklad, Shoptet, SAP, CRM, hlasová brána, AI | 0,05 až 0,50 Kč podle toho, co za to platíme sami |
|
||||
| Pásmo | Příklady | Návrh ceny |
|
||||
| ----------- | ----------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Obecné | pauza, zápis do logu, podmínka, transformace dat | 0 Kč. Účtovat podmínku je jako účtovat mezeru v textu. |
|
||||
| Naše práce | webhook, plánovač, založení ticketu, přiřazení řešitele, HTTP požadavek | 0,02 Kč |
|
||||
| Cizí služba | iDoklad, Shoptet, SAP, CRM, hlasová brána, AI | 0,05 až 0,50 Kč podle toho, co za to platíme sami |
|
||||
|
||||
Číslo je jedno pole u operace, takže se dá měnit bez zásahu do kódu. Ceník
|
||||
je verzovaný: změna ceny nepřepíše historii, jinak by se zpětně změnila
|
||||
@@ -75,11 +75,11 @@ Uzavřený měsíc se nedá změnit, jen opravit dobropisem.
|
||||
Cena za krok samotná zákazníka děsí, protože nezná svoje čísla. Proto balíčky
|
||||
s předplacenými kroky a stejnou cenou nad limit:
|
||||
|
||||
| Balíček | Paušál | Kroků v ceně | Nad limit |
|
||||
| --- | --- | --- | --- |
|
||||
| Start | 490 Kč | 5 000 | 0,05 Kč |
|
||||
| Provoz | 1 900 Kč | 40 000 | 0,04 Kč |
|
||||
| Firma | 6 900 Kč | 200 000 | 0,03 Kč |
|
||||
| Balíček | Paušál | Kroků v ceně | Nad limit |
|
||||
| ------- | -------- | ------------ | --------- |
|
||||
| Start | 490 Kč | 5 000 | 0,05 Kč |
|
||||
| Provoz | 1 900 Kč | 40 000 | 0,04 Kč |
|
||||
| Firma | 6 900 Kč | 200 000 | 0,03 Kč |
|
||||
|
||||
Nad limit se **nevypíná**. Zastavit zákazníkovi fakturaci objednávek kvůli
|
||||
překročení limitu je horší než mu to dofakturovat. Limit hlásí varování
|
||||
|
||||
@@ -14,11 +14,11 @@ souborů, ze kterých se jeden opraví a ostatní ne.
|
||||
|
||||
Proto tři vrstvy, každá napsaná jednou:
|
||||
|
||||
| Vrstva | Kde | Co dělá |
|
||||
| --- | --- | --- |
|
||||
| Úložiště | `src/data/store/` | `EntityStore<T>` a dvě implementace. Volající nepozná, jestli běží Postgres nebo JSON soubor. |
|
||||
| API | `src/routes/crud.ts` | `crudRouter` vyrobí pětici endpointů včetně práva a auditu. |
|
||||
| Klient | `components/dashboard/EntityAdmin.tsx` | Tabulka, modál, validace, mazání. |
|
||||
| Vrstva | Kde | Co dělá |
|
||||
| -------- | -------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| Úložiště | `src/data/store/` | `EntityStore<T>` a dvě implementace. Volající nepozná, jestli běží Postgres nebo JSON soubor. |
|
||||
| API | `src/routes/crud.ts` | `crudRouter` vyrobí pětici endpointů včetně práva a auditu. |
|
||||
| Klient | `components/dashboard/EntityAdmin.tsx` | Tabulka, modál, validace, mazání. |
|
||||
|
||||
Nová entita v nastavení pak znamená: `defineStore` v modulu entity, jeden řádek
|
||||
v `bootstrap.ts`, jeden `crudRouter` v `settings.ts`, jeden popis v
|
||||
@@ -72,11 +72,11 @@ společnou mezivrstvu. Automatizace běží sama, akce je tlačítko, které zm
|
||||
|
||||
Tělo akce je jedno z trojice:
|
||||
|
||||
| Tělo | Kdy | Příklad |
|
||||
| --- | --- | --- |
|
||||
| Operace konektoru | běžný případ | odeslat objednávku do iDokladu |
|
||||
| Vlastní strom | akce má víc kroků a rozhodování | dohledat kontakt, vystavit fakturu, odeslat e-mailem |
|
||||
| Skript | nic z toho nestačí | vlastní výpočet nebo cizí API, které v katalogu není |
|
||||
| Tělo | Kdy | Příklad |
|
||||
| ----------------- | ------------------------------- | ---------------------------------------------------- |
|
||||
| Operace konektoru | běžný případ | odeslat objednávku do iDokladu |
|
||||
| Vlastní strom | akce má víc kroků a rozhodování | dohledat kontakt, vystavit fakturu, odeslat e-mailem |
|
||||
| Skript | nic z toho nestačí | vlastní výpočet nebo cizí API, které v katalogu není |
|
||||
|
||||
Server vrací k ticketu **jen akce, které v té situaci opravdu jdou spustit**:
|
||||
sedí typ nebo tag, projdou podmínky a volající na ně má právo. Klient
|
||||
@@ -117,12 +117,12 @@ takže ho nemůže ani omylem prodloužit.
|
||||
|
||||
## Co se ukládá a co se drží v paměti
|
||||
|
||||
| Data | Jak | Proč |
|
||||
| --- | --- | --- |
|
||||
| Firmy, uživatelé, role, řešitelé, skupiny, typy, akce, widgety, záložky | `withCache` | Čtou se při každém requestu, mění se zřídka. Kopie v paměti, obnova po zápisu. |
|
||||
| Tickety včetně logu, automatizace, incidenty, rozložení dashboardu | `withMirror` | Mění se v paměti za provozu, po každé změně se celý záznam zapíše. |
|
||||
| Audit | přímo do úložiště | Jen se připisuje, nikdy nečte při každém requestu. |
|
||||
| Konektory | vlastní úložiště | Nesou šifrovaná tajemství a potřebují částečný unikátní index. Viz [14-databaze.md](14-databaze.md). |
|
||||
| Data | Jak | Proč |
|
||||
| ----------------------------------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| Firmy, uživatelé, role, řešitelé, skupiny, typy, akce, widgety, záložky | `withCache` | Čtou se při každém requestu, mění se zřídka. Kopie v paměti, obnova po zápisu. |
|
||||
| Tickety včetně logu, automatizace, incidenty, rozložení dashboardu | `withMirror` | Mění se v paměti za provozu, po každé změně se celý záznam zapíše. |
|
||||
| Audit | přímo do úložiště | Jen se připisuje, nikdy nečte při každém requestu. |
|
||||
| Konektory | vlastní úložiště | Nesou šifrovaná tajemství a potřebují částečný unikátní index. Viz [14-databaze.md](14-databaze.md). |
|
||||
|
||||
Čítače ID se při startu dopočítají z uložených záznamů, takže nový ticket
|
||||
nikdy nepřepíše starý.
|
||||
|
||||
@@ -60,10 +60,10 @@ slučovalo věci, které spolu nesouvisí.
|
||||
|
||||
Dvě různé věci, které se snadno pletou:
|
||||
|
||||
| | Co to je | Kde se bere |
|
||||
| --- | --- | --- |
|
||||
| **Událost** | fakt zvenku, celá přijatá data | poslal odesílatel |
|
||||
| **Řádek logu** | naše stopa toho, co se dělo uvnitř | zapsala aplikace |
|
||||
| | Co to je | Kde se bere |
|
||||
| -------------- | ---------------------------------- | ----------------- |
|
||||
| **Událost** | fakt zvenku, celá přijatá data | poslal odesílatel |
|
||||
| **Řádek logu** | naše stopa toho, co se dělo uvnitř | zapsala aplikace |
|
||||
|
||||
Události se ukazují na detailu ticketu nad logem a dají se rozbalit na celý
|
||||
přijatý JSON. Když se někdo ptá, proč ticket vypadá takhle, je to jediná
|
||||
@@ -75,12 +75,12 @@ rostoucí ticket by při každém zápisu přepisoval víc a víc dat.
|
||||
Aby šlo říct, kdo kolik odbavil a komu to nejde, nestačí počítat vyřešené.
|
||||
Ticket proto nese:
|
||||
|
||||
| Pole | Kdy se zapíše | K čemu |
|
||||
| --- | --- | --- |
|
||||
| Pole | Kdy se zapíše | K čemu |
|
||||
| ----------------- | ------------------------------------------------ | ----------------------------------- |
|
||||
| `firstResponseAt` | při prvním přiřazení, komentáři nebo změně stavu | jak dlouho zákazník čekal na reakci |
|
||||
| `resolvedAt` | při přechodu na vyřešeno | doba řešení |
|
||||
| `resolvedById` | tamtéž, je to ten, kdo ho měl u sebe | komu se vyřešení připíše |
|
||||
| `reopenCount` | při návratu z vyřešeno | kolikrát to hotové nebylo |
|
||||
| `resolvedAt` | při přechodu na vyřešeno | doba řešení |
|
||||
| `resolvedById` | tamtéž, je to ten, kdo ho měl u sebe | komu se vyřešení připíše |
|
||||
| `reopenCount` | při návratu z vyřešeno | kolikrát to hotové nebylo |
|
||||
|
||||
`firstResponseAt` se zapisuje **jednou a nepřepisuje**. Je to okamžik, kdy
|
||||
zákazník přestal čekat. Kdyby se přepisoval při každé změně, měřil by poslední
|
||||
@@ -109,12 +109,12 @@ něco jiného.
|
||||
|
||||
## Pohledy
|
||||
|
||||
| Stránka | Co ukazuje |
|
||||
| --- | --- |
|
||||
| Tickety | seznam s filtry, **tabulka nebo dlaždice** |
|
||||
| Detail ticketu | obsah, události, log, akce, typ, tagy, řešitel, skupina |
|
||||
| Lidé | řešitelé firmy a jejich vytížení, **tabulka nebo dlaždice** |
|
||||
| Detail osoby | její výkon, co má u sebe, co naposledy vyřešila |
|
||||
| Stránka | Co ukazuje |
|
||||
| -------------- | ----------------------------------------------------------- |
|
||||
| Tickety | seznam s filtry, **tabulka nebo dlaždice** |
|
||||
| Detail ticketu | obsah, události, log, akce, typ, tagy, řešitel, skupina |
|
||||
| Lidé | řešitelé firmy a jejich vytížení, **tabulka nebo dlaždice** |
|
||||
| Detail osoby | její výkon, co má u sebe, co naposledy vyřešila |
|
||||
|
||||
Přepínač pohledu je jedna komponenta (`components/dashboard/ViewSwitch.tsx`)
|
||||
a používají ji obě stránky se seznamem.
|
||||
@@ -124,14 +124,14 @@ a používají ji obě stránky se seznamem.
|
||||
Widget je dvojice: `render` (jak se to kreslí) a `source` (odkud jsou data).
|
||||
Zdroje:
|
||||
|
||||
| Zdroj | Co dělá |
|
||||
| --- | --- |
|
||||
| `ticketCount` | počet ticketů podle filtru, volitelně seskupený |
|
||||
| `ticketList` | seznam ticketů |
|
||||
| `ticketSeries` | časová řada |
|
||||
| `workload` | kdo co má u sebe |
|
||||
| `agentStats` | výkon řešitelů |
|
||||
| `connector` | **data z napojené služby** |
|
||||
| Zdroj | Co dělá |
|
||||
| -------------- | ----------------------------------------------- |
|
||||
| `ticketCount` | počet ticketů podle filtru, volitelně seskupený |
|
||||
| `ticketList` | seznam ticketů |
|
||||
| `ticketSeries` | časová řada |
|
||||
| `workload` | kdo co má u sebe |
|
||||
| `agentStats` | výkon řešitelů |
|
||||
| `connector` | **data z napojené služby** |
|
||||
|
||||
Zdroj `connector` zavolá **tentýž skript**, který používá krok automatizace
|
||||
i akce na ticketu, a z výsledku vezme, co je v `path`. Widget nemá vlastní
|
||||
@@ -158,17 +158,75 @@ Data všech dlaždic chodí **jedním requestem** (`POST /api/dashboard/widget-d
|
||||
Widget, který selže, hlásí chybu **na své pozici** a celou, nezkrácenou - jeden
|
||||
rozbitý zdroj nesmí zhasnout celý přehled.
|
||||
|
||||
## Helpdesk: ticket, ktery vidi dve firmy
|
||||
|
||||
Ticket patri jedne firme. U helpdesku ale figuruji dve: ta, ktera pozadavek
|
||||
poslala, a ta, ktera ho resi. Reseni je jedno pole navic, ne druha hranice
|
||||
viditelnosti.
|
||||
|
||||
| Pole | Kdo to je |
|
||||
| ------------------------- | ---------------------------------------------- |
|
||||
| `Ticket.tenantId` | firma, ktera pozadavek **resi**, tedy vlastnik |
|
||||
| `Ticket.helpdeskSourceId` | firma, ktera pozadavek **poslala** |
|
||||
|
||||
**Vlastnikem je zamerne dodavatel, ne zadavatel.** Kdyby byl vlastnikem
|
||||
zadavatel, mel by resitel pozadavek jen jako cizi ticket a nemel by ho ve sve
|
||||
fronte, ve statistikach ani v prirazovani. Takhle je to na jeho strane obycejny
|
||||
ticket a nemuselo se kvuli tomu sahnout na nic z toho, co uz funguje.
|
||||
|
||||
Komu pozadavek pripadne, urcuje `helpdeskProviderId` **na firme zadavatele**.
|
||||
Nastavuje ho spravce platformy v Nastaveni, Firmy. Kdo koho obsluhuje je
|
||||
obchodni vztah, ne volba klienta - kdyby si dodavatele vybiral uzivatel, poslal
|
||||
by pozadavek nekomu, s kym nema smlouvu. Bez vyplneneho dodavatele se pozadavek
|
||||
nezalozi a rekne se to nahlas.
|
||||
|
||||
### Co smi zadavatel
|
||||
|
||||
| Akce | Smi |
|
||||
| ------------------------------ | --- |
|
||||
| Videt svoje pozadavky | ano |
|
||||
| Otevrit detail a prubeh | ano |
|
||||
| Pripsat komentar | ano |
|
||||
| Menit stav, resitele, typ | ne |
|
||||
| Videt ostatni tickety resitele | ne |
|
||||
|
||||
Komentar je jedina zmena, kterou nad cizim ticketem smi. Doplnit, co zapomnel
|
||||
napsat, je presne to, kvuli cemu se pozadavek otevira; stav urcuje ten, kdo to
|
||||
resi.
|
||||
|
||||
Filtruje se podle `helpdeskSourceIds`, ktere **nahrazuje** filtr podle
|
||||
vlastnika - zadavatel vlastnikem neni, takze by mu jinak nezbylo nic. Bezny
|
||||
seznam ticketu tim zustava nedotceny: `listTickets({ tenantIds })` se nezmenil.
|
||||
|
||||
### Kdo helpdesk vidi
|
||||
|
||||
Pravo `helpdesk.view` (videt sekci) a `helpdesk.create` (poslat pozadavek).
|
||||
Obe prideluje **admin te firmy** pres role, stejne jako u ostatnich prav.
|
||||
Zalozka `helpdesk` je v katalogu modulu jako povinna, aby ji mely i firmy
|
||||
zalozene driv - o tom, kdo ji uvidi, stejne rozhoduje pravo.
|
||||
|
||||
### API
|
||||
|
||||
| Metoda | Cesta | Popis |
|
||||
| ------ | ------------------------------------- | -------------------------- |
|
||||
| GET | `/api/dashboard/helpdesk` | pozadavky teto firmy |
|
||||
| POST | `/api/dashboard/helpdesk` | poslat pozadavek |
|
||||
| GET | `/api/dashboard/helpdesk/:id` | detail vlastniho pozadavku |
|
||||
| POST | `/api/dashboard/helpdesk/:id/comment` | pripsat komentar |
|
||||
|
||||
Stranka portalu je `/dashboard/helpdesk`.
|
||||
|
||||
## Kde se co definuje
|
||||
|
||||
Akce a widgety **nejsou v nastavení**. Je to definice toho, co aplikace umí,
|
||||
stejná úroveň jako automatizace, a mají vlastní záložku:
|
||||
|
||||
| Záložka | Co tam patří |
|
||||
| --- | --- |
|
||||
| Automatizace | stromy, které běží samy |
|
||||
| Akce | tlačítka na ticketu, tělo je **tentýž strom** |
|
||||
| Widgety | dlaždice na přehled |
|
||||
| Nastavení | firmy, lidé, role, typy ticketů, audit |
|
||||
| Záložka | Co tam patří |
|
||||
| ------------ | --------------------------------------------- |
|
||||
| Automatizace | stromy, které běží samy |
|
||||
| Akce | tlačítka na ticketu, tělo je **tentýž strom** |
|
||||
| Widgety | dlaždice na přehled |
|
||||
| Nastavení | firmy, lidé, role, typy ticketů, audit |
|
||||
|
||||
Tělo akce se skládá stejným editorem jako automatizace. Místo karty spouštěče
|
||||
je karta "spouští člověk tlačítkem na ticketu" a parametry, na které jde
|
||||
|
||||
@@ -13,12 +13,12 @@ Jeden proces, režim souboru, tickety se posílaly přes příjem událostí.
|
||||
Měřeno na vývojovém stroji, tedy horní hranice latence, ne serveru.
|
||||
|
||||
| Ticketů | Výpis seznamu | Statistiky řešitelů | Soubor |
|
||||
| --- | --- | --- | --- |
|
||||
| 103 | 1,9 ms | 1,6 ms | 153 kB |
|
||||
| 503 | 6,1 ms | 12,1 ms | 729 kB |
|
||||
| 1 003 | 14,2 ms | 2,7 ms | 1,4 MB |
|
||||
| 2 003 | 18,8 ms | 21,1 ms | 2,9 MB |
|
||||
| 5 003 | 52,9 ms | 1,7 ms | 7,2 MB |
|
||||
| ------- | ------------- | ------------------- | ------ |
|
||||
| 103 | 1,9 ms | 1,6 ms | 153 kB |
|
||||
| 503 | 6,1 ms | 12,1 ms | 729 kB |
|
||||
| 1 003 | 14,2 ms | 2,7 ms | 1,4 MB |
|
||||
| 2 003 | 18,8 ms | 21,1 ms | 2,9 MB |
|
||||
| 5 003 | 52,9 ms | 1,7 ms | 7,2 MB |
|
||||
|
||||
Příjem událostí: **130 až 190 událostí za sekundu** včetně celého kola
|
||||
HTTP, uložení a zápisu do logu.
|
||||
@@ -36,12 +36,12 @@ Z toho plyne:
|
||||
|
||||
200 firem, 10 automatizací každá, 15 lidí. Odhad provozu:
|
||||
|
||||
| Veličina | Výpočet | Za den | Za měsíc |
|
||||
| --- | --- | --- | --- |
|
||||
| Běhy automatizací | 2 000 automatizací, 100 běhů denně | 200 000 | 6 mil. |
|
||||
| Kroky | 3 kroky na běh | 600 000 | 18 mil. |
|
||||
| Tickety | 200 firem, 200 denně | 40 000 | 1,2 mil. |
|
||||
| Řádky logu | 5 na ticket plus kroky | ~800 000 | 24 mil. |
|
||||
| Veličina | Výpočet | Za den | Za měsíc |
|
||||
| ----------------- | ---------------------------------- | -------- | -------- |
|
||||
| Běhy automatizací | 2 000 automatizací, 100 běhů denně | 200 000 | 6 mil. |
|
||||
| Kroky | 3 kroky na běh | 600 000 | 18 mil. |
|
||||
| Tickety | 200 firem, 200 denně | 40 000 | 1,2 mil. |
|
||||
| Řádky logu | 5 na ticket plus kroky | ~800 000 | 24 mil. |
|
||||
|
||||
Špička není průměr. 7 kroků za sekundu v průměru znamená ve špičce klidně
|
||||
100 za sekundu, protože e-shopy neposílají objednávky rovnoměrně.
|
||||
@@ -101,14 +101,14 @@ rozejdou. Řeší to `LISTEN/NOTIFY` na obnovu kopií a sdílený kanál na stre
|
||||
Za rychlost a výsledek cizí služby neručíme, a proto se s tím musí počítat
|
||||
v návrhu, ne v provozu:
|
||||
|
||||
| Riziko | Co s tím |
|
||||
| --- | --- |
|
||||
| Služba odpovídá pomalu | Timeout na krok, ne na celý běh. Běh se uspí a pokračuje. |
|
||||
| Služba je chvíli mimo | Opakování s rostoucí prodlevou, ne hned a ne donekonečna. |
|
||||
| Služba je mimo dlouho | Vypnout ji po sérii chyb a nezkoušet každý běh znovu, ať netrpí ostatní. |
|
||||
| Služba má limit volání | Strop souběžných volání **na dvojici firma a služba**, ne globálně. |
|
||||
| Služba odpoví dvakrát jinak | Klíč proti dvojímu provedení u kroku, aby se nevystavila druhá faktura. |
|
||||
| Služba je pomalá jen pro jednu firmu | Fronta po firmách, aby jedna firma nezablokovala ostatní. |
|
||||
| Riziko | Co s tím |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| Služba odpovídá pomalu | Timeout na krok, ne na celý běh. Běh se uspí a pokračuje. |
|
||||
| Služba je chvíli mimo | Opakování s rostoucí prodlevou, ne hned a ne donekonečna. |
|
||||
| Služba je mimo dlouho | Vypnout ji po sérii chyb a nezkoušet každý běh znovu, ať netrpí ostatní. |
|
||||
| Služba má limit volání | Strop souběžných volání **na dvojici firma a služba**, ne globálně. |
|
||||
| Služba odpoví dvakrát jinak | Klíč proti dvojímu provedení u kroku, aby se nevystavila druhá faktura. |
|
||||
| Služba je pomalá jen pro jednu firmu | Fronta po firmách, aby jedna firma nezablokovala ostatní. |
|
||||
|
||||
Timeout a rozlišení "zkusit znovu" a "marné" už v `scripts/http.ts` je,
|
||||
klíč proti dvojímu provedení taky. Chybí to, co je nad tím: fronta, opakování
|
||||
@@ -134,12 +134,12 @@ bylo použitelné.
|
||||
|
||||
Po těch úpravách, pro zadaných 200 firem:
|
||||
|
||||
| Část | Kolik | Proč |
|
||||
| --- | --- | --- |
|
||||
| Web a API | 2 instance, 1 vCPU a 1 GB každá | Požadavky jsou krátké, jde hlavně o dostupnost při restartu. |
|
||||
| Část | Kolik | Proč |
|
||||
| ----------- | --------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| Web a API | 2 instance, 1 vCPU a 1 GB každá | Požadavky jsou krátké, jde hlavně o dostupnost při restartu. |
|
||||
| Worker běhů | 2 instance, 1 vCPU a 512 MB každá | Kroky čekají na cizí službu, procesor se skoro nepoužije. Jeden proces zvládne stovky souběžných kroků. |
|
||||
| Postgres | 4 vCPU, 8 GB RAM, 200 GB disku | 10 až 20 zápisů za sekundu v průměru je málo, disk sežere log. |
|
||||
| Celkem | ~8 vCPU, ~11 GB RAM | |
|
||||
| Postgres | 4 vCPU, 8 GB RAM, 200 GB disku | 10 až 20 zápisů za sekundu v průměru je málo, disk sežere log. |
|
||||
| Celkem | ~8 vCPU, ~11 GB RAM | |
|
||||
|
||||
Kritické číslo není procesor, ale **disk pod databází** a retence logu.
|
||||
S plnou odpovědí služby u každého kroku je to zhruba 20 GB měsíčně na
|
||||
|
||||
@@ -33,11 +33,11 @@ v `GET /api/dashboard/runs` nebo v logu ticketu.
|
||||
|
||||
## Co frontu plní
|
||||
|
||||
| Druh | Kdo to spustí | Příklad |
|
||||
| --- | --- | --- |
|
||||
| Push | cizí služba zavolá nás | e-shop pošle novou objednávku |
|
||||
| Vnitřní událost | něco se stalo u nás | vznikl nebo se změnil ticket |
|
||||
| Pull | ptáme se sami | e-mail, zprávy z Messengeru |
|
||||
| Druh | Kdo to spustí | Příklad |
|
||||
| --------------- | ---------------------- | ----------------------------- |
|
||||
| Push | cizí služba zavolá nás | e-shop pošle novou objednávku |
|
||||
| Vnitřní událost | něco se stalo u nás | vznikl nebo se změnil ticket |
|
||||
| Pull | ptáme se sami | e-mail, zprávy z Messengeru |
|
||||
|
||||
### Pull, tedy pravidelné dotazování
|
||||
|
||||
@@ -68,9 +68,9 @@ s vnořenými objekty a poli. Proto má každý parametr spouštěče **cestu**:
|
||||
}
|
||||
```
|
||||
|
||||
| Parametr | Cesta | Typ |
|
||||
| --- | --- | --- |
|
||||
| `docId` | `document.id` | string |
|
||||
| Parametr | Cesta | Typ |
|
||||
| -------------- | ------------------ | ------ |
|
||||
| `docId` | `document.id` | string |
|
||||
| `errorMessage` | `errors.0.message` | string |
|
||||
|
||||
Ve stromu se pak píše `{{docId}}` bez ohledu na to, jak hluboko to odesílatel
|
||||
@@ -86,13 +86,13 @@ adresa včetně domény**.
|
||||
|
||||
## Opakování a vzdání se
|
||||
|
||||
| Pokus | Kdy |
|
||||
| --- | --- |
|
||||
| 1. | hned |
|
||||
| 2. | za 30 s |
|
||||
| 3. | za 2 min |
|
||||
| 4. | za 10 min |
|
||||
| 5. | za hodinu |
|
||||
| Pokus | Kdy |
|
||||
| ----- | --------- |
|
||||
| 1. | hned |
|
||||
| 2. | za 30 s |
|
||||
| 3. | za 2 min |
|
||||
| 4. | za 10 min |
|
||||
| 5. | za hodinu |
|
||||
|
||||
Pak běh skončí jako `failed` a zůstane k nahlédnutí. Nemaže se: bez záznamu
|
||||
by nikdo nezjistil, že se něco nestalo.
|
||||
@@ -144,24 +144,24 @@ Založit ticket nebo přehodit ho na člověka není volání cizí služby, tak
|
||||
nejde přes skript - sahá to do našeho úložiště. Pro uživatele je to v katalogu
|
||||
operace jako každá jiná.
|
||||
|
||||
| Krok | Co dělá |
|
||||
| --- | --- |
|
||||
| `ticket/upsert` | podle externího ID založí ticket, nebo na existující navěsí událost |
|
||||
| Krok | Co dělá |
|
||||
| -------------------------- | --------------------------------------------------------------------- |
|
||||
| `ticket/upsert` | podle externího ID založí ticket, nebo na existující navěsí událost |
|
||||
| `ticket/assign-least-busy` | předá nejvolnějšímu ze skupiny, při shodě rozhoduje podíl ke kapacitě |
|
||||
| `ticket/set-type` | nastaví typ, za kterým stojí vlastní pole |
|
||||
| `ticket/set-stage` | posune do další fáze workflow daného typu |
|
||||
| `ticket/add-tags` | přidá štítky, existující nechá |
|
||||
| `ticket/set-status` | změní stav v životním cyklu |
|
||||
| `incident/create` | založí incident |
|
||||
| `flow/pause`, `flow/log` | pauza a zápis do logu |
|
||||
| `ticket/set-type` | nastaví typ, za kterým stojí vlastní pole |
|
||||
| `ticket/set-stage` | posune do další fáze workflow daného typu |
|
||||
| `ticket/add-tags` | přidá štítky, existující nechá |
|
||||
| `ticket/set-status` | změní stav v životním cyklu |
|
||||
| `incident/create` | založí incident |
|
||||
| `flow/pause`, `flow/log` | pauza a zápis do logu |
|
||||
|
||||
## Tři osy na ticketu
|
||||
|
||||
| Osa | Kdo ji určuje | K čemu |
|
||||
| --- | --- | --- |
|
||||
| `status` | pevná čtveřice (nový, v řešení, čeká, vyřešeno) | životní cyklus, počítají se z něj statistiky a fronta |
|
||||
| `stage` | firma u typu ticketu (`TicketType.statuses`) | postup uvnitř typu: čeká na zabalení, předáno dopravci |
|
||||
| `tags` | kdokoliv, volně | označení, která spolu nemusí souviset |
|
||||
| Osa | Kdo ji určuje | K čemu |
|
||||
| -------- | ----------------------------------------------- | ------------------------------------------------------ |
|
||||
| `status` | pevná čtveřice (nový, v řešení, čeká, vyřešeno) | životní cyklus, počítají se z něj statistiky a fronta |
|
||||
| `stage` | firma u typu ticketu (`TicketType.statuses`) | postup uvnitř typu: čeká na zabalení, předáno dopravci |
|
||||
| `tags` | kdokoliv, volně | označení, která spolu nemusí souviset |
|
||||
|
||||
Fáze může být **jen jedna**, proto se na ni dá spolehnout v podmínce. Přes
|
||||
štítky by to fungovalo taky, ale ticket by mohl mít "čeká na zabalení"
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# 21 - Realne sluzby a co k nim potreba
|
||||
|
||||
Naprogramovano. Tenhle dokument rika, **ktera sluzba v katalogu ma za sebou
|
||||
opravdu bezici aplikaci**, jake udaje po firme chce a co s ni umime udelat.
|
||||
|
||||
Obecny popis vrstev je v [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md),
|
||||
popis skriptu v [11-skripty-konektoru.md](11-skripty-konektoru.md).
|
||||
|
||||
## Zdroj pravdy
|
||||
|
||||
Seznam bezicich aplikaci je na `https://services.csbot.cz/apps`. Kazda ma
|
||||
`/docs` se Swaggerem a `/openapi.json` (u .NET aplikaci `/docs/v1/swagger.json`)
|
||||
se strojove citelnym popisem.
|
||||
|
||||
Katalog v `src/data/services.ts` z toho vychazi. **Neni to totez**: jedna
|
||||
aplikace muze nest vic sluzeb katalogu a nektere sluzby katalogu zatim zadnou
|
||||
aplikaci nemaji.
|
||||
|
||||
## Ktera sluzba stoji na cem
|
||||
|
||||
| Sluzba katalogu | Aplikace (`appId`) | Overeni (`verifyPath`) |
|
||||
| ------------------ | ----------------------------- | ---------------------------------------------- |
|
||||
| iDoklad | `idoklad` | `/account/agenda` |
|
||||
| RAYNET CRM | `raynet` | `/company?limit=1` |
|
||||
| CSOB (PSD2) | `csob` | `/accounts?size=1` |
|
||||
| SAP Business One | `sap-bo` | `/api/system/info` |
|
||||
| PPL CPL | `pplcplapi` | `/customer` |
|
||||
| Microsoft 365 | `microsoft-365-service` | `/status` |
|
||||
| Google Workspace | `google-service` | `/google/drive/files` |
|
||||
| Google Analytics 4 | `analytics` | `/ga/admin/accountSummaries` |
|
||||
| Search Console | `analytics` | `/gsc/sites` |
|
||||
| Google Ads | `analytics` | `/googleads/customers:listAccessibleCustomers` |
|
||||
| Sklik | `analytics` | `/sklik/limits` |
|
||||
| Meta Ads | `meta` | `/ads/me/adaccounts` |
|
||||
| Prepis hovoru | `audio-transcription` | nema, overi se jen dostupnost |
|
||||
| E-mail | SMTP server firmy | prihlaseni na server, nic se neodesila |
|
||||
| OpenAI | mimo nas, `api.openai.com/v1` | `/models` |
|
||||
|
||||
**Ctyri sluzby na jedne aplikaci.** GA4, Search Console, Google Ads a Sklik
|
||||
bezi v `analytics`, ale kazda ma **jine pristupove udaje** a jine ceny za
|
||||
pristup. Slucovat je do jedne sluzby by znamenalo, ze firma, ktera ma jen
|
||||
Sklik, musi zaroven vyplnit Google. Proto jsou to ctyri sluzby s jednim `appId`.
|
||||
|
||||
**Prepis hovoru nema overeni udaju.** Jeho jedine volani je prepis, ktery se
|
||||
uctuje. Overeni proto rekne jen "sluzba odpovida" a nahlas dodá, ze udaje
|
||||
overene nejsou - test, ktery projde i se spatnym klicem, by lhal.
|
||||
|
||||
## Sluzby, ktere aplikaci zatim nemaji
|
||||
|
||||
E-shop, WhatsApp, Facebook Messenger, Instagram, SMS, Slack, Voicebot
|
||||
a AI zpracovani textu jsou v katalogu jako popis toho, co chceme umet. Konektor
|
||||
u nich zalozit jde, ale overeni skonci chybou, protoze na te adrese nic nebezi.
|
||||
|
||||
Meta Ads je **neco jineho nez Facebook Messenger a Instagram**: aplikace `meta`
|
||||
je nad Marketing API, tedy reklamy, a je jen pro cteni. Zpravy ze stranky
|
||||
a prime zpravy ta aplikace neumi, proto se na ni ty dve sluzby nenapojily.
|
||||
|
||||
## Co ktera sluzba chce po firme
|
||||
|
||||
Udaje patri konektoru, ne prostredi. Tajne se z API nikdy nevraci.
|
||||
|
||||
### RAYNET CRM
|
||||
|
||||
| Pole | Hlavicka | Kde to vzit |
|
||||
| ---------------- | ----------------- | ----------------------------- |
|
||||
| API klic | `X-Api-Key` | RAYNET: Nastaveni, Klic k API |
|
||||
| E-mail uzivatele | `X-Raynet-Email` | prihlasovaci e-mail |
|
||||
| Nazev instance | `X-Instance-Name` | subdomena uctu |
|
||||
|
||||
### CSOB (PSD2)
|
||||
|
||||
Nejvic udaju z celeho katalogu, a je to tak spravne: bankovni rozhrani chce
|
||||
certifikat i token. `X-Access-Token` navic **casem vyprsi** a musi se prepsat,
|
||||
jinak konektor prestane fungovat, aniz by se cokoliv jineho zmenilo.
|
||||
|
||||
Certifikat QWAC se vklada jako PFX zakodovany do Base64.
|
||||
|
||||
### SAP Business One
|
||||
|
||||
`X-SAP-B1-BaseUrl` je adresa Service Layer u zakaznika a musi byt dostupna
|
||||
z internetu. `X-SAP-B1-Reject-Unauthorized` se nastavi na `false` jen tam, kde
|
||||
ma Service Layer self-signed certifikat.
|
||||
|
||||
### PPL CPL
|
||||
|
||||
Client ID a Client Secret z vyvojarskeho portalu. `X-Environment` prepne na
|
||||
testovaci prostredi, ktere **nevytvari skutecne zasilky** - hodi se pri
|
||||
zkousení stromu.
|
||||
|
||||
### Microsoft 365
|
||||
|
||||
Tenant ID, Client ID a Client Secret registrovane aplikace v Entra ID.
|
||||
Prihlasuje se aplikace, ne clovek, takze kazdy krok rika, **ktere schranky**
|
||||
se tyka.
|
||||
|
||||
### Google Workspace
|
||||
|
||||
Dve cesty, staci jedna:
|
||||
|
||||
- **JSON klic service accountu** (`X-Google-Service-Account-Json`) plus
|
||||
opravneni (`X-Google-Service-Account-Scopes`). Tohle je cesta pro provoz bez
|
||||
cloveka: sluzba si z klice vystavi token sama.
|
||||
- **Hotovy access token** (`X-Google-Access-Token`). Plati asi hodinu, takze
|
||||
na trvaly provoz to neni.
|
||||
|
||||
Overeni konektoru cte Disk, takze service account potrebuje aspon scope
|
||||
`https://www.googleapis.com/auth/drive.readonly`. Bez nej test skonci chybou,
|
||||
i kdyz je klic v poradku.
|
||||
|
||||
### Google Analytics 4, Search Console, Google Ads
|
||||
|
||||
Vsechny tri pouzivaji tentyz princip prihlaseni pres Google, jen s jinym
|
||||
prefixem hlavicky. **Jeden service account staci na vsechny tri**, kdyz se mu
|
||||
v kazde sluzbe udeli pristup. Google Ads navic vzdy potrebuje developer token.
|
||||
|
||||
### Sklik
|
||||
|
||||
Jeden token z API Drak. Vygenerovani noveho tokenu **zneplatni ten predchozi**,
|
||||
takze se u sdileneho uctu vyplati vedet, kdo ho generoval naposled.
|
||||
|
||||
### Meta Ads
|
||||
|
||||
Token systemoveho uzivatele z Business Manageru. Uzivatelsky token prestane
|
||||
platit, kdyz clovek odejde z firmy nebo si zmeni heslo, takze se pro
|
||||
server-to-server nehodi. App secret je povinny tam, kde ma aplikace zapnute
|
||||
`appsecret_proof`.
|
||||
|
||||
### Prepis hovoru
|
||||
|
||||
Deepgram i OpenAI klic. Obe sluzby bezi paralelne a treti volani jejich
|
||||
vysledky slucuje, takze bez obou klicu to nefunguje.
|
||||
|
||||
### OpenAI
|
||||
|
||||
Jen API klic. Zadava se **holy**, slovo `Bearer` dopise portal - viz nize.
|
||||
|
||||
## OpenAI: sluzba, ktera nebezi u nas
|
||||
|
||||
Zbytek katalogu jsou nase aplikace za `services.csbot.cz/apps`. OpenAI je cizi
|
||||
domena, se kterou nemuzeme hnout, takze se s ni zachazi jinak na trech mistech.
|
||||
|
||||
### Adresa je u sluzby, ne z `appId`
|
||||
|
||||
`Service` ma nove nepovinne pole `baseUrl` s absolutni adresou. Skladat adresu
|
||||
ze `SERVICES_BASE_URL` by u ni nedavalo smysl - to je zaklad **nasich**
|
||||
aplikaci.
|
||||
|
||||
Prepsat ji jde dvema zpusoby:
|
||||
|
||||
| Kudy | Pro koho plati | K cemu |
|
||||
| ------------------ | -------------- | ------------------------------ |
|
||||
| `OPENAI_BASE_URL` | cela instance | brána, napodobenina pri vyvoji |
|
||||
| adresa u konektoru | jedna firma | vlastni Azure OpenAI |
|
||||
|
||||
Obecne: `<SLUZBA>_BASE_URL`, kde se z ID sluzby udelaji velka pismena
|
||||
a pomlcka je podtrzitko (`sap-bo` je `SAP_BO_BASE_URL`).
|
||||
|
||||
### Klic se zadava holy
|
||||
|
||||
OpenAI chce `Authorization: Bearer <klic>`. Kdyby si mel uzivatel slovo
|
||||
`Bearer` psat sam, byl by to prvni zdroj chyb, ktery **neni videt ani zpetne** -
|
||||
hodnota se z API nevraci, takze preklep v ni uz nikdo nenajde.
|
||||
|
||||
Pole udaju proto ma nepovinny `prefix` a runtime ho doplni az pri sestaveni
|
||||
hlavicky. Redakce v logu se dela na obojí: na cely retezec i na samotny klic,
|
||||
protoze cizi sluzby vraci v chybe jednou jedno a jednou druhé.
|
||||
|
||||
### Co s OpenAI umime
|
||||
|
||||
| Operace | Endpoint | K cemu |
|
||||
| -------------------- | ---------------------------- | -------------------------------------- |
|
||||
| Zeptat se modelu | `POST /chat/completions` | shrnuti, klasifikace, sepsani odpovedi |
|
||||
| Nahrat soubor | `POST /files` | vrati `fileId` pro dalsi krok |
|
||||
| Zeptat se na soubor | `POST /responses` | vytezeni faktury, smlouvy, fotky |
|
||||
| Prepsat zvuk | `POST /audio/transcriptions` | jeden pruchod prepisem |
|
||||
| Nacist seznam modelu | `GET /models` | co ucet umi, nic nestoji |
|
||||
|
||||
**Model je volny text s vychozi hodnotou**, ne vyber ze seznamu. Pevny seznam
|
||||
by zestarl pri kazdem vydani noveho modelu a krok stromu by pak odmital
|
||||
hodnotu, kterou ucet umi. Co ucet umi, vrati operace Nacist seznam modelu.
|
||||
|
||||
**Otazka nad souborem je jiny endpoint nez obycejny dotaz.** Soubor jako vstup
|
||||
umi az Responses API; Chat Completions by prijalo jen text, takze by se obsah
|
||||
PDF musel vlepit rucne - a to nejde.
|
||||
|
||||
**Nahrani a dotaz jsou dva kroky.** Jednoho souboru se casto pta vic dotazu
|
||||
a nahravat ho pokazde znovu by stalo cas i penize.
|
||||
|
||||
`temperature` se posila **jen kdyz ji uzivatel vyplni**. Novejsi modely ji
|
||||
odmitaji uplne, takze poslat vychozi hodnotu by krok rozbilo tam, kde o ni
|
||||
nikdo nestal.
|
||||
|
||||
## E-mail: sluzba, ktera nejde pres HTTP
|
||||
|
||||
Zbytek katalogu se vola pres HTTP a operaci vykona skript. SMTP neni HTTP,
|
||||
a skript umi jen `ctx.http` - dat mu sit jinudy by zrusilo pravidlo, ze skript
|
||||
nema jak zavolat ven mimo nas klient.
|
||||
|
||||
E-mail je proto **vnitrni krok** (`src/runtime/builtinSteps.ts`), stejne jako
|
||||
zalozeni ticketu. Rozdil je jen v tom, kam saha: ticket do naseho uloziste,
|
||||
e-mail na posmovni server firmy.
|
||||
|
||||
Sluzba to o sobe rika sama, priznakem `transport: 'smtp'`. Podle nej se rozhodne
|
||||
i overeni konektoru. Neni to vlastnost konektoru: jak se sluzba vola, je
|
||||
vlastnost sluzby.
|
||||
|
||||
### Co si firma vyplni
|
||||
|
||||
| Pole | Klic | Poznamka |
|
||||
| ------------------- | ---------- | -------------------------------------------------------- |
|
||||
| SMTP server | `host` | napriklad smtp.seznam.cz |
|
||||
| Port | `port` | 587 pro STARTTLS, 465 pro sifrovane od zacatku |
|
||||
| Sifrovani | `security` | prazdne se ridi portem, prepsat lze ssl, starttls, zadne |
|
||||
| Uzivatel | `user` | obvykle cela adresa |
|
||||
| Heslo | `password` | tajne, z API se nikdy nevraci |
|
||||
| Adresa odesilatele | `from` | server ji musi povolit |
|
||||
| Jmeno odesilatele | `fromName` | co uvidi prijemce misto hole adresy |
|
||||
| Adresa pro odpovedi | `replyTo` | kdyz maji odpovedi chodit jinam |
|
||||
|
||||
Prazdne sifrovani se ridi portem, protoze to je zvyklost, kterou zna kazdy.
|
||||
Vyslovna hodnota to prebije - jsou servery, ktere to maji jinak.
|
||||
|
||||
Adresa serveru se hlida stejne jako u HTTP: **nesmi mirit do vnitrni site**.
|
||||
Vyplnuje ji firma, takze je to jedina zabrana proti tomu, aby si nechala
|
||||
navazat spojeni dovnitr.
|
||||
|
||||
### Co se vyplnuje v kroku
|
||||
|
||||
| Pole | Druh | Poznamka |
|
||||
| ------------------- | -------- | ----------------------------------- |
|
||||
| Prijemce | text | adresy oddelene carkou |
|
||||
| Kopie, skryta kopie | text | nepovinne |
|
||||
| Predmet | text | sablona, tedy `Ticket {{ticketId}}` |
|
||||
| Telo zpravy | **html** | pise se jako HTML, vice radku |
|
||||
| Textova verze | longtext | bez vyplneni se vyrobi z HTML |
|
||||
| Adresa pro odpovedi | text | prebije hodnotu z konektoru |
|
||||
|
||||
Krok vraci `messageId`, `accepted` a `rejected`, takze se za nim da vetvit
|
||||
podminkou na to, jestli server nekoho odmitl.
|
||||
|
||||
Textova verze neni pridavek. Klient, ktery HTML nezobrazi, by dostal prazdnou
|
||||
zpravu, a filtry nevyzadane posty berou chybejici textovou cast jako priznak
|
||||
spamu.
|
||||
|
||||
### HTML telo a dosazovani promennych
|
||||
|
||||
Druh pole `html` je novy vedle `text` a `longtext` a znamena dve veci: builder
|
||||
ho vykresli jako vysoke pole s neproporcionalnim pismem, a runtime v nem
|
||||
**escapuje dosazene hodnoty**.
|
||||
|
||||
Escapuje se hodnota, ne sablona. Znacky, ktere napsal autor sablony, jsou zamer;
|
||||
ostre zavorky v hodnote od zakaznika ne. Bez toho by text ticketu s `<b>`
|
||||
prepsal rozvrzeni zpravy a `<script>` by se dostal prijemci do schranky.
|
||||
|
||||
Deje se to v `fillTemplates` v executoru, protoze jen tam je jeste videt, co
|
||||
byla sablona a co dosazena hodnota. Escapovat az vysledek nejde - v nem uz se
|
||||
to nerozlisi.
|
||||
|
||||
Druh pole se urcuje **vyslovne v katalogu**, ne odhadem podle nazvu. Hadat
|
||||
podle `id === 'html'` by fungovalo do prvni akce, ktera to pole pojmenuje jinak.
|
||||
|
||||
### Overeni konektoru
|
||||
|
||||
Misto cteciho volani se konektor **prihlasi na server** (`verify` v nodemaileru).
|
||||
Nic se neodesila, takze test nikomu nic nedorucí, a bez platneho hesla neprojde.
|
||||
|
||||
Hlaska rozlisuje, co se stalo, stejne jako u HTTP:
|
||||
|
||||
| Co server rekl | Co to znamena |
|
||||
| -------------------------- | -------------------------------------------------- |
|
||||
| `EAUTH`, kod 535 | udaje dostal a neuznal je, jde o uzivatele a heslo |
|
||||
| `EENVELOPE` | neprijal odesilatele nebo prijemce |
|
||||
| `ECONNECTION`, `ETIMEDOUT` | nespojilo se, tedy adresa, port nebo sifrovani |
|
||||
| kod 4xx | docasne odmitnuti, opakovani ma smysl |
|
||||
|
||||
**Odeslani e-mailu je jediny vnitrni krok, ktery smi rict "zkus to znovu".**
|
||||
Ostatni selhavaji na spatnem nastaveni, ktere se opakovanim nespravi. Nedostupny
|
||||
posmovni server ale za minutu bezet muze, kdezto spatne heslo bude spatne porad.
|
||||
|
||||
U schranek s dvoufazovym overenim musi byt v konektoru **heslo pro aplikaci**,
|
||||
ne heslo k uctu. Server na to odpovi obycejnym 535 a hlaska to proto rika sama.
|
||||
|
||||
## Soubory ve skriptech
|
||||
|
||||
Soubor prochazi krokem stromu jako **Base64 retezec**. Duvod: parametr skriptu
|
||||
je vzdy hodnota, kterou jde zapsat do JSONu, protoze se uklada do zaznamu behu.
|
||||
Binarni data by se tam nevesla.
|
||||
|
||||
Odesila se pres `ctx.http.postForm`, ktery slozi `multipart/form-data`:
|
||||
|
||||
```js
|
||||
await ctx.http.postForm('/files', {
|
||||
purpose: 'user_data',
|
||||
file: { filename: 'faktura.pdf', base64: inputs.obsah, contentType: 'application/pdf' },
|
||||
});
|
||||
```
|
||||
|
||||
Hranici (boundary) dopisuje az `fetch`. Kdyby si ji skript nastavoval sam,
|
||||
chybela by v hlavicce a sluzba by telo neprecetla.
|
||||
|
||||
Strop je `SCRIPT_MAX_UPLOAD_BYTES`, vychozi 10 MB. Je zamerne nizsi nez u cizich
|
||||
sluzeb: OpenAI zvladne stovky megabajtu, nas zaznam behu ne.
|
||||
|
||||
## Skripty, ktere k realnym sluzbam existuji
|
||||
|
||||
| Skript | Co dela |
|
||||
| ------------------------------ | ------------------------------------------------ |
|
||||
| `raynet.find-company` | dohleda firmu, nenalezeno neni chyba |
|
||||
| `raynet.upsert-contact` | najde podle e-mailu a doplni, jinak zalozi |
|
||||
| `raynet.create-lead` | zalozi poptavku, volitelne s vazbou na firmu |
|
||||
| `csob.list-transactions` | prelozi cislo uctu na ID banky a stahne pohyby |
|
||||
| `sap-bo.find-business-partner` | hleda pres OData filtr, apostrof se zdvojuje |
|
||||
| `sap-bo.list-orders` | objednavky partnera nebo za obdobi |
|
||||
| `ppl.create-shipment` | zalozi zasilku a pocka na zpracovani davky |
|
||||
| `ppl.track` | stav zasilky, nenalezeno neni chyba |
|
||||
| `microsoft365.send-mail` | e-mail z konkretni schranky |
|
||||
| `microsoft365.create-event` | schuzka vcetne casove zony |
|
||||
| `google.send-email` | Gmail: sklada cely RFC 2822 e-mail |
|
||||
| `google.append-sheet-row` | pripise radek do tabulky, ID vytahne z odkazu |
|
||||
| `ga4.run-report` | srovna GA4 hlavicky a hodnoty na radky |
|
||||
| `search-console.run-report` | pojmenuje dimenze zpatky, CTR na procenta |
|
||||
| `google-ads.campaign-report` | GAQL, mikrojednotky na koruny |
|
||||
| `sklik.campaign-report` | RPC pole argumentu, halere na koruny |
|
||||
| `meta-ads.insights` | vykon reklam, cisla z textu na cisla |
|
||||
| `transcription.transcribe` | dva enginy a sloucení, s nahradnikem pri vypadku |
|
||||
| `openai.chat` | dotaz na model |
|
||||
| `openai.upload-file` | nahrani souboru pres multipart |
|
||||
| `openai.ask-about-file` | otazka nad souborem pres Responses API |
|
||||
| `openai.transcribe-audio` | prepis zvuku |
|
||||
| `openai.list-models` | co ucet umi |
|
||||
|
||||
E-mail v tabulce neni schvalne: neni to skript, ale vnitrni krok, viz vyse.
|
||||
|
||||
Skripty pro iDoklad jsou popsane v [11-skripty-konektoru.md](11-skripty-konektoru.md).
|
||||
|
||||
## Co chybi
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| ---------------------------------- | ----------------------------------------------------------------- |
|
||||
| Spoustece ze skutecnych sluzeb | triggery v katalogu jsou zatim popis, ne kod |
|
||||
| Stazeni binarni odpovedi | `ctx.http` cte odpoved jako text, PDF etikety se nevraci |
|
||||
| Google: dohledani souboru na Disku | wrapper nema v OpenAPI parametry dotazu, neni jiste, co propousti |
|
||||
| OAuth toky (Google, CSOB, Meta) | token se zadava rucne a po vyprsení se rucne prepisuje |
|
||||
| Prilohy u e-mailu | krok posila jen telo, soubor zatim neprilozi |
|
||||
| Prijem e-mailu (IMAP) | spoustec Prijat e-mail je zatim jen popis v katalogu |
|
||||
| Overeni udaju u Prepisu hovoru | sluzba nema levne cteci volani, ktere by udaje otestovalo |
|
||||
+185
-12
@@ -2,6 +2,179 @@
|
||||
|
||||
Nejnovejsi nahore.
|
||||
|
||||
## 2026-08-26 - helpdesk: pozadavek, ktery vidi zadavatel i resitel
|
||||
|
||||
Zakaznik nemel jak poslat pozadavek. Ticket pritom patri jedne firme, kdezto
|
||||
u helpdesku figuruji dve - ta, ktera se pta, a ta, ktera to resi.
|
||||
|
||||
### Pridano
|
||||
|
||||
- **Pole `Ticket.helpdeskSourceId`** s firmou, ktera pozadavek poslala.
|
||||
Vlastnikem (`tenantId`) zustava ta, ktera ho **resi**. Zamerne tak: kdyby byl
|
||||
vlastnikem zadavatel, mel by resitel pozadavek jen jako cizi ticket a nemel by
|
||||
ho ve sve fronte, ve statistikach ani v prirazovani. Takhle je to na jeho
|
||||
strane obycejny ticket a nemuselo se sahnout na nic, co uz funguje.
|
||||
- **`Tenant.helpdeskProviderId`**, tedy komu firma posila pozadavky. Nastavuje
|
||||
spravce platformy v Nastaveni, Firmy. Kdo koho obsluhuje je obchodni vztah,
|
||||
ne volba klienta - kdyby si dodavatele vybiral uzivatel, poslal by pozadavek
|
||||
nekomu, s kym nema smlouvu. Bez vyplneneho dodavatele se pozadavek nezalozi
|
||||
a rekne se to nahlas.
|
||||
- **Sekce Helpdesk** (`/dashboard/helpdesk`) a router
|
||||
`/api/dashboard/helpdesk`: seznam vlastnich pozadavku, zalozeni, detail
|
||||
s prubehem a komentar. Zamerne to **neni druhy seznam ticketu** - chybi tu
|
||||
filtry, prirazovani i fronta, protoze zadavatele nezajima, kdo to ma u sebe.
|
||||
- **Prava `helpdesk.view` a `helpdesk.create`.** Prideluje je admin te firmy
|
||||
pres role, stejne jako u ostatnich prav.
|
||||
|
||||
### Zmeneno
|
||||
|
||||
- **`listTickets` umi filtrovat podle zdroje** (`helpdeskSourceIds`). Vyplnene
|
||||
**nahrazuje** filtr podle vlastnika, protoze zadavatel vlastnikem neni.
|
||||
Bezny seznam ticketu se nezmenil.
|
||||
- **`getTicket` a `addComment` maji druhou cestu dovnitr.** Firma, ktera
|
||||
pozadavek poslala, ho smi cist a pripsat k nemu komentar, i kdyz ho nevlastni.
|
||||
Komentar je jedina zmena, kterou nad nim smi: stav, resitele a typ urcuje ten,
|
||||
kdo to resi.
|
||||
|
||||
## 2026-08-26 - portal prestal ukazovat provozni veci a ticket jde zalozit rucne
|
||||
|
||||
Sada oprav podle toho, co v portalu drhlo.
|
||||
|
||||
### Zmeneno
|
||||
|
||||
- **Hlasky o ulozisti a o odchozi IP adrese vidi jen spravce platformy.**
|
||||
Kam se uklada a z jake adresy volame ven resime my, ne zakaznik. "Data se
|
||||
ukladaji do souboru na serveru" na nej navic pusobi jako priznani, ze mu tu
|
||||
praci muzeme ztratit. Nemazou se, jen se schovavaji - nam poradi porad.
|
||||
Totez plati pro radek "volano z IP" v historii overeni.
|
||||
- **Typ ticketu se v automatizaci vybira ze seznamu.** Bylo to textove pole,
|
||||
do ktereho mel clovek opsat ID typu odjinud. Novy druh pole `lookup` je
|
||||
**ciselnik a volny text zaroven**: bud se vybere ze seznamu typu te firmy,
|
||||
nebo se hodnota dosadi z dat (`{{data.typ}}`). Typy ticketu jsou vlastnost
|
||||
firmy, takze nabidka chodi za tu, ve ktere clovek je.
|
||||
- **Stav ticketu je otevreny naseptavac, ne ciselnik.** Stav je volny retezec
|
||||
a vzdycky byl - ticket muze prijit z cizi aplikace s jejim vlastnim stavem.
|
||||
Vyber ze seznamu tomu odporoval. Ted je to textove pole s nabidkou toho, co
|
||||
firma uz pouziva (`GET /api/dashboard/tickets/statuses`), a nova hodnota
|
||||
projde stejne dobre. Zapisuje se az pri opusteni pole, ne po kazdem pismenu.
|
||||
- **"Kanal" se prejmenoval na "Odkud pozadavek prisel"** a rika o sobe, ze je
|
||||
nepovinny a slouzi jen k filtrovani a ikone v seznamu.
|
||||
- **Vstupni parametry u webhooku jsou oznacene jako nepovinne.** Webhook prijme
|
||||
cokoliv; rucne vypsany seznam parametru neni podminka, ale pohodli pro
|
||||
podminky - a vyplni se sam z vlepene ukazky tela. Prazdny seznam uz nehlasi,
|
||||
ze "bez nich nelze pridat podminku".
|
||||
|
||||
### Pridano
|
||||
|
||||
- **Zalozeni ticketu rucne** (`POST /api/dashboard/tickets`, pravo
|
||||
`ticket.create`, tlacitko Novy ticket na strance Tickety). Dosud ticket
|
||||
vznikal jen z automatizace nebo z prichozi udalosti, takze pozadavek prijaty
|
||||
telefonem nemel jak do systemu.
|
||||
- **Zakaznik u ticketu je nepovinny.** Rucne zalozeny ticket je casto ukol, ne
|
||||
pozadavek od nekoho zvenku; povinna firma a kontakt by znamenaly, ze si je
|
||||
clovek vymysli. Ve formulari je zakaznik schovany pod odkazem.
|
||||
|
||||
### Co z teze davky jeste neni
|
||||
|
||||
- **Sekce Helpdesk** pro zakaznika vcetne sdileni ticketu mezi admin firmou
|
||||
a tim, kdo ho zalozil.
|
||||
- **Prepinac firmy nad celym dashboardem.** Dnes je to stav uvnitr stranky
|
||||
Prehled, takze se prepnuti neprojevi v Lidech ani jinde - ostatni stranky
|
||||
volaji API bez `tenantId` a server pouzije vychozi firmu. Ma to doplnovat
|
||||
API vrstva na jednom miste, ne kazda stranka zvlast.
|
||||
|
||||
## 2026-08-26 - odesilani e-mailu ze schranky firmy
|
||||
|
||||
Sluzba E-mail byla v katalogu jako popis: bez udaju, bez vykonne casti. Ted se
|
||||
odesila doopravdy, ze schranky, kterou si firma vyplni v konektoru.
|
||||
|
||||
### Pridano
|
||||
|
||||
- **Sluzba E-mail pres SMTP.** V konektoru server, port, sifrovani, uzivatel,
|
||||
heslo, adresa a jmeno odesilatele a adresa pro odpovedi. Zadne udaje
|
||||
v prostredi - kazda firma odesila ze sve schranky.
|
||||
- **Vnitrni krok `email/send`.** SMTP neni HTTP a skript umi jen `ctx.http`,
|
||||
takze operaci vykonava vnitrni krok stejne jako zalozeni ticketu. Pristupove
|
||||
udaje pritom zustavaji v konektoru.
|
||||
- **Druh pole `html`.** Telo zpravy se pise jako HTML a builder ho vykresli
|
||||
jako vysoke pole s neproporcionalnim pismem. Runtime v nem **escapuje
|
||||
dosazene hodnoty**: znacky autora sablony jsou zamer, ostre zavorky
|
||||
v hodnote od zakaznika ne. Bez toho by text ticketu s `<b>` prepsal
|
||||
rozvrzeni zpravy a `<script>` by dosel prijemci do schranky.
|
||||
- **Overeni konektoru prihlasenim.** Sluzba s `transport: 'smtp'` se neoveruje
|
||||
ctecim volanim, ale prihlasenim na server. Nic se neodesila, takze test
|
||||
nikomu nic nedoruci, a bez platneho hesla neprojde.
|
||||
- **Vnitrni krok smi rict "zkus to znovu"** (`StepOutcome.retryable`). Dosud
|
||||
se opakovani u vnitrnich kroku vzdy vypinalo, protoze selhavaly na spatnem
|
||||
nastaveni. Nedostupny posmovni server ale za minutu bezet muze.
|
||||
- **Textova verze zpravy.** Bez vyplneni se vyrobi z HTML. Klient, ktery HTML
|
||||
nezobrazi, by jinak dostal prazdnou zpravu a filtry nevyzadane posty berou
|
||||
chybejici textovou cast jako priznak spamu.
|
||||
- **Zavislost `nodemailer`.** Vlastnorucne psany SMTP klient by byl 300 radku,
|
||||
ktere nejde bez schranky overit.
|
||||
|
||||
### Opraveno
|
||||
|
||||
- **SMTP server z konektoru nesmi mirit do vnitrni site.** Stejne pravidlo jako
|
||||
u HTTP (`isPrivateHost`), protoze adresu vyplnuje firma.
|
||||
|
||||
## 2026-08-26 - katalog stoji na sluzbach, ktere opravdu bezi, a pribyla OpenAI
|
||||
|
||||
Katalog popisoval, co chceme umet. U vetsiny sluzeb chybely pristupove udaje,
|
||||
takze konektor nemel co vyplnit, a `appId` u nekterych ukazovalo na aplikaci,
|
||||
ktera neexistuje. Zdroj pravdy o tom, co bezi, je `services.csbot.cz/apps`
|
||||
a jeji `/openapi.json`.
|
||||
|
||||
Novy dokument: [21-realne-sluzby.md](21-realne-sluzby.md).
|
||||
|
||||
### Zmeneno
|
||||
|
||||
- **Trinact sluzeb katalogu ma napojeni na bezici aplikaci** vcetne poli udaju
|
||||
a levneho cteciho volani na overeni. RAYNET, CSOB, PPL, Microsoft 365, GA4,
|
||||
Search Console, Google Ads, Sklik a Prepis hovoru mely `credentials: []`,
|
||||
takze konektor nesel vyplnit a test se nemel ceho chytit.
|
||||
- **Opravena `appId`, ktera nikam nevedla.** PPL bezi na `pplcplapi`, ne na
|
||||
`ppl`. Microsoft 365 na `microsoft-365-service`. Prepis hovoru na
|
||||
`audio-transcription`. GA4, Search Console, Google Ads a Sklik bezi vsechny
|
||||
na aplikaci `analytics`, kazdy s vlastnimi udaji - slucovat je do jedne
|
||||
sluzby by znamenalo, ze firma se samotnym Sklikem musi vyplnit i Google.
|
||||
- **Prepis hovoru ma jen operaci, kterou aplikace umi.** Operace "Vytvorit
|
||||
souhrn" za sebou nic nemela; souhrn ted udela OpenAI.
|
||||
- **Query ve `verifyPath` se do hlasky nedava.** Odrizne se stejne jako
|
||||
u `ScriptRequestInfo` - v query muze byt tajemstvi.
|
||||
|
||||
### Pridano
|
||||
|
||||
- **Sluzba OpenAI.** Firma zada svuj API klic do konektoru a muze se ptat
|
||||
modelu, poslat soubor a nechat si prepsat zvuk. Skripty `openai.chat`,
|
||||
`openai.upload-file`, `openai.ask-about-file`, `openai.transcribe-audio`
|
||||
a `openai.list-models`.
|
||||
- **Sluzba, ktera nebezi u nas.** `Service.baseUrl` nese absolutni adresu cizi
|
||||
sluzby. Skladat ji ze `SERVICES_BASE_URL` by nedavalo smysl, to je zaklad
|
||||
nasich aplikaci. Prepsat ji jde promennou `<SLUZBA>_BASE_URL` (dosud jen
|
||||
slibenou v dokumentaci, ted opravdu implementovanou) nebo adresou u konektoru,
|
||||
cimz vede cesta na Azure OpenAI.
|
||||
- **Predpona hlavicky u pole udaju** (`ServiceCredentialField.prefix`).
|
||||
OpenAI chce `Authorization: Bearer <klic>`; uzivatel vlepi holy klic a slovo
|
||||
pred nim dopise runtime. Preklep v rucne psanem `Bearer` by nesel najit ani
|
||||
zpetne, protoze se hodnota z API nevraci.
|
||||
- **Odesilani souboru ze skriptu** (`ctx.http.postForm`). Obsah prichazi jako
|
||||
Base64, protoze parametr kroku se uklada do zaznamu behu a binarni data by se
|
||||
tam nevesla. Strop `SCRIPT_MAX_UPLOAD_BYTES`, vychozi 10 MB.
|
||||
- **Sluzby SAP Business One, Google Workspace a Meta Ads.** Bezi, ale
|
||||
v katalogu nebyly. Meta Ads je zamerne oddelena od Facebook Messengeru
|
||||
a Instagramu: aplikace `meta` je nad Marketing API, tedy reklamy, a je jen
|
||||
pro cteni.
|
||||
- **Dvacet tri skriptu k realnym sluzbam**, od dohledani firmy v RAYNETu po vykon
|
||||
kampani ve Skliku. Seznam je v [21-realne-sluzby.md](21-realne-sluzby.md).
|
||||
|
||||
### Opraveno
|
||||
|
||||
- **Redakce tajemstvi bere i holou hodnotu.** Dosud se skrtaly jen hodnoty
|
||||
hlavicek, takze u `Authorization: Bearer sk-...` by samotne `sk-...` v chybove
|
||||
odpovedi proslo do logu. `targetSecrets` ted vraci obojí a pouziva ho
|
||||
i overeni konektoru, ktere si dosud sestavovalo seznam samo.
|
||||
|
||||
## 2026-08-25 - chybova hlaseni konektoru rikaji, co se stalo
|
||||
|
||||
"Pristup zamitnut: GET /apps/idoklad/account/agenda vratilo HTTP 403.
|
||||
@@ -143,12 +316,12 @@ Skript je **ciste prevod hodnot**: dostane `input`, vrati objekt. Nema `require`
|
||||
`import`, `process`, `fetch` ani `console`. Volani ven patri do kroku konektoru,
|
||||
ktery ma pristupove udaje, opakovani i zapis do logu.
|
||||
|
||||
| Pojistka | Proc |
|
||||
| --- | --- |
|
||||
| limit 2 s | zacykleny skript by jinak zablokoval workera vsem firmam |
|
||||
| vysledek do 256 kB | vetsi objekt uz stejne nikdo dal nezpracuje |
|
||||
| kod do 20 000 znaku | delsi uz neni prevod, ale aplikace |
|
||||
| zadny stav mezi behy | stav, ktery prezije beh, je zdroj nejhur hledanych chyb |
|
||||
| Pojistka | Proc |
|
||||
| -------------------- | -------------------------------------------------------- |
|
||||
| limit 2 s | zacykleny skript by jinak zablokoval workera vsem firmam |
|
||||
| vysledek do 256 kB | vetsi objekt uz stejne nikdo dal nezpracuje |
|
||||
| kod do 20 000 znaku | delsi uz neni prevod, ale aplikace |
|
||||
| zadny stav mezi behy | stav, ktery prezije beh, je zdroj nejhur hledanych chyb |
|
||||
|
||||
**Cim to neni.** `node:vm` neni bezpecnostni hranice proti nekomu, kdo se chce
|
||||
dostat ven. Je to izolace proti nehode a proti zacykleni. Skript pise spravce
|
||||
@@ -307,12 +480,12 @@ ukazovalo 373 za automatizaci, ktera bezi tri mesice.
|
||||
|
||||
### Nove soubory
|
||||
|
||||
| Soubor | Co dela |
|
||||
| --- | --- |
|
||||
| `src/data/invites.ts` | Entita pozvanky, kod, platnost. |
|
||||
| `src/routes/invites.ts` | Verejne cesty (prohlednuti a prijeti) a sprava. |
|
||||
| `web/src/pages/Invite.tsx` | Stranka za odkazem: jmeno, e-mail, heslo. |
|
||||
| `web/src/components/dashboard/InvitePanel.tsx` | Sprava pozvanek v zalozce Lide. |
|
||||
| Soubor | Co dela |
|
||||
| ---------------------------------------------- | ----------------------------------------------- |
|
||||
| `src/data/invites.ts` | Entita pozvanky, kod, platnost. |
|
||||
| `src/routes/invites.ts` | Verejne cesty (prohlednuti a prijeti) a sprava. |
|
||||
| `web/src/pages/Invite.tsx` | Stranka za odkazem: jmeno, e-mail, heslo. |
|
||||
| `web/src/components/dashboard/InvitePanel.tsx` | Sprava pozvanek v zalozce Lide. |
|
||||
|
||||
## 2026-08-13 - vyrizeno je vyslovny priznak, ne hadani ze stavu
|
||||
|
||||
|
||||
Generated
+21
@@ -12,6 +12,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^9.0.5",
|
||||
"pg": "^8.23.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"zod": "^3.24.1"
|
||||
@@ -23,6 +24,7 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.21.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
@@ -1620,6 +1622,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.21.0.tgz",
|
||||
@@ -3186,6 +3198,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^9.0.5",
|
||||
"pg": "^8.23.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"zod": "^3.24.1"
|
||||
@@ -30,6 +31,7 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.21.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
|
||||
+8
-1
@@ -58,7 +58,8 @@ export const manifest = {
|
||||
* @param {Record<string, string | number | boolean | null>} inputs
|
||||
* Uz overene a prevedene na typy z manifestu.
|
||||
* @param {{
|
||||
* http: { get: Function, post: Function, patch: Function, put: Function, del: Function },
|
||||
* http: { get: Function, post: Function, patch: Function, put: Function, del: Function,
|
||||
* postForm: Function },
|
||||
* util: { unwrap: Function, pick: Function, first: Function, text: Function,
|
||||
* num: Function, bool: Function, date: Function, round: Function, need: Function },
|
||||
* log: Function, config: Record<string, string>, idempotencyKey: string,
|
||||
@@ -66,6 +67,12 @@ export const manifest = {
|
||||
* }} ctx
|
||||
*/
|
||||
export async function run(inputs, ctx) {
|
||||
// Soubor se posila pres `ctx.http.postForm`, obsah jako Base64:
|
||||
// await ctx.http.postForm('/files', {
|
||||
// purpose: 'user_data',
|
||||
// file: { filename: 'faktura.pdf', base64: inputs.obsah, contentType: 'application/pdf' },
|
||||
// });
|
||||
|
||||
// Cesta je relativni k adrese napojeni, cela adresa se nikam nepise.
|
||||
const { body } = await ctx.http.get('/nejaky-endpoint', {
|
||||
query: { hledat: inputs.prikladVstupu },
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* CSOB (PSD2): nacteni pohybu na uctu.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/csob
|
||||
* Endpointy: GET /accounts, GET /accounts/{id}/transactions
|
||||
*
|
||||
* Vzor **dvou volani, kde prvni jen zjistuje ID**. Cislo uctu, ktere clovek
|
||||
* zna, neni to, cim se ucet v PSD2 adresuje - banka pouziva vlastni
|
||||
* identifikator. Prekladat si ho rucne do kroku stromu by znamenalo, ze po
|
||||
* kazde vymene souhlasu prestane strom fungovat.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'csob.list-transactions',
|
||||
name: 'Načíst pohyby',
|
||||
description:
|
||||
'Stáhne zaúčtované pohyby na účtu za období. Nic nemění, jen čte. ' +
|
||||
'Účet se vybírá číslem účtu, převod na identifikátor banky řeší krok sám.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'accountNumber',
|
||||
label: 'Číslo účtu',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint:
|
||||
'Ve tvaru, jakým ho zná klient, například 123456789/0300, nebo IBAN. ' +
|
||||
'Bez vyplnění se vezme první účet, na který souhlas dosáhne.',
|
||||
},
|
||||
{
|
||||
id: 'dateFrom',
|
||||
label: 'Od data',
|
||||
type: 'date',
|
||||
required: false,
|
||||
hint: 'Bez vyplnění rozhoduje banka, obvykle vrací poslední pohyby.',
|
||||
},
|
||||
{ id: 'dateTo', label: 'Do data', type: 'date', required: false },
|
||||
{
|
||||
id: 'limit',
|
||||
label: 'Nejvýše kolik pohybů',
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 100,
|
||||
hint: 'Strop na jednu stránku. Vyšší číslo znamená větší odpověď.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'accountId', label: 'Identifikátor účtu', type: 'string', required: true },
|
||||
{ id: 'count', label: 'Počet pohybů', type: 'number', required: true },
|
||||
{ id: 'transactions', label: 'Pohyby', type: 'list', required: true },
|
||||
{ id: 'newest', label: 'Datum nejnovějšího pohybu', type: 'string', required: false },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
/** Jen cislice. Cislo uctu se pise s pomlckami, lomitkem i s mezerami. */
|
||||
function digits(value) {
|
||||
return String(value ?? '').replace(/[^0-9]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam z odpovedi banky.
|
||||
*
|
||||
* COBS obaluje kolekci pod vlastni nazev (`accounts`, `transactions`), jiny
|
||||
* standard pouziva `content` nebo `items`. Hadat jeden tvar by znamenalo, ze
|
||||
* pri zmene obalky krok tise vrati nula zaznamu misto chyby.
|
||||
*/
|
||||
function collection(ctx, body, ...names) {
|
||||
if (Array.isArray(body)) return body;
|
||||
const found = ctx.util.pick(body, ...names, 'data', 'content', 'items');
|
||||
if (Array.isArray(found)) return found;
|
||||
const unwrapped = ctx.util.unwrap(body);
|
||||
return Array.isArray(unwrapped) ? unwrapped : null;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, need } = ctx.util;
|
||||
|
||||
const { body: accountsBody } = await ctx.http.get('/accounts', { query: { size: 50 } });
|
||||
const accounts = collection(ctx, accountsBody, 'accounts');
|
||||
if (accounts === null) ctx.fail('Odpověď banky nemá seznam účtů.', accountsBody);
|
||||
if (accounts.length === 0) {
|
||||
ctx.fail('Souhlas nedosáhne na žádný účet. Zkontrolujte platnost souhlasu v ČSOB.');
|
||||
}
|
||||
|
||||
let account = accounts[0];
|
||||
|
||||
if (inputs.accountNumber) {
|
||||
const wanted = digits(inputs.accountNumber);
|
||||
const match = accounts.find((item) => {
|
||||
const iban = digits(pick(item, 'iban'));
|
||||
const number = digits(pick(pick(item, 'accountNumber'), 'number') ?? pick(item, 'accountNumber'));
|
||||
return (
|
||||
(iban !== '' && iban.includes(wanted)) || (number !== '' && wanted.endsWith(number))
|
||||
);
|
||||
});
|
||||
if (!match) {
|
||||
ctx.fail(
|
||||
`Účet ${inputs.accountNumber} mezi ${accounts.length} dostupnými není. ` +
|
||||
'Souhlas může platit na jiný účet.',
|
||||
);
|
||||
}
|
||||
account = match;
|
||||
} else {
|
||||
ctx.log(`Číslo účtu nezadáno, bere se první z ${accounts.length} dostupných.`);
|
||||
}
|
||||
|
||||
const accountId = need(text(pick(account, 'accountId', 'id')), 'identifikátor účtu');
|
||||
|
||||
const { body } = await ctx.http.get(`/accounts/${accountId}/transactions`, {
|
||||
query: {
|
||||
dateFrom: inputs.dateFrom ? inputs.dateFrom.slice(0, 10) : undefined,
|
||||
dateTo: inputs.dateTo ? inputs.dateTo.slice(0, 10) : undefined,
|
||||
size: inputs.limit,
|
||||
},
|
||||
});
|
||||
|
||||
const transactions = collection(ctx, body, 'transactions');
|
||||
if (transactions === null) ctx.fail('Odpověď banky nemá seznam pohybů.', body);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
count: transactions.length,
|
||||
transactions,
|
||||
newest: transactions.length > 0
|
||||
? text(pick(transactions[0], 'bookingDate', 'valueDate', 'creationDate'))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Google Analytics 4: report.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/analytics
|
||||
* Endpoint: POST /ga/data/properties/{property_id}/runReport
|
||||
*
|
||||
* GA4 vraci report jako **hlavicky zvlast a hodnoty zvlast**, tedy tvar, se
|
||||
* kterym se ve strome nedá pracovat. Skript ho proto srovna na seznam objektu,
|
||||
* kde ma kazdy radek pojmenovana pole - podminka za krokem se pak muze zeptat
|
||||
* na `activeUsers` misto na `metricValues.0.value`.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'ga4.run-report',
|
||||
name: 'Načíst report',
|
||||
description:
|
||||
'Stáhne metriky z Google Analytics 4 za období. Vrací řádky s pojmenovanými ' +
|
||||
'poli, takže se s nimi dá rovnou počítat.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'propertyId',
|
||||
label: 'ID property',
|
||||
type: 'string',
|
||||
required: true,
|
||||
pattern: '^[0-9]+$',
|
||||
hint: 'Jen číslice. GA4: Administrace, Nastavení property, ID property.',
|
||||
},
|
||||
{
|
||||
id: 'metrics',
|
||||
label: 'Metriky',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'activeUsers,sessions',
|
||||
hint: 'Oddělené čárkou, například activeUsers,sessions,conversions.',
|
||||
},
|
||||
{
|
||||
id: 'dimensions',
|
||||
label: 'Dimenze',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Oddělené čárkou, například date,country. Bez nich vyjde jeden souhrnný řádek.',
|
||||
},
|
||||
{
|
||||
id: 'startDate',
|
||||
label: 'Od',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: '7daysAgo',
|
||||
hint: 'Datum jako 2026-08-01, nebo relativně: 7daysAgo, yesterday, today.',
|
||||
},
|
||||
{
|
||||
id: 'endDate',
|
||||
label: 'Do',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'today',
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
label: 'Nejvýše kolik řádků',
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 100,
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
|
||||
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
|
||||
{ id: 'totalRows', label: 'Řádků celkem v GA', type: 'number', required: false },
|
||||
{ id: 'firstRow', label: 'První řádek', type: 'object', required: false },
|
||||
],
|
||||
|
||||
timeoutMs: 45000,
|
||||
};
|
||||
|
||||
/** Pole, nebo prazdno. Cizi odpoved nemusi mit tvar, ktery cekame. */
|
||||
function list(value) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
/** Seznam z jednoho radku. Prazdne polozky se zahazuji, at neposilame "". */
|
||||
function names(value) {
|
||||
return String(value ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '');
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num } = ctx.util;
|
||||
|
||||
const metrics = names(inputs.metrics);
|
||||
if (metrics.length === 0) ctx.fail('Vyplňte aspoň jednu metriku.');
|
||||
const dimensions = names(inputs.dimensions);
|
||||
|
||||
const { body } = await ctx.http.post(`/ga/data/properties/${inputs.propertyId}/runReport`, {
|
||||
dateRanges: [{ startDate: inputs.startDate, endDate: inputs.endDate }],
|
||||
metrics: metrics.map((name) => ({ name })),
|
||||
...(dimensions.length > 0 ? { dimensions: dimensions.map((name) => ({ name })) } : {}),
|
||||
limit: inputs.limit,
|
||||
});
|
||||
|
||||
const dimensionHeaders = list(pick(body, 'dimensionHeaders')).map((header) =>
|
||||
String(pick(header, 'name')),
|
||||
);
|
||||
const metricHeaders = list(pick(body, 'metricHeaders')).map((header) =>
|
||||
String(pick(header, 'name')),
|
||||
);
|
||||
|
||||
const rows = list(pick(body, 'rows')).map((row) => {
|
||||
const flat = {};
|
||||
const dimensionValues = list(pick(row, 'dimensionValues'));
|
||||
const metricValues = list(pick(row, 'metricValues'));
|
||||
|
||||
dimensionHeaders.forEach((name, index) => {
|
||||
flat[name] = pick(dimensionValues[index], 'value') ?? null;
|
||||
});
|
||||
// Metriky se prevadeji na cisla. GA je vraci jako text a soucet retezcu
|
||||
// by v dalsim kroku dal "12" misto 3.
|
||||
metricHeaders.forEach((name, index) => {
|
||||
flat[name] = num(pick(metricValues[index], 'value'), 0);
|
||||
});
|
||||
return flat;
|
||||
});
|
||||
|
||||
return {
|
||||
count: rows.length,
|
||||
rows,
|
||||
totalRows: num(pick(body, 'rowCount')),
|
||||
firstRow: rows[0] ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Google Ads: vykon kampani.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/analytics
|
||||
* Endpoint: POST /googleads/customers/{customer_id}/search
|
||||
*
|
||||
* Google Ads se pta jazykem GAQL. Skript proto nabizi dve cesty: bud se vyplni
|
||||
* obdobi a vezme se pripraveny dotaz na kampane, nebo se napise vlastni GAQL.
|
||||
* Bez te druhe moznosti by kazdy jiny report znamenal novy skript.
|
||||
*
|
||||
* Castky chodi v mikrojednotkach (milion = jedna koruna). Prepocitavaji se
|
||||
* tady, aby se v kazdem dalsim kroku nedelilo znovu a nekde spatne.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'google-ads.campaign-report',
|
||||
name: 'Načíst výkon kampaní',
|
||||
description:
|
||||
'Stáhne náklady, prokliky a konverze podle kampaní za období. ' +
|
||||
'Místo období lze zadat vlastní GAQL dotaz.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'customerId',
|
||||
label: 'ID účtu',
|
||||
type: 'string',
|
||||
required: true,
|
||||
pattern: '^[0-9-]{10,14}$',
|
||||
hint: 'Desetimístné číslo účtu. Pomlčky se odstraní.',
|
||||
},
|
||||
{
|
||||
id: 'period',
|
||||
label: 'Období',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'LAST_7_DAYS',
|
||||
options: [
|
||||
{ value: 'TODAY', label: 'Dnes' },
|
||||
{ value: 'YESTERDAY', label: 'Včera' },
|
||||
{ value: 'LAST_7_DAYS', label: 'Posledních 7 dní' },
|
||||
{ value: 'LAST_30_DAYS', label: 'Posledních 30 dní' },
|
||||
{ value: 'THIS_MONTH', label: 'Tento měsíc' },
|
||||
{ value: 'LAST_MONTH', label: 'Minulý měsíc' },
|
||||
],
|
||||
hint: 'Použije se jen tehdy, když není vyplněný vlastní dotaz.',
|
||||
},
|
||||
{
|
||||
id: 'query',
|
||||
label: 'Vlastní GAQL dotaz',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
hint: 'Když je vyplněný, období se ignoruje. Například SELECT ad_group.name FROM ad_group.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
|
||||
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
|
||||
{ id: 'cost', label: 'Náklady celkem', type: 'number', required: true },
|
||||
{ id: 'clicks', label: 'Prokliky celkem', type: 'number', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 60000,
|
||||
};
|
||||
|
||||
const campaignQuery = (period) =>
|
||||
'SELECT campaign.id, campaign.name, campaign.status, metrics.impressions, ' +
|
||||
'metrics.clicks, metrics.cost_micros, metrics.conversions ' +
|
||||
`FROM campaign WHERE segments.date DURING ${period}`;
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num, round } = ctx.util;
|
||||
|
||||
const customerId = inputs.customerId.replace(/-/g, '');
|
||||
const query = inputs.query ?? campaignQuery(inputs.period);
|
||||
|
||||
const { body } = await ctx.http.post(`/googleads/customers/${customerId}/search`, { query });
|
||||
|
||||
const raw = pick(body, 'results', 'rows');
|
||||
const items = Array.isArray(raw) ? raw : [];
|
||||
|
||||
// Vlastni dotaz vraci vlastni tvar. Prevlekat ho do poli kampane by znamenalo
|
||||
// seznam samych null - radky se proto vrati tak, jak prisly.
|
||||
if (inputs.query) {
|
||||
ctx.log(`Vlastní dotaz vrátil ${items.length} řádků, souhrny se nepočítají.`);
|
||||
return { count: items.length, rows: items, cost: 0, clicks: 0 };
|
||||
}
|
||||
|
||||
let cost = 0;
|
||||
let clicks = 0;
|
||||
|
||||
const rows = items.map((row) => {
|
||||
const campaign = pick(row, 'campaign') ?? {};
|
||||
const metrics = pick(row, 'metrics') ?? {};
|
||||
|
||||
// Mikrojednotky na koruny. Pri deleni az v dalsim kroku by se na to
|
||||
// nekde zapomnelo a report by hlasil milionove castky.
|
||||
const rowCost = round(num(pick(metrics, 'costMicros', 'cost_micros'), 0) / 1_000_000, 2);
|
||||
const rowClicks = num(pick(metrics, 'clicks'), 0);
|
||||
cost += rowCost;
|
||||
clicks += rowClicks;
|
||||
|
||||
return {
|
||||
campaignId: pick(campaign, 'id') ?? null,
|
||||
campaignName: pick(campaign, 'name') ?? null,
|
||||
status: pick(campaign, 'status') ?? null,
|
||||
impressions: num(pick(metrics, 'impressions'), 0),
|
||||
clicks: rowClicks,
|
||||
cost: rowCost,
|
||||
conversions: num(pick(metrics, 'conversions'), 0),
|
||||
};
|
||||
});
|
||||
|
||||
return { count: rows.length, rows, cost: round(cost, 2), clicks };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Google Workspace: pripsani radku do tabulky.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/google-service
|
||||
* Endpoint: POST /google/sheets/spreadsheets/{spreadsheetId}/values/append
|
||||
*
|
||||
* Tabulka se zadava odkazem, ne ID. Odkaz je to, co ma clovek v prohlizeci;
|
||||
* ID z nej vytahne skript. Kdyby ho mel opisovat rucne, byl by to prvni zdroj
|
||||
* chyb, ktery se pozna az podle prazdne tabulky.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'google.append-sheet-row',
|
||||
name: 'Přidat řádek do tabulky',
|
||||
description:
|
||||
'Připíše řádek na konec listu v Google Tabulkách. Hodí se na evidenci ' +
|
||||
'poptávek, objednávek nebo běhů automatizace.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'spreadsheet',
|
||||
label: 'Tabulka',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Odkaz na tabulku z prohlížeče, nebo samotné ID.',
|
||||
},
|
||||
{
|
||||
id: 'sheetName',
|
||||
label: 'Název listu',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'List1',
|
||||
hint: 'Přesně tak, jak je napsaný na záložce dole.',
|
||||
},
|
||||
{
|
||||
id: 'values',
|
||||
label: 'Hodnoty řádku',
|
||||
type: 'list',
|
||||
required: true,
|
||||
hint: 'Seznam hodnot zleva doprava, například ["2026-08-26", "Nová poptávka", 1200].',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'updatedRange', label: 'Kam se zapsalo', type: 'string', required: true },
|
||||
{ id: 'updatedCells', label: 'Počet zapsaných buněk', type: 'number', required: false },
|
||||
{ id: 'spreadsheetId', label: 'ID tabulky', type: 'string', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
/**
|
||||
* ID tabulky z odkazu.
|
||||
*
|
||||
* Odkaz ma tvar .../spreadsheets/d/<id>/edit. Kdyz uzivatel vlozi rovnou ID,
|
||||
* projde beze zmeny - obe cesty vedou ke stejnemu vysledku.
|
||||
*/
|
||||
function spreadsheetId(value) {
|
||||
const match = /\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/.exec(value);
|
||||
return match ? match[1] : value.trim();
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, num, need } = ctx.util;
|
||||
|
||||
if (inputs.values.length === 0) ctx.fail('Řádek nemá žádné hodnoty.');
|
||||
|
||||
const id = spreadsheetId(inputs.spreadsheet);
|
||||
// Rozsah je jen nazev listu: append si sam najde prvni volny radek.
|
||||
const range = inputs.sheetName;
|
||||
|
||||
const { body } = await ctx.http.post(
|
||||
`/google/sheets/spreadsheets/${encodeURIComponent(id)}/values/append`,
|
||||
{
|
||||
range,
|
||||
majorDimension: 'ROWS',
|
||||
// Vsechno na text: Sheets si cisla i data prevede samo podle formatu
|
||||
// bunky, kdezto smisene typy v jednom poli odmita.
|
||||
values: [inputs.values.map((value) => (value === null ? '' : String(value)))],
|
||||
},
|
||||
{ query: { range } },
|
||||
);
|
||||
|
||||
const updates = pick(body, 'updates') ?? body;
|
||||
|
||||
return {
|
||||
updatedRange: need(text(pick(updates, 'updatedRange')), 'zapsaný rozsah'),
|
||||
updatedCells: num(pick(updates, 'updatedCells')),
|
||||
spreadsheetId: id,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Google Workspace: odeslani e-mailu pres Gmail.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/google-service
|
||||
* Endpoint: POST /google/gmail/messages/send
|
||||
*
|
||||
* Gmail neprijima predmet a telo zvlast. Chce **cely e-mail podle RFC 2822**
|
||||
* zakodovany do base64url, takze zpravu sklada skript. Predmet s diakritikou
|
||||
* se navic musi zabalit do MIME slova, jinak z nej cestou zbydou otazniky.
|
||||
*
|
||||
* Kodovani se dela pres `TextEncoder` a `btoa`, ne pres Buffer: jsou to
|
||||
* standardni funkce, ktere budou k dispozici i az skripty pobezi v sandboxu.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'google.send-email',
|
||||
name: 'Odeslat e-mail',
|
||||
description:
|
||||
'Pošle e-mail přes Gmail účtu, pod kterým je napojení. Příjemců může být víc, ' +
|
||||
'oddělují se čárkou.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'to',
|
||||
label: 'Příjemci',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'E-maily oddělené čárkou.',
|
||||
},
|
||||
{ id: 'cc', label: 'Kopie', type: 'string', required: false },
|
||||
{ id: 'subject', label: 'Předmět', type: 'string', required: true },
|
||||
{ id: 'body', label: 'Text zprávy', type: 'string', required: true, multiline: true },
|
||||
{
|
||||
id: 'html',
|
||||
label: 'Text je HTML',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
hint: 'Vypnuté odešle zprávu jako čistý text.',
|
||||
},
|
||||
{
|
||||
id: 'from',
|
||||
label: 'Odesílatel',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Jen když má účet povolený alias. Bez vyplnění pošle Gmail zprávu za vlastníka schránky.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'messageId', label: 'ID zprávy', type: 'string', required: true },
|
||||
{ id: 'threadId', label: 'ID konverzace', type: 'string', required: false },
|
||||
{ id: 'recipients', label: 'Počet příjemců', type: 'number', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
/** Adresy z jednoho radku. Clovek pise carky, Gmail chce jeden retezec. */
|
||||
function addresses(value) {
|
||||
return String(value ?? '')
|
||||
.split(/[,;]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '');
|
||||
}
|
||||
|
||||
/** Base64 z textu. Pres bajty, aby diakritika prezila. */
|
||||
function base64(value) {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Base64url bez vypln, jak ho chce Gmail. */
|
||||
function base64Url(value) {
|
||||
return base64(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Hlavicka s diakritikou musi byt MIME slovo, jinak z ni zbydou otazniky. */
|
||||
function header(value) {
|
||||
for (const character of value) {
|
||||
if (character.codePointAt(0) > 127) return `=?UTF-8?B?${base64(value)}?=`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, need } = ctx.util;
|
||||
|
||||
const to = addresses(inputs.to);
|
||||
if (to.length === 0) ctx.fail('Nezůstal žádný příjemce, zkontrolujte pole Příjemci.');
|
||||
const cc = addresses(inputs.cc);
|
||||
|
||||
const lines = [
|
||||
`To: ${to.join(', ')}`,
|
||||
...(cc.length > 0 ? [`Cc: ${cc.join(', ')}`] : []),
|
||||
...(inputs.from ? [`From: ${inputs.from}`] : []),
|
||||
`Subject: ${header(inputs.subject)}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: text/${inputs.html ? 'html' : 'plain'}; charset="UTF-8"`,
|
||||
'Content-Transfer-Encoding: 8bit',
|
||||
'',
|
||||
inputs.body,
|
||||
];
|
||||
|
||||
const { body } = await ctx.http.post('/google/gmail/messages/send', {
|
||||
raw: base64Url(lines.join('\r\n')),
|
||||
});
|
||||
|
||||
ctx.log(`Odesláno ${to.length + cc.length} příjemcům.`);
|
||||
|
||||
return {
|
||||
messageId: need(text(pick(body, 'id')), 'ID zprávy'),
|
||||
threadId: text(pick(body, 'threadId')),
|
||||
recipients: to.length + cc.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Meta Ads: vykon reklam na Facebooku a Instagramu.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/meta
|
||||
* Endpoint: GET /ads/insights/{object_id}
|
||||
*
|
||||
* Meta pocita **velke reporty asynchronne**. Tenhle krok je synchronni verze
|
||||
* pro bezne obdobi a uroven; kdyz Meta odpovi, ze uloha bezi, rekne se to
|
||||
* nahlas misto toho, aby krok predstiral prazdny vysledek.
|
||||
*
|
||||
* Utrata chodi v mene uctu jako text. Prevadi se na cislo tady, aby se s ni
|
||||
* v dalsim kroku dalo pocitat.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'meta-ads.insights',
|
||||
name: 'Načíst výkon reklam',
|
||||
description:
|
||||
'Stáhne útratu, zobrazení, prokliky a konverze za období. Objektem může být ' +
|
||||
'reklamní účet, kampaň, sestava i jednotlivá reklama.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'objectId',
|
||||
label: 'ID objektu',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Reklamní účet jako act_123456789, nebo ID kampaně, sestavy či reklamy.',
|
||||
},
|
||||
{
|
||||
id: 'level',
|
||||
label: 'Úroveň',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'campaign',
|
||||
options: [
|
||||
{ value: 'account', label: 'Účet' },
|
||||
{ value: 'campaign', label: 'Kampaň' },
|
||||
{ value: 'adset', label: 'Sestava' },
|
||||
{ value: 'ad', label: 'Reklama' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'period',
|
||||
label: 'Období',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'last_7d',
|
||||
options: [
|
||||
{ value: 'today', label: 'Dnes' },
|
||||
{ value: 'yesterday', label: 'Včera' },
|
||||
{ value: 'last_7d', label: 'Posledních 7 dní' },
|
||||
{ value: 'last_30d', label: 'Posledních 30 dní' },
|
||||
{ value: 'this_month', label: 'Tento měsíc' },
|
||||
{ value: 'last_month', label: 'Minulý měsíc' },
|
||||
],
|
||||
hint: 'Použije se, když není vyplněné vlastní datum.',
|
||||
},
|
||||
{
|
||||
id: 'dateFrom',
|
||||
label: 'Od',
|
||||
type: 'date',
|
||||
required: false,
|
||||
hint: 'Vyplněné datum od i do přebije volbu období.',
|
||||
},
|
||||
{ id: 'dateTo', label: 'Do', type: 'date', required: false },
|
||||
{
|
||||
id: 'fields',
|
||||
label: 'Pole',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'campaign_name,impressions,clicks,spend,ctr,cpc,actions',
|
||||
hint: 'Oddělená čárkou, podle dokumentace Meta Marketing API.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
|
||||
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
|
||||
{ id: 'spend', label: 'Útrata celkem', type: 'number', required: true },
|
||||
{ id: 'clicks', label: 'Prokliky celkem', type: 'number', required: true },
|
||||
{ id: 'impressions', label: 'Zobrazení celkem', type: 'number', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 60000,
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num, round } = ctx.util;
|
||||
|
||||
const query = { level: inputs.level, fields: inputs.fields };
|
||||
|
||||
if (inputs.dateFrom && inputs.dateTo) {
|
||||
// Meta chce rozsah jako JSON v query parametru, ne jako dva parametry.
|
||||
query.time_range = JSON.stringify({
|
||||
since: inputs.dateFrom.slice(0, 10),
|
||||
until: inputs.dateTo.slice(0, 10),
|
||||
});
|
||||
} else {
|
||||
if (inputs.dateFrom || inputs.dateTo) {
|
||||
ctx.log('Vyplněné je jen jedno datum, použije se zvolené období.');
|
||||
}
|
||||
query.date_preset = inputs.period;
|
||||
}
|
||||
|
||||
const { body } = await ctx.http.get(`/ads/insights/${inputs.objectId}`, { query });
|
||||
|
||||
const raw = pick(body, 'data', 'rows');
|
||||
const items = Array.isArray(raw) ? raw : [];
|
||||
|
||||
if (items.length === 0 && pick(body, 'report_run_id') !== undefined) {
|
||||
ctx.fail(
|
||||
'Meta report počítá na pozadí, synchronní volání ho nestihlo. ' +
|
||||
'Zkraťte období nebo zvolte vyšší úroveň.',
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
let spend = 0;
|
||||
let clicks = 0;
|
||||
let impressions = 0;
|
||||
|
||||
const rows = items.map((row) => {
|
||||
// Meta vraci cisla jako text, vcetne utraty.
|
||||
const rowSpend = num(pick(row, 'spend'), 0);
|
||||
const rowClicks = num(pick(row, 'clicks'), 0);
|
||||
const rowImpressions = num(pick(row, 'impressions'), 0);
|
||||
|
||||
spend += rowSpend;
|
||||
clicks += rowClicks;
|
||||
impressions += rowImpressions;
|
||||
|
||||
return {
|
||||
...row,
|
||||
spend: rowSpend,
|
||||
clicks: rowClicks,
|
||||
impressions: rowImpressions,
|
||||
};
|
||||
});
|
||||
|
||||
if (ctx.util.bool(pick(body, 'truncated'))) {
|
||||
ctx.log('Meta vrátila jen část stránek, výsledek není úplný.');
|
||||
}
|
||||
|
||||
return { count: rows.length, rows, spend: round(spend, 2), clicks, impressions };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Microsoft 365: zalozeni schuzky v kalendari.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/microsoft-365-service
|
||||
* Endpoint: POST /users/{user_id}/calendar/events
|
||||
*
|
||||
* Casova zona se posila vzdy vyslovne. Graph bez ni bere cas jako UTC, takze
|
||||
* schuzka domluvena na desatou by v letnim case vysla na dvanactou - a nikdo
|
||||
* by nevedel proc.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'microsoft365.create-event',
|
||||
name: 'Vytvořit schůzku',
|
||||
description: 'Založí schůzku v kalendáři zvoleného uživatele a pozve účastníky.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'mailbox',
|
||||
label: 'Kalendář uživatele',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'E-mail nebo ID uživatele, jehož kalendář se použije.',
|
||||
},
|
||||
{ id: 'subject', label: 'Název schůzky', type: 'string', required: true },
|
||||
{
|
||||
id: 'start',
|
||||
label: 'Začátek',
|
||||
type: 'date',
|
||||
required: true,
|
||||
hint: 'Datum a čas začátku.',
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
label: 'Konec',
|
||||
type: 'date',
|
||||
required: true,
|
||||
hint: 'Musí být po začátku.',
|
||||
},
|
||||
{
|
||||
id: 'timeZone',
|
||||
label: 'Časová zóna',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'Central Europe Standard Time',
|
||||
hint: 'Název zóny podle Windows, jak ho očekává Graph.',
|
||||
},
|
||||
{
|
||||
id: 'attendees',
|
||||
label: 'Účastníci',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'E-maily oddělené čárkou.',
|
||||
},
|
||||
{ id: 'location', label: 'Místo', type: 'string', required: false },
|
||||
{
|
||||
id: 'body',
|
||||
label: 'Popis',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
id: 'onlineMeeting',
|
||||
label: 'Přidat schůzku v Teams',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'eventId', label: 'ID schůzky', type: 'string', required: true },
|
||||
{ id: 'webLink', label: 'Odkaz na schůzku', type: 'string', required: false },
|
||||
{ id: 'joinUrl', label: 'Odkaz do Teams', type: 'string', required: false },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, need } = ctx.util;
|
||||
|
||||
if (Date.parse(inputs.end) <= Date.parse(inputs.start)) {
|
||||
ctx.fail('Konec schůzky musí být po jejím začátku.');
|
||||
}
|
||||
|
||||
// Graph chce cas bez zonoveho posunu a zonu zvlast. ISO z validace parametru
|
||||
// konci na Z, takze se posun odrizne a zona se posle v `timeZone`.
|
||||
const local = (value) => value.replace(/(\.\d+)?Z$/, '');
|
||||
|
||||
const payload = {
|
||||
subject: inputs.subject,
|
||||
start: { dateTime: local(inputs.start), timeZone: inputs.timeZone },
|
||||
end: { dateTime: local(inputs.end), timeZone: inputs.timeZone },
|
||||
is_online_meeting: inputs.onlineMeeting,
|
||||
};
|
||||
if (inputs.body) payload.body = inputs.body;
|
||||
if (inputs.location) payload.location = inputs.location;
|
||||
if (inputs.onlineMeeting) payload.online_meeting_provider = 'teamsForBusiness';
|
||||
|
||||
const attendees = String(inputs.attendees ?? '')
|
||||
.split(/[,;]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
.map((address) => ({ address }));
|
||||
if (attendees.length > 0) payload.attendees = attendees;
|
||||
|
||||
const { body } = await ctx.http.post(
|
||||
`/users/${encodeURIComponent(inputs.mailbox)}/calendar/events`,
|
||||
payload,
|
||||
);
|
||||
|
||||
return {
|
||||
eventId: need(text(pick(body, 'id')), 'ID schůzky'),
|
||||
webLink: text(pick(body, 'webLink')),
|
||||
joinUrl: text(pick(body, 'onlineMeetingUrl') ?? pick(pick(body, 'onlineMeeting'), 'joinUrl')),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Microsoft 365: odeslani e-mailu pres Graph.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/microsoft-365-service
|
||||
* Endpoint: POST /users/{user_id}/mail/send
|
||||
*
|
||||
* Napojeni je **aplikacni**, ne uzivatelske: prihlasuje se registrovana
|
||||
* aplikace, ne clovek. Proto je schranka vzdy soucasti kroku - aplikace sama
|
||||
* o sobe zadnou nema a bez uvedeneho uzivatele by Graph nevedel, odkud poslat.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'microsoft365.send-mail',
|
||||
name: 'Odeslat e-mail',
|
||||
description:
|
||||
'Odešle e-mail ze zvolené schránky Microsoft 365. Příjemců může být víc, ' +
|
||||
'oddělují se čárkou.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'mailbox',
|
||||
label: 'Schránka odesílatele',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'E-mail nebo ID uživatele, například servis@firma.cz.',
|
||||
},
|
||||
{
|
||||
id: 'to',
|
||||
label: 'Příjemci',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'E-maily oddělené čárkou.',
|
||||
},
|
||||
{ id: 'cc', label: 'Kopie', type: 'string', required: false },
|
||||
{ id: 'subject', label: 'Předmět', type: 'string', required: true },
|
||||
{
|
||||
id: 'body',
|
||||
label: 'Text zprávy',
|
||||
type: 'string',
|
||||
required: true,
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
id: 'html',
|
||||
label: 'Text je HTML',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: true,
|
||||
hint: 'Vypnuté odešle zprávu jako čistý text.',
|
||||
},
|
||||
{
|
||||
id: 'replyTo',
|
||||
label: 'Adresa pro odpověď',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Když se má odpovídat jinam než do schránky odesílatele.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'sent', label: 'Odesláno', type: 'boolean', required: true },
|
||||
{ id: 'recipients', label: 'Počet příjemců', type: 'number', required: true },
|
||||
{ id: 'subject', label: 'Předmět', type: 'string', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
/** Seznam adres z jednoho radku. Graph chce pole objektu, clovek pise carky. */
|
||||
function addresses(value) {
|
||||
return String(value ?? '')
|
||||
.split(/[,;]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
.map((address) => ({ address }));
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const to = addresses(inputs.to);
|
||||
if (to.length === 0) ctx.fail('Nezůstal žádný příjemce, zkontrolujte pole Příjemci.');
|
||||
|
||||
const payload = {
|
||||
subject: inputs.subject,
|
||||
body: inputs.body,
|
||||
body_content_type: inputs.html ? 'HTML' : 'Text',
|
||||
to,
|
||||
};
|
||||
|
||||
const cc = addresses(inputs.cc);
|
||||
if (cc.length > 0) payload.cc = cc;
|
||||
if (inputs.replyTo) payload.reply_to = addresses(inputs.replyTo);
|
||||
|
||||
// Cesta obsahuje e-mail, ktery muze mit znaky mimo ASCII.
|
||||
await ctx.http.post(`/users/${encodeURIComponent(inputs.mailbox)}/mail/send`, payload);
|
||||
|
||||
ctx.log(`Odesláno ${to.length + cc.length} příjemcům ze schránky ${inputs.mailbox}.`);
|
||||
|
||||
// Graph na uspesne odeslani nevraci telo, vystup se sklada ze vstupu.
|
||||
return { sent: true, recipients: to.length + cc.length, subject: inputs.subject };
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* OpenAI: otazka nad nahranym souborem.
|
||||
*
|
||||
* Sluzba: https://api.openai.com/v1
|
||||
* Endpoint: POST /responses
|
||||
*
|
||||
* Proc jiny endpoint nez `openai.chat`: soubor jako vstup umi az Responses API.
|
||||
* Chat Completions by prijalo jen text, takze by se obsah musel do dotazu
|
||||
* vlepit rucne - a u PDF nebo tabulky to nejde.
|
||||
*
|
||||
* Soubor se nejdriv nahraje krokem `openai.upload-file`, ktery vrati `fileId`.
|
||||
* Rozdeleni na dva kroky je zamer: jednoho souboru se casto pta vic dotazu
|
||||
* a nahravat ho pokazde znovu by stalo cas i penize.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'openai.ask-about-file',
|
||||
name: 'Zeptat se na soubor',
|
||||
description:
|
||||
'Položí otázku nad souborem nahraným do OpenAI. Zvládne dokument i obrázek, ' +
|
||||
'takže se hodí na vytěžení faktury, smlouvy nebo fotky.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'fileId',
|
||||
label: 'ID souboru',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Vrací ho krok Nahrát soubor jako fileId.',
|
||||
},
|
||||
{
|
||||
id: 'kind',
|
||||
label: 'Druh souboru',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'document',
|
||||
options: [
|
||||
{ value: 'document', label: 'Dokument (PDF, text, tabulka)' },
|
||||
{ value: 'image', label: 'Obrázek' },
|
||||
],
|
||||
hint:
|
||||
'Obrázek se modelu předává jinak než dokument. Musí být nahraný ' +
|
||||
's účelem Obrázek k analýze.',
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
label: 'Otázka',
|
||||
type: 'string',
|
||||
required: true,
|
||||
multiline: true,
|
||||
hint: 'Například: Vypiš číslo faktury, datum splatnosti a celkovou částku.',
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'gpt-4o-mini',
|
||||
hint: 'Musí to být model, který umí číst soubory a obrázky.',
|
||||
},
|
||||
{
|
||||
id: 'instructions',
|
||||
label: 'Instrukce pro model',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
id: 'maxTokens',
|
||||
label: 'Strop na délku odpovědi',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint: 'V tokenech. Bez vyplnění rozhoduje model.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'text', label: 'Odpověď', type: 'string', required: true },
|
||||
{ id: 'model', label: 'Použitý model', type: 'string', required: true },
|
||||
{ id: 'inputTokens', label: 'Tokeny na vstupu', type: 'number', required: false },
|
||||
{ id: 'outputTokens', label: 'Tokeny na výstupu', type: 'number', required: false },
|
||||
{ id: 'totalTokens', label: 'Tokeny celkem', type: 'number', required: false },
|
||||
],
|
||||
|
||||
// Cteni dokumentu trva dele nez bezny dotaz.
|
||||
timeoutMs: 90000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Odpoved Responses API je strom, ne jedno pole.
|
||||
*
|
||||
* Prochazi se cely: model muze pred odpovedi vratit i jine polozky (napriklad
|
||||
* zaznam o uvaze) a brat naslepo prvni prvek by u nich vratilo prazdno.
|
||||
*/
|
||||
function answerText(output) {
|
||||
if (!Array.isArray(output)) return null;
|
||||
const parts = [];
|
||||
|
||||
for (const item of output) {
|
||||
const content = item && item.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const part of content) {
|
||||
if (part && part.type === 'output_text' && typeof part.text === 'string') {
|
||||
parts.push(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join('\n') : null;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, num, need } = ctx.util;
|
||||
|
||||
const attachment =
|
||||
inputs.kind === 'image'
|
||||
? { type: 'input_image', file_id: inputs.fileId }
|
||||
: { type: 'input_file', file_id: inputs.fileId };
|
||||
|
||||
const body = {
|
||||
model: inputs.model,
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [attachment, { type: 'input_text', text: inputs.prompt }],
|
||||
},
|
||||
],
|
||||
};
|
||||
if (inputs.instructions) body.instructions = inputs.instructions;
|
||||
if (inputs.maxTokens !== null) body.max_output_tokens = inputs.maxTokens;
|
||||
|
||||
const { body: answer } = await ctx.http.post('/responses', body);
|
||||
|
||||
if (text(pick(answer, 'status')) === 'incomplete') {
|
||||
ctx.log('Odpověď je neúplná, model narazil na strop délky.');
|
||||
}
|
||||
|
||||
const usage = pick(answer, 'usage') ?? {};
|
||||
|
||||
return {
|
||||
text: need(answerText(pick(answer, 'output')), 'odpověď modelu'),
|
||||
model: need(text(pick(answer, 'model')), 'název modelu'),
|
||||
inputTokens: num(pick(usage, 'input_tokens')),
|
||||
outputTokens: num(pick(usage, 'output_tokens')),
|
||||
totalTokens: num(pick(usage, 'total_tokens')),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* OpenAI: dotaz na jazykovy model.
|
||||
*
|
||||
* Sluzba: https://api.openai.com/v1 (nebezi u nas, adresa je v katalogu sluzeb)
|
||||
* Endpoint: POST /chat/completions
|
||||
*
|
||||
* Model je **volny text s vychozi hodnotou**, ne vyber z pevneho seznamu.
|
||||
* Seznam by zestarl pri kazdem vydani noveho modelu a krok stromu by pak
|
||||
* odmital hodnotu, kterou ucet umi. Co ucet umi, vrati `openai.list-models`.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'openai.chat',
|
||||
name: 'Zeptat se modelu',
|
||||
description:
|
||||
'Pošle otázku vybranému modelu OpenAI a vrátí odpověď včetně spotřeby tokenů. ' +
|
||||
'Hodí se na shrnutí, klasifikaci i sepsání odpovědi zákazníkovi.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'gpt-4o-mini',
|
||||
hint: 'Například gpt-4o-mini, gpt-4o nebo gpt-4.1. Seznam vrátí operace Načíst seznam modelů.',
|
||||
},
|
||||
{
|
||||
id: 'instructions',
|
||||
label: 'Instrukce pro model',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
hint: 'Role a pravidla, například: Jsi zákaznická podpora, odpovídej česky a stručně.',
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
label: 'Otázka',
|
||||
type: 'string',
|
||||
required: true,
|
||||
multiline: true,
|
||||
hint: 'Text dotazu. Může obsahovat data z předchozích kroků.',
|
||||
},
|
||||
{
|
||||
id: 'temperature',
|
||||
label: 'Teplota',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint:
|
||||
'Od 0 do 2. Nižší je předvídatelnější. Nevyplněné se neposílá vůbec, ' +
|
||||
'protože novější modely teplotu nepřijímají.',
|
||||
},
|
||||
{
|
||||
id: 'maxTokens',
|
||||
label: 'Strop na délku odpovědi',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint: 'V tokenech. Bez vyplnění rozhoduje model.',
|
||||
},
|
||||
{
|
||||
id: 'jsonOutput',
|
||||
label: 'Vyžádat odpověď v JSONu',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
hint: 'Model pak vrátí platný JSON. V instrukcích popište, jaká pole má mít.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'text', label: 'Odpověď', type: 'string', required: true },
|
||||
{ id: 'model', label: 'Použitý model', type: 'string', required: true },
|
||||
{ id: 'finishReason', label: 'Důvod ukončení', type: 'string', required: false },
|
||||
{ id: 'promptTokens', label: 'Tokeny na vstupu', type: 'number', required: false },
|
||||
{ id: 'completionTokens', label: 'Tokeny na výstupu', type: 'number', required: false },
|
||||
{ id: 'totalTokens', label: 'Tokeny celkem', type: 'number', required: false },
|
||||
],
|
||||
|
||||
// Delsi odpoved trva. Vychozich 15 s je na generovani textu malo.
|
||||
timeoutMs: 60000,
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, num, need } = ctx.util;
|
||||
|
||||
const messages = [];
|
||||
if (inputs.instructions) messages.push({ role: 'system', content: inputs.instructions });
|
||||
messages.push({ role: 'user', content: inputs.prompt });
|
||||
|
||||
const body = { model: inputs.model, messages };
|
||||
// Nevyplnene se neposila. Novejsi modely `temperature` odmitaji uplne,
|
||||
// takze poslat vychozi hodnotu by krok rozbilo tam, kde o ni nikdo nestal.
|
||||
if (inputs.temperature !== null) body.temperature = inputs.temperature;
|
||||
if (inputs.maxTokens !== null) body.max_completion_tokens = inputs.maxTokens;
|
||||
if (inputs.jsonOutput) body.response_format = { type: 'json_object' };
|
||||
|
||||
const { body: answer } = await ctx.http.post('/chat/completions', body);
|
||||
|
||||
const choice = ctx.util.first(pick(answer, 'choices'));
|
||||
if (!choice) ctx.fail('OpenAI nevrátilo žádnou odpověď.', answer);
|
||||
|
||||
const usage = pick(answer, 'usage') ?? {};
|
||||
|
||||
return {
|
||||
text: need(text(pick(pick(choice, 'message'), 'content')), 'odpověď modelu'),
|
||||
model: need(text(pick(answer, 'model')), 'název modelu'),
|
||||
finishReason: text(pick(choice, 'finish_reason')),
|
||||
promptTokens: num(pick(usage, 'prompt_tokens')),
|
||||
completionTokens: num(pick(usage, 'completion_tokens')),
|
||||
totalTokens: num(pick(usage, 'total_tokens')),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* OpenAI: seznam modelu, na ktere ucet dosahne.
|
||||
*
|
||||
* Sluzba: https://api.openai.com/v1
|
||||
* Endpoint: GET /models
|
||||
*
|
||||
* Proc to tu je: v kroku `openai.chat` je model volny text, aby seznam
|
||||
* v manifestu nezestarl. Tohle je zpusob, jak zjistit, co do nej napsat,
|
||||
* aniz by clovek odchazel z portalu.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'openai.list-models',
|
||||
name: 'Načíst seznam modelů',
|
||||
description:
|
||||
'Vrátí modely, na které API klíč dosáhne. Nic nemění a nic nestojí. ' +
|
||||
'Použije se ke zjištění, co napsat do pole Model.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'contains',
|
||||
label: 'Obsahuje text',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Zúží seznam, například gpt nebo whisper. Bez vyplnění se vrátí vše.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet modelů', type: 'number', required: true },
|
||||
{ id: 'models', label: 'Seznam modelů', type: 'list', required: true },
|
||||
{ id: 'first', label: 'První model', type: 'string', required: false },
|
||||
],
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text } = ctx.util;
|
||||
|
||||
const { body } = await ctx.http.get('/models');
|
||||
const items = pick(body, 'data');
|
||||
if (!Array.isArray(items)) ctx.fail('OpenAI nevrátilo seznam modelů.', body);
|
||||
|
||||
const needle = (inputs.contains ?? '').toLowerCase();
|
||||
const models = items
|
||||
.map((item) => text(pick(item, 'id')))
|
||||
.filter((id) => id !== null && (needle === '' || id.toLowerCase().includes(needle)))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return { count: models.length, models, first: models[0] ?? null };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* OpenAI: prepis nahravky na text.
|
||||
*
|
||||
* Sluzba: https://api.openai.com/v1
|
||||
* Endpoint: POST /audio/transcriptions (multipart/form-data)
|
||||
*
|
||||
* Rozdil proti sluzbe Prepis hovoru: tam bezi dva enginy naraz a jejich
|
||||
* vysledky se slucuji, coz je presnejsi a drazsi. Tohle je jeden pruchod
|
||||
* pod vlastnim klicem, kdyz staci obycejny prepis.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'openai.transcribe-audio',
|
||||
name: 'Přepsat zvuk',
|
||||
description: 'Přepíše nahrávku na text jedním přepisovacím modelem OpenAI.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'fileName',
|
||||
label: 'Název nahrávky',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Včetně přípony, například hovor.mp3. Podle ní se pozná formát.',
|
||||
},
|
||||
{
|
||||
id: 'contentBase64',
|
||||
label: 'Nahrávka (Base64)',
|
||||
type: 'string',
|
||||
required: true,
|
||||
multiline: true,
|
||||
hint: 'Obsah zakódovaný v Base64. Formáty mp3, mp4, m4a, wav, webm, ogg a flac.',
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'gpt-4o-mini-transcribe',
|
||||
hint: 'Například gpt-4o-mini-transcribe, gpt-4o-transcribe nebo whisper-1.',
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
label: 'Jazyk nahrávky',
|
||||
type: 'string',
|
||||
required: false,
|
||||
pattern: '^[a-zA-Z]{2}$',
|
||||
hint: 'Dvoupísmenný kód, například cs. Vyplněný jazyk přepis zpřesní i zrychlí.',
|
||||
},
|
||||
{
|
||||
id: 'prompt',
|
||||
label: 'Nápověda k přepisu',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
hint: 'Vlastní jména, zkratky a názvy produktů, které se v nahrávce objeví.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'text', label: 'Přepis', type: 'string', required: true },
|
||||
{ id: 'model', label: 'Použitý model', type: 'string', required: true },
|
||||
{ id: 'length', label: 'Délka přepisu ve znacích', type: 'number', required: true },
|
||||
],
|
||||
|
||||
// Prepis je nejpomalejsi operace v katalogu, delsi zaznam trva minuty.
|
||||
timeoutMs: 120000,
|
||||
};
|
||||
|
||||
/** Typ obsahu podle pripony. Zaroven seznam toho, co OpenAI prijme. */
|
||||
const byExtension = {
|
||||
mp3: 'audio/mpeg',
|
||||
mpga: 'audio/mpeg',
|
||||
mpeg: 'audio/mpeg',
|
||||
mp4: 'audio/mp4',
|
||||
m4a: 'audio/mp4',
|
||||
wav: 'audio/wav',
|
||||
webm: 'audio/webm',
|
||||
ogg: 'audio/ogg',
|
||||
oga: 'audio/ogg',
|
||||
flac: 'audio/flac',
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, need } = ctx.util;
|
||||
|
||||
const parts = inputs.fileName.split('.');
|
||||
const extension = parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
|
||||
const contentType = byExtension[extension];
|
||||
if (!contentType) {
|
||||
ctx.fail(
|
||||
`Přípona "${extension}" není podporovaný formát zvuku. ` +
|
||||
'Použijte mp3, mp4, m4a, wav, webm, ogg nebo flac.',
|
||||
);
|
||||
}
|
||||
|
||||
const { body } = await ctx.http.postForm('/audio/transcriptions', {
|
||||
model: inputs.model,
|
||||
language: inputs.language,
|
||||
prompt: inputs.prompt,
|
||||
response_format: 'json',
|
||||
file: {
|
||||
filename: inputs.fileName,
|
||||
base64: inputs.contentBase64.trim(),
|
||||
contentType,
|
||||
},
|
||||
});
|
||||
|
||||
const transcript = need(text(pick(body, 'text')), 'přepis');
|
||||
|
||||
return { text: transcript, model: inputs.model, length: transcript.length };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* OpenAI: nahrani souboru.
|
||||
*
|
||||
* Sluzba: https://api.openai.com/v1
|
||||
* Endpoint: POST /files (multipart/form-data)
|
||||
*
|
||||
* Vzor **odeslani souboru**. Obsah prichazi jako Base64, protoze parametr kroku
|
||||
* je vzdy hodnota zapsatelna do JSONu - binarni data by se do stromu ani do
|
||||
* zaznamu behu jinak nevesla. Prevod na bajty a slozeni multipart tela dela
|
||||
* `ctx.http.postForm`, skript o hranicich ani o hlavickach nevi.
|
||||
*
|
||||
* Vraci ID souboru. Otazku nad nim polozi `openai.ask-about-file`.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'openai.upload-file',
|
||||
name: 'Nahrát soubor',
|
||||
description:
|
||||
'Odešle soubor do OpenAI a vrátí jeho ID. To se pak použije jako vstup ' +
|
||||
'operace Zeptat se na soubor.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'fileName',
|
||||
label: 'Název souboru',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Včetně přípony, například smlouva.pdf. Podle ní se pozná typ obsahu.',
|
||||
},
|
||||
{
|
||||
id: 'contentBase64',
|
||||
label: 'Obsah souboru (Base64)',
|
||||
type: 'string',
|
||||
required: true,
|
||||
multiline: true,
|
||||
hint: 'Obsah zakódovaný v Base64. Bez předpony data:.',
|
||||
},
|
||||
{
|
||||
id: 'contentType',
|
||||
label: 'Typ obsahu',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Například application/pdf. Bez vyplnění se odvodí z přípony.',
|
||||
},
|
||||
{
|
||||
id: 'purpose',
|
||||
label: 'K čemu soubor je',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'user_data',
|
||||
options: [
|
||||
{ value: 'user_data', label: 'Podklad k dotazu' },
|
||||
{ value: 'assistants', label: 'Asistenti a vyhledávání v souborech' },
|
||||
{ value: 'vision', label: 'Obrázek k analýze' },
|
||||
{ value: 'batch', label: 'Dávkové zpracování' },
|
||||
],
|
||||
hint: 'OpenAI podle toho rozhoduje, kde se soubor smí použít.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'fileId', label: 'ID souboru', type: 'string', required: true },
|
||||
{ id: 'fileName', label: 'Název souboru', type: 'string', required: true },
|
||||
{ id: 'bytes', label: 'Velikost v bajtech', type: 'number', required: false },
|
||||
{ id: 'purpose', label: 'K čemu soubor je', type: 'string', required: false },
|
||||
],
|
||||
|
||||
timeoutMs: 60000,
|
||||
};
|
||||
|
||||
/** Typ obsahu podle pripony. Jen to, co se v automatizaci opravdu posila. */
|
||||
const byExtension = {
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
csv: 'text/csv',
|
||||
json: 'application/json',
|
||||
html: 'text/html',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, num, need } = ctx.util;
|
||||
|
||||
const extension = inputs.fileName.split('.').pop()?.toLowerCase() ?? '';
|
||||
const contentType = inputs.contentType ?? byExtension[extension] ?? 'application/octet-stream';
|
||||
|
||||
// Prazdny obsah se pozna tady, ne az podle nicnerikajici chyby od OpenAI.
|
||||
if (inputs.contentBase64.trim() === '') ctx.fail('Obsah souboru je prázdný.');
|
||||
|
||||
const { body } = await ctx.http.postForm('/files', {
|
||||
purpose: inputs.purpose,
|
||||
file: {
|
||||
filename: inputs.fileName,
|
||||
base64: inputs.contentBase64.trim(),
|
||||
contentType,
|
||||
},
|
||||
});
|
||||
|
||||
ctx.log(`Nahráno ${inputs.fileName} jako ${contentType}.`);
|
||||
|
||||
return {
|
||||
fileId: need(text(pick(body, 'id')), 'ID souboru'),
|
||||
fileName: need(text(pick(body, 'filename')) ?? inputs.fileName, 'název souboru'),
|
||||
bytes: num(pick(body, 'bytes')),
|
||||
purpose: text(pick(body, 'purpose')),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* PPL CPL: zalozeni zasilky.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/pplcplapi
|
||||
* Endpoint: POST /shipments/create-and-wait
|
||||
*
|
||||
* CPL zpracovava zasilky **davkove**: zalozi se batch, pak se ceka na jeho
|
||||
* zpracovani a teprve pak je znamé cislo zasilky. Sluzba na to ma synchronni
|
||||
* obalku, ktera cely tok provede za nas - proto se vola ona a ne
|
||||
* `/shipments/batch`. Krok stromu, ktery by musel sam pollovat, by potreboval
|
||||
* runtime s cekanim, a ten zatim neni.
|
||||
*
|
||||
* Idempotence: hlavicku `Idempotency-Key` doplnuje runtime a je stabilni pres
|
||||
* vsechny pokusy tehoz kroku, takze druhy pokus po timeoutu neposle druhy balik.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'ppl.create-shipment',
|
||||
name: 'Vytvořit zásilku',
|
||||
description:
|
||||
'Založí zásilku v PPL a počká na její zpracování. Vrací číslo zásilky ' +
|
||||
'a volitelně i etiketu k tisku.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'referenceId',
|
||||
label: 'Reference',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Vaše číslo, podle kterého zásilku poznáte. Typicky číslo objednávky.',
|
||||
},
|
||||
{
|
||||
id: 'productType',
|
||||
label: 'Produkt',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'BUSS',
|
||||
hint: 'Kód produktu PPL, například BUSS (Business) nebo PRIV (Private). Číselník vrací /codelists/product.',
|
||||
},
|
||||
{ id: 'recipientName', label: 'Příjemce', type: 'string', required: true },
|
||||
{ id: 'recipientStreet', label: 'Ulice a číslo', type: 'string', required: true },
|
||||
{ id: 'recipientCity', label: 'Město', type: 'string', required: true },
|
||||
{
|
||||
id: 'recipientZip',
|
||||
label: 'PSČ',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Bez mezer, například 60200.',
|
||||
},
|
||||
{
|
||||
id: 'recipientCountry',
|
||||
label: 'Země',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'CZ',
|
||||
hint: 'Dvoupísmenný kód země.',
|
||||
},
|
||||
{ id: 'recipientPhone', label: 'Telefon příjemce', type: 'string', required: false },
|
||||
{ id: 'recipientEmail', label: 'E-mail příjemce', type: 'string', required: false },
|
||||
{
|
||||
id: 'note',
|
||||
label: 'Poznámka',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Text pro kurýra. Diakritiku odstraní služba sama, CPL ji nepřijímá.',
|
||||
},
|
||||
{
|
||||
id: 'withLabel',
|
||||
label: 'Vrátit i etiketu',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
hint: 'Etiketa je binární PDF, takže se vrací jen odkaz na ni, ne obsah.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'shipmentNumber', label: 'Číslo zásilky', type: 'string', required: false },
|
||||
{ id: 'batchId', label: 'ID dávky', type: 'string', required: false },
|
||||
{ id: 'completed', label: 'Zpracováno', type: 'boolean', required: true },
|
||||
{ id: 'result', label: 'Celá odpověď PPL', type: 'object', required: true },
|
||||
],
|
||||
|
||||
// Sluzba uvnitr ceka na zpracovani davky. Kratsi limit by utnul i uspech.
|
||||
timeoutMs: 90000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Cislo zasilky z odpovedi.
|
||||
*
|
||||
* CPL ho vraci na ruznych mistech podle toho, kolik zasilek davka mela.
|
||||
* Hledani do hloubky je levnejsi nez tri varianty `pick` za sebou, ktere by
|
||||
* stejne nepokryly dalsi tvar.
|
||||
*/
|
||||
function findShipmentNumber(value, depth = 0) {
|
||||
if (value === null || typeof value !== 'object' || depth > 5) return null;
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (/^shipmentnumber$/i.test(key) && typeof item === 'string' && item !== '') return item;
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of Array.isArray(value) ? value : Object.values(value)) {
|
||||
const found = findShipmentNumber(item, depth + 1);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, bool } = ctx.util;
|
||||
|
||||
const recipient = {
|
||||
name: inputs.recipientName,
|
||||
street: inputs.recipientStreet,
|
||||
city: inputs.recipientCity,
|
||||
zipCode: inputs.recipientZip.replace(/\s+/g, ''),
|
||||
country: inputs.recipientCountry,
|
||||
};
|
||||
if (inputs.recipientPhone) recipient.phone = inputs.recipientPhone;
|
||||
if (inputs.recipientEmail) recipient.email = inputs.recipientEmail;
|
||||
|
||||
const shipment = {
|
||||
referenceId: inputs.referenceId,
|
||||
productType: inputs.productType,
|
||||
recipient,
|
||||
};
|
||||
if (inputs.note) shipment.note = inputs.note;
|
||||
|
||||
const { body } = await ctx.http.post(
|
||||
'/shipments/create-and-wait',
|
||||
{ shipments: [shipment] },
|
||||
{ query: { include_labels: inputs.withLabel } },
|
||||
);
|
||||
|
||||
const completed = bool(pick(body, 'completed'));
|
||||
const shipmentNumber = findShipmentNumber(body);
|
||||
|
||||
if (!completed) {
|
||||
// Neni to chyba: davka bezi dal a stav se da dotahnout pres batchId.
|
||||
// Vyhodit chybu by znamenalo, ze uz zalozena zasilka vypadne ze stromu.
|
||||
ctx.log('PPL dávku ještě nedokončil, stav zjistíte podle ID dávky.');
|
||||
}
|
||||
|
||||
return {
|
||||
shipmentNumber,
|
||||
batchId: text(pick(body, 'batchId', 'batch_id')),
|
||||
completed,
|
||||
result: body,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* PPL CPL: stav zasilky.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/pplcplapi
|
||||
* Endpoint: GET /shipments?shipmentNumbers=...
|
||||
*
|
||||
* Vzor **cteciho kroku, u ktereho nenalezeno neni chyba**. Zasilka, kterou PPL
|
||||
* jeste nenacetl, je bezny stav prvnich minut po zalozeni, ne selhani.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'ppl.track',
|
||||
name: 'Zjistit stav zásilky',
|
||||
description: 'Vrátí aktuální stav zásilky a historii jejích událostí. Nic nemění.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'shipmentNumber',
|
||||
label: 'Číslo zásilky',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Vrací ho krok Vytvořit zásilku. Vyplňte tohle, nebo referenci.',
|
||||
},
|
||||
{
|
||||
id: 'reference',
|
||||
label: 'Reference',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Vaše číslo zadané při zakládání, typicky číslo objednávky.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'found', label: 'Zásilka nalezena', type: 'boolean', required: true },
|
||||
{ id: 'shipmentNumber', label: 'Číslo zásilky', type: 'string', required: false },
|
||||
{ id: 'state', label: 'Stav', type: 'string', required: false },
|
||||
{ id: 'stateDate', label: 'Datum stavu', type: 'string', required: false },
|
||||
{ id: 'delivered', label: 'Doručeno', type: 'boolean', required: true },
|
||||
{ id: 'shipment', label: 'Celý záznam zásilky', type: 'object', required: false },
|
||||
],
|
||||
};
|
||||
|
||||
const notFound = {
|
||||
found: false,
|
||||
shipmentNumber: null,
|
||||
state: null,
|
||||
stateDate: null,
|
||||
delivered: false,
|
||||
shipment: null,
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, first, text } = ctx.util;
|
||||
|
||||
if (!inputs.shipmentNumber && !inputs.reference) {
|
||||
ctx.fail('Vyplňte číslo zásilky nebo referenci, jinak není co hledat.');
|
||||
}
|
||||
|
||||
const { body } = await ctx.http.get('/shipments', {
|
||||
query: {
|
||||
shipmentNumbers: inputs.shipmentNumber ?? undefined,
|
||||
customerReferences: inputs.reference ?? undefined,
|
||||
limit: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const items = Array.isArray(body) ? body : pick(body, 'shipments', 'data', 'items');
|
||||
const shipment = first(Array.isArray(items) ? items : []);
|
||||
if (!shipment) {
|
||||
ctx.log('PPL o zásilce zatím nic neví. Krátce po založení je to běžné.');
|
||||
return notFound;
|
||||
}
|
||||
|
||||
// Posledni udalost je aktualni stav. PPL vraci historii vzestupne.
|
||||
const events = pick(shipment, 'shipmentEvents', 'events', 'states');
|
||||
const last = Array.isArray(events) && events.length > 0 ? events[events.length - 1] : null;
|
||||
|
||||
const state = text(pick(last, 'code', 'name', 'statusCode') ?? pick(shipment, 'statusCode', 'state'));
|
||||
const stateDate = text(pick(last, 'date', 'eventDate') ?? pick(shipment, 'statusDate'));
|
||||
|
||||
return {
|
||||
found: true,
|
||||
shipmentNumber: text(pick(shipment, 'shipmentNumber')),
|
||||
state,
|
||||
stateDate,
|
||||
// Kod doruceno je u PPL DELIVERED. Porovnava se bez ohledu na velikost pismen.
|
||||
delivered: (state ?? '').toUpperCase().includes('DELIVER'),
|
||||
shipment,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* RAYNET CRM: zalozeni poptavky (lead).
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/raynet
|
||||
* Endpoint: POST /lead
|
||||
*
|
||||
* Navazuje na `raynet.find-company`: kdyz firma existuje, poptavka se na ni
|
||||
* naveze, jinak vznikne bez vazby a doplni ji obchodnik. Zakladat firmu tady
|
||||
* zamerne neumime - to je rozhodnuti, ktere ma udelat clovek.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'raynet.create-lead',
|
||||
name: 'Založit poptávku',
|
||||
description:
|
||||
'Vytvoří v RAYNETu novou poptávku. Volitelně ji naváže na už dohledanou firmu ' +
|
||||
'a kontaktní osobu.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'topic',
|
||||
label: 'Předmět poptávky',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'To, co obchodník uvidí v seznamu. Například: Poptávka z webu, {{subject}}.',
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
label: 'Popis',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
hint: 'Celý text poptávky, typicky obsah e-mailu nebo formuláře.',
|
||||
},
|
||||
{
|
||||
id: 'companyId',
|
||||
label: 'ID firmy',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint: 'Vrací ho krok Dohledat firmu.',
|
||||
},
|
||||
{
|
||||
id: 'personId',
|
||||
label: 'ID kontaktní osoby',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint: 'Vrací ho krok Založit nebo aktualizovat kontakt.',
|
||||
},
|
||||
{
|
||||
id: 'ownerId',
|
||||
label: 'ID vlastníka',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint: 'Uživatel RAYNETu, kterému poptávka připadne. Bez něj rozhodne RAYNET.',
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: 'Priorita',
|
||||
type: 'string',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: 'LOW', label: 'Nízká' },
|
||||
{ value: 'NORMAL', label: 'Normální' },
|
||||
{ value: 'HIGH', label: 'Vysoká' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'leadId', label: 'ID poptávky', type: 'number', required: true },
|
||||
{ id: 'topic', label: 'Předmět poptávky', type: 'string', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, num, need } = ctx.util;
|
||||
|
||||
const payload = { topic: inputs.topic };
|
||||
if (inputs.description) payload.description = inputs.description;
|
||||
if (inputs.companyId !== null) payload.company = { id: inputs.companyId };
|
||||
if (inputs.personId !== null) payload.person = { id: inputs.personId };
|
||||
if (inputs.ownerId !== null) payload.owner = { id: inputs.ownerId };
|
||||
if (inputs.priority) payload.priority = inputs.priority;
|
||||
|
||||
const { body } = await ctx.http.post('/lead', payload);
|
||||
const id = need(num(pick(unwrap(body), 'id')), 'ID poptávky');
|
||||
|
||||
ctx.log(`Založena poptávka ${id}.`);
|
||||
|
||||
return { leadId: id, topic: inputs.topic };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* RAYNET CRM: dohledani firmy.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/raynet
|
||||
* Endpoint: GET /company?fulltext=...
|
||||
*
|
||||
* Vzor **predvalidace**: nenalezeno tady neni chyba. Prave podle toho se strom
|
||||
* vetvi na "znameho zakaznika" a "noveho zajemce", takze chyba by rozhodnuti
|
||||
* zahodila misto aby ho umoznila.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'raynet.find-company',
|
||||
name: 'Dohledat firmu',
|
||||
description:
|
||||
'Zjistí, jestli je odesílatel v CRM. Hledá podle IČO, e-mailu nebo názvu. ' +
|
||||
'Nic nezakládá, výsledek slouží k větvení stromu.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'regNumber',
|
||||
label: 'IČO',
|
||||
type: 'string',
|
||||
required: false,
|
||||
pattern: '^[0-9]{6,12}$',
|
||||
hint: 'Jen číslice. Nejpřesnější, hledá se podle něj první.',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'E-mail',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Například {{from}} u e-mailu.',
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Název firmy',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Použije se jako poslední, název se píše pokaždé jinak.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'customerKnown', label: 'Firma nalezena', type: 'boolean', required: true },
|
||||
{ id: 'companyId', label: 'ID firmy', type: 'number', required: false },
|
||||
{ id: 'companyName', label: 'Název firmy', type: 'string', required: false },
|
||||
{ id: 'regNumber', label: 'IČO', type: 'string', required: false },
|
||||
{ id: 'ownerName', label: 'Vlastník záznamu', type: 'string', required: false },
|
||||
{ id: 'matchedBy', label: 'Podle čeho se našla', type: 'string', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
const notFound = {
|
||||
customerKnown: false,
|
||||
companyId: null,
|
||||
companyName: null,
|
||||
regNumber: null,
|
||||
ownerName: null,
|
||||
matchedBy: 'nenalezeno',
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, first, text, num } = ctx.util;
|
||||
|
||||
if (!inputs.regNumber && !inputs.email && !inputs.name) {
|
||||
ctx.fail('Vyplňte IČO, e-mail nebo název firmy, jinak není podle čeho hledat.');
|
||||
}
|
||||
|
||||
/** Jedno hledani. RAYNET ma na vsechno jeden fulltext, filtr podle pole nema. */
|
||||
async function search(value) {
|
||||
const { body } = await ctx.http.get('/company', { query: { fulltext: value, limit: 5 } });
|
||||
const items = unwrap(body);
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
|
||||
// Poradi je zamer: ICO je jednoznacne, e-mail muze mit vic firem stejny
|
||||
// a nazev se v kazdem systemu pise jinak.
|
||||
const attempts = [];
|
||||
if (inputs.regNumber) attempts.push([inputs.regNumber, 'IČO']);
|
||||
if (inputs.email) attempts.push([inputs.email, 'e-mail']);
|
||||
if (inputs.name) attempts.push([inputs.name, 'název']);
|
||||
|
||||
for (const [value, label] of attempts) {
|
||||
const items = await search(value);
|
||||
const company = first(items);
|
||||
if (!company) {
|
||||
ctx.log(`Podle ${label} se nic nenašlo.`);
|
||||
continue;
|
||||
}
|
||||
if (items.length > 1) {
|
||||
ctx.log(`Podle ${label} vyšlo ${items.length} záznamů, bere se první.`);
|
||||
}
|
||||
|
||||
return {
|
||||
customerKnown: true,
|
||||
companyId: num(pick(company, 'id')),
|
||||
companyName: text(pick(company, 'name', 'companyName')),
|
||||
regNumber: text(pick(company, 'regNumber', 'identificationNumber')),
|
||||
ownerName: text(pick(pick(company, 'owner'), 'fullName', 'name')),
|
||||
matchedBy: label,
|
||||
};
|
||||
}
|
||||
|
||||
return notFound;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* RAYNET CRM: zalozeni nebo doplneni kontaktni osoby.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/raynet
|
||||
* Endpointy: GET /person?fulltext=..., POST /person, PUT /person/{id}
|
||||
*
|
||||
* Vzor **upsert**: nejdriv se hleda, pak se rozhoduje. Zakladat naslepo by
|
||||
* po tydnu znamenalo tri kontakty s tymz e-mailem a obchodnik by nevedel,
|
||||
* do ktereho psat.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'raynet.upsert-contact',
|
||||
name: 'Založit nebo aktualizovat kontakt',
|
||||
description:
|
||||
'Podle e-mailu kontakt najde a doplní chybějící údaje, jinak založí nový. ' +
|
||||
'Vrací ID kontaktu a to, jestli teprve vznikl.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'email',
|
||||
label: 'E-mail',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Podle něj se pozná, jestli kontakt už existuje.',
|
||||
},
|
||||
{ id: 'firstName', label: 'Jméno', type: 'string', required: false },
|
||||
{
|
||||
id: 'lastName',
|
||||
label: 'Příjmení',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Bez příjmení se u nového kontaktu dosadí část e-mailu před zavináčem.',
|
||||
},
|
||||
{ id: 'phone', label: 'Telefon', type: 'string', required: false },
|
||||
{
|
||||
id: 'companyId',
|
||||
label: 'ID firmy',
|
||||
type: 'number',
|
||||
required: false,
|
||||
hint: 'Vrací ho krok Dohledat firmu. Bez něj kontakt zůstane bez vazby.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'contactId', label: 'ID kontaktu', type: 'number', required: true },
|
||||
{ id: 'created', label: 'Kontakt teprve vznikl', type: 'boolean', required: true },
|
||||
{ id: 'fullName', label: 'Jméno kontaktu', type: 'string', required: false },
|
||||
],
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { unwrap, pick, first, text, num, need } = ctx.util;
|
||||
|
||||
const { body: found } = await ctx.http.get('/person', {
|
||||
query: { fulltext: inputs.email, limit: 5 },
|
||||
});
|
||||
const existing = first(unwrap(found));
|
||||
|
||||
const contactInfo = {};
|
||||
if (inputs.email) contactInfo.email = inputs.email;
|
||||
if (inputs.phone) contactInfo.tel1 = inputs.phone;
|
||||
|
||||
if (existing) {
|
||||
const id = need(num(pick(existing, 'id')), 'ID kontaktu');
|
||||
|
||||
// Prepisuji se jen vyplnena pole. Prazdna hodnota v kroku stromu znamena
|
||||
// "nevim", ne "smaz" - jinak by jeden beh bez telefonu telefon vymazal.
|
||||
const patch = {};
|
||||
if (inputs.firstName) patch.firstName = inputs.firstName;
|
||||
if (inputs.lastName) patch.lastName = inputs.lastName;
|
||||
if (Object.keys(contactInfo).length > 0) patch.contactInfo = contactInfo;
|
||||
if (inputs.companyId !== null) patch.company = { id: inputs.companyId };
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await ctx.http.put(`/person/${id}`, patch);
|
||||
ctx.log(`Kontakt ${id} doplněn.`);
|
||||
} else {
|
||||
ctx.log(`Kontakt ${id} už má vše, nic se nemění.`);
|
||||
}
|
||||
|
||||
return {
|
||||
contactId: id,
|
||||
created: false,
|
||||
fullName: text(pick(existing, 'fullName', 'lastName')),
|
||||
};
|
||||
}
|
||||
|
||||
// Prijmeni je v RAYNETu povinne. Kdyz nedoslo, vezme se cast e-mailu -
|
||||
// kontakt bez jmena se v seznamu neda najit a nikdo ho uz neopravi.
|
||||
const lastName = inputs.lastName ?? inputs.email.split('@')[0];
|
||||
|
||||
const { body: created } = await ctx.http.post('/person', {
|
||||
...(inputs.firstName ? { firstName: inputs.firstName } : {}),
|
||||
lastName,
|
||||
contactInfo,
|
||||
...(inputs.companyId !== null ? { company: { id: inputs.companyId } } : {}),
|
||||
});
|
||||
|
||||
const id = need(num(pick(unwrap(created), 'id')), 'ID nového kontaktu');
|
||||
ctx.log(`Založen kontakt ${id}.`);
|
||||
|
||||
return {
|
||||
contactId: id,
|
||||
created: true,
|
||||
fullName: [inputs.firstName, lastName].filter(Boolean).join(' '),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* SAP Business One: dohledani obchodniho partnera.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/sap-bo
|
||||
* Endpoint: GET /api/business-partners?$filter=...
|
||||
*
|
||||
* Vzor **predvalidace nad OData**. Service Layer filtruje jazykem OData, kde
|
||||
* se retezce uvozuji apostrofem - a apostrof v nazvu firmy by dotaz rozbil,
|
||||
* takze se zdvojuje. Nenalezeno neni chyba: podle toho se strom vetvi na
|
||||
* zalozeni noveho partnera.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'sap-bo.find-business-partner',
|
||||
name: 'Najít obchodního partnera',
|
||||
description:
|
||||
'Dohledá partnera v SAP Business One podle kódu, IČO nebo názvu. ' +
|
||||
'Nic nezakládá, výsledek slouží k větvení stromu.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'cardCode',
|
||||
label: 'Kód partnera',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Například C00123. Nejpřesnější, hledá se podle něj první.',
|
||||
},
|
||||
{
|
||||
id: 'taxId',
|
||||
label: 'IČO',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Porovnává se s polem FederalTaxID.',
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Název',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Stačí část názvu, hledá se podřetězec.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'found', label: 'Partner nalezen', type: 'boolean', required: true },
|
||||
{ id: 'cardCode', label: 'Kód partnera', type: 'string', required: false },
|
||||
{ id: 'cardName', label: 'Název partnera', type: 'string', required: false },
|
||||
{ id: 'taxId', label: 'IČO', type: 'string', required: false },
|
||||
{ id: 'currency', label: 'Měna', type: 'string', required: false },
|
||||
{ id: 'matchedBy', label: 'Podle čeho se našel', type: 'string', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
/** Retezec do OData filtru. Apostrof se zdvojuje, jinak dotaz spadne. */
|
||||
function quote(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
const notFound = {
|
||||
found: false,
|
||||
cardCode: null,
|
||||
cardName: null,
|
||||
taxId: null,
|
||||
currency: null,
|
||||
matchedBy: 'nenalezeno',
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text } = ctx.util;
|
||||
|
||||
if (!inputs.cardCode && !inputs.taxId && !inputs.name) {
|
||||
ctx.fail('Vyplňte kód partnera, IČO nebo název, jinak není podle čeho hledat.');
|
||||
}
|
||||
|
||||
// Poradi je zamer: kod je klic, ICO je jednoznacne, nazev se pise pokazde jinak.
|
||||
const attempts = [];
|
||||
if (inputs.cardCode) attempts.push([`CardCode eq ${quote(inputs.cardCode)}`, 'kód']);
|
||||
if (inputs.taxId) attempts.push([`FederalTaxID eq ${quote(inputs.taxId)}`, 'IČO']);
|
||||
if (inputs.name) attempts.push([`contains(CardName,${quote(inputs.name)})`, 'název']);
|
||||
|
||||
for (const [filter, label] of attempts) {
|
||||
const { body } = await ctx.http.get('/api/business-partners', {
|
||||
query: {
|
||||
$filter: filter,
|
||||
$select: 'CardCode,CardName,FederalTaxID,Currency,CardType',
|
||||
$top: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const raw = pick(body, 'value', 'data', 'items');
|
||||
const items = Array.isArray(raw) ? raw : Array.isArray(body) ? body : [];
|
||||
if (items.length === 0) {
|
||||
ctx.log(`Podle ${label} se nic nenašlo.`);
|
||||
continue;
|
||||
}
|
||||
if (items.length > 1) ctx.log(`Podle ${label} vyšlo ${items.length} partnerů, bere se první.`);
|
||||
|
||||
const partner = items[0];
|
||||
return {
|
||||
found: true,
|
||||
cardCode: text(pick(partner, 'CardCode')),
|
||||
cardName: text(pick(partner, 'CardName')),
|
||||
taxId: text(pick(partner, 'FederalTaxID')),
|
||||
currency: text(pick(partner, 'Currency')),
|
||||
matchedBy: label,
|
||||
};
|
||||
}
|
||||
|
||||
return notFound;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* SAP Business One: nacteni prodejnich objednavek.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/sap-bo
|
||||
* Endpoint: GET /api/orders?$filter=...
|
||||
*
|
||||
* Filtr se sklada z toho, co je vyplnene. Poslat prazdny filtr by znamenalo
|
||||
* stahnout cely archiv objednavek - u SAPu jsou to desetitisice radku, ktere
|
||||
* by krok stromu stejne neunesl.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'sap-bo.list-orders',
|
||||
name: 'Načíst objednávky',
|
||||
description:
|
||||
'Vrátí prodejní objednávky partnera nebo za období. Nic nemění, jen čte.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'cardCode',
|
||||
label: 'Kód partnera',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Vrací ho krok Najít obchodního partnera.',
|
||||
},
|
||||
{ id: 'dateFrom', label: 'Od data', type: 'date', required: false },
|
||||
{ id: 'dateTo', label: 'Do data', type: 'date', required: false },
|
||||
{
|
||||
id: 'openOnly',
|
||||
label: 'Jen otevřené',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: true,
|
||||
hint: 'Vypnuté vrátí i uzavřené a stornované objednávky.',
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
label: 'Nejvýše kolik objednávek',
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet objednávek', type: 'number', required: true },
|
||||
{ id: 'orders', label: 'Objednávky', type: 'list', required: true },
|
||||
{ id: 'total', label: 'Součet částek', type: 'number', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
function quote(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num, text, round } = ctx.util;
|
||||
|
||||
const conditions = [];
|
||||
if (inputs.cardCode) conditions.push(`CardCode eq ${quote(inputs.cardCode)}`);
|
||||
if (inputs.dateFrom) conditions.push(`DocDate ge ${quote(inputs.dateFrom.slice(0, 10))}`);
|
||||
if (inputs.dateTo) conditions.push(`DocDate le ${quote(inputs.dateTo.slice(0, 10))}`);
|
||||
if (inputs.openOnly) conditions.push("DocumentStatus eq 'bost_Open'");
|
||||
|
||||
if (conditions.length === 0) {
|
||||
ctx.fail('Vyplňte partnera nebo období, jinak by se stahoval celý archiv objednávek.');
|
||||
}
|
||||
|
||||
const { body } = await ctx.http.get('/api/orders', {
|
||||
query: {
|
||||
$filter: conditions.join(' and '),
|
||||
$select: 'DocEntry,DocNum,CardCode,CardName,DocDate,DocDueDate,DocTotal,DocCurrency,DocumentStatus',
|
||||
$orderby: 'DocDate desc',
|
||||
$top: inputs.limit,
|
||||
},
|
||||
});
|
||||
|
||||
const raw = pick(body, 'value', 'data', 'items');
|
||||
const items = Array.isArray(raw) ? raw : Array.isArray(body) ? body : [];
|
||||
|
||||
let total = 0;
|
||||
const orders = items.map((order) => {
|
||||
const amount = num(pick(order, 'DocTotal'), 0);
|
||||
total += amount;
|
||||
return {
|
||||
docEntry: num(pick(order, 'DocEntry')),
|
||||
docNum: num(pick(order, 'DocNum')),
|
||||
cardCode: text(pick(order, 'CardCode')),
|
||||
cardName: text(pick(order, 'CardName')),
|
||||
date: text(pick(order, 'DocDate')),
|
||||
dueDate: text(pick(order, 'DocDueDate')),
|
||||
total: amount,
|
||||
currency: text(pick(order, 'DocCurrency')),
|
||||
status: text(pick(order, 'DocumentStatus')),
|
||||
};
|
||||
});
|
||||
|
||||
return { count: orders.length, orders, total: round(total, 2) };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Google Search Console: vykon ve vyhledavani.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/analytics
|
||||
* Endpoint: POST /gsc/searchAnalytics/query?siteUrl=...
|
||||
*
|
||||
* Search Console vraci dimenze jako pole `keys` bez nazvu - poradi odpovida
|
||||
* tomu, co jsme si vyzadali. Skript je proto pojmenuje zpatky, jinak by ve
|
||||
* strome zbyly hodnoty, u kterych nikdo nevi, co znamenaji.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'search-console.run-report',
|
||||
name: 'Načíst výkon ve vyhledávání',
|
||||
description:
|
||||
'Vrátí dotazy, prokliky, zobrazení, CTR a průměrnou pozici za období. Nic nemění.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'siteUrl',
|
||||
label: 'Web',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Buď https://example.com/ (property s prefixem), nebo sc-domain:example.com (doménová).',
|
||||
},
|
||||
{
|
||||
id: 'startDate',
|
||||
label: 'Od',
|
||||
type: 'date',
|
||||
required: true,
|
||||
hint: 'Search Console má data se zpožděním dvou až tří dnů.',
|
||||
},
|
||||
{ id: 'endDate', label: 'Do', type: 'date', required: true },
|
||||
{
|
||||
id: 'dimensions',
|
||||
label: 'Dimenze',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'query',
|
||||
hint: 'Oddělené čárkou: query, page, country, device, date.',
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
label: 'Nejvýše kolik řádků',
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 100,
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
|
||||
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
|
||||
{ id: 'clicks', label: 'Prokliky celkem', type: 'number', required: true },
|
||||
{ id: 'impressions', label: 'Zobrazení celkem', type: 'number', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 45000,
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num, round } = ctx.util;
|
||||
|
||||
const dimensions = String(inputs.dimensions ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '');
|
||||
|
||||
const { body } = await ctx.http.post(
|
||||
'/gsc/searchAnalytics/query',
|
||||
{
|
||||
startDate: inputs.startDate.slice(0, 10),
|
||||
endDate: inputs.endDate.slice(0, 10),
|
||||
...(dimensions.length > 0 ? { dimensions } : {}),
|
||||
rowLimit: inputs.limit,
|
||||
},
|
||||
{ query: { siteUrl: inputs.siteUrl } },
|
||||
);
|
||||
|
||||
const raw = pick(body, 'rows');
|
||||
let clicks = 0;
|
||||
let impressions = 0;
|
||||
|
||||
const rows = (Array.isArray(raw) ? raw : []).map((row) => {
|
||||
const keys = pick(row, 'keys');
|
||||
const flat = {};
|
||||
dimensions.forEach((name, index) => {
|
||||
flat[name] = Array.isArray(keys) ? (keys[index] ?? null) : null;
|
||||
});
|
||||
|
||||
flat.clicks = num(pick(row, 'clicks'), 0);
|
||||
flat.impressions = num(pick(row, 'impressions'), 0);
|
||||
// CTR chodi jako podil, lidi ho ctou v procentech.
|
||||
flat.ctr = round(num(pick(row, 'ctr'), 0) * 100, 2);
|
||||
flat.position = round(num(pick(row, 'position'), 0), 1);
|
||||
|
||||
clicks += flat.clicks;
|
||||
impressions += flat.impressions;
|
||||
return flat;
|
||||
});
|
||||
|
||||
return { count: rows.length, rows, clicks, impressions };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Sklik: statistiky kampani.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/analytics
|
||||
* Endpoint: POST /sklik/report/campaigns
|
||||
*
|
||||
* Sklik prijima parametry jako **pole argumentu**, ne jako objekt - je to
|
||||
* zbytek jeho RPC rozhrani. Prvni prvek je filtr obdobi, druhy nastaveni
|
||||
* vypisu. Skript to sklada za uzivatele, aby se do kroku stromu nemusel psat
|
||||
* cizi tvar volani.
|
||||
*
|
||||
* Castky jsou u Skliku v halerich. Prepocitavaji se tady, at se v dalsim kroku
|
||||
* nedeli stem znovu a nekde spatne.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'sklik.campaign-report',
|
||||
name: 'Načíst výkon kampaní',
|
||||
description: 'Stáhne statistiky kampaní ze Skliku za období. Nic nemění.',
|
||||
|
||||
inputs: [
|
||||
{ id: 'dateFrom', label: 'Od', type: 'date', required: true },
|
||||
{ id: 'dateTo', label: 'Do', type: 'date', required: true },
|
||||
{
|
||||
id: 'granularity',
|
||||
label: 'Podrobnost',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'total',
|
||||
options: [
|
||||
{ value: 'total', label: 'Souhrn za období' },
|
||||
{ value: 'daily', label: 'Po dnech' },
|
||||
{ value: 'weekly', label: 'Po týdnech' },
|
||||
{ value: 'monthly', label: 'Po měsících' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
|
||||
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
|
||||
{ id: 'cost', label: 'Útrata celkem', type: 'number', required: true },
|
||||
{ id: 'clicks', label: 'Prokliky celkem', type: 'number', required: true },
|
||||
{ id: 'impressions', label: 'Zobrazení celkem', type: 'number', required: true },
|
||||
],
|
||||
|
||||
timeoutMs: 60000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Statistiky z odpovedi Skliku.
|
||||
*
|
||||
* Report chodi jako `report[].stats[]`, souhrn jako `stats[]` a nekdy jen
|
||||
* jako pole. Vsechny tri tvary vraci tatáž metoda podle podrobnosti, takze
|
||||
* hadat jeden by znamenalo, ze pri prepnuti na denni report vyjde nula.
|
||||
*/
|
||||
function statRows(ctx, body) {
|
||||
const { pick } = ctx.util;
|
||||
if (Array.isArray(body)) return body;
|
||||
|
||||
const report = pick(body, 'report');
|
||||
if (Array.isArray(report)) {
|
||||
return report.flatMap((item) => {
|
||||
const stats = pick(item, 'stats');
|
||||
return Array.isArray(stats) ? stats : [item];
|
||||
});
|
||||
}
|
||||
|
||||
const stats = pick(body, 'stats');
|
||||
if (Array.isArray(stats)) return stats;
|
||||
|
||||
const data = pick(body, 'data');
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num, round } = ctx.util;
|
||||
|
||||
const { body } = await ctx.http.post('/sklik/report/campaigns', [
|
||||
{
|
||||
dateFrom: inputs.dateFrom.slice(0, 10),
|
||||
dateTo: inputs.dateTo.slice(0, 10),
|
||||
statGranularity: inputs.granularity,
|
||||
},
|
||||
{ statGranularity: inputs.granularity },
|
||||
]);
|
||||
|
||||
let cost = 0;
|
||||
let clicks = 0;
|
||||
let impressions = 0;
|
||||
|
||||
const rows = statRows(ctx, body).map((row) => {
|
||||
// Halere na koruny.
|
||||
const rowCost = round(num(pick(row, 'money', 'price', 'cost'), 0) / 100, 2);
|
||||
const rowClicks = num(pick(row, 'clicks'), 0);
|
||||
const rowImpressions = num(pick(row, 'impressions'), 0);
|
||||
|
||||
cost += rowCost;
|
||||
clicks += rowClicks;
|
||||
impressions += rowImpressions;
|
||||
|
||||
return {
|
||||
date: pick(row, 'date') ?? null,
|
||||
campaignId: pick(row, 'campaignId', 'id') ?? null,
|
||||
campaignName: pick(row, 'name', 'campaignName') ?? null,
|
||||
impressions: rowImpressions,
|
||||
clicks: rowClicks,
|
||||
cost: rowCost,
|
||||
conversions: num(pick(row, 'conversions'), 0),
|
||||
};
|
||||
});
|
||||
|
||||
return { count: rows.length, rows, cost: round(cost, 2), clicks, impressions };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Prepis hovoru: dva enginy naraz a sloucení jejich vysledku.
|
||||
*
|
||||
* Sluzba: https://services.csbot.cz/apps/audio-transcription
|
||||
* Endpoint: POST /dual-with-merge (multipart/form-data)
|
||||
*
|
||||
* Vzor **volani, ktere vraci tri verze teze veci**. Sluzba prepise nahravku
|
||||
* Deepgramem i Whisperem a treti odpoved slozi z obou. Vraci se vsechny tri,
|
||||
* protoze kdyz jeden engine vypadne, druhy porad neco rekne - a strom se pak
|
||||
* ma podle ceho rozhodnout, misto aby cely krok spadl.
|
||||
*/
|
||||
|
||||
export const manifest = {
|
||||
id: 'transcription.transcribe',
|
||||
name: 'Přepsat nahrávku',
|
||||
description:
|
||||
'Přepíše nahrávku Deepgramem i Whisperem a oba přepisy sloučí do jednoho. ' +
|
||||
'Sloučený výsledek rozlišuje mluvčí a je přesnější než každý engine zvlášť.',
|
||||
|
||||
inputs: [
|
||||
{
|
||||
id: 'fileName',
|
||||
label: 'Název nahrávky',
|
||||
type: 'string',
|
||||
required: true,
|
||||
hint: 'Včetně přípony, například hovor.mp3.',
|
||||
},
|
||||
{
|
||||
id: 'contentBase64',
|
||||
label: 'Nahrávka (Base64)',
|
||||
type: 'string',
|
||||
required: true,
|
||||
multiline: true,
|
||||
hint: 'Obsah zakódovaný v Base64.',
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
label: 'Jazyk nahrávky',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'cs',
|
||||
hint: 'ISO kód jazyka, například cs.',
|
||||
},
|
||||
{
|
||||
id: 'diarize',
|
||||
label: 'Rozlišovat mluvčí',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: true,
|
||||
hint: 'Zapnuté přidá do přepisu štítky Mluvčí 0, Mluvčí 1.',
|
||||
},
|
||||
{
|
||||
id: 'combinePrompt',
|
||||
label: 'Instrukce pro sloučení',
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
hint: 'Bez vyplnění se použije výchozí instrukce služby, která je psaná pro české hovory.',
|
||||
},
|
||||
{
|
||||
id: 'chatModel',
|
||||
label: 'Model pro sloučení',
|
||||
type: 'string',
|
||||
required: false,
|
||||
hint: 'Bez vyplnění rozhoduje služba. Slučování dělá OpenAI chat model.',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [
|
||||
{ id: 'text', label: 'Sloučený přepis', type: 'string', required: true },
|
||||
{ id: 'deepgram', label: 'Přepis z Deepgramu', type: 'string', required: false },
|
||||
{ id: 'whisper', label: 'Přepis z Whisperu', type: 'string', required: false },
|
||||
{ id: 'partial', label: 'Část přepisu selhala', type: 'boolean', required: true },
|
||||
{ id: 'problems', label: 'Co selhalo', type: 'string', required: false },
|
||||
],
|
||||
|
||||
// Dva prepisy a sloucení za sebou. Kratsi limit by utnul i kratkou nahravku.
|
||||
timeoutMs: 120000,
|
||||
};
|
||||
|
||||
const byExtension = {
|
||||
mp3: 'audio/mpeg',
|
||||
mpga: 'audio/mpeg',
|
||||
mp4: 'audio/mp4',
|
||||
m4a: 'audio/mp4',
|
||||
wav: 'audio/wav',
|
||||
webm: 'audio/webm',
|
||||
ogg: 'audio/ogg',
|
||||
flac: 'audio/flac',
|
||||
};
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text } = ctx.util;
|
||||
|
||||
const parts = inputs.fileName.split('.');
|
||||
const extension = parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
|
||||
|
||||
const { body } = await ctx.http.postForm('/dual-with-merge', {
|
||||
language: inputs.language,
|
||||
diarize: inputs.diarize,
|
||||
combine_prompt: inputs.combinePrompt,
|
||||
chat_model: inputs.chatModel,
|
||||
file: {
|
||||
filename: inputs.fileName,
|
||||
base64: inputs.contentBase64.trim(),
|
||||
contentType: byExtension[extension] ?? 'application/octet-stream',
|
||||
},
|
||||
});
|
||||
|
||||
const merged = text(pick(body, 'merge'), '');
|
||||
const deepgram = text(pick(body, 'deepgram'), '');
|
||||
const whisper = text(pick(body, 'gpt'), '');
|
||||
const errors = pick(body, 'errors');
|
||||
const problems = Array.isArray(errors) && errors.length > 0 ? errors.join(' | ') : null;
|
||||
|
||||
if (problems) ctx.log(`Část přepisu selhala: ${problems}`);
|
||||
|
||||
// Kdyz sloucení selhalo, prazdny vystup by strom poslal dal jako platny text.
|
||||
// Radsi se sahne po tom, co prislo - a nahlas se rekne, ze je to nahradnik.
|
||||
const fallback = deepgram !== '' ? deepgram : whisper;
|
||||
if (merged === '' && fallback === '') {
|
||||
ctx.fail('Služba nevrátila žádný přepis.', problems ?? body);
|
||||
}
|
||||
if (merged === '') ctx.log('Sloučení nevyšlo, vrací se přepis z jednoho enginu.');
|
||||
|
||||
return {
|
||||
text: merged !== '' ? merged : fallback,
|
||||
deepgram: deepgram !== '' ? deepgram : null,
|
||||
whisper: whisper !== '' ? whisper : null,
|
||||
partial: problems !== null || merged === '',
|
||||
problems,
|
||||
};
|
||||
}
|
||||
@@ -146,6 +146,14 @@ export const config = {
|
||||
scriptTimeoutMs: positiveNumber(process.env.SCRIPT_TIMEOUT_MS, 15_000),
|
||||
/** Vetsi odpoved cizi sluzby se zahodi, misto aby snedla pamet procesu. */
|
||||
scriptMaxResponseBytes: positiveNumber(process.env.SCRIPT_MAX_RESPONSE_BYTES, 1_000_000),
|
||||
/**
|
||||
* Strop na soubor odeslany z `ctx.http.postForm`.
|
||||
*
|
||||
* Soubor prochazi krokem stromu jako Base64, takze se cely drzi v pameti
|
||||
* a zapisuje se do zaznamu behu. Nizsi cislo nez u cizich sluzeb je zamer:
|
||||
* OpenAI zvladne stovky megabajtu, nas beh kroku ne.
|
||||
*/
|
||||
scriptMaxUploadBytes: positiveNumber(process.env.SCRIPT_MAX_UPLOAD_BYTES, 10_000_000),
|
||||
/**
|
||||
* Strop na text chybove odpovedi cizi sluzby.
|
||||
*
|
||||
|
||||
@@ -36,6 +36,12 @@ export interface PermissionDefinition {
|
||||
export const permissionCatalog: PermissionDefinition[] = [
|
||||
// ------------------------------------------------------------------ tickety
|
||||
{ key: 'ticket.view', label: 'Vidět tickety', group: 'Tickety' },
|
||||
{
|
||||
key: 'ticket.create',
|
||||
label: 'Založit ticket ručně',
|
||||
group: 'Tickety',
|
||||
hint: 'Bez tohoto práva vznikají tickety jen z automatizací a z příchozích událostí.',
|
||||
},
|
||||
{ key: 'ticket.comment', label: 'Komentovat ticket', group: 'Tickety' },
|
||||
{ key: 'ticket.assign.self', label: 'Vzít ticket na sebe', group: 'Tickety' },
|
||||
{
|
||||
@@ -53,6 +59,20 @@ export const permissionCatalog: PermissionDefinition[] = [
|
||||
|
||||
// ------------------------------------------------------------- nastaveni firmy
|
||||
{ key: 'ticketType.manage', label: 'Spravovat typy ticketů', group: 'Nastavení firmy' },
|
||||
|
||||
// ----------------------------------------------------------------- helpdesk
|
||||
{
|
||||
key: 'helpdesk.view',
|
||||
label: 'Vidět helpdesk',
|
||||
group: 'Helpdesk',
|
||||
hint: 'Sekce s požadavky, které vaše firma poslala svému dodavateli.',
|
||||
},
|
||||
{
|
||||
key: 'helpdesk.create',
|
||||
label: 'Poslat požadavek na helpdesk',
|
||||
group: 'Helpdesk',
|
||||
hint: 'Bez tohoto práva člověk požadavky jen vidí.',
|
||||
},
|
||||
{ key: 'action.manage', label: 'Spravovat definice akcí', group: 'Nastavení firmy' },
|
||||
{ key: 'automation.edit', label: 'Upravovat automatizace', group: 'Nastavení firmy' },
|
||||
{ key: 'connector.manage', label: 'Spravovat konektory', group: 'Nastavení firmy' },
|
||||
@@ -153,6 +173,7 @@ export function systemRoles(): Role[] {
|
||||
description: 'Řeší tickety. Nemůže přehazovat práci ani měnit nastavení.',
|
||||
permissions: [
|
||||
'ticket.view',
|
||||
'ticket.create',
|
||||
'ticket.comment',
|
||||
'ticket.assign.self',
|
||||
'ticket.status.change',
|
||||
|
||||
+839
-37
File diff suppressed because it is too large
Load Diff
+30
-2
@@ -67,7 +67,34 @@ function asText(value: unknown): string {
|
||||
* mit prednost - jinak by se hledal krok `st_faktura` a v nem pole `invoiceId`,
|
||||
* coz je sice totez, ale jen nahodou.
|
||||
*/
|
||||
export function renderTemplate(template: string, source: unknown): string {
|
||||
export interface RenderOptions {
|
||||
/**
|
||||
* Uprava **dosazene hodnoty**, ne cele sablony.
|
||||
*
|
||||
* Kvuli HTML telu e-mailu: sablonu pise nas clovek a ostre zavorky v ni jsou
|
||||
* zamer, kdezto hodnota casto prichazi od zakaznika. Bez tohohle by text
|
||||
* ticketu s `<b>` prepsal rozvrzeni zpravy a `<script>` by se dostal
|
||||
* prijemci do schranky. Escapovat az vysledek nejde - to uz nejde poznat,
|
||||
* co bylo sablona a co dosazena hodnota.
|
||||
*/
|
||||
escape?: (value: string) => string;
|
||||
}
|
||||
|
||||
/** Nahrada znaku, ktere by v HTML zmenily vyznam. */
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function renderTemplate(
|
||||
template: string,
|
||||
source: unknown,
|
||||
options: RenderOptions = {},
|
||||
): string {
|
||||
return template.replace(REFERENCE, (_match, reference: string) => {
|
||||
const direct =
|
||||
source !== null && typeof source === 'object'
|
||||
@@ -80,6 +107,7 @@ export function renderTemplate(template: string, source: unknown): string {
|
||||
console.warn(`[template] odkaz na neznamy parametr "${reference}", dosazuji prazdno`);
|
||||
return '';
|
||||
}
|
||||
return asText(value);
|
||||
const text = asText(value);
|
||||
return options.escape ? options.escape(text) : text;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export type ModuleKey =
|
||||
| 'connectors'
|
||||
| 'scripts'
|
||||
| 'tickets'
|
||||
| 'helpdesk'
|
||||
| 'people'
|
||||
| 'incidents'
|
||||
| 'settings';
|
||||
@@ -52,6 +53,16 @@ export interface ModuleDefinition {
|
||||
export const moduleCatalog: ModuleDefinition[] = [
|
||||
{ key: 'overview', label: 'Přehled', permissions: [], required: true },
|
||||
{ key: 'tickets', label: 'Tickety', permissions: ['ticket.view'] },
|
||||
/*
|
||||
* Helpdesk je pohled **zadavatele**, ne resitele: firma tu vidi pozadavky,
|
||||
* ktere sama poslala svemu dodavateli. Tickety, ktere resi ona, zustavaji
|
||||
* v zalozce Tickety.
|
||||
*
|
||||
* `required` je tu ze stejneho duvodu jako u akci a widgetu: firmy zalozene
|
||||
* driv maji v ulozisti seznam modulu bez tohohle klice. Kdo zalozku uvidi,
|
||||
* stejne rozhoduje pravo `helpdesk.view`, ktere prideluje admin firmy.
|
||||
*/
|
||||
{ key: 'helpdesk', label: 'Helpdesk', permissions: ['helpdesk.view'], required: true },
|
||||
{ key: 'people', label: 'Lidé', permissions: ['ticket.view'], required: true },
|
||||
{ key: 'automations', label: 'Automatizace', permissions: ['automation.edit'] },
|
||||
/*
|
||||
|
||||
+34
-3
@@ -30,6 +30,17 @@ export interface Tenant extends TenantEntity {
|
||||
* a tim i to, v jakem rozsahu je externi ID unikatni.
|
||||
*/
|
||||
intakeToken: string;
|
||||
/**
|
||||
* Firma, ktera teto firme resi pozadavky z helpdesku.
|
||||
*
|
||||
* `null` = helpdesk nema komu poslat, takze se pozadavek nezalozi a rekne se
|
||||
* to nahlas. Nastavuje to spravce platformy, ne firma sama: kdo koho obsluhuje
|
||||
* je obchodni vztah, ne volba klienta.
|
||||
*
|
||||
* Pozadavek pak vznikne jako ticket **teto dodavatelske firmy** a zadavatel
|
||||
* zustane v `helpdeskSourceId`.
|
||||
*/
|
||||
helpdeskProviderId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,11 +58,31 @@ export function generateIntakeToken(): string {
|
||||
|
||||
export function seedTenants(): Tenant[] {
|
||||
const timestamp = nowIso();
|
||||
const base = { tenantId: null, note: '', enabled: true, createdAt: timestamp, updatedAt: timestamp };
|
||||
const base = {
|
||||
tenantId: null,
|
||||
note: '',
|
||||
enabled: true,
|
||||
helpdeskProviderId: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
return [
|
||||
{ ...base, id: 'tnt_automia', name: 'Automia', intakeToken: generateIntakeToken() },
|
||||
{ ...base, id: 'tnt_nordis', name: 'Nordis a.s.', intakeToken: generateIntakeToken() },
|
||||
{ ...base, id: 'tnt_logitrans', name: 'LogiTrans', intakeToken: generateIntakeToken() },
|
||||
// Obe klientske firmy maji helpdesk u Automie - je to ta, ktera jim to spravuje.
|
||||
{
|
||||
...base,
|
||||
id: 'tnt_nordis',
|
||||
name: 'Nordis a.s.',
|
||||
intakeToken: generateIntakeToken(),
|
||||
helpdeskProviderId: 'tnt_automia',
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: 'tnt_logitrans',
|
||||
name: 'LogiTrans',
|
||||
intakeToken: generateIntakeToken(),
|
||||
helpdeskProviderId: 'tnt_automia',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+76
-7
@@ -130,6 +130,18 @@ export interface Ticket {
|
||||
id: string;
|
||||
/** Firma, ktere ticket patri. Hranice viditelnosti, viz data/access.ts. */
|
||||
tenantId: string;
|
||||
/**
|
||||
* Firma, ze ktere pozadavek prisel pres helpdesk. `null` u beznych ticketu.
|
||||
*
|
||||
* Vlastnikem ticketu (`tenantId`) je ta firma, ktera pozadavek **resi** -
|
||||
* u helpdesku se dosadi sama podle toho, koho ma zadavatel nastaveneho jako
|
||||
* dodavatele. Zadavatel neni vlastnik: kdyby byl, videl by ho resitel jen
|
||||
* jako cizi ticket a nemel by ho ve sve fronte.
|
||||
*
|
||||
* Podle tohohle pole vidi zadavatel svoje pozadavky, a **jen je**. Do bezneho
|
||||
* seznamu ticketu se nepromita, ten zustava vlastnikovi.
|
||||
*/
|
||||
helpdeskSourceId: string | null;
|
||||
/**
|
||||
* ID u odesilatele, napr. cislo objednavky.
|
||||
*
|
||||
@@ -233,8 +245,10 @@ interface StoredTicket
|
||||
| 'resolvedAt'
|
||||
| 'resolvedById'
|
||||
| 'reopenCount'
|
||||
| 'helpdeskSourceId'
|
||||
> {
|
||||
assigneeId: string | null;
|
||||
helpdeskSourceId?: string | null;
|
||||
assigneeGroupId?: string | null;
|
||||
typeId?: string | null;
|
||||
fields?: Record<string, string | number | boolean | null>;
|
||||
@@ -862,6 +876,7 @@ function toTicket(stored: StoredTicket): Ticket {
|
||||
resolvedAt: stored.resolvedAt ?? null,
|
||||
resolvedById: stored.resolvedById ?? null,
|
||||
reopenCount: stored.reopenCount ?? 0,
|
||||
helpdeskSourceId: stored.helpdeskSourceId ?? null,
|
||||
};
|
||||
|
||||
if (!assigneeId) return { ...base, assignee: null };
|
||||
@@ -883,6 +898,14 @@ export interface TicketFilter {
|
||||
* driv nebo pozdeji nekdo zapomene a endpoint vrati cizi data.
|
||||
*/
|
||||
tenantIds: string[];
|
||||
/**
|
||||
* Firmy, ze kterych pozadavek prisel pres helpdesk.
|
||||
*
|
||||
* Vyplnene **nahrazuje** filtr podle vlastnika: zadavatel vlastnikem neni,
|
||||
* takze by mu jinak nezbylo nic. Prazdne pole tady znamena "nefiltrovat
|
||||
* podle zdroje", ne "nic" - vlastnicky filtr plati dal.
|
||||
*/
|
||||
helpdeskSourceIds?: string[];
|
||||
/** ID resitele, nebo 'unassigned' pro nepridelene. */
|
||||
assignee?: string;
|
||||
status?: TicketStatus;
|
||||
@@ -896,8 +919,14 @@ export interface TicketFilter {
|
||||
}
|
||||
|
||||
export function listTickets(filter: TicketFilter): Ticket[] {
|
||||
const sources = filter.helpdeskSourceIds;
|
||||
const selected = tickets.filter((ticket) => {
|
||||
if (!filter.tenantIds.includes(ticket.tenantId)) return false;
|
||||
if (sources && sources.length > 0) {
|
||||
// Pohled zadavatele: vidi svoje pozadavky bez ohledu na to, kdo je resi.
|
||||
if (!ticket.helpdeskSourceId || !sources.includes(ticket.helpdeskSourceId)) return false;
|
||||
} else if (!filter.tenantIds.includes(ticket.tenantId)) {
|
||||
return false;
|
||||
}
|
||||
if (filter.status && ticket.status !== filter.status) return false;
|
||||
if (filter.channel && ticket.channel !== filter.channel) return false;
|
||||
if (filter.typeId === 'none' ? ticket.typeId : filter.typeId && ticket.typeId !== filter.typeId)
|
||||
@@ -930,11 +959,28 @@ export function listTickets(filter: TicketFilter): Ticket[] {
|
||||
.map(toTicket);
|
||||
}
|
||||
|
||||
/** Vraci ticket jen z povolenych firem. Cizi se tvari jako neexistujici. */
|
||||
export function getTicket(id: string, tenantIds: string[]): TicketDetail | undefined {
|
||||
/**
|
||||
* Vraci ticket jen z povolenych firem. Cizi se tvari jako neexistujici.
|
||||
*
|
||||
* `helpdeskSourceIds` je druha cesta dovnitr: firma, ktera pozadavek poslala,
|
||||
* ho smi cist, i kdyz ho nevlastni. Bez toho by zadavatel videl v seznamu
|
||||
* pozadavek, ktery si nemuze otevrit.
|
||||
*/
|
||||
export function getTicket(
|
||||
id: string,
|
||||
tenantIds: string[],
|
||||
helpdeskSourceIds: string[] = [],
|
||||
): TicketDetail | undefined {
|
||||
const stored = tickets.find((t) => t.id === id);
|
||||
if (!stored) return undefined;
|
||||
if (!tenantIds.includes(stored.tenantId)) {
|
||||
|
||||
const owns = tenantIds.includes(stored.tenantId);
|
||||
const asked =
|
||||
stored.helpdeskSourceId !== null &&
|
||||
stored.helpdeskSourceId !== undefined &&
|
||||
helpdeskSourceIds.includes(stored.helpdeskSourceId);
|
||||
|
||||
if (!owns && !asked) {
|
||||
console.warn(`[tickets] pokus o cteni ticketu ${id} mimo povolene firmy`);
|
||||
return undefined;
|
||||
}
|
||||
@@ -1361,7 +1407,14 @@ export interface CreateTicketInput {
|
||||
body?: string;
|
||||
sourceRef?: string | null;
|
||||
channel: TicketChannel;
|
||||
customer: TicketCustomer;
|
||||
/**
|
||||
* Zakaznik je **nepovinny**. U pozadavku z helpdesku dava smysl vedet, kdo
|
||||
* ho poslal, u ticketu zalozeneho rucne casto nikdo takovy neni - je to
|
||||
* ukol, ne pozadavek od nekoho zvenku.
|
||||
*/
|
||||
customer?: TicketCustomer;
|
||||
/** Firma, ktera pozadavek poslala pres helpdesk. Vlastnikem je ta, ktera resi. */
|
||||
helpdeskSourceId?: string | null;
|
||||
priority: TicketPriority;
|
||||
/** Vychozi stav, kdyz se nezada. Volny retezec, ne ciselnik. */
|
||||
status?: string;
|
||||
@@ -1401,7 +1454,8 @@ export function createTicket(input: CreateTicketInput): Ticket {
|
||||
body: input.body ?? '',
|
||||
sourceRef: input.sourceRef ?? null,
|
||||
channel: input.channel,
|
||||
customer: input.customer,
|
||||
customer: input.customer ?? { id: null, company: '', contact: '', reply: '' },
|
||||
helpdeskSourceId: input.helpdeskSourceId ?? null,
|
||||
// Vychozi stav je jen doporuceni. Kdo posle vlastni, ma vlastni.
|
||||
status: input.status ?? defaultStatuses[0],
|
||||
priority: input.priority,
|
||||
@@ -1682,13 +1736,28 @@ export function ticketAssignee(id: string, tenantIds: string[]): string | null |
|
||||
return ticket.assigneeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Komentar k ticketu.
|
||||
*
|
||||
* `helpdeskSourceIds` pusti ke slovu i zadavatele z helpdesku. Je to jedina
|
||||
* zmena, kterou nad cizim ticketem smi - doplnit, co zapomnel napsat, je presne
|
||||
* to, kvuli cemu se pozadavek otevira. Prehazovat resitele nebo menit stav uz
|
||||
* ne, na to se ho nikdo neptal.
|
||||
*/
|
||||
export function addComment(
|
||||
id: string,
|
||||
author: string,
|
||||
text: string,
|
||||
tenantIds: string[],
|
||||
helpdeskSourceIds: string[] = [],
|
||||
): Ticket | undefined {
|
||||
const ticket = findWritable(id, tenantIds);
|
||||
const found = tickets.find((item) => item.id === id);
|
||||
const asked =
|
||||
found?.helpdeskSourceId !== null &&
|
||||
found?.helpdeskSourceId !== undefined &&
|
||||
helpdeskSourceIds.includes(found.helpdeskSourceId);
|
||||
|
||||
const ticket = asked ? found : findWritable(id, tenantIds);
|
||||
if (!ticket) {
|
||||
console.warn(`[tickets] komentar k nedostupnemu ticketu: ${id}`);
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Odesilani e-mailu pres SMTP.
|
||||
*
|
||||
* Proc to neni skript: skript umi jen `ctx.http`. SMTP neni HTTP, takze by
|
||||
* musel dostat sit, a tim by padlo pravidlo, ze skript nema jak zavolat ven
|
||||
* jinudy nez pres nas klient. E-mail je proto **vnitrni krok** stejne jako
|
||||
* zalozeni ticketu, jen misto do naseho uloziste sahá na posmovni server.
|
||||
*
|
||||
* Pristupove udaje ale zustavaji tam, kde maji - v konektoru firmy. Kazda
|
||||
* firma odesila ze sve schranky a nikdo z nich nesmi videt hodnoty ostatnich.
|
||||
*
|
||||
* Chyby se **nevyhazuji**. Vraci se vysledek s `ok`, hlaskou a priznakem,
|
||||
* jestli ma smysl zkusit znovu: plna schranka za hodinu neni, spatne heslo
|
||||
* za hodinu porad spatne bude.
|
||||
*/
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
import { config } from '../config.js';
|
||||
import type { ResolvedTarget } from '../scripts/connections.js';
|
||||
import { isPrivateHost } from '../scripts/http.js';
|
||||
import { createRedactor, truncate } from '../scripts/util.js';
|
||||
|
||||
export interface SmtpSettings {
|
||||
host: string;
|
||||
port: number;
|
||||
/** true = sifrovane od navazani spojeni (port 465). */
|
||||
secure: boolean;
|
||||
/** true = vyzadovat STARTTLS, tedy nespokojit se s nesifrovanym spojenim. */
|
||||
requireTls: boolean;
|
||||
user: string;
|
||||
password: string;
|
||||
from: string;
|
||||
fromName: string | null;
|
||||
replyTo: string | null;
|
||||
}
|
||||
|
||||
export interface MailMessage {
|
||||
to: string;
|
||||
cc?: string | null;
|
||||
bcc?: string | null;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string | null;
|
||||
replyTo?: string | null;
|
||||
}
|
||||
|
||||
export interface MailResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
/** Cela odpoved serveru, uz zredigovana. Tam je napsane, co mu vadilo. */
|
||||
detail: string | null;
|
||||
/** Ciselny kod SMTP, kdyz se k odpovedi serveru vubec doslo. */
|
||||
status: number | null;
|
||||
/** true = docasna prekazka, opakovani ma smysl. */
|
||||
retryable: boolean;
|
||||
/** Vyplnene jen u uspesneho odeslani. */
|
||||
messageId?: string;
|
||||
accepted?: number;
|
||||
rejected?: number;
|
||||
}
|
||||
|
||||
/** Kam se to posilalo. Do hlasky i na kartu konektoru, bez udaju. */
|
||||
export function smtpTargetUrl(settings: SmtpSettings): string {
|
||||
return `smtp://${settings.host}:${settings.port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nastaveni z konektoru.
|
||||
*
|
||||
* Vraci `null` a duvod, kdyz udaje nedavaji smysl. Padat na tom nemuzeme:
|
||||
* spatne vyplneny konektor je bezny stav, ne chyba aplikace.
|
||||
*/
|
||||
export function smtpSettings(target: ResolvedTarget): { settings: SmtpSettings } | { error: string } {
|
||||
const value = (key: string): string => (target.serviceConfig[key] ?? '').trim();
|
||||
|
||||
const host = value('host');
|
||||
const user = value('user');
|
||||
const password = value('password');
|
||||
const from = value('from') || user;
|
||||
|
||||
if (host === '') return { error: 'Konektor nemá vyplněný SMTP server.' };
|
||||
// Stejne pravidlo jako u HTTP: adresu vyplnuje firma, takze tohle je jedina
|
||||
// zabrana proti navazani spojeni dovnitr nasi site.
|
||||
if (!config.allowPrivateTargets && isPrivateHost(host)) {
|
||||
return {
|
||||
error:
|
||||
`Server ${host} míří do vnitřní sítě. ` +
|
||||
'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.',
|
||||
};
|
||||
}
|
||||
if (user === '') return { error: 'Konektor nemá vyplněného uživatele.' };
|
||||
if (password === '') return { error: 'Konektor nemá vyplněné heslo.' };
|
||||
if (from === '') return { error: 'Konektor nemá vyplněnou adresu odesílatele.' };
|
||||
|
||||
const port = Number(value('port'));
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return { error: `Port "${value('port')}" není platné číslo portu.` };
|
||||
}
|
||||
|
||||
/*
|
||||
* Prazdne sifrovani se ridi portem, protoze to je zvyklost, kterou zna
|
||||
* kazdy: 465 je sifrovane od zacatku, 587 zacina necisté a povysi se
|
||||
* STARTTLS. Vyslovna hodnota to prebije - jsou servery, ktere to maji jinak.
|
||||
*/
|
||||
const security = value('security').toLowerCase();
|
||||
let secure = port === 465;
|
||||
let requireTls = !secure;
|
||||
|
||||
if (security !== '') {
|
||||
if (security === 'ssl' || security === 'tls') {
|
||||
secure = true;
|
||||
requireTls = false;
|
||||
} else if (security === 'starttls') {
|
||||
secure = false;
|
||||
requireTls = true;
|
||||
} else if (security === 'zadne' || security === 'žádné' || security === 'none') {
|
||||
secure = false;
|
||||
requireTls = false;
|
||||
} else {
|
||||
return { error: `Šifrování "${security}" neznáme. Použijte ssl, starttls nebo zadne.` };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
settings: {
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
requireTls,
|
||||
user,
|
||||
password,
|
||||
from,
|
||||
fromName: value('fromName') || null,
|
||||
replyTo: value('replyTo') || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function transportFor(settings: SmtpSettings) {
|
||||
return nodemailer.createTransport({
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
secure: settings.secure,
|
||||
requireTLS: settings.requireTls,
|
||||
auth: { user: settings.user, pass: settings.password },
|
||||
// Bez stropu by krok visel na nedostupnem serveru, dokud ho nekdo nezabije.
|
||||
connectionTimeout: 15_000,
|
||||
greetingTimeout: 15_000,
|
||||
socketTimeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Kody od nodemaileru, u kterych ma smysl zkusit znovu. */
|
||||
const retryableCodes = new Set(['ECONNECTION', 'ETIMEDOUT', 'ESOCKET', 'EDNS', 'ECONNRESET']);
|
||||
|
||||
/**
|
||||
* Prevede vyjimku od nodemaileru na vysledek.
|
||||
*
|
||||
* Duvod, proc se to nevraci jen jako `err.message`: hlaska nodemaileru je
|
||||
* casto jen "Invalid login", kdezto server k tomu napsal celou vetu o tom,
|
||||
* ze ucet chce heslo pro aplikaci. Prave ta veta cloveku pomuze.
|
||||
*/
|
||||
function failure(err: unknown, settings: SmtpSettings, what: string): MailResult {
|
||||
// Redaguje se heslo, ne uzivatel. Uzivatel je adresa schranky a prave ta
|
||||
// v hlasce pomaha - skrtnout ji by z vety udelalo hadanku.
|
||||
const redact = createRedactor([settings.password]);
|
||||
const error = err as { code?: string; responseCode?: number; response?: string; message?: string };
|
||||
|
||||
const code = error?.code ?? '';
|
||||
const status = typeof error?.responseCode === 'number' ? error.responseCode : null;
|
||||
const said = error?.response ? ` Server odpověděl: ${redact(error.response)}` : '';
|
||||
const where = `${what} na ${smtpTargetUrl(settings)}`;
|
||||
|
||||
// 4xx je docasne odmitnuti (fronta, limit), 5xx koncove.
|
||||
const softStatus = status !== null && status >= 400 && status < 500;
|
||||
const retryable = retryableCodes.has(code) || softStatus;
|
||||
|
||||
let message: string;
|
||||
if (code === 'EAUTH' || status === 535) {
|
||||
message =
|
||||
`Přihlášení odmítnuto: ${where}. Server údaje dostal a neuznal je, jde tedy ` +
|
||||
'o uživatele nebo heslo. U schránek s dvoufázovým ověřením musí být heslo ' +
|
||||
`pro aplikaci, ne heslo k účtu.${said}`;
|
||||
} else if (code === 'EENVELOPE') {
|
||||
message =
|
||||
`Adresy neprošly: ${where}. Server nepřijal odesílatele nebo některého ` +
|
||||
`příjemce. Odesílatel musí být adresa, kterou má účet povolenou.${said}`;
|
||||
} else if (retryableCodes.has(code)) {
|
||||
message =
|
||||
`Nepodařilo se spojit se serverem: ${where} (${code}). Zkontrolujte adresu, ` +
|
||||
`port a šifrování.${said}`;
|
||||
} else {
|
||||
message = `${what} selhalo na ${smtpTargetUrl(settings)}.${said || ` ${error?.message ?? ''}`}`;
|
||||
}
|
||||
|
||||
const detail = redact(
|
||||
truncate(
|
||||
[error?.message, error?.response, code && `code=${code}`].filter(Boolean).join('\n'),
|
||||
config.errorDetailBytes,
|
||||
),
|
||||
);
|
||||
|
||||
return { ok: false, message: message.trim(), detail: detail || null, status, retryable };
|
||||
}
|
||||
|
||||
/**
|
||||
* Overi, ze se na server da prihlasit.
|
||||
*
|
||||
* `verify` nic neposila, jen naváže spojeni a prihlasi se. Presne to, co
|
||||
* overeni konektoru potrebuje: **cteci** akce, ktera bez platnych udaju
|
||||
* neprojde a nikomu nic nedorucí.
|
||||
*/
|
||||
export async function verifySmtp(target: ResolvedTarget): Promise<MailResult> {
|
||||
const resolved = smtpSettings(target);
|
||||
if ('error' in resolved) {
|
||||
return { ok: false, message: resolved.error, detail: null, status: null, retryable: false };
|
||||
}
|
||||
|
||||
const transport = transportFor(resolved.settings);
|
||||
try {
|
||||
await transport.verify();
|
||||
return {
|
||||
ok: true,
|
||||
message: `Přihlášení na ${smtpTargetUrl(resolved.settings)} funguje, údaje jsou platné.`,
|
||||
detail: null,
|
||||
status: null,
|
||||
retryable: false,
|
||||
};
|
||||
} catch (err) {
|
||||
return failure(err, resolved.settings, 'Přihlášení');
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Textova verze z HTML.
|
||||
*
|
||||
* Neni to plnohodnotny prevod a byt nemusi: je to nahradnik pro klienty, ktere
|
||||
* HTML nezobrazi. Bez nej by takovy prijemce dostal prazdnou zpravu a filtry
|
||||
* nespamu berou chybejici textovou cast jako priznak spamu.
|
||||
*/
|
||||
export function textFromHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|tr|h[1-6]|li)>/gi, '\n')
|
||||
.replace(/<li[^>]*>/gi, '- ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Odesle jednu zpravu. Nikdy nevyhodi vyjimku. */
|
||||
export async function sendMail(target: ResolvedTarget, message: MailMessage): Promise<MailResult> {
|
||||
const resolved = smtpSettings(target);
|
||||
if ('error' in resolved) {
|
||||
return { ok: false, message: resolved.error, detail: null, status: null, retryable: false };
|
||||
}
|
||||
|
||||
const settings = resolved.settings;
|
||||
const transport = transportFor(settings);
|
||||
|
||||
try {
|
||||
const info = await transport.sendMail({
|
||||
from: settings.fromName
|
||||
? { name: settings.fromName, address: settings.from }
|
||||
: settings.from,
|
||||
to: message.to,
|
||||
...(message.cc ? { cc: message.cc } : {}),
|
||||
...(message.bcc ? { bcc: message.bcc } : {}),
|
||||
// Krok smi adresu pro odpovedi prebit: odpoved casto ma zamirit do
|
||||
// ticketu, ne do schranky, ze ktere se odeslalo.
|
||||
...(message.replyTo ?? settings.replyTo
|
||||
? { replyTo: message.replyTo ?? settings.replyTo ?? undefined }
|
||||
: {}),
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
text: message.text && message.text.trim() !== '' ? message.text : textFromHtml(message.html),
|
||||
});
|
||||
|
||||
const accepted = Array.isArray(info.accepted) ? info.accepted.length : 0;
|
||||
const rejected = Array.isArray(info.rejected) ? info.rejected.length : 0;
|
||||
|
||||
return {
|
||||
ok: rejected === 0,
|
||||
message:
|
||||
rejected === 0
|
||||
? `Odesláno ${accepted} příjemcům.`
|
||||
: `Odesláno ${accepted} příjemcům, ${rejected} server nepřijal.`,
|
||||
detail: typeof info.response === 'string' ? info.response : null,
|
||||
status: null,
|
||||
retryable: false,
|
||||
messageId: typeof info.messageId === 'string' ? info.messageId : '',
|
||||
accepted,
|
||||
rejected,
|
||||
};
|
||||
} catch (err) {
|
||||
return failure(err, settings, 'Odeslání');
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,8 @@ import {
|
||||
} from '../data/services.js';
|
||||
import { config } from '../config.js';
|
||||
import { egressIp } from '../data/egressIp.js';
|
||||
import { resolveTarget, serviceBaseUrl } from '../scripts/connections.js';
|
||||
import { smtpSettings, smtpTargetUrl, verifySmtp } from '../mail/smtp.js';
|
||||
import { resolveTarget, serviceBaseUrl, targetSecrets } from '../scripts/connections.js';
|
||||
import { createHttp } from '../scripts/http.js';
|
||||
import { ScriptError } from '../scripts/types.js';
|
||||
import { createRedactor, describe, truncate } from '../scripts/util.js';
|
||||
@@ -313,11 +314,57 @@ connectorsRouter.post('/:id/test', async (req, res) => {
|
||||
return res.json({ ok: false, checked: 'nic', message, baseUrl: target.baseUrl });
|
||||
}
|
||||
|
||||
/*
|
||||
* SMTP se neoveruje ctecim volanim, ale prihlasenim. `verify` nic neposila,
|
||||
* takze test nikomu nic nedorucí - a pritom bez platneho hesla neprojde,
|
||||
* coz je presne to, co ma overeni rict.
|
||||
*/
|
||||
if (service.transport === 'smtp') {
|
||||
const settings = smtpSettings(target);
|
||||
const url = 'settings' in settings ? smtpTargetUrl(settings.settings) : '';
|
||||
const outcome = await verifySmtp(target);
|
||||
const request = url === '' ? null : { method: 'SMTP', path: 'LOGIN', url };
|
||||
const checked = 'přístupové údaje';
|
||||
|
||||
await setConnectorStatus(
|
||||
connector.id,
|
||||
outcome.ok ? 'ok' : 'error',
|
||||
outcome.ok ? null : outcome.message,
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
ok: outcome.ok,
|
||||
checked,
|
||||
status: outcome.status,
|
||||
message: outcome.message,
|
||||
detail: outcome.detail,
|
||||
request,
|
||||
responseHeaders: null,
|
||||
// Posmovni servery seznamy povolenych IP nemaji, radu by to nedalo.
|
||||
egressIp: null,
|
||||
},
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
if (!outcome.ok) console.warn(`[connectors] test ${connector.id} selhal: ${outcome.message}`);
|
||||
|
||||
// Neuspesne overeni neni chyba API, je to vysledek. Proto 200.
|
||||
return res.json({
|
||||
ok: outcome.ok,
|
||||
checked,
|
||||
...(outcome.status !== null ? { status: outcome.status } : {}),
|
||||
request,
|
||||
message: outcome.message,
|
||||
baseUrl: url,
|
||||
...(outcome.detail ? { detail: outcome.detail } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const path = service.verifyPath ?? '/health';
|
||||
const checked = service.verifyPath ? 'přístupové údaje' : 'jen dostupnost služby';
|
||||
// Zaloha pro pripad, ze se k volani vubec nedoslo a chyba tedy `request` nema.
|
||||
const verifyUrl = `${target.baseUrl.replace(/\/+$/, '')}${path}`;
|
||||
const redact = createRedactor(Object.values(target.headers));
|
||||
// Query se odrizne stejne jako v `ScriptRequestInfo` - muze v ni byt tajemstvi.
|
||||
const verifyUrl = `${target.baseUrl.replace(/\/+$/, '')}${path.split('?')[0]}`;
|
||||
const redact = createRedactor(targetSecrets(target));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
@@ -336,7 +383,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
|
||||
const 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í.';
|
||||
const request = { method: 'GET', path, url: verifyUrl };
|
||||
const request = { method: 'GET', path: path.split('?')[0], url: verifyUrl };
|
||||
|
||||
// Do historie patri i uspech. Bez nej se neda poznat, jestli konektor
|
||||
// nesel nikdy, nebo prestal jit ve chvili, kdy se sahlo na udaje.
|
||||
@@ -378,7 +425,9 @@ connectorsRouter.post('/:id/test', async (req, res) => {
|
||||
: redact(truncate(String(err instanceof Error ? err.stack ?? err.message : err), config.errorDetailBytes));
|
||||
|
||||
const request =
|
||||
isScriptError && err.request ? err.request : { method: 'GET', path, url: verifyUrl };
|
||||
isScriptError && err.request
|
||||
? err.request
|
||||
: { method: 'GET', path: path.split('?')[0], url: verifyUrl };
|
||||
// Hlavicky rikaji, kdo odpoved vydal. U 403 bez tela je to vsechno,
|
||||
// co zbyde: `Server: Kestrel` je aplikace, `Via: 1.1 Caddy` proxy.
|
||||
const responseHeaders = isScriptError ? (err.responseHeaders ?? null) : null;
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
addComment,
|
||||
assignTicket,
|
||||
claimTicket,
|
||||
createTicket,
|
||||
ticketAssignee,
|
||||
getAgentStats,
|
||||
getTicket,
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validateRules } from '../scripts/mapping.js';
|
||||
import { connectorsRouter } from './connectors.js';
|
||||
import { helpdeskRouter } from './helpdesk.js';
|
||||
import { settingsRouter } from './settings.js';
|
||||
import { ticketActionsRouter } from './ticketActions.js';
|
||||
import { widgetDataRouter } from './widgetData.js';
|
||||
@@ -546,6 +548,109 @@ dashboardRouter.get('/tickets', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Rucne zalozeny ticket.
|
||||
*
|
||||
* Dosud ticket vznikal jen z automatizace nebo z prichozi udalosti. Jenze
|
||||
* pozadavek casto prijde telefonem nebo pri kafi a nekdo ho musi zapsat -
|
||||
* bez toho konci na papirku a v systemu neni.
|
||||
*
|
||||
* **Zakaznik je nepovinny.** Ticket zalozeny rucne je casto ukol, ne pozadavek
|
||||
* od nekoho zvenku, a nutit k nemu firmu a kontakt by znamenalo vymyslet si je.
|
||||
*/
|
||||
const createTicketSchema = z.object({
|
||||
subject: z.string().trim().min(1, 'Předmět nesmí být prázdný.'),
|
||||
body: z.string().default(''),
|
||||
priority: z.enum(['low', 'normal', 'high', 'critical']).default('normal'),
|
||||
typeId: z.string().trim().min(1).nullable().optional(),
|
||||
assigneeId: z.string().trim().min(1).nullable().optional(),
|
||||
assigneeGroupId: z.string().trim().min(1).nullable().optional(),
|
||||
tags: z.array(z.string().trim().min(1)).max(20).default([]),
|
||||
/** Nepovinny. Prazdna pole se neukladaji jako prazdne retezce nasilim. */
|
||||
customer: z
|
||||
.object({
|
||||
company: z.string().trim().default(''),
|
||||
contact: z.string().trim().default(''),
|
||||
reply: z.string().trim().default(''),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
dashboardRouter.post('/tickets', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
if (!hasPermission(req.user!, 'ticket.create')) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: 'Nemáte právo zakládat tickety.',
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = createTicketSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Ticket se nepodařilo založit.',
|
||||
issues: parsed.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Zaklada se vzdy do firmy, ve ktere clovek prave je. Vybirat ji ve formulari
|
||||
// by znamenalo, ze se ticket omylem zalozi jinam.
|
||||
const tenantId = scope.tenantId;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({
|
||||
error: 'no_tenant',
|
||||
message: 'Vyberte firmu, do které ticket patří.',
|
||||
});
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
const customer = input.customer;
|
||||
const hasCustomer =
|
||||
customer !== undefined &&
|
||||
(customer.company !== '' || customer.contact !== '' || customer.reply !== '');
|
||||
|
||||
const ticket = createTicket({
|
||||
tenantId,
|
||||
subject: input.subject,
|
||||
body: input.body,
|
||||
// Rucne zalozeny ticket prisel z portalu, ne z WhatsAppu ani z e-mailu.
|
||||
channel: 'portal',
|
||||
priority: input.priority,
|
||||
typeId: input.typeId ?? null,
|
||||
assigneeId: input.assigneeId ?? null,
|
||||
assigneeGroupId: input.assigneeGroupId ?? null,
|
||||
tags: input.tags,
|
||||
...(hasCustomer
|
||||
? { customer: { id: null, company: customer.company, contact: customer.contact, reply: customer.reply } }
|
||||
: {}),
|
||||
trace: [
|
||||
{
|
||||
kind: 'note',
|
||||
label: 'Založeno ručně',
|
||||
status: 'info',
|
||||
response: `Ticket založil ${req.user!.email} v portálu.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
recordAudit({
|
||||
userId: req.user!.id,
|
||||
userEmail: req.user!.email,
|
||||
tenantId,
|
||||
action: 'ticket.create',
|
||||
target: ticket.id,
|
||||
detail: { subject: ticket.subject },
|
||||
});
|
||||
|
||||
return res.status(201).json(ticket);
|
||||
});
|
||||
|
||||
/**
|
||||
* Prevzeti ticketu.
|
||||
*
|
||||
@@ -615,6 +720,28 @@ dashboardRouter.post('/tickets/:id/claim', (req, res) => {
|
||||
});
|
||||
|
||||
/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */
|
||||
/**
|
||||
* Stavy, ktere firma opravdu pouziva.
|
||||
*
|
||||
* Stav je **volny retezec**, ne ciselnik: ticket muze prijit z cizi aplikace
|
||||
* s jejim vlastnim stavem. Pevny seznam v rozhrani by na nej nikdy nesedel.
|
||||
* Tohle je proto jen naseptavac - vraci to, co uz v datech je, a nova hodnota
|
||||
* projde stejne dobre.
|
||||
*
|
||||
* Musi byt registrovane pred `/tickets/:id`, jinak by se `statuses` chytilo
|
||||
* jako ID ticketu.
|
||||
*/
|
||||
dashboardRouter.get('/tickets/statuses', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
const items = [
|
||||
...new Set(listTickets({ tenantIds: scope.tenantIds }).map((ticket) => ticket.status)),
|
||||
].sort((a, b) => a.localeCompare(b, 'cs'));
|
||||
|
||||
return res.json({ items });
|
||||
});
|
||||
|
||||
dashboardRouter.get('/tickets/workload', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
@@ -749,6 +876,10 @@ dashboardRouter.use('/widget-data', widgetDataRouter);
|
||||
// Akce na ticketu. Musi byt pred obecnym `/tickets/:id`.
|
||||
dashboardRouter.use('/tickets', ticketActionsRouter);
|
||||
|
||||
// Pohled zadavatele na jeho vlastni pozadavky. Vlastni router, protoze se
|
||||
// scopuje podle `helpdeskSourceId`, ne podle vlastnika ticketu.
|
||||
dashboardRouter.use('/helpdesk', helpdeskRouter);
|
||||
|
||||
// ------------------------------------------------------------------- sluzby
|
||||
|
||||
/**
|
||||
@@ -779,6 +910,12 @@ dashboardRouter.get('/services', (req, res) => {
|
||||
id: script.id,
|
||||
name: script.name,
|
||||
})),
|
||||
// Typy ticketu jsou vlastnost firmy. Bez teto nabidky by uzivatel musel
|
||||
// ID typu nekde vyhledat a prepsat, coz je presne to, co nikdo nedela.
|
||||
ticketTypes: listTicketTypes(tenantIds).map((type) => ({
|
||||
id: type.id,
|
||||
name: type.name,
|
||||
})),
|
||||
},
|
||||
),
|
||||
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Helpdesk: pohled **zadavatele**, ne resitele.
|
||||
*
|
||||
* Firma tu vidi pozadavky, ktere sama poslala svemu dodavateli, a smi k nim
|
||||
* pripsat komentar. Nic vic - stav, resitele ani prioritu urcuje ten, kdo to
|
||||
* resi.
|
||||
*
|
||||
* Rozdeleni, na kterem to cele stoji:
|
||||
*
|
||||
* | Pole | Kdo to je |
|
||||
* | ------------------------ | ------------------------------------------ |
|
||||
* | `Ticket.tenantId` | firma, ktera pozadavek **resi**, tedy vlastnik |
|
||||
* | `Ticket.helpdeskSourceId`| firma, ktera pozadavek **poslala** |
|
||||
*
|
||||
* Vlastnikem je zamerne dodavatel: kdyby jim byl zadavatel, mel by resitel
|
||||
* pozadavek jen jako cizi ticket a nemel by ho ve sve fronte. Diky tomuhle
|
||||
* rozdeleni funguje strana resitele beze zmeny - je to obycejny ticket v jeho
|
||||
* seznamu.
|
||||
*
|
||||
* Kdo z firmy helpdesk vidi, urcuje pravo `helpdesk.view`, ktere prideluje
|
||||
* admin te firmy. Komu pozadavek pripadne, urcuje `helpdeskProviderId` na
|
||||
* firme, ktery nastavujeme my - kdo koho obsluhuje je obchodni vztah, ne volba
|
||||
* klienta.
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { accessFor } from '../data/access.js';
|
||||
import { recordAudit } from '../data/audit.js';
|
||||
import { hasPermission } from '../data/permissions.js';
|
||||
import { findPersonByEmail } from '../data/people.js';
|
||||
import { findTenant } from '../data/tenants.js';
|
||||
import { addComment, createTicket, getTicket, listTickets } from '../data/ticketStore.js';
|
||||
|
||||
export const helpdeskRouter = Router();
|
||||
|
||||
/**
|
||||
* Firma, za kterou clovek prave jedna, plus kontrola prava.
|
||||
*
|
||||
* Helpdesk je vzdy za **jednu** firmu. Pohled pres vsechny firmy tu nedava
|
||||
* smysl: pozadavek se posila konkretnimu dodavateli a ten se odviji od toho,
|
||||
* kdo se pta.
|
||||
*/
|
||||
function asking(req: Request, res: Response, permission: string): string | null {
|
||||
if (!hasPermission(req.user!, permission)) {
|
||||
res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message:
|
||||
permission === 'helpdesk.create'
|
||||
? 'Nemáte právo posílat požadavky na helpdesk.'
|
||||
: 'Nemáte přístup k helpdesku.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
|
||||
const access = accessFor(req.user!, requested);
|
||||
const tenantId = requested ?? access.defaultTenantId;
|
||||
|
||||
if (!tenantId || !access.tenants.some((tenant) => tenant.id === tenantId)) {
|
||||
res.status(403).json({
|
||||
error: 'no_tenant',
|
||||
message: 'Účet nepatří do žádné firmy, za kterou by šlo požadavek poslat.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/** Komu firma posila pozadavky. Vraci null a duvod uz odpovi. */
|
||||
function providerFor(tenantId: string, res: Response): string | null {
|
||||
const tenant = findTenant(tenantId);
|
||||
const provider = tenant?.helpdeskProviderId ?? null;
|
||||
|
||||
if (!provider) {
|
||||
res.status(400).json({
|
||||
error: 'no_provider',
|
||||
message:
|
||||
'Vaše firma nemá nastaveného dodavatele helpdesku, takže není komu ' +
|
||||
'požadavek poslat. Ozvěte se nám a doplníme to.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!findTenant(provider)) {
|
||||
res.status(400).json({
|
||||
error: 'unknown_provider',
|
||||
message: 'Nastavený dodavatel helpdesku už neexistuje.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pozadavky teto firmy.
|
||||
*
|
||||
* Filtruje se podle `helpdeskSourceIds`, ne podle vlastnika - zadavatel
|
||||
* vlastnikem neni. Bezny seznam ticketu tim zustava nedotceny.
|
||||
*/
|
||||
helpdeskRouter.get('/', (req, res) => {
|
||||
const tenantId = asking(req, res, 'helpdesk.view');
|
||||
if (!tenantId) return;
|
||||
|
||||
const tenant = findTenant(tenantId);
|
||||
const provider = tenant?.helpdeskProviderId ?? null;
|
||||
|
||||
return res.json({
|
||||
items: listTickets({ tenantIds: [], helpdeskSourceIds: [tenantId] }),
|
||||
tenantId,
|
||||
/** Komu se posila. Klient podle toho rekne, na koho se pozadavek obraci. */
|
||||
provider: provider ? { id: provider, name: findTenant(provider)?.name ?? provider } : null,
|
||||
canCreate: hasPermission(req.user!, 'helpdesk.create'),
|
||||
});
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
subject: z.string().trim().min(1, 'Napište, čeho se požadavek týká.'),
|
||||
body: z.string().default(''),
|
||||
priority: z.enum(['low', 'normal', 'high', 'critical']).default('normal'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Novy pozadavek.
|
||||
*
|
||||
* Vlastnika **nevybira uzivatel**, dosadi se dodavatel jeho firmy. Kdyby si ho
|
||||
* vybiral sam, poslal by pozadavek nekomu, kdo s nim nema smlouvu.
|
||||
*/
|
||||
helpdeskRouter.post('/', (req, res) => {
|
||||
const tenantId = asking(req, res, 'helpdesk.create');
|
||||
if (!tenantId) return;
|
||||
|
||||
const provider = providerFor(tenantId, res);
|
||||
if (!provider) return;
|
||||
|
||||
const parsed = createSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Požadavek se nepodařilo poslat.',
|
||||
});
|
||||
}
|
||||
|
||||
const tenant = findTenant(tenantId);
|
||||
const person = findPersonByEmail(req.user!.email);
|
||||
|
||||
const ticket = createTicket({
|
||||
// Vlastnikem je dodavatel, aby to mel ve sve fronte jako kazdy jiny ticket.
|
||||
tenantId: provider,
|
||||
helpdeskSourceId: tenantId,
|
||||
subject: parsed.data.subject,
|
||||
body: parsed.data.body,
|
||||
channel: 'portal',
|
||||
priority: parsed.data.priority,
|
||||
// Zakaznik se vyplni sam: u helpdesku je znamy a je to prave ten, kdo se pta.
|
||||
customer: {
|
||||
id: null,
|
||||
company: tenant?.name ?? '',
|
||||
contact: person?.name ?? req.user!.email,
|
||||
reply: req.user!.email,
|
||||
},
|
||||
trace: [
|
||||
{
|
||||
kind: 'note',
|
||||
label: 'Požadavek z helpdesku',
|
||||
status: 'info',
|
||||
response: `Poslal ${req.user!.email} za firmu ${tenant?.name ?? tenantId}.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
recordAudit({
|
||||
userId: req.user!.id,
|
||||
userEmail: req.user!.email,
|
||||
tenantId,
|
||||
action: 'helpdesk.create',
|
||||
target: ticket.id,
|
||||
detail: { subject: ticket.subject, provider },
|
||||
});
|
||||
|
||||
return res.status(201).json(ticket);
|
||||
});
|
||||
|
||||
/** Detail vlastniho pozadavku. Cizi se tvari jako neexistujici. */
|
||||
helpdeskRouter.get('/:id', (req, res) => {
|
||||
const tenantId = asking(req, res, 'helpdesk.view');
|
||||
if (!tenantId) return;
|
||||
|
||||
// Prazdne `tenantIds`: pres helpdesk se ctou jen pozadavky, ktere firma
|
||||
// poslala. Vlastni tickety ma v zalozce Tickety.
|
||||
const ticket = getTicket(req.params.id, [], [tenantId]);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Požadavek neexistuje.' });
|
||||
}
|
||||
return res.json(ticket);
|
||||
});
|
||||
|
||||
const commentSchema = z.object({ text: z.string().trim().min(1, 'Komentář nesmí být prázdný.') });
|
||||
|
||||
/**
|
||||
* Komentar zadavatele.
|
||||
*
|
||||
* Jedina zmena, kterou nad pozadavkem smi. Doplnit, co zapomnel napsat, je
|
||||
* presne to, kvuli cemu se pozadavek otevira; stav a resitele urcuje ten,
|
||||
* kdo to resi.
|
||||
*/
|
||||
helpdeskRouter.post('/:id/comment', (req, res) => {
|
||||
const tenantId = asking(req, res, 'helpdesk.view');
|
||||
if (!tenantId) return;
|
||||
|
||||
const parsed = commentSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Komentář se nepodařilo uložit.',
|
||||
});
|
||||
}
|
||||
|
||||
const person = findPersonByEmail(req.user!.email);
|
||||
const author = person?.name ?? req.user!.email;
|
||||
const updated = addComment(req.params.id, author, parsed.data.text, [], [tenantId]);
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Požadavek neexistuje.' });
|
||||
}
|
||||
return res.json(updated);
|
||||
});
|
||||
@@ -103,6 +103,8 @@ settingsRouter.use(
|
||||
name: z.string().trim().min(2).max(80).optional(),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
/** Kdo teto firme resi helpdesk. null = nikdo, pozadavek nepujde poslat. */
|
||||
helpdeskProviderId: z.string().trim().min(1).nullable().optional(),
|
||||
}),
|
||||
writePermission: 'tenant.manage',
|
||||
platformOnly: true,
|
||||
@@ -115,6 +117,8 @@ settingsRouter.use(
|
||||
// Token dostane firma hned pri zalozeni, aby prijem udalosti fungoval
|
||||
// bez dalsiho kroku. Menit ho zvenku nejde, viz updateSchema vyse.
|
||||
intakeToken: generateIntakeToken(),
|
||||
// Dodavatele helpdesku doplni spravce az pri nastaveni vztahu.
|
||||
helpdeskProviderId: null,
|
||||
}),
|
||||
validate: (tenant, all) =>
|
||||
all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase())
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
* a ve stromu se nastavuje stejne.
|
||||
*/
|
||||
|
||||
import { defaultConnectorFor, getConnector } from '../data/connectorStore.js';
|
||||
import { createIncident } from '../data/incidentStore.js';
|
||||
import { sendMail } from '../mail/smtp.js';
|
||||
import { resolveTarget } from '../scripts/connections.js';
|
||||
import { findTenantScript, noteRun } from '../data/tenantScripts.js';
|
||||
import { runSandbox } from './sandbox.js';
|
||||
import { findGroup, findPerson, findPersonByExternalId, listPeople } from '../data/people.js';
|
||||
@@ -32,6 +35,14 @@ export interface StepContext {
|
||||
tenantId: string;
|
||||
/** Ticket, ke kteremu beh patri. Nekdy vznikne az behem nej. */
|
||||
ticketId: string | null;
|
||||
/**
|
||||
* Napojeni, pod kterym se ma krok vykonat. `null` znamena vychozi konektor
|
||||
* firmy pro tuhle sluzbu, stejne jako u skriptu.
|
||||
*
|
||||
* Vetsina vnitrnich kroku ho nepotrebuje - zalozeni ticketu sahá do naseho
|
||||
* uloziste. E-mail ano: odesila se ze schranky firmy.
|
||||
*/
|
||||
connectorId?: string | null;
|
||||
/**
|
||||
* Data, kterymi beh zacal - u webhooku cele prijate telo.
|
||||
*
|
||||
@@ -54,6 +65,14 @@ export interface StepOutcome {
|
||||
* misto osmdesati radku.
|
||||
*/
|
||||
quiet?: boolean;
|
||||
/**
|
||||
* true = docasna prekazka, opakovani ma smysl.
|
||||
*
|
||||
* Vnitrni kroky obvykle selhavaji na spatnem nastaveni, ktere se opakovanim
|
||||
* nespravi. E-mail je vyjimka: nedostupny posmovni server za minutu bezet
|
||||
* muze, kdezto spatne heslo bude spatne porad.
|
||||
*/
|
||||
retryable?: boolean;
|
||||
/** Vyplnene, kdyz krok zalozil nebo nasel ticket. Dalsi kroky ho pak maji. */
|
||||
ticketId?: string;
|
||||
}
|
||||
@@ -116,6 +135,63 @@ function scalarFields(value: Record<string, unknown>): Record<string, string | n
|
||||
* Registr kroku. Klic je `serviceId/operationId` z katalogu.
|
||||
*/
|
||||
const handlers: Record<string, Handler> = {
|
||||
/**
|
||||
* Odesle e-mail ze schranky firmy.
|
||||
*
|
||||
* Proc je to vnitrni krok a ne skript: SMTP neni HTTP a skript umi jen
|
||||
* `ctx.http`. Pristupove udaje ale zustavaji v konektoru, takze kazda firma
|
||||
* odesila ze sve schranky - stejne jako u sluzeb, ktere jdou pres HTTP.
|
||||
*
|
||||
* Sablony jsou uz dosazene, vcetne escapovani hodnot v HTML tele. Deje se to
|
||||
* v `fillTemplates` v executoru, protoze jen tam je jeste videt, co byla
|
||||
* sablona a co dosazena hodnota.
|
||||
*/
|
||||
'email/send': async (inputs, context) => {
|
||||
const to = inputs.to?.trim();
|
||||
const subject = inputs.subject?.trim();
|
||||
const html = inputs.html ?? '';
|
||||
|
||||
if (!to) return missing('příjemce');
|
||||
if (!subject) return missing('předmět');
|
||||
if (html.trim() === '') return missing('tělo zprávy');
|
||||
|
||||
const connector = context.connectorId
|
||||
? await getConnector(context.connectorId, [context.tenantId])
|
||||
: await defaultConnectorFor(context.tenantId, 'email');
|
||||
|
||||
const target = resolveTarget('email', connector ?? null);
|
||||
if (!target.ready) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: 'napojení není hotové',
|
||||
detail: target.missing.join(', '),
|
||||
outputs: {},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await sendMail(target, {
|
||||
to,
|
||||
cc: inputs.cc?.trim() || null,
|
||||
bcc: inputs.bcc?.trim() || null,
|
||||
subject,
|
||||
html,
|
||||
text: inputs.text ?? null,
|
||||
replyTo: inputs.replyTo?.trim() || null,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: result.ok,
|
||||
summary: result.ok ? `e-mail odeslán na ${to}` : result.message,
|
||||
detail: result.detail,
|
||||
retryable: result.retryable,
|
||||
outputs: {
|
||||
messageId: result.messageId ?? '',
|
||||
accepted: result.accepted ?? 0,
|
||||
rejected: result.rejected ?? 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Zalozi ticket, nebo doplni existujici podle externiho ID.
|
||||
*
|
||||
|
||||
+24
-6
@@ -24,7 +24,8 @@
|
||||
import { defaultConnectorFor, getConnector, type Connector } from '../data/connectorStore.js';
|
||||
import type { FlowStep } from '../data/automationStore.js';
|
||||
import { isUnary, type ConditionOperator } from '../data/conditions.js';
|
||||
import { renderTemplate } from '../data/templates.js';
|
||||
import { escapeHtml, renderTemplate } from '../data/templates.js';
|
||||
import { actionInputsFor, type OperationField } from '../data/services.js';
|
||||
import { getPath } from '../scripts/mapping.js';
|
||||
import { appendTrace, type TraceInput } from '../data/ticketStore.js';
|
||||
import { scriptIdFor } from '../scripts/lookup.js';
|
||||
@@ -308,7 +309,11 @@ async function runAction(
|
||||
const startedAt = Date.now();
|
||||
const label = `${step.serviceId}/${step.operationId}`;
|
||||
|
||||
const inputsForStep = fillTemplates(step.inputs ?? {}, context);
|
||||
const inputsForStep = fillTemplates(
|
||||
step.inputs ?? {},
|
||||
context,
|
||||
actionInputsFor(step.serviceId, step.operationId),
|
||||
);
|
||||
|
||||
/*
|
||||
* Nejdriv nase vlastni kroky. Zalozit ticket nebo prehodit ho na cloveka
|
||||
@@ -321,6 +326,9 @@ async function runAction(
|
||||
const outcome = await builtin(inputsForStep, {
|
||||
tenantId: options.tenantId,
|
||||
ticketId: options.ticketId,
|
||||
// Vetsina vnitrnich kroku napojeni nepotrebuje. Odeslani e-mailu ano:
|
||||
// posila se ze schranky firmy, tedy pod jejim konektorem.
|
||||
connectorId: step.connectorId ?? null,
|
||||
trigger: options.trigger,
|
||||
});
|
||||
|
||||
@@ -347,9 +355,13 @@ async function runAction(
|
||||
detail: outcome.detail ?? null,
|
||||
// Krok muze rict, ze se nic nestalo a do logu ticketu nepatri.
|
||||
quiet: outcome.quiet === true,
|
||||
// Vnitrni krok selhava na spatnem nastaveni, ne na vypadku. Opakovani
|
||||
// by jen pettkrat zopakovalo tutéz chybu.
|
||||
retryable: false,
|
||||
/*
|
||||
* Vnitrni krok obvykle selhava na spatnem nastaveni, ne na vypadku,
|
||||
* takze opakovani by jen pettkrat zopakovalo tutéz chybu. Vyjimka je
|
||||
* odeslani e-mailu: nedostupny posmovni server za minutu bezet muze.
|
||||
* Rozhoduje o tom krok sam, protoze jen on vi, co se stalo.
|
||||
*/
|
||||
retryable: outcome.retryable === true,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -440,15 +452,21 @@ async function runAction(
|
||||
function fillTemplates(
|
||||
inputs: Record<string, string>,
|
||||
context: RunContext,
|
||||
fields: OperationField[] = [],
|
||||
): Record<string, string> {
|
||||
/*
|
||||
* Kontext se predava **syrovy**, ne predem prevedeny na retezce. Odkaz
|
||||
* muze byt cesta do struktury (`{{data.order.items[0].name}}`) a do plocheho
|
||||
* seznamu retezcu se zanorit neda.
|
||||
*/
|
||||
const html = new Set(fields.filter((field) => field.kind === 'html').map((field) => field.id));
|
||||
|
||||
const filled: Record<string, string> = {};
|
||||
for (const [key, template] of Object.entries(inputs)) {
|
||||
filled[key] = renderTemplate(template, context);
|
||||
// U HTML pole se escapuje **dosazena hodnota**, ne sablona. Znacky, ktere
|
||||
// napsal autor sablony, jsou zamer; ostre zavorky v hodnote od zakaznika
|
||||
// ne. Viz `RenderOptions` v templates.ts.
|
||||
filled[key] = renderTemplate(template, context, html.has(key) ? { escape: escapeHtml } : {});
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ export interface ResolvedTarget {
|
||||
baseUrl: string;
|
||||
/** Vcetne tajemstvi. Nikdy neposilat na klienta ani do logu. */
|
||||
headers: Record<string, string>;
|
||||
/**
|
||||
* Vsechno, co se musi zredigovat, nez to skonci v logu nebo v chybe.
|
||||
*
|
||||
* Je toho vic nez hodnot hlavicek: u `Authorization: Bearer <klic>` je
|
||||
* tajemstvim i samotny klic bez slova Bearer, a prave v tom tvaru ho cizi
|
||||
* sluzby rady vraceji v chybove hlasce.
|
||||
*/
|
||||
secretValues: string[];
|
||||
/** Necitliva cast, skript ji vidi jako `ctx.config`. */
|
||||
serviceConfig: Record<string, string>;
|
||||
/** false = chybi povinne udaje, volat nema smysl. */
|
||||
@@ -31,11 +39,34 @@ export interface ResolvedTarget {
|
||||
missing: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Presmerovani jedne sluzby promennou `<SLUZBA>_BASE_URL`.
|
||||
*
|
||||
* Nazev vznikne z ID sluzby velkymi pismeny, pomlcka je podtrzitko:
|
||||
* `openai` je `OPENAI_BASE_URL`, `sap-bo` je `SAP_BO_BASE_URL`.
|
||||
*
|
||||
* K cemu to je: ukazat sluzbu na napodobeninu pri vyvoji, nebo cizi API
|
||||
* prehodit na branu (Azure OpenAI). Konektor si adresu smi prepsat taky,
|
||||
* ale to je vlastnost jedne firmy - tohle plati pro celou instanci.
|
||||
*/
|
||||
function baseUrlOverride(serviceId: string): string | null {
|
||||
const variable = `${serviceId.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_BASE_URL`;
|
||||
const value = (process.env[variable] ?? '').trim().replace(/\/+$/, '');
|
||||
return value === '' ? null : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vychozi adresa sluzby. Verejna domena se nikdy nehardcoduje do logiky,
|
||||
* bere se ze `SERVICES_BASE_URL` (AGENTS.md).
|
||||
*
|
||||
* Sluzba, ktera **nebezi u nas** (OpenAI), ma misto `appId` vlastni absolutni
|
||||
* `baseUrl`. Skladat ji ze `SERVICES_BASE_URL` by nedavalo smysl - to je zaklad
|
||||
* nasich aplikaci, ne cizich.
|
||||
*/
|
||||
export function serviceBaseUrl(service: Service): string {
|
||||
const override = baseUrlOverride(service.id);
|
||||
if (override !== null) return override;
|
||||
if (service.baseUrl) return service.baseUrl.replace(/\/+$/, '');
|
||||
if (service.appId === null) return '';
|
||||
return `${config.servicesBaseUrl}/${service.appId}`;
|
||||
}
|
||||
@@ -56,6 +87,7 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R
|
||||
connectorId: connector?.id ?? null,
|
||||
baseUrl: '',
|
||||
headers: {},
|
||||
secretValues: [],
|
||||
serviceConfig: {},
|
||||
ready: false,
|
||||
missing: [`Služba ${serviceId} v katalogu neexistuje.`],
|
||||
@@ -63,6 +95,7 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const secretValues: string[] = [];
|
||||
const serviceConfig: Record<string, string> = {};
|
||||
const missing: string[] = [];
|
||||
|
||||
@@ -74,8 +107,18 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.target === 'header') headers[field.name] = value;
|
||||
else serviceConfig[field.name] = value;
|
||||
if (field.target === 'header') {
|
||||
const header = `${field.prefix ?? ''}${value}`;
|
||||
headers[field.name] = header;
|
||||
// Obojí: cizi sluzba vraci v chybe jednou celou hlavicku, jindy jen klic.
|
||||
if (field.secret) {
|
||||
secretValues.push(value);
|
||||
if (header !== value) secretValues.push(header);
|
||||
}
|
||||
} else {
|
||||
serviceConfig[field.name] = value;
|
||||
if (field.secret) secretValues.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!service.general && connector === null) {
|
||||
@@ -90,13 +133,19 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R
|
||||
connectorId: connector?.id ?? null,
|
||||
baseUrl: connector?.baseUrl ?? serviceBaseUrl(service),
|
||||
headers,
|
||||
secretValues,
|
||||
serviceConfig,
|
||||
ready: missing.length === 0,
|
||||
missing,
|
||||
};
|
||||
}
|
||||
|
||||
/** Hodnoty, ktere se musi zredigovat, nez cokoliv skonci v logu. */
|
||||
/**
|
||||
* Hodnoty, ktere se musi zredigovat, nez cokoliv skonci v logu.
|
||||
*
|
||||
* Vsechny hlavicky, protoze i ta neoznacena jako tajna nese identifikator uctu,
|
||||
* a k tomu holé tajne hodnoty bez predpony `Bearer `.
|
||||
*/
|
||||
export function targetSecrets(target: ResolvedTarget): string[] {
|
||||
return Object.values(target.headers);
|
||||
return [...Object.values(target.headers), ...target.secretValues];
|
||||
}
|
||||
|
||||
+57
-3
@@ -12,6 +12,7 @@ import { config } from '../config.js';
|
||||
import type { ResolvedTarget } from './connections.js';
|
||||
import {
|
||||
ScriptError,
|
||||
type ScriptFormValue,
|
||||
type ScriptHttp,
|
||||
type ScriptHttpOptions,
|
||||
type ScriptHttpResponse,
|
||||
@@ -43,6 +44,17 @@ function joinUrl(baseUrl: string, path: string): string {
|
||||
return `${base}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Miri tohle jmeno do vnitrni site?
|
||||
*
|
||||
* Exportovane, protoze totez potrebuje odesilani e-mailu: adresu SMTP serveru
|
||||
* vyplnuje firma a bez teto kontroly by si mohla nechat navazat spojeni na
|
||||
* cokoliv uvnitr. Pravidlo ma byt jedno, ne dve, ktera se casem rozejdou.
|
||||
*/
|
||||
export function isPrivateHost(hostname: string): boolean {
|
||||
return privateHostPattern.test(hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresu skladame my z napojeni, ale az budou napojeni nastavovat klienti,
|
||||
* je tohle to jedine, co brani volani na vnitrni sit. Proto tady, ne pozdeji.
|
||||
@@ -51,7 +63,7 @@ function assertAllowedUrl(url: URL): void {
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw new ScriptError('config', `Adresa ${url.protocol} není povolená, jen http a https.`);
|
||||
}
|
||||
if (!config.allowPrivateTargets && privateHostPattern.test(url.hostname)) {
|
||||
if (!config.allowPrivateTargets && isPrivateHost(url.hostname)) {
|
||||
throw new ScriptError(
|
||||
'config',
|
||||
`Adresa ${url.hostname} míří do vnitřní sítě. Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.`,
|
||||
@@ -282,6 +294,44 @@ Příčina: ${err.cause.message}` : '';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Slozi `multipart/form-data` z toho, co skript predal.
|
||||
*
|
||||
* Prazdne polozky se vynechavaji: `null` prevedeny na text by cizi sluzba
|
||||
* dostala jako retezec "null" a hadala by, co s nim.
|
||||
*
|
||||
* Hranici (boundary) i `Content-Type` dopisuje az fetch. Kdybychom hlavicku
|
||||
* nastavili sami, chybela by v ni hranice a sluzba by telo neprecetla.
|
||||
*/
|
||||
function buildForm(fields: Record<string, ScriptFormValue | null | undefined>): FormData {
|
||||
const form = new FormData();
|
||||
|
||||
for (const [name, value] of Object.entries(fields)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const bytes = Buffer.from(value.base64, 'base64');
|
||||
if (bytes.byteLength === 0) {
|
||||
throw new ScriptError('validation', `Soubor ${value.filename} je prázdný nebo to není Base64.`);
|
||||
}
|
||||
if (bytes.byteLength > config.scriptMaxUploadBytes) {
|
||||
throw new ScriptError(
|
||||
'validation',
|
||||
`Soubor ${value.filename} má ${bytes.byteLength} B, což je nad povolený strop ` +
|
||||
`${config.scriptMaxUploadBytes} B.`,
|
||||
);
|
||||
}
|
||||
const blob = new Blob([bytes], { type: value.contentType ?? 'application/octet-stream' });
|
||||
form.append(name, blob, value.filename);
|
||||
continue;
|
||||
}
|
||||
|
||||
form.append(name, String(value));
|
||||
}
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
export interface CreateHttpOptions {
|
||||
target: ResolvedTarget;
|
||||
signal: AbortSignal;
|
||||
@@ -300,6 +350,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
|
||||
path: string,
|
||||
body: unknown,
|
||||
httpOptions?: ScriptHttpOptions,
|
||||
form?: FormData,
|
||||
): Promise<ScriptHttpResponse<T>> {
|
||||
const url = buildUrl(target, path, httpOptions);
|
||||
// Server ano, query ne. Bez serveru neni z hlasky poznat, kam to vlastne
|
||||
@@ -309,7 +360,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
|
||||
path: url.pathname,
|
||||
url: `${url.origin}${url.pathname}`,
|
||||
};
|
||||
const hasBody = body !== undefined && method !== 'GET' && method !== 'DELETE';
|
||||
const hasBody = form === undefined && body !== undefined && method !== 'GET' && method !== 'DELETE';
|
||||
const startedAt = Date.now();
|
||||
onCall();
|
||||
|
||||
@@ -326,7 +377,8 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
|
||||
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
...httpOptions?.headers,
|
||||
},
|
||||
body: hasBody ? JSON.stringify(body) : undefined,
|
||||
// U formulare se telo predava jako FormData, aby fetch dopsal hranici.
|
||||
body: form ?? (hasBody ? JSON.stringify(body) : undefined),
|
||||
});
|
||||
} catch (err) {
|
||||
throw transportError(err, request);
|
||||
@@ -387,5 +439,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
|
||||
patch: (path, body, httpOptions) => request('PATCH', path, body, httpOptions),
|
||||
put: (path, body, httpOptions) => request('PUT', path, body, httpOptions),
|
||||
del: (path, httpOptions) => request('DELETE', path, undefined, httpOptions),
|
||||
postForm: (path, fields, httpOptions) =>
|
||||
request('POST', path, undefined, httpOptions, buildForm(fields)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,6 +229,23 @@ export interface ScriptHttpOptions {
|
||||
allowStatus?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Soubor v multipart pozadavku.
|
||||
*
|
||||
* Obsah je Base64, protoze parametr skriptu je vzdy hodnota, ktera jde zapsat
|
||||
* do JSONu - binarni data by se do stromu ani do logu behu nevesla jinak.
|
||||
* Prevod zpatky na bajty dela runtime, ne skript.
|
||||
*/
|
||||
export interface ScriptFormFile {
|
||||
filename: string;
|
||||
/** Obsah souboru zakodovany v Base64, bez prefixu `data:`. */
|
||||
base64: string;
|
||||
/** Typ obsahu. Kdyz chybi, posle se application/octet-stream. */
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export type ScriptFormValue = string | number | boolean | ScriptFormFile;
|
||||
|
||||
/**
|
||||
* HTTP klient predany skriptu. Adresu a autorizaci doplnuje runtime podle
|
||||
* napojeni, takze **skript se k pristupovym udajum nedostane**.
|
||||
@@ -251,6 +268,18 @@ export interface ScriptHttp {
|
||||
options?: ScriptHttpOptions,
|
||||
): Promise<ScriptHttpResponse<T>>;
|
||||
del<T = unknown>(path: string, options?: ScriptHttpOptions): Promise<ScriptHttpResponse<T>>;
|
||||
/**
|
||||
* Odeslani formulare, vcetne souboru (`multipart/form-data`).
|
||||
*
|
||||
* Je to zvlast metoda, ne priznak u `post`: telo se sklada jinak, hranici
|
||||
* dopisuje az runtime a musi se hlidat velikost. Prazdne a nevyplnene polozky
|
||||
* se vynechavaji, aby se cizi sluzbe neposilalo `null` jako text.
|
||||
*/
|
||||
postForm<T = unknown>(
|
||||
path: string,
|
||||
fields: Record<string, ScriptFormValue | null | undefined>,
|
||||
options?: ScriptHttpOptions,
|
||||
): Promise<ScriptHttpResponse<T>>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ 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'));
|
||||
const Helpdesk = lazy(() => import('@/pages/dashboard/Helpdesk'));
|
||||
const TicketDetail = lazy(() => import('@/pages/dashboard/TicketDetail'));
|
||||
const Incidents = lazy(() => import('@/pages/dashboard/Incidents'));
|
||||
const Settings = lazy(() => import('@/pages/dashboard/Settings'));
|
||||
@@ -75,6 +76,7 @@ export default function App() {
|
||||
<Route path="konektory" element={<Connectors />} />
|
||||
<Route path="skripty" element={<Scripts />} />
|
||||
<Route path="tickety" element={<Tickets />} />
|
||||
<Route path="helpdesk" element={<Helpdesk />} />
|
||||
<Route path="tickety/:id" element={<TicketDetail />} />
|
||||
<Route path="incidenty" element={<Incidents />} />
|
||||
<Route path="nastaveni" element={<Settings />} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
LifeBuoy,
|
||||
MessageSquarePlus,
|
||||
LogOut,
|
||||
Menu,
|
||||
MousePointerClick,
|
||||
@@ -41,6 +42,7 @@ const moduleRoutes: Record<string, { to: string; icon: typeof LayoutDashboard; e
|
||||
connectors: { to: '/dashboard/konektory', icon: Plug, end: false },
|
||||
scripts: { to: '/dashboard/skripty', icon: ScrollText, end: false },
|
||||
tickets: { to: '/dashboard/tickety', icon: LifeBuoy, end: false },
|
||||
helpdesk: { to: '/dashboard/helpdesk', icon: MessageSquarePlus, end: false },
|
||||
people: { to: '/dashboard/lide', icon: Users, end: false },
|
||||
actions: { to: '/dashboard/akce', icon: MousePointerClick, end: false },
|
||||
widgets: { to: '/dashboard/widgety', icon: LayoutGrid, end: false },
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { TicketPriority } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Rucni zalozeni ticketu.
|
||||
*
|
||||
* Dosud ticket vznikal jen z automatizace nebo z prichozi udalosti. Jenze
|
||||
* pozadavek casto prijde telefonem a nekdo ho musi zapsat - bez toho konci
|
||||
* na papirku a v systemu neni.
|
||||
*
|
||||
* **Zakaznik je nepovinny a je schovany.** Rucne zalozeny ticket je casto ukol,
|
||||
* ne pozadavek od nekoho zvenku, a povinna firma s kontaktem by znamenala, ze
|
||||
* si je clovek vymysli.
|
||||
*/
|
||||
|
||||
interface TicketTypeOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PeopleResponse {
|
||||
items: Array<{ id: string; name: string }>;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
const priorities: Array<{ value: TicketPriority; label: string }> = [
|
||||
{ value: 'low', label: 'Nízká' },
|
||||
{ value: 'normal', label: 'Normální' },
|
||||
{ value: 'high', label: 'Vysoká' },
|
||||
{ value: 'critical', label: 'Kritická' },
|
||||
];
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs text-white/55">{label}</span>
|
||||
{children}
|
||||
{hint && <span className="mt-0.5 block text-xs text-white/35">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function NewTicketDialog({
|
||||
open,
|
||||
tenantId,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** Firma, do ktere ticket patri. Posila se na server, ne se vybira ve formulari. */
|
||||
tenantId: string | null;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [priority, setPriority] = useState<TicketPriority>('normal');
|
||||
const [typeId, setTypeId] = useState('');
|
||||
const [assigneeId, setAssigneeId] = useState('');
|
||||
const [assigneeGroupId, setAssigneeGroupId] = useState('');
|
||||
const [customerOpen, setCustomerOpen] = useState(false);
|
||||
const [company, setCompany] = useState('');
|
||||
const [contact, setContact] = useState('');
|
||||
const [reply, setReply] = useState('');
|
||||
|
||||
const [types, setTypes] = useState<TicketTypeOption[]>([]);
|
||||
const [people, setPeople] = useState<PeopleResponse | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
|
||||
const query = tenantId ? `?tenantId=${encodeURIComponent(tenantId)}` : '';
|
||||
// Typy i řešitelé jsou vlastnost firmy, proto se načítají s jejím ID.
|
||||
apiFetch<{ items: TicketTypeOption[] }>(`/api/dashboard/settings/ticket-types${query}`)
|
||||
.then((response) => setTypes(response.items))
|
||||
.catch(() => setTypes([]));
|
||||
apiFetch<PeopleResponse>(`/api/dashboard/people${query}`)
|
||||
.then(setPeople)
|
||||
.catch(() => setPeople(null));
|
||||
}, [open, tenantId]);
|
||||
|
||||
function reset() {
|
||||
setSubject('');
|
||||
setBody('');
|
||||
setPriority('normal');
|
||||
setTypeId('');
|
||||
setAssigneeId('');
|
||||
setAssigneeGroupId('');
|
||||
setCustomerOpen(false);
|
||||
setCompany('');
|
||||
setContact('');
|
||||
setReply('');
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (subject.trim() === '') {
|
||||
setError('Vyplňte předmět, jinak ticket v seznamu nikdo nepozná.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const query = tenantId ? `?tenantId=${encodeURIComponent(tenantId)}` : '';
|
||||
await apiFetch(`/api/dashboard/tickets${query}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
subject: subject.trim(),
|
||||
body,
|
||||
priority,
|
||||
typeId: typeId === '' ? null : typeId,
|
||||
assigneeId: assigneeId === '' ? null : assigneeId,
|
||||
assigneeGroupId: assigneeGroupId === '' ? null : assigneeGroupId,
|
||||
// Prázdné údaje se neposílají: zákazník je nepovinný.
|
||||
...(company || contact || reply ? { customer: { company, contact, reply } } : {}),
|
||||
}),
|
||||
});
|
||||
reset();
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Ticket se nepodařilo založit.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Nový ticket"
|
||||
description="Požadavek, který přišel mimo automatizaci - telefonem, osobně, odkudkoliv."
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<form onSubmit={submit} className="space-y-4 p-5">
|
||||
<Field label="Předmět">
|
||||
<input
|
||||
value={subject}
|
||||
onChange={(event) => setSubject(event.target.value)}
|
||||
placeholder="Co se má vyřešit"
|
||||
className={cn(inputClass, 'mt-1')}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Popis" hint="Celý text požadavku. Nepovinné.">
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
rows={5}
|
||||
className={cn(inputClass, 'mt-1 resize-y')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Priorita">
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value as TicketPriority)}
|
||||
className={cn(inputClass, 'mt-1')}
|
||||
>
|
||||
{priorities.map((item) => (
|
||||
<option key={item.value} value={item.value} className="bg-ink-850">
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Typ" hint="Za typem stojí vlastní pole a akce na ticketu.">
|
||||
<select
|
||||
value={typeId}
|
||||
onChange={(event) => setTypeId(event.target.value)}
|
||||
className={cn(inputClass, 'mt-1')}
|
||||
>
|
||||
<option value="" className="bg-ink-850">
|
||||
Bez typu
|
||||
</option>
|
||||
{types.map((type) => (
|
||||
<option key={type.id} value={type.id} className="bg-ink-850">
|
||||
{type.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Řešitel">
|
||||
<select
|
||||
value={assigneeId}
|
||||
onChange={(event) => setAssigneeId(event.target.value)}
|
||||
className={cn(inputClass, 'mt-1')}
|
||||
>
|
||||
<option value="" className="bg-ink-850">
|
||||
Nechat ve frontě
|
||||
</option>
|
||||
{(people?.items ?? []).map((person) => (
|
||||
<option key={person.id} value={person.id} className="bg-ink-850">
|
||||
{person.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Skupina">
|
||||
<select
|
||||
value={assigneeGroupId}
|
||||
onChange={(event) => setAssigneeGroupId(event.target.value)}
|
||||
className={cn(inputClass, 'mt-1')}
|
||||
>
|
||||
<option value="" className="bg-ink-850">
|
||||
Bez skupiny
|
||||
</option>
|
||||
{(people?.groups ?? []).map((group) => (
|
||||
<option key={group.id} value={group.id} className="bg-ink-850">
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Zákazník je schovaný, protože u ručně založeného ticketu obvykle
|
||||
žádný není. Rozbalí ho ten, kdo ho opravdu má.
|
||||
*/}
|
||||
<div className="border-t border-ink-600/50 pt-4">
|
||||
{customerOpen ? (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field label="Firma">
|
||||
<input
|
||||
value={company}
|
||||
onChange={(event) => setCompany(event.target.value)}
|
||||
className={cn(inputClass, 'mt-1')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kontaktní osoba">
|
||||
<input
|
||||
value={contact}
|
||||
onChange={(event) => setContact(event.target.value)}
|
||||
className={cn(inputClass, 'mt-1')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kam odpovídat">
|
||||
<input
|
||||
value={reply}
|
||||
onChange={(event) => setReply(event.target.value)}
|
||||
placeholder="E-mail nebo telefon"
|
||||
className={cn(inputClass, 'mt-1')}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomerOpen(true)}
|
||||
className="text-sm text-white/50 transition-colors hover:text-white"
|
||||
>
|
||||
Přidat zákazníka (nepovinné)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm text-danger-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 border-t border-ink-600/50 pt-4">
|
||||
<Button type="submit" size="sm" disabled={saving}>
|
||||
{saving ? 'Zakládám...' : 'Založit ticket'}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={saving}>
|
||||
Zrušit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +165,56 @@ export function StepInputs({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.kind === 'longtext' ? (
|
||||
) : field.kind === 'lookup' ? (
|
||||
/*
|
||||
Ciselnik a volny text zaroven.
|
||||
|
||||
Duvod, proc to neni obycejny `choice`: hodnotu casto zna az beh,
|
||||
takze se dosazuje sablonou (`{{data.typ}}`). A duvod, proc to
|
||||
neni obycejny `text`: nikdo nema opisovat ID typu z jine
|
||||
stranky. Vyber tedy jen **vyplni** textove pole, hodnota
|
||||
zustava jedna.
|
||||
*/
|
||||
<div className="mt-1 flex gap-2">
|
||||
<input
|
||||
id={`input-${field.id}`}
|
||||
ref={(element) => {
|
||||
if (element) elements.current.set(field.id, element);
|
||||
else elements.current.delete(field.id);
|
||||
}}
|
||||
value={value}
|
||||
onFocus={() => setFocused(field.id)}
|
||||
onChange={(event) => set(field.id, event.target.value)}
|
||||
placeholder="Vyberte vpravo, nebo dosaďte {{parametr}}"
|
||||
className={cn(
|
||||
inputClass,
|
||||
'flex-1',
|
||||
missing || broken.length > 0
|
||||
? 'border-warn-400/60'
|
||||
: 'border-ink-600/70 focus:border-brand-400/70',
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
aria-label={`${field.label}: vybrat ze seznamu`}
|
||||
value={
|
||||
field.options?.some((option) => option.value === value) ? value : ''
|
||||
}
|
||||
onChange={(event) => set(field.id, event.target.value)}
|
||||
className={cn(inputClass, 'w-40 shrink-0 border-ink-600/70')}
|
||||
>
|
||||
<option value="" className="bg-ink-850">
|
||||
ze seznamu
|
||||
</option>
|
||||
{field.options
|
||||
?.filter((option) => option.value !== '')
|
||||
.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-ink-850">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : field.kind === 'longtext' || field.kind === 'html' ? (
|
||||
<textarea
|
||||
id={`input-${field.id}`}
|
||||
ref={(element) => {
|
||||
@@ -173,13 +222,16 @@ export function StepInputs({
|
||||
else elements.current.delete(field.id);
|
||||
}}
|
||||
value={value}
|
||||
rows={3}
|
||||
// HTML se pise po odstavcich, ne po vete. Tri radky by
|
||||
// znamenaly psat telo e-mailu do skulinky.
|
||||
rows={field.kind === 'html' ? 10 : 3}
|
||||
onFocus={() => setFocused(field.id)}
|
||||
onChange={(event) => set(field.id, event.target.value)}
|
||||
placeholder={field.hint ?? 'Text, nebo {{parametr}} ze spouštěče'}
|
||||
className={cn(
|
||||
inputClass,
|
||||
'mt-1 resize-y',
|
||||
field.kind === 'html' && 'font-mono text-xs',
|
||||
missing || broken.length > 0
|
||||
? 'border-warn-400/60'
|
||||
: 'border-ink-600/70 focus:border-brand-400/70',
|
||||
|
||||
@@ -123,10 +123,12 @@ function CustomFields({
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Vstupní parametry</h3>
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Vstupní parametry <span className="font-normal text-white/40">nepovinné</span>
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-white/45">
|
||||
{editable
|
||||
? 'Co bude na spouštěč přicházet. Podle těchto hodnot pak stavíte podmínky.'
|
||||
? 'Vyplňovat je ručně nemusíte. Jednodušší je vlepit níže ukázku toho, co vám přijde - parametry se z ní vyčtou samy. Webhook přijme cokoliv, tohle je jen seznam hodnot, na které pak jdou stavět podmínky.'
|
||||
: 'Tato služba předává vlastní data. Parametry pro podmínky si můžete doplnit ručně.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -142,8 +144,9 @@ function CustomFields({
|
||||
|
||||
{fields.length === 0 ? (
|
||||
<p className="mt-3 rounded-xl border border-dashed border-ink-600/70 px-4 py-3 text-xs text-white/40">
|
||||
Zatím žádné parametry. Bez nich nelze přidat podmínku - nebylo by podle čeho
|
||||
se rozhodovat.
|
||||
Zatím žádné parametry, a to je v pořádku. Spouštěč funguje i bez nich.
|
||||
Přidají se samy, jakmile níže vlepíte ukázku toho, co vám přijde -
|
||||
ručně je psát nemusíte.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Bot,
|
||||
Boxes,
|
||||
Building2,
|
||||
Chrome,
|
||||
Clock,
|
||||
Database,
|
||||
Facebook,
|
||||
FileAudio,
|
||||
FileInput,
|
||||
@@ -39,9 +42,12 @@ import {
|
||||
*/
|
||||
const icons: Record<string, LucideIcon> = {
|
||||
BarChart3,
|
||||
Bot,
|
||||
Boxes,
|
||||
Building2,
|
||||
Chrome,
|
||||
Clock,
|
||||
Database,
|
||||
Facebook,
|
||||
FileAudio,
|
||||
FileInput,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { ErrorDetail } from '@/components/dashboard/ErrorDetail';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
@@ -50,6 +51,7 @@ import type {
|
||||
export default function Connectors() {
|
||||
usePageMeta({ title: 'Konektory - portál Automia' });
|
||||
|
||||
const { user } = useAuth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const preselected = params.get('service');
|
||||
|
||||
@@ -119,6 +121,8 @@ export default function Connectors() {
|
||||
if (preselected) setParams({});
|
||||
}
|
||||
|
||||
const platformAdmin = user?.platformAdmin === true;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
@@ -138,12 +142,18 @@ export default function Connectors() {
|
||||
</header>
|
||||
|
||||
{/*
|
||||
Kam se uklada. Bez teto hlasky se clovek divi, kam se podely jeho
|
||||
konektory, a hleda chybu v aplikaci - presne to se stalo.
|
||||
*/}
|
||||
{storage && storage.mode !== 'postgres' && <StorageNotice storage={storage} />}
|
||||
Provozni hlasky **jen pro spravce platformy**.
|
||||
|
||||
{egress && <EgressNotice egress={egress} />}
|
||||
Kam se uklada a z jake IP adresy volame ven jsou veci, ktere resime my,
|
||||
ne zakaznik. Jemu nerikaji nic pouziteleho a "data se ukladaji do souboru
|
||||
na serveru" na nej pusobi jako priznani, ze mu tu praci muzeme ztratit.
|
||||
Nam to pouziteleho rika porad, takze se to nemaze, jen schovava.
|
||||
*/}
|
||||
{platformAdmin && storage && storage.mode !== 'postgres' && (
|
||||
<StorageNotice storage={storage} />
|
||||
)}
|
||||
|
||||
{platformAdmin && egress && <EgressNotice egress={egress} />}
|
||||
|
||||
<DataState loading={loading} error={error} onRetry={load}>
|
||||
{(connectors?.length ?? 0) === 0 ? (
|
||||
@@ -307,7 +317,9 @@ function ConnectorCard({
|
||||
* a konektor ji smi prepsat, takze bez ni je "vratilo 403" jen pulka
|
||||
* informace: nerika, jestli se to vubec trefilo na spravny stroj.
|
||||
*/
|
||||
const targetUrl = connector.baseUrl ?? service?.baseUrl ?? null;
|
||||
// Prazdny retezec znamena, ze sluzba pevnou adresu nema - u SMTP ji nese
|
||||
// az konektor ve svych udajich. Radek "miri na" by pak byl prazdny.
|
||||
const targetUrl = connector.baseUrl ?? (service?.baseUrl || null);
|
||||
const Icon = serviceIcon(service?.icon ?? 'Plug');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [test, setTest] = useState<ConnectorTestResult | null>(null);
|
||||
@@ -466,6 +478,9 @@ function ConnectorLogs({
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Odchozi IP adresa je nase provozni vec, ne zakaznikova. V logu zustava,
|
||||
// ale vidi ji jen spravce platformy - stejne jako hlasky nad seznamem.
|
||||
const { user } = useAuth();
|
||||
const [items, setItems] = useState<ConnectorCheck[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -508,7 +523,7 @@ function ConnectorLogs({
|
||||
<span className="text-white/70">{formatDateTime(check.at)}</span>
|
||||
{check.ok ? <Badge tone="ok">prošlo</Badge> : <Badge tone="danger">selhalo</Badge>}
|
||||
<span>Ověřeno: {check.checked}</span>
|
||||
{check.egressIp && (
|
||||
{user?.platformAdmin && check.egressIp && (
|
||||
<span>
|
||||
voláno z IP <span className="font-mono text-white/60">{check.egressIp}</span>
|
||||
</span>
|
||||
@@ -757,19 +772,25 @@ function ConnectorEditor({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label="Vlastní adresa služby"
|
||||
hint="Nechte prázdné, pokud nemáte vlastní instanci."
|
||||
>
|
||||
<input
|
||||
name="connector-endpoint"
|
||||
autoComplete="off"
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
placeholder="Výchozí podle služby"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
{/*
|
||||
U SMTP nemá vlastní adresa co dělat: server, port i šifrování jsou
|
||||
mezi údaji výše. Prázdné pole navíc by svádělo sem psát adresu znovu.
|
||||
*/}
|
||||
{service?.transport !== 'smtp' && (
|
||||
<Field
|
||||
label="Vlastní adresa služby"
|
||||
hint="Nechte prázdné, pokud nemáte vlastní instanci."
|
||||
>
|
||||
<input
|
||||
name="connector-endpoint"
|
||||
autoComplete="off"
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
placeholder="Výchozí podle služby"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { MessageSquarePlus, Plus, Send } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { TicketStatusBadge } from '@/components/dashboard/StatusBadge';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatDateTime, formatRelative } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type { Ticket, TicketDetail, TicketPriority } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Helpdesk: pohled **zadavatele**.
|
||||
*
|
||||
* Firma tu vidi pozadavky, ktere sama poslala svemu dodavateli, a smi k nim
|
||||
* pripsat komentar. Stav, resitele ani prioritu neurcuje - to je na tom, kdo
|
||||
* pozadavek resi, a ten ho ma v zalozce Tickety jako kazdy jiny.
|
||||
*
|
||||
* Zamerne to **neni druhy seznam ticketu**: chybi tu filtry, prirazovani i
|
||||
* fronta. Zadavatele nezajima, kdo to ma u sebe, ale jestli se to hnulo.
|
||||
*/
|
||||
|
||||
interface HelpdeskResponse {
|
||||
items: Ticket[];
|
||||
tenantId: string;
|
||||
provider: { id: string; name: string } | null;
|
||||
canCreate: boolean;
|
||||
}
|
||||
|
||||
const priorities: Array<{ value: TicketPriority; label: string }> = [
|
||||
{ value: 'low', label: 'Nízká' },
|
||||
{ value: 'normal', label: 'Normální' },
|
||||
{ value: 'high', label: 'Vysoká' },
|
||||
{ value: 'critical', label: 'Kritická' },
|
||||
];
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
|
||||
|
||||
export default function Helpdesk() {
|
||||
usePageMeta({ title: 'Helpdesk - portál Automia' });
|
||||
|
||||
const helpdesk = useApiQuery<HelpdeskResponse>('/api/dashboard/helpdesk', {
|
||||
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.resolved'],
|
||||
});
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
|
||||
const data = helpdesk.data;
|
||||
const provider = data?.provider ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Helpdesk</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-white/50">
|
||||
{provider
|
||||
? `Požadavky, které jste poslali na ${provider.name}. Uvidíte tu i to, jak se s nimi naložilo.`
|
||||
: 'Požadavky, které jste poslali svému dodavateli.'}
|
||||
</p>
|
||||
</div>
|
||||
{data?.canCreate && provider && (
|
||||
<Button size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus className="size-4" />
|
||||
Nový požadavek
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/*
|
||||
Bez dodavatele nemá požadavek kam odejít. Říká se to rovnou a s tím,
|
||||
co s tím - prázdný seznam by vypadal jako by se nic nestalo.
|
||||
*/}
|
||||
{data && !provider && (
|
||||
<div className="rounded-card border border-warn-400/40 bg-warn-500/8 p-4 text-sm text-warn-400">
|
||||
Vaše firma zatím nemá nastaveného dodavatele helpdesku, takže není komu
|
||||
požadavek poslat. Ozvěte se nám a doplníme to.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataState loading={helpdesk.loading} error={helpdesk.error} onRetry={helpdesk.reload}>
|
||||
{(data?.items.length ?? 0) === 0 ? (
|
||||
<div className="glass rounded-card p-10 text-center">
|
||||
<MessageSquarePlus className="mx-auto size-8 text-white/25" />
|
||||
<p className="mt-3 text-sm text-white/45">
|
||||
Zatím jste neposlali žádný požadavek.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{(data?.items ?? []).map((ticket) => (
|
||||
<li key={ticket.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenId(ticket.id)}
|
||||
className="glass w-full rounded-card p-4 text-left transition-colors hover:border-brand-400/40 sm:p-5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<span className="font-mono text-xs text-white/40">{ticket.id}</span>
|
||||
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
|
||||
{ticket.priority !== 'normal' && (
|
||||
<Badge tone={ticket.priority === 'low' ? 'neutral' : 'warn'}>
|
||||
{priorities.find((item) => item.value === ticket.priority)?.label}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-white/35">
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 font-medium text-white">{ticket.subject}</p>
|
||||
{ticket.body && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-white/45">{ticket.body}</p>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DataState>
|
||||
|
||||
<NewRequestDialog
|
||||
open={creating}
|
||||
providerName={provider?.name ?? null}
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={() => helpdesk.reload()}
|
||||
/>
|
||||
|
||||
<RequestDetail id={openId} onClose={() => setOpenId(null)} onChanged={() => helpdesk.reload()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewRequestDialog({
|
||||
open,
|
||||
providerName,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
providerName: string | null;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [priority, setPriority] = useState<TicketPriority>('normal');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (subject.trim() === '') {
|
||||
setError('Napište, čeho se požadavek týká.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch('/api/dashboard/helpdesk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ subject: subject.trim(), body, priority }),
|
||||
});
|
||||
setSubject('');
|
||||
setBody('');
|
||||
setPriority('normal');
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Požadavek se nepodařilo poslat.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Nový požadavek"
|
||||
description={
|
||||
providerName
|
||||
? `Odejde na ${providerName}. Uvidíte tu, jak se s ním naloží.`
|
||||
: 'Popište, s čím potřebujete pomoct.'
|
||||
}
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<form onSubmit={submit} className="space-y-4 p-5">
|
||||
<label className="block">
|
||||
<span className="text-xs text-white/55">Čeho se to týká</span>
|
||||
<input
|
||||
value={subject}
|
||||
onChange={(event) => setSubject(event.target.value)}
|
||||
placeholder="Krátce, jednou větou"
|
||||
className={cn(inputClass, 'mt-1')}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-white/55">Popis</span>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
rows={7}
|
||||
placeholder="Co se stalo, co jste zkusili, kde se to projevuje."
|
||||
className={cn(inputClass, 'mt-1 resize-y')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block sm:w-56">
|
||||
<span className="text-xs text-white/55">Naléhavost</span>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value as TicketPriority)}
|
||||
className={cn(inputClass, 'mt-1')}
|
||||
>
|
||||
{priorities.map((item) => (
|
||||
<option key={item.value} value={item.value} className="bg-ink-850">
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm text-danger-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 border-t border-ink-600/50 pt-4">
|
||||
<Button type="submit" size="sm" disabled={saving}>
|
||||
{saving ? 'Odesílám...' : 'Odeslat požadavek'}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={saving}>
|
||||
Zrušit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail pozadavku ocima zadavatele.
|
||||
*
|
||||
* Ukazuje se **prubeh**, ne fronta: co se s pozadavkem delo a co k nemu kdo
|
||||
* napsal. Zadne prirazovani ani zmena stavu - to zadavateli nepatri.
|
||||
*/
|
||||
function RequestDetail({
|
||||
id,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
id: string | null;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [ticket, setTicket] = useState<TicketDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setTicket(null);
|
||||
setError(null);
|
||||
setText('');
|
||||
apiFetch<TicketDetail>(`/api/dashboard/helpdesk/${id}`)
|
||||
.then(setTicket)
|
||||
.catch((err: unknown) =>
|
||||
setError(err instanceof Error ? err.message : 'Požadavek se nepodařilo načíst.'),
|
||||
);
|
||||
}, [id]);
|
||||
|
||||
async function comment(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!id || text.trim() === '') return;
|
||||
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/dashboard/helpdesk/${id}/comment`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text: text.trim() }),
|
||||
});
|
||||
setText('');
|
||||
const fresh = await apiFetch<TicketDetail>(`/api/dashboard/helpdesk/${id}`);
|
||||
setTicket(fresh);
|
||||
onChanged();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Komentář se nepodařilo uložit.');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={id !== null}
|
||||
onClose={onClose}
|
||||
title={ticket ? ticket.subject : 'Požadavek'}
|
||||
description={ticket ? `${ticket.id}, založeno ${formatDateTime(ticket.createdAt)}` : undefined}
|
||||
className="max-w-3xl"
|
||||
>
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-5">
|
||||
{error && <p className="text-sm text-danger-400">{error}</p>}
|
||||
{!error && !ticket && <p className="text-sm text-white/40">Načítám...</p>}
|
||||
|
||||
{ticket && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
|
||||
<span className="text-xs text-white/35">
|
||||
naposledy {formatRelative(ticket.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{ticket.body && (
|
||||
<p className="rounded-card border border-ink-600/50 bg-ink-850/50 p-4 text-sm whitespace-pre-wrap text-white/70">
|
||||
{ticket.body}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
Průběh
|
||||
</h3>
|
||||
{ticket.trace.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-white/40">Zatím se nic nestalo.</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{ticket.trace.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="rounded-lg border border-ink-600/50 p-3 text-sm text-white/70"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-3 text-xs text-white/35">
|
||||
<span>{formatDateTime(entry.at)}</span>
|
||||
</div>
|
||||
<p className="mt-1">{entry.label}</p>
|
||||
{entry.response && (
|
||||
<p className="mt-1 text-xs text-white/45">{entry.response}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={comment} className="border-t border-ink-600/50 pt-4">
|
||||
<label className="block">
|
||||
<span className="text-xs text-white/55">Doplnit k požadavku</span>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="Co jste zapomněli napsat, nebo co se mezitím změnilo."
|
||||
className={cn(inputClass, 'mt-1 resize-y')}
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" size="sm" className="mt-3" disabled={sending || text.trim() === ''}>
|
||||
<Send className="size-4" />
|
||||
{sending ? 'Odesílám...' : 'Odeslat'}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,15 @@ export default function Settings() {
|
||||
const { user } = useAuth();
|
||||
const access = useApiQuery<Access>('/api/dashboard/access');
|
||||
const catalog = useApiQuery<SettingsCatalog>('/api/dashboard/settings/catalog');
|
||||
/*
|
||||
* Seznam firem zvlast, i kdyz si ho `EntityAdmin` nacita sam. Potrebuje se
|
||||
* do nabidky "helpdesk resi firma", tedy do konfigurace tehoz seznamu -
|
||||
* dostat se k tomu, co si komponenta nacetla dovnitr, by znamenalo
|
||||
* protahnout to skrz ni jen kvuli jednomu poli.
|
||||
*/
|
||||
const tenants = useApiQuery<{ items: Array<{ id: string; name: string }> }>(
|
||||
'/api/dashboard/settings/tenants',
|
||||
);
|
||||
const [tab, setTab] = useState('ucet');
|
||||
|
||||
const can = useMemo(() => {
|
||||
@@ -217,6 +226,15 @@ export default function Settings() {
|
||||
{ label: 'Název', render: (tenant: NamedRecord) => String(tenant.name ?? '') },
|
||||
{ label: 'ID', render: (tenant: NamedRecord) => <code>{tenant.id}</code> },
|
||||
{ label: 'Poznámka', render: (tenant: NamedRecord) => String(tenant.note ?? '') },
|
||||
{
|
||||
label: 'Helpdesk řeší',
|
||||
render: (tenant: NamedRecord) => {
|
||||
const provider = tenants.data?.items.find(
|
||||
(item) => item.id === tenant.helpdeskProviderId,
|
||||
);
|
||||
return provider ? provider.name : <span className="text-white/30">nikdo</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Stav',
|
||||
narrow: true,
|
||||
@@ -228,6 +246,25 @@ export default function Settings() {
|
||||
{ name: 'name', label: 'Název firmy', kind: 'text', required: true },
|
||||
{ name: 'note', label: 'Poznámka', kind: 'textarea', hint: 'Pro nás, klient ji nevidí.' },
|
||||
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
|
||||
{
|
||||
/*
|
||||
Komu firma posila pozadavky z helpdesku. Urcuje to spravce
|
||||
platformy, ne firma sama: kdo koho obsluhuje je obchodni vztah.
|
||||
Prazdna hodnota znamena, ze helpdesk nema komu poslat, a rekne
|
||||
se to na strance Helpdesk nahlas.
|
||||
*/
|
||||
name: 'helpdeskProviderId',
|
||||
label: 'Helpdesk řeší firma',
|
||||
kind: 'select',
|
||||
hint: 'Komu půjdou požadavky z helpdesku této firmy. Bez vyplnění je poslat nelze.',
|
||||
options: [
|
||||
{ value: '', label: 'Nikdo' },
|
||||
...(tenants.data?.items ?? []).map((tenant) => ({
|
||||
value: tenant.id,
|
||||
label: tenant.name,
|
||||
})),
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -66,6 +66,8 @@ export default function TicketDetail() {
|
||||
* zmizela a prvni zmena stavu by ji nenavratne prepsala.
|
||||
*/
|
||||
const [types, setTypes] = useState<Array<{ id: string; statuses: string[] }>>([]);
|
||||
/** Stavy, ktere firma uz nekde pouziva. Jen naseptavac, ne omezeni. */
|
||||
const [usedStatuses, setUsedStatuses] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ items: Array<{ id: string; statuses: string[] }> }>(
|
||||
@@ -73,6 +75,9 @@ export default function TicketDetail() {
|
||||
)
|
||||
.then((data) => setTypes(data.items))
|
||||
.catch(() => setTypes([]));
|
||||
apiFetch<{ items: string[] }>('/api/dashboard/tickets/statuses')
|
||||
.then((data) => setUsedStatuses(data.items))
|
||||
.catch(() => setUsedStatuses([]));
|
||||
}, []);
|
||||
|
||||
const statusChoices = useMemo(() => {
|
||||
@@ -80,8 +85,17 @@ export default function TicketDetail() {
|
||||
const own = type?.statuses ?? [];
|
||||
const base = own.length > 0 ? own : defaultStatuses;
|
||||
const current = ticket.data?.status;
|
||||
return [...new Set([...base, ...(current ? [current] : [])])];
|
||||
}, [types, ticket.data?.typeId, ticket.data?.status]);
|
||||
return [...new Set([...base, ...usedStatuses, ...(current ? [current] : [])])];
|
||||
}, [types, usedStatuses, ticket.data?.typeId, ticket.data?.status]);
|
||||
|
||||
/*
|
||||
* Rozepsany stav.
|
||||
*
|
||||
* Drzi se zvlast od ticketu, protoze zapis nesmi odejit na server po kazdem
|
||||
* pismenu - "Ce" na ceste k "Ceka na zakaznika" je platny stav a ulozil by se.
|
||||
* Posila se az pri opusteni pole nebo pri Enteru.
|
||||
*/
|
||||
const [statusDraft, setStatusDraft] = useState<string | null>(null);
|
||||
|
||||
usePageMeta({ title: `${ticket.data ? ticket.data.id : 'Ticket'} - portál Automia` });
|
||||
|
||||
@@ -363,25 +377,43 @@ export default function TicketDetail() {
|
||||
>
|
||||
Stav
|
||||
</label>
|
||||
<select
|
||||
{/*
|
||||
Otevreny naseptavac, ne ciselnik. Stav je volny retezec:
|
||||
ticket muze prijit z cizi aplikace s jejim vlastnim stavem
|
||||
a firma si smi zavest vlastni, aniz by ho nekdo predem
|
||||
zapisoval do nastaveni. Nabidka je pohodli, ne omezeni.
|
||||
*/}
|
||||
<input
|
||||
id="ticket-status"
|
||||
value={ticket.data.status}
|
||||
list="ticket-status-options"
|
||||
value={statusDraft ?? ticket.data.status}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
void mutate(
|
||||
'/status',
|
||||
{ status: event.target.value },
|
||||
'Změna stavu se nepodařila.',
|
||||
)
|
||||
}
|
||||
onChange={(event) => setStatusDraft(event.target.value)}
|
||||
onBlur={(event) => {
|
||||
const next = event.target.value.trim();
|
||||
setStatusDraft(null);
|
||||
if (next === '' || next === ticket.data?.status) return;
|
||||
void mutate('/status', { status: next }, 'Změna stavu se nepodařila.');
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur();
|
||||
// Escape zahodi rozepsane a vrati puvodni hodnotu.
|
||||
if (event.key === 'Escape') {
|
||||
setStatusDraft(null);
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Napište stav, nebo vyberte z nabídky"
|
||||
className={cn(selectClass, 'mt-2 w-full')}
|
||||
>
|
||||
/>
|
||||
<datalist id="ticket-status-options">
|
||||
{statusChoices.map((option) => (
|
||||
<option key={option} value={option} className="bg-ink-850">
|
||||
{option}
|
||||
</option>
|
||||
<option key={option} value={option} />
|
||||
))}
|
||||
</select>
|
||||
</datalist>
|
||||
<p className="mt-1 text-xs text-white/35">
|
||||
Stav je volný text. Nabídka je jen z toho, co už používáte.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-card p-5">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
TicketPriorityBadge,
|
||||
TicketStatusBadge,
|
||||
} from '@/components/dashboard/StatusBadge';
|
||||
import { NewTicketDialog } from '@/components/dashboard/NewTicketDialog';
|
||||
import { TicketWorkload } from '@/components/dashboard/TicketWorkload';
|
||||
import { TicketTable } from '@/components/dashboard/TicketTable';
|
||||
import { ViewSwitch } from '@/components/dashboard/ViewSwitch';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatDateTime, formatRelative } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
@@ -74,6 +76,7 @@ export default function Tickets() {
|
||||
const [query, setQuery] = useState('');
|
||||
/** Tabulka na porovnavani, dlazdice na rychly prehled. */
|
||||
const [view, setView] = useState<'table' | 'grid'>('table');
|
||||
const [creating, setCreating] = useState(false);
|
||||
/** Jen moje. Server to resi pohledem `mine`, klient jen prepina. */
|
||||
const [mine, setMine] = useState(false);
|
||||
|
||||
@@ -123,6 +126,7 @@ export default function Tickets() {
|
||||
const meId = data?.meId ?? null;
|
||||
const filtered = assignee !== null || status !== null || channel !== null;
|
||||
|
||||
const canCreate = access.data?.permissions.includes('ticket.create') === true;
|
||||
const allowedScopes = access.data?.scopes ?? [];
|
||||
const tenants = access.data?.tenants ?? [];
|
||||
// Dokud server neodpovi, drzime se toho, co poslal u seznamu.
|
||||
@@ -138,9 +142,28 @@ export default function Tickets() {
|
||||
V detailu je vidět, co která služba vrátila.
|
||||
</p>
|
||||
</div>
|
||||
<ViewSwitch view={view} onChange={setView} />
|
||||
<div className="flex items-center gap-3">
|
||||
{/*
|
||||
Ticket nevznika jen z automatizace. Pozadavek casto prijde telefonem
|
||||
a nekdo ho musi zapsat - bez tohohle tlacitka konci na papirku.
|
||||
*/}
|
||||
{canCreate && (
|
||||
<Button size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus className="size-4" />
|
||||
Nový ticket
|
||||
</Button>
|
||||
)}
|
||||
<ViewSwitch view={view} onChange={setView} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<NewTicketDialog
|
||||
open={creating}
|
||||
tenantId={tenantId ?? data?.tenantId ?? null}
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={() => tickets.reload()}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
|
||||
<div className="space-y-4">
|
||||
<div className="glass rounded-card p-4 sm:p-5">
|
||||
|
||||
@@ -93,6 +93,8 @@ export interface Ticket {
|
||||
* ID u odesilatele, napr. cislo objednavky. **Unikatni v ramci firmy.**
|
||||
* Dalsi zprava se stejnym ID se navesi na tenhle ticket.
|
||||
*/
|
||||
/** Firma, ktera pozadavek poslala pres helpdesk. null u beznych ticketu. */
|
||||
helpdeskSourceId: string | null;
|
||||
externalId: string | null;
|
||||
/** Odkud externi ID pochazi. Jen informace, do klice nevstupuje. */
|
||||
externalSource: string | null;
|
||||
@@ -300,7 +302,7 @@ export interface OperationField {
|
||||
* `json` a `mapping` nesou strukturu zapsanou jako JSON v retezci.
|
||||
* `object` je odkaz na parametr typu objekt nebo seznam.
|
||||
*/
|
||||
kind: 'text' | 'longtext' | 'choice' | 'json' | 'mapping' | 'object';
|
||||
kind: 'text' | 'longtext' | 'html' | 'choice' | 'lookup' | 'json' | 'mapping' | 'object';
|
||||
required: boolean;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
hint?: string;
|
||||
@@ -317,6 +319,8 @@ export interface Service {
|
||||
/** true = funguje bez konektoru (webhook, pauza, transformace dat). */
|
||||
general: boolean;
|
||||
appId: string | null;
|
||||
/** `smtp` = sluzba se nevola pres HTTP, adresu nese konektor v udajich. */
|
||||
transport?: 'http' | 'smtp';
|
||||
visibility: ServiceVisibility;
|
||||
credentials: ServiceCredentialField[];
|
||||
verifyPath?: string;
|
||||
|
||||
Reference in New Issue
Block a user