Struktura podle zasad: rozdeleni souboru, lint, testy, prisny TypeScript

Projekt srovnan se zasadami v D:\GitHubRepository\CLAUDE.md bez zmeny chovani.

Struktura: scripts/ (skripty konektoru) -> connectors/, src/scripts ->
src/runtime/scripts; src/index.ts jen startuje, novy src/app.ts s createApp();
routes/dashboard.ts a routes/settings.ts rozdeleny do slozek; openapi.ts
rozdelen na openapi/{index,helpers,components} a paths/* (98 cest overeno
shodnych); ticketStore, automationStore a services jsou fasady nad slozkami
data/tickets, data/automations a data/services/catalog. process.env se cte
jen v config.ts. Web: hooky v hooks/, sdilena ui/Table a ui/ServiceIcon,
surove inputy nahrazeny komponentami, sedm velkych souboru rozdeleno.

Nastroje: eslint (typescript-eslint, react-hooks v7), prettier, editorconfig,
nvmrc, .env.example, vitest; skripty lint, format, test. Lint je cisty bez
jedineho eslint-disable (nove hooky useLatest a useSyncFromSource, odvozeny
stav misto setState v effectu). noUncheckedIndexedAccess v obou tsconfig,
84 mist zuzeno bez non-null operatoru; odhalilo zalohu backoffu fronty pri
nule pokusu a Retry-After NaN pri max 0. Cely kod naformatovan prettierem.

Testy: 8 souboru, 105 testu (prava, viditelnost, podminky a opakovani
v executoru, redaktor tajemstvi, sitove guardy, migrace resitelu, tickety,
health a prihlaseni pres supertest). Testy odhalily dve chyby ve vyhodnoceni
podminek, obe opravene: chybejici castka se porovnavala jako nula a podminka
nad vystupem druheho kroku cetla hodnotu prvniho se stejnym nazvem.

Pojmenovane konstanty misto magickych hodnot, ctx.util.base64 pro skripty
konektoru, README a dokumentace aktualizovany vcetne znamych odchylek.
This commit is contained in:
JiriUhlir
2026-09-09 15:11:02 +02:00
parent 42b3da8303
commit 22dda2d139
312 changed files with 21085 additions and 15319 deletions
+17
View File
@@ -0,0 +1,17 @@
# Zakladni pravidla editoru, nezavisle na nastroji. UTF-8 bez BOM je pravidlo
# celeho adresare, viz D:\GitHubRepository\START.md, sekce 6.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.sql]
indent_size = 2
+66
View File
@@ -0,0 +1,66 @@
# Promenne prostredi aplikace. Zkopirovat do .env (ten je mimo git) nebo
# nastavit v AppFactory jako variables a secrets. Vsechny se ctou jen
# v src/config.ts. Zadna neni povinna: aplikace nastartuje i bez nich,
# jen s omezenim popsanym u kazde.
# --- beh a proxy ---------------------------------------------------------
# Port urcuje AppFactory sablona, nemenit bez upravy metadat.
PORT=3000
# Prefix verejne adresy za Caddy, napr. /apps/csbot-prototype.
ROOT_PATH=
# Verejna adresa bez prefixu, napr. https://services.csbot.cz. Sklada se z ni
# absolutni adresa webhooku. Prazdne = relativni tvar.
PUBLIC_ORIGIN=
# Povolene originy CORS pro lokalni vyvoj s Vite.
CORS_ORIGIN=http://localhost:5173,http://localhost:4173
NODE_ENV=development
# --- prihlaseni (secret) -------------------------------------------------
# Bez JWT_SECRET se vygeneruje nahodny klic platny do restartu, tokeny pak
# po restartu neplati.
JWT_SECRET=
JWT_EXPIRES_IN=8h
# --- uloziste ------------------------------------------------------------
# Postgres, napr. postgres://user:pass@host:5432/csbot. Prazdne = soubor.
DATABASE_URL=
# Klic pro sifrovani pristupovych udaju konektoru (secret). Bez nej se
# konektory neukladaji do databaze. Generovani viz documentation/14-databaze.md.
SECRETS_KEY=
DATABASE_POOL_MAX=10
DATABASE_SSL=false
# Slozka pro JSON bez databaze. Prazdna hodnota = jen pamet procesu.
DATA_DIR=./data
# 1 = nasypat ukazkove tickety do prazdneho uloziste.
SEED_DEMO=
# --- znacka --------------------------------------------------------------
BRAND_NAME=WorkNuke
# Klient cte VITE_BRAND_NAME pri buildu (staticky soubor).
VITE_BRAND_NAME=WorkNuke
# --- skripty konektoru a volani ven --------------------------------------
# Slozka se skripty konektoru, vychozi ./connectors.
SCRIPTS_DIR=
# Zaklad adres napojenych sluzeb; konkretni sluzba jde presmerovat pres
# <KONEKTOR>_BASE_URL (nazev promenne je v katalogu sluzby).
SERVICES_BASE_URL=https://services.csbot.cz/apps
# Odkud se zjistuje odchozi IP containeru. Prazdne = vypnuto.
EGRESS_IP_URL=https://api.ipify.org?format=json
EGRESS_IP_TTL_MS=600000
SCRIPT_TIMEOUT_MS=15000
SCRIPT_MAX_RESPONSE_BYTES=1000000
SCRIPT_MAX_UPLOAD_BYTES=10000000
SCRIPT_MAX_VALUE_BYTES=256000
SCRIPT_ERROR_DETAIL_BYTES=8000
# true jen pro lokalni vyvoj: povoli volani na localhost a privatni rozsahy.
ALLOW_PRIVATE_TARGETS=false
# Registr ARES pro zalozeni firmy. Menit jen pro testovaci zrcadlo.
ARES_BASE_URL=https://ares.gov.cz/ekonomicke-subjekty-v-be/rest
# --- fronta a planovac ---------------------------------------------------
# 0 = tento proces frontu nezpracovava (jen prijima). Cokoliv jineho = ano.
WORKER=1
SCHEDULER_INTERVAL_SEC=30
# Pevny token ukazkove webhookove automatizace (jen pro test).
WEBHOOK_TOKEN_TEST=
+1
View File
@@ -0,0 +1 @@
20
+7
View File
@@ -0,0 +1,7 @@
dist
node_modules
data
design
package-lock.json
# Dokumentace ma vlastni styl tabulek, prettier by je prelamal.
*.md
+7
View File
@@ -0,0 +1,7 @@
{
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all",
"semi": true,
"endOfLine": "lf"
}
+1 -1
View File
@@ -18,7 +18,7 @@ RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
# Skripty konektoru jsou obycejny JavaScript, nekompiluji se. Musi se ale
# dostat do image, jinak by konektory nemely zadnou vykonnou cast.
COPY --from=build /app/scripts ./scripts
COPY --from=build /app/connectors ./connectors
# Migrace jsou .sql, ktere tsc do dist nekopiruje. Bez nich by se schema
# nevytvorilo, aplikace by spadla na migracich a jela dal v pameti - tedy presne
# to, co ma databaze resit. Cesta musi odpovidat dist/db/migrations, protoze
+13 -6
View File
@@ -59,12 +59,19 @@ Secrets se nikdy nelogují ani neukladaji do kodu.
## Skripty
| Prikaz | Co dela |
| ------------------- | ------------------------------------ |
| `npm run build` | Zbuilduje server i web do `dist/` |
| `npm start` | Spusti zbuildovanou aplikaci |
| `npm run dev` | Vyvoj s hot reloadem |
| `npm run typecheck` | Kontrola typu bez generovani vystupu |
| Prikaz | Co dela |
| ---------------------- | ---------------------------------------------- |
| `npm run build` | Zbuilduje server i web do `dist/` |
| `npm start` | Spusti zbuildovanou aplikaci |
| `npm run dev` | Vyvoj s hot reloadem |
| `npm run typecheck` | Kontrola typu bez generovani vystupu |
| `npm run lint` | ESLint nad serverem, webem a skripty konektoru |
| `npm run format` | Prettier prepise soubory podle konfigurace |
| `npm run format:check` | Prettier jen zkontroluje, nic neprepisuje |
| `npm test` | Testy serveru (vitest) ve slozce `tests/` |
Skripty konektoru (vykonna cast sluzeb) jsou obycejny JavaScript ve slozce
`connectors/`, do buildu se nekompiluji, jen kopiruji.
## Dokumentace
@@ -49,9 +49,7 @@ export const manifest = {
* Povinny vystup, ktery skript nevrati, je chyba skriptu. Zamerne: strom by
* jinak veril parametru, ktery nikdy nedosel.
*/
outputs: [
{ id: 'prikladVystupu', label: 'Příklad výstupu', type: 'string', required: true },
],
outputs: [{ id: 'prikladVystupu', label: 'Příklad výstupu', type: 'string', required: true }],
};
/**
@@ -63,7 +61,7 @@ export const manifest = {
* util: { unwrap: Function, pick: Function, first: Function, list: Function,
* text: Function, num: Function, bool: Function, date: Function, day: Function,
* round: Function, need: Function, get: Function, addresses: Function,
* quote: Function, applyRules: Function, fillJson: Function },
* quote: Function, base64: Function, applyRules: Function, fillJson: Function },
* log: Function, config: Record<string, string>, idempotencyKey: string,
* fail: Function, retry: Function,
* }} ctx
@@ -77,10 +77,10 @@ export async function run(inputs, ctx) {
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))
const number = digits(
pick(pick(item, 'accountNumber'), 'number') ?? pick(item, 'accountNumber'),
);
return (iban !== '' && iban.includes(wanted)) || (number !== '' && wanted.endsWith(number));
});
if (!match) {
ctx.fail(
@@ -110,8 +110,9 @@ export async function run(inputs, ctx) {
accountId,
count: transactions.length,
transactions,
newest: transactions.length > 0
? text(pick(transactions[0], 'bookingDate', 'valueDate', 'creationDate'))
: null,
newest:
transactions.length > 0
? text(pick(transactions[0], 'bookingDate', 'valueDate', 'creationDate'))
: null,
};
}
@@ -8,8 +8,8 @@
* 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.
* Kodovani dela `ctx.util.base64`, ne Buffer ani `btoa`: skript nesmi
* zaviset na globalech prostredi, aby bezel stejne i az pobezi v sandboxu.
*/
export const manifest = {
@@ -56,21 +56,13 @@ export const manifest = {
timeoutMs: 30000,
};
/** 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) {
/** Base64url bez vypln, jak ho chce Gmail. `base64` je `ctx.util.base64`. */
function base64Url(base64, value) {
return base64(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** Hlavicka s diakritikou musi byt MIME slovo, jinak z ni zbydou otazniky. */
function header(value) {
function header(base64, value) {
for (const character of value) {
if (character.codePointAt(0) > 127) return `=?UTF-8?B?${base64(value)}?=`;
}
@@ -78,7 +70,7 @@ function header(value) {
}
export async function run(inputs, ctx) {
const { pick, text, need, addresses } = ctx.util;
const { pick, text, need, addresses, base64 } = ctx.util;
// Clovek pise carky, Gmail chce jeden retezec.
const to = addresses(inputs.to);
@@ -89,7 +81,7 @@ export async function run(inputs, ctx) {
`To: ${to.join(', ')}`,
...(cc.length > 0 ? [`Cc: ${cc.join(', ')}`] : []),
...(inputs.from ? [`From: ${inputs.from}`] : []),
`Subject: ${header(inputs.subject)}`,
`Subject: ${header(base64, inputs.subject)}`,
'MIME-Version: 1.0',
`Content-Type: text/${inputs.html ? 'html' : 'plain'}; charset="UTF-8"`,
'Content-Transfer-Encoding: 8bit',
@@ -98,7 +90,7 @@ export async function run(inputs, ctx) {
];
const { body } = await ctx.http.post('/google/gmail/messages/send', {
raw: base64Url(lines.join('\r\n')),
raw: base64Url(base64, lines.join('\r\n')),
});
ctx.log(`Odesláno ${to.length + cc.length} příjemcům.`);
@@ -60,7 +60,9 @@ export async function run(inputs, ctx) {
}
// Vzor nese zbytek k zaplaceni i vychozi zpusob platby.
const defaults = unwrap((await ctx.http.get(`/issued-payments/default/${inputs.invoiceId}`)).body);
const defaults = unwrap(
(await ctx.http.get(`/issued-payments/default/${inputs.invoiceId}`)).body,
);
if (!defaults || typeof defaults !== 'object') {
ctx.fail(`K faktuře ${inputs.invoiceId} nejde zapsat úhradu, iDoklad ji nezná.`);
}
@@ -75,7 +75,9 @@ export async function run(inputs, ctx) {
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 state = text(
pick(last, 'code', 'name', 'statusCode') ?? pick(shipment, 'statusCode', 'state'),
);
const stateDate = text(pick(last, 'date', 'eventDate') ?? pick(shipment, 'statusDate'));
return {
@@ -12,8 +12,7 @@
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.',
description: 'Vrátí prodejní objednávky partnera nebo za období. Nic nemění, jen čte.',
inputs: [
{
@@ -68,7 +67,8 @@ export async function run(inputs, ctx) {
const { body } = await ctx.http.get('/api/orders', {
query: {
$filter: conditions.join(' and '),
$select: 'DocEntry,DocNum,CardCode,CardName,DocDate,DocDueDate,DocTotal,DocCurrency,DocumentStatus',
$select:
'DocEntry,DocNum,CardCode,CardName,DocDate,DocDueDate,DocTotal,DocCurrency,DocumentStatus',
$orderby: 'DocDate desc',
$top: inputs.limit,
},
@@ -12,8 +12,7 @@
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í.',
description: 'Vrátí dotazy, prokliky, zobrazení, CTR a průměrnou pozici za období. Nic nemění.',
inputs: [
{
@@ -43,9 +43,7 @@ export const manifest = {
},
],
outputs: [
{ id: 'result', label: 'Výsledek', type: 'object', required: true },
],
outputs: [{ id: 'result', label: 'Výsledek', type: 'object', required: true }],
};
export async function run(inputs, ctx) {
+46 -5
View File
@@ -33,7 +33,7 @@ Ctyri pojmy. Plete se to a z te zamneny vznikaji nejhorsi chyby.
| Pojem | Co to je | Ciji je |
| ------------ | ----------------------------------------------- | ------------ |
| **Sluzba** | co umime: iDoklad, e-mail, MCP server, webhook | nase |
| **Skript** | vykonna cast operace, obycejny JS ve `scripts/` | nase |
| **Skript** | vykonna cast operace, obycejny JS v `connectors/` | nase |
| **Konektor** | pristupove udaje jedne firmy k jedne sluzbe | firmy |
| **Krok** | jedno pouziti operace v automatizaci | automatizace |
@@ -46,6 +46,46 @@ jeden pristupovy udaj.
## Pravidla, ktera plati vsude
Obecne zasady (struktura projektu, lint, testy, navrh kodu) jsou
v `D:\GitHubRepository\CLAUDE.md` a tenhle projekt se jimi ridi. Tady je jen
to, co je specificke pro portal, a par zasad, ktere se tu nejvic porusovaly.
Kde se projekt od zasad lisi, je to zapsane jako znamy stav
v [01-prehled-a-stav.md](01-prehled-a-stav.md).
**`process.env` se cte jen v `src/config.ts`.** Kazda promenna ma vychozi
hodnotu, komentar a radek v `.env.example`. Jedina vyjimka s dynamickym nazvem
je `config.serviceBaseUrlOverride('<SLUZBA>_BASE_URL')`, protoze sluzby
pribyvaji v katalogu a vypisovat kazdou do konfigurace by znamenalo dve mista.
`process.env` jinde v `src/` je chyba, i kdyz "je to jen jedna promenna".
**Testy lezi v `tests/` se stejnou cestou jako modul.**
`tests/data/tickets.test.ts` testuje `src/data/tickets/`. Bezi v rezimu pameti
(`tests/setup.ts`), bez databaze a bez site; co potrebuji ven, dostanou vstrikle.
Aspon na logiku, ktera rozhoduje o pravech, penezich a datech. Aplikace se
v testu stavi pres `createApp()` ze `src/app.ts`, ne pres `index.ts`, aby se
neotevrel port.
**Pred predanim prace lint a typecheck.** `npm run lint` a `npm run typecheck`
maji byt ciste, bez `eslint-disable`. Spousti se ale jen se svolenim
uzivatele (pravidlo 2 v CLAUDE.md), takze se o ne pozada, nespusti se "pro
kontrolu".
**Sdilene UI prvky jsou komponenty v `components/ui`.** Pole formulare,
tlacitka, odznaky, tabulky, dialogy. Stranka, ktera si kresli vlastni `<input>`
nebo vlastni `<table>`, je chyba, i kdyz je to poprve - presne tak vzniklo
13 verzi `inputClass` a ctyri tabulky v jinem odstinu.
**Pravidlo tri.** Stejny kus se poprve napise, podruhe se snese kopie,
potreti se vytahne. Vytahnout se ma **tentyz pojem**, ne jen podobny kod:
`Table` v `ui/` vznikl ze ctyr seznamu, ale huste tabulky ticketu a vykonu
zustavaji zvlast, protoze jsou to jine tabulky.
**Soubor nad 500 radku je signal k rozdeleni.** Route, uloziste i stranka
se deli podle odpovednosti, ne podle delky; puvodni cesta zustava jako fasada
(`ticketStore.ts`, `automationStore.ts`, `services.ts`), aby se nemusely
menit importy. Ktere soubory limit stale prekracuji a proc, je
v [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md).
**Filtr na firmu je povinny argument.** `listTickets(tenantIds)`,
`getConnector(id, tenantIds)`, `listConnectors(tenantIds)`. Zapomenuty filtr tak
neznamena "vse", ale nezkompiluje se. Cizi zaznam se chova jako neexistujici
@@ -68,7 +108,7 @@ cely strom a zustane bila plocha. Obsah portalu je proto v pojistce
v konzoli prohlizece uzivatele. Pomocna funkce, ktera jen neco vykresluje,
si navic ma chybu osetrit sama a degradovat, ne spadnout.
**Katalog je zdroj pravdy.** Co neni v `src/data/services.ts`, to nejde ulozit
**Katalog je zdroj pravdy.** Co neni v `src/data/services/catalog/`, to nejde ulozit
do stromu. Validace pri ukladani se pta katalogu, ne klienta.
**Ve strome je vsechno retezec.** Hodnota kroku je sablona (`{{subject}}`),
@@ -132,8 +172,8 @@ Tri cesty, kazda ma svuj duvod:
| Cesta | Kdy | Kde |
| -------------------- | --------------------------------------- | ----------------------------- |
| **Zapis v katalogu** | popis toho, co umime nebo budeme umet | `src/data/services.ts` |
| **Skript** | volani cizi sluzby pres HTTP | `scripts/*.js` + manifest |
| **Zapis v katalogu** | popis toho, co umime nebo budeme umet | `src/data/services/catalog/` |
| **Skript** | volani cizi sluzby pres HTTP | `connectors/*.js` + manifest |
| **Vnitrni krok** | sahá do naseho uloziste, nebo neni HTTP | `src/runtime/builtinSteps.ts` |
Runtime zkousi **nejdriv vnitrni krok, pak skript**. Kdyz operace nema ani
@@ -202,7 +242,8 @@ prvni misto, kam se divat.
**Novou sluzbu** popiste v katalogu vcetne `credentials` a `verifyPath`. Bez
`verifyPath` overeni konektoru nerika nic o udajich, jen ze neco odpovida.
**Novy endpoint** patri do `src/openapi.ts`. Neni to volitelne, vyzaduje to
**Novy endpoint** patri do `src/openapi/paths/<router>.ts` (jeden soubor na
router, sklada je `src/openapi/index.ts`). Neni to volitelne, vyzaduje to
`AGENTS.md`, a nezdokumentovany endpoint neexistuje pro nikoho krome toho, kdo
ho napsal.
+17
View File
@@ -77,6 +77,10 @@ React aplikaci ze slozky `dist/public`.
| Formularova vrstva | hotovo | `ui/form`, `useSubmit`, `options`, jedna sada trid |
| Firma z registru ARES | hotovo | IC nebo nazev, statutari jako ucty, jen spravce platformy |
| Sdilene typy `src/shared` | hotovo | web je re-exportuje pres `@shared/*`, nic nekopiruje |
| Struktura podle zasad | hotovo | `index.ts` a `app.ts`, routy a data po slozkach, `connectors/` |
| Lint a formatovani v repu | hotovo | eslint a prettier, `npm run lint` cisty bez vyjimek |
| Prisny TypeScript | hotovo | `noUncheckedIndexedAccess` v obou tsconfig, zadne `!` |
| Testy | castecne | vitest v `tests/`, 8 souboru a 105 testu: prava, tickety, executor, sit, health |
## Znama omezeni
@@ -114,6 +118,19 @@ Log ticketu uz plni skutecny beh: kazdy krok stromu se do nej zapise vcetne
toho, co sluzba vratila. Ukazkova sada ticketu ma log psany rucne, aby bylo
co ukazat i na prazdne instanci.
### Znamy stav proti zasadam
Projekt se ridi `D:\GitHubRepository\CLAUDE.md`. Kde se od nej lisi, je to
zamer nebo odlozena prace, ne opomenuti:
| Odchylka | Proc a co s tim |
| ------------------------------------------ | ------------------------------------------------------------------------------- |
| jeden `package.json` pro server i web | mala aplikace v jednom containeru; workspaces az bude mit kazda strana vlastni build |
| logovani `console.*` s prefixem modulu | strukturovany logger (`pino`) zatim neni potreba, prefix `[modul]` staci k dohledani |
| zadny soubor CI | lint, typecheck a testy se spousti rucne se svolenim (pravidlo 2) |
| testy jen na cast logiky | pokryta prava, tickety, executor, cteni tela a health; routy nastaveni a runtime fronty cekaji |
| ctyri soubory nad 500 radku | `AutomationDetail`, `TicketDetail`, `MappingEditor`, `catalog/ticket.ts`; duvod v [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md) |
## Dalsi krok
Runtime je hotovy: fronta, worker jako pool, opakovani jen u chyb, ktere
+1 -1
View File
@@ -99,7 +99,7 @@ shodovala i s `/docs/` a presmerovani by se zacyklilo.
```
Diky tomu tlacitko Try it out vola endpointy pres prefix, ne na koreni domeny.
Definici sestavuje `src/openapi.ts`.
Definici sestavuje `src/openapi/paths/*.ts`.
Pozn.: `/api/dashboard/stream` je Server-Sent Events. Swagger UI streamovanou
odpoved rozumne nezobrazi, testuje se prohlizecem nebo curlem.
+104 -20
View File
@@ -10,10 +10,11 @@
| 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`.
Jeden `package.json` pro server i web (znamy stav, viz nize). Runtime zavislosti
jsou v `dependencies`, nastroje pro build webu v `devDependencies` - runtime
image je pak instaluje pres `--omit=dev`.
## Build
## Build a kontroly
```
tsc src/**.ts -> dist/*.js
@@ -21,17 +22,52 @@ vite web/ -> dist/public/
```
Server obsluhuje `dist/public` jako statiku. Dockerfile kopiruje do vysledneho
image jen `dist`, takze staci jedna slozka.
image `dist` a `connectors/` - skripty konektoru se ctou za behu ze souboru,
ne z buildu.
| Prikaz | Co dela |
| ---------------------- | ------------------------------------------------------------- |
| `npm run build` | server (`tsc`) a web (`vite build`) |
| `npm run typecheck` | `tsc --noEmit` pro oba `tsconfig`, nic nezapisuje |
| `npm run lint` | `eslint .`: server, web, testy i `connectors/` |
| `npm run format` | `prettier --write .`; `format:check` jen kontroluje |
| `npm run test` | `vitest run` nad `tests/**`; `test:watch` pri vyvoji |
Build, lint i testy se spousti **jen se svolenim uzivatele** (pravidlo 2
v `D:\GitHubRepository\CLAUDE.md`).
Oba `tsconfig` maji `strict`, `noUncheckedIndexedAccess`, `noUnusedLocals`
a `noFallthroughCasesInSwitch`. Index do pole nebo slovniku je tak
`T | undefined` a kod to musi osetrit; `!` na umlceni se nepouziva. Prave
tahle volba odhalila dve skryte chyby (prodleva fronty a `Retry-After`),
viz [99-zmeny.md](99-zmeny.md).
## Nastroje v korenu
| Soubor | K cemu |
| -------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `eslint.config.js` | typescript-eslint, `react-hooks` v7 pro web, `connectors/**` jako obycejny JS bez globalu; zadne `any`, zadny prazdny `catch` |
| `.prettierrc`, `.prettierignore` | jednotne formatovani (jednoduche uvozovky, sirka 100) |
| `.editorconfig` | odsazeni, konce radku a kodovani pro editor |
| `.nvmrc` | Node 20, stejne jako `engines` v `package.json` |
| `.env.example` | vsechny promenne prostredi s popisem; `.env` neni v gitu |
| `vitest.config.ts` | testy z `tests/**/*.test.ts`, alias `@shared`, `tests/setup.ts` pred kazdym souborem |
| `tests/tsconfig.json` | typecheck testu nad `src/` bez emitu |
| `Dockerfile` | vicefazovy build, runtime jen s `--omit=dev`, kopiruje `dist` a `connectors` |
## 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/index.ts` | start: nacteni dat, worker, `listen`, signaly, `process.on`; nic jineho |
| `src/app.ts` | `createApp()`: middleware, routery, health, Swagger, statika, SPA. Bez `listen`, aby sla postavit v testu (supertest) |
| `src/config.ts` | **jedine misto, kde se cte `process.env`**; `serviceBaseUrlOverride(variable)` pro `<SLUZBA>_BASE_URL` |
| `src/openapi/index.ts` | `buildOpenApiDocument()`: sklada dokument, `servers` s prefixem proxy |
| `src/openapi/helpers.ts` | `crudPaths` a opakujici se parametry, tela a odpovedi |
| `src/openapi/components.ts` | schemata a zabezpeceni |
| `src/openapi/paths/*.ts` | cesty po routerech: `ops`, `auth`, `dashboard`, `tickets`, `automations`, `settings`, `connectors`, `scripts`, `helpdesk`, `invites`, `admin`, `contact`, `webhook` |
| `src/types.ts` | typy uzivatele a JWT payloadu |
| `src/shared/` | ciste typove moduly API, jediny zdroj typu pro server i web |
| `src/shared/` | ciste typove moduly API, jediny zdroj typu pro server i web |
| `src/middleware/auth.ts` | `requireAuth`, `requireRole`, `requirePlatformAdmin` |
| `src/middleware/asyncHandler.ts` | `wrap`, `safeRouter`: odchyceni odmitnute promise v handleru |
| `src/middleware/tenant.ts` | `attachAccess`, `tenantOrDeny`, `scopeOrDeny`: firma requestu na jednom miste |
@@ -41,18 +77,34 @@ image jen `dist`, takze staci jedna slozka.
| `src/net/guard.ts` | kontrola adresy, cteni tela s limitem, popis chyby site - pro vsechno, co vola ven |
| `src/events/bus.ts` | sbernice udalosti, ze ktere cerpa SSE stream, udalost nese firmu |
| `src/routes/auth.ts` | prihlaseni, odhlaseni, kdo jsem |
| `src/routes/dashboard.ts` | data portalu, tickety, behy, CRUD automatizaci |
| `src/routes/crud.ts` | `crudRouter`: fabrika CRUD nad jednou entitou |
| `src/routes/dashboard/index.ts` | mount routeru dashboardu, `attachAccess` jednou za request |
| `src/routes/dashboard/misc.ts` | prava, prehled, uloziste, katalog sluzeb |
| `src/routes/dashboard/tickets.ts` | seznam s filtrem, rucni zalozeni, stavy, vytizeni, detail |
| `src/routes/dashboard/people.ts` | resitele pro nabidky a detail cloveka |
| `src/routes/dashboard/automations.ts` | strom akci, validace, webhook, fronta behu |
| `src/routes/dashboard/incidents.ts` | incidenty: seznam, detail, posun stavu |
| `src/routes/dashboard/layout.ts` | katalog widgetu a ulozene rozlozeni |
| `src/routes/dashboard/intake.ts` | adresa prijmu udalosti a jeji obnova |
| `src/routes/dashboard/notifications.ts` | upozorneni a pocet otevrenych ticketu |
| `src/routes/dashboard/clientCrash.ts` | hlaseni padu portalu, z nej incident |
| `src/routes/dashboard/shared.ts` | strankovani: `pageFrom`, `paginate` |
| `src/routes/ticketActions.ts` | akce nad ticketem vcetne vestavenych, pravo za firmu ticketu |
| `src/routes/settings.ts` | CRUD entit pres `crud.ts`, uzivatele, ARES |
| `src/routes/settings/index.ts` | mount routeru nastaveni |
| `src/routes/settings/{tenants,users,roles,people,groups,features,ticketTypes,actions,widgets}.ts` | jedna entita = jeden soubor nad `crudRouter`; `people` a `users` maji vlastni handlery |
| `src/routes/settings/catalog.ts` | co jde v nastaveni zvolit: prava, moduly, limity, widgety |
| `src/routes/settings/shared.ts` | `memberOf` pro ucty a resitele |
| `src/routes/ares.ts` | firma z registru ARES, jen spravce platformy |
| `src/routes/connectors.ts` | konektory firmy, overeni, nastroje MCP |
| `src/routes/scripts.ts`, `tenantScripts.ts` | skripty konektoru a skripty firmy |
| `src/routes/stream.ts` | SSE stream zmen, filtr podle firem uzivatele |
| `src/routes/webhook.ts` | verejny prijem dat do automatizace |
| `src/routes/contact.ts` | poptavkovy formular z webu |
| `src/ares/client.ts` | klient verejneho API ARES |
| `src/data/store/` | tri rezimy uloziste, `withCache`, `withMirror`, `initStores` |
| `src/data/snapshot.ts` | atomicky zapis JSONu pro rezim `file` |
| `src/data/ticketStore.ts` | tickety, jejich resitele, log prubehu, prehled vytizeni |
| `src/data/ticketStore.ts` | fasada nad `src/data/tickets/`, importy zustavaji |
| `src/data/tickets/` | `index` (verejne API), `model` (tvar, `toTicket`), `state` (pamet a indexy), `persist` (zapis, `initTickets`), `queries` (seznam, detail, strop viditelnosti), `store` (zapisy: zalozeni, stav, resitel, typ, tagy, skupina, komentar), `intake` (udalost zvenku), `trace` (log prubehu), `stats` (vytizeni a vykon), `seed`, `remap` |
| `src/data/people.ts` | resitele jako pohled na clenstvi uctu (`personView`), skupiny |
| `src/data/migratePeople.ts` | jednorazovy prevod starych zaznamu resitelu `ppl_` na ucty |
| `src/data/tenants.ts` | firmy, ktere portal pouzivaji, vcetne udaju z ARES |
@@ -60,16 +112,21 @@ image jen `dist`, takze staci jedna slozka.
| `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, filtr na firmu povinny |
| `src/data/automationStore.ts` | automatizace, strom akci, tokeny webhooku |
| `src/data/services.ts` | katalog sluzeb, jejich spousteču a akci |
| `src/data/automationStore.ts` | fasada nad `src/data/automations/` |
| `src/data/automations/` | `index`, `model` (tvar, `rulesOf`, `matchOf`), `state` (pamet, citac ID), `persist` (zapis, `initAutomations`), `store` (cteni a zapisy, `recordRun`), `validation` (pocet kroku, nedodelky, druh), `webhook` (token, posledni volani), `runs` (historie po dnech), `seed`, `seedDemo`, `remap` |
| `src/data/services.ts` | fasada nad `src/data/services/` |
| `src/data/services/index.ts` | katalog za behu: `findService`, `actionsFor`, `setScriptActions`, `setMcpOperations`, `withRuntimeOptions`, `serviceCatalog` |
| `src/data/services/catalog/` | staticky zapis po skupinach: `triggers`, `incident`, `ticket`, `crm`, `finance`, `logistics`, `email`, `messaging`, `social`, `office`, `analytics`, `ai`, `mcp`, `tools`, `polstryn`; `index.ts` urcuje poradi v nabidce |
| `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` | uzivatele portalu, demo ucty |
| `src/data/mock.ts` | souhrn pro prehled a casova rada grafu |
| `src/runtime/` | fronta, worker, executor stromu, vestavene kroky, sandbox skriptu firmy |
| `src/scripts/` | skripty konektoru: registr, runner, HTTP, pomocne funkce |
| `src/runtime/` | fronta, worker, executor stromu, vestavene kroky, planovac, sandbox skriptu firmy |
| `src/runtime/scripts/` | runtime skriptu konektoru: registr, runner, HTTP, napojeni, manifest, kontrola hodnot, `mapping`, `util` (vcetne `ctx.util`) |
| `connectors/` | skripty konektoru (obycejny JS) a `_sablona.js`; cesta z `config.scriptsDir`, promenna `SCRIPTS_DIR` |
| `src/mcp/` | klient MCP, prihlaseni, dialekty, `errors.ts` se spolecnou chybou prihlaseni |
| `tests/` | vitest, stejna cesta jako modul (`tests/data/tickets.test.ts` pro `src/data/tickets/`); `setup.ts` nastavi rezim pameti a umlci `console.info` |
## Mapa kodu - web
@@ -81,24 +138,51 @@ image jen `dist`, takze staci jedna slozka.
| `web/src/config/brand.ts` | vsechny firemni udaje na jednom miste |
| `web/src/lib/api.ts` | fetch wrapper, sprava tokenu, skladani adres, `auth:expired` na 401 |
| `web/src/lib/eventStream.ts` | cteni SSE streamu pres fetch |
| `web/src/lib/useApiQuery.ts` | nacitani dat, cache, spolecny debounce, `refreshing` misto odmontovani |
| `web/src/lib/collections.tsx` | klientsky sklad ciselniku za firmu, opravovany z udalosti |
| `web/src/lib/ticketEvents.ts` | oprava seznamu ticketu z `payload.ticket` bez dotazu |
| `web/src/lib/useSubmit.ts` | odeslani formulare: `saving`, chyba, reset na jednom miste |
| `web/src/lib/options.ts` | pevne ciselniky (priority) |
| `web/src/lib/useUnsavedChanges.ts` | varovani pri odchodu z rozepsaneho formulare |
| `web/src/lib/flow.ts` | ciste funkce nad stromem automatizace |
| `web/src/lib/exampleBody.ts` | vzorove telo spoustece pro ukazku a strom modelu |
| `web/src/lib/serviceIcons.ts` | klic ikony ze serveru na komponentu lucide |
| `web/src/hooks/useApiQuery.ts` | nacitani dat, cache, spolecny debounce, `refreshing` misto odmontovani |
| `web/src/hooks/useSubmit.ts` | odeslani formulare: `busy`, chyba, `issues` na jednom miste |
| `web/src/hooks/useUnsavedChanges.ts` | varovani pri odchodu z rozepsaneho formulare |
| `web/src/hooks/useLatest.ts` | ref s posledni hodnotou pro callbacky mimo zavislosti effectu |
| `web/src/hooks/useSyncFromSource.ts` | prevzeti dat ze zdroje do rozepsaneho stavu pri vykresleni, ne v effectu |
| `web/src/hooks/useMediaQuery.ts` | sirka obrazovky pres `useSyncExternalStore` |
| `web/src/hooks/usePageMeta.ts` | titulek a popis stranky |
| `web/src/types/` | fasada nad `src/shared` (alias `@shared/*`), zadne vlastni typy API |
| `web/src/components/ui/` | zakladni prvky, `Chip` |
| `web/src/components/ui/` | zakladni prvky: `Badge`, `Button`, `Card`, `Chip`, `Modal`, `Section`, `Spinner`, ... |
| `web/src/components/ui/Table.tsx` | `Table`, `TableHead`, `Th`, `TableRow`, `Td`: jedna tabulka seznamu pro `EntityAdmin`, `InvitePanel`, `People`, `AuditView` |
| `web/src/components/ui/ServiceIcon.tsx` | ikona sluzby podle klice z katalogu, misto `const Icon = serviceIcon()` v JSX |
| `web/src/components/ui/form/` | `Field`, `Input`, `Select`, `Textarea`, `controlClass`: jedna sada trid |
| `web/src/components/dashboard/` | shell portalu, dlazdice, graf, stream, `TicketCard` |
| `web/src/components/dashboard/flow/` | strom akci: `FlowCanvas` a karty `ActionCard`, `ConditionCard`, `ForeachCard`, `StepControls` |
| `web/src/components/dashboard/EntityAdmin.tsx`, `EntityForm.tsx` | sprava jedne entity: tabulka a formular v modalu |
| `web/src/components/dashboard/flow/` | strom akci: `FlowCanvas` a karty `ActionCard`, `ConditionCard`, `ForeachCard`, `StepControls`; k tomu `TriggerConfig`, `SampleBody`, `ModelTree`, `WebhookCalls`, `MappingEditor` |
| `web/src/components/dashboard/scripts/` | `TestPanel` (zkusebni spusteni) a `CodeEditor` (uprava kodu) pro stranku Skripty |
| `web/src/components/dashboard/settings/` | `FeaturesAdmin` (zalozky a limity), `AuditView`, `types` |
| `web/src/components/dashboard/widgets/` | `WidgetCard`, `WidgetPicker`, `CustomWidget`, `EditBar` (lista uprav rozlozeni) |
| `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 |
| `web/src/pages/dashboard/connectors/` | casti stranky Konektory: karta, editor, log, nastroje |
### Soubory nad 500 radku
Zasada rika, ze soubor nad 500 radku je signal k rozdeleni. Po rozdeleni
zustavaji ctyri, kazdy z duvodu:
| Soubor | Proc zustava |
| ---------------------------------------------- | ---------------------------------------------------------------------- |
| `web/src/pages/dashboard/AutomationDetail.tsx` | stranka drzi stav stromu a ukladani; casti bez stavu uz jsou ve `flow/` |
| `web/src/pages/dashboard/TicketDetail.tsx` | detail sklada sest komponent, zbytek je stav a odeslani akci |
| `web/src/components/dashboard/flow/MappingEditor.tsx` | dva rezimy editoru nad jednim stavem, deleni by stav zdvojilo |
| `src/data/services/catalog/ticket.ts` | jedna sluzba s nejvic operacemi; deleni jedne sluzby do dvou souboru by rozbilo "jedna vec v jednom souboru" |
Dalsi velke soubory (`builtinSteps.ts`, `mcp/client.ts`, `executor.ts`,
`routes/connectors.ts`) jsou kandidati na priste, az se do nich bude sahat.
## Klicova rozhodnuti
**Jeden container misto dvou.** AppFactory nasazuje jednu aplikaci, proto Express
+2 -2
View File
@@ -103,7 +103,7 @@ fabrika (`src/routes/crud.ts`):
`widgets`, `features`.
`people` ma stejne cesty a stejne pravo (`people.manage`), ale vlastni
handlery v `settings.ts`: zaznam, ktery se meni, je ucet bez firmy
handlery v `src/routes/settings/people.ts`: zaznam, ktery se meni, je ucet bez firmy
a odpoved je pohled za jednu firmu, coz fabrika neumi. Popis je nize
v sekci Lide.
@@ -458,5 +458,5 @@ zkusit to znovu.
## Pri pridani endpointu
Soucasne aktualizovat `src/openapi.ts` a tenhle soubor. Swagger musi odpovidat
Soucasne aktualizovat `src/openapi/paths/*.ts` a tenhle soubor. Swagger musi odpovidat
skutecnemu chovani aplikace, jinak je horsi nez zadny.
+10 -2
View File
@@ -148,7 +148,15 @@ callbacky se predavaji podle ID kroku a `collectScopes` je memoizovane -
u stromu o padesati krocich byl driv kazdy stisk klavesy v poli prekreslenim
vseho.
Odchod z rozepsaneho stromu hlida `lib/useUnsavedChanges.ts`.
Odchod z rozepsaneho stromu hlida `hooks/useUnsavedChanges.ts`.
Stranka `pages/dashboard/AutomationDetail.tsx` uz jen drzi stav stromu
a ukladani. Casti bez vlastniho stavu jsou ve `flow/`: `TriggerConfig.tsx`
(nastaveni spoustece, vstupy z `ui/form`), `SampleBody.tsx` (ukazka tela),
`ModelTree.tsx` (strom modelu), `WebhookCalls.tsx` (posledni volani
webhooku); vzorove telo sklada `lib/exampleBody.ts`. Prevzeti nacteneho
stromu do rozepsaneho stavu dela `hooks/useSyncFromSource.ts` uz pri
vykresleni, ne v effectu, takze stara verze neproblikne.
Duvod: kazde zanoreni pulí dostupnou sirku. S beznym `lg:grid-cols-2` vypadal
strom na sirokem monitoru dobre v prvni urovni a ve treti uz mel karty siroke
@@ -205,7 +213,7 @@ je vypise. Rozdelana prace se nikdy nezahazuje.
## Pridani konektoru
1. Pridat zaznam do katalogu v `src/data/services.ts` vcetne `triggers`
1. Pridat zaznam do katalogu v `src/data/services/catalog/<skupina>.ts` vcetne `triggers`
a `actions`. ID operace musi byt v ramci sluzby unikatni,
`checkOperationIds()` duplicitu pri nacteni zaloguje - druha by tise
prekryla prvni.
+3 -3
View File
@@ -25,7 +25,7 @@ a **co ktera sluzba vratila**. Kdyz neco nesedi, neni potreba hadat.
## Datovy model
`src/data/ticketStore.ts`
`src/data/tickets/model.ts` (fasada `src/data/ticketStore.ts`)
```ts
interface Ticket {
@@ -415,7 +415,7 @@ automatizace na kanal, smerovani je jedna spolecna nad vsemi tickety.
## Vzorove automatizace
V `automationStore.ts` jsou nasazene presne v tomhle rozdeleni:
V `src/data/automations/seed.ts` (ukazkove v `seedDemo.ts`) jsou nasazene presne v tomhle rozdeleni:
| Automatizace | Co ukazuje |
| ----------------------------- | --------------------------------------------------- |
@@ -459,7 +459,7 @@ Katalog je proto deklaruje v `providedFields` u operace. Chovaji se pak takhle:
- builder je ukazuje **jen ke cteni**, pridat ani prejmenovat nejdou,
- server je pri ulozeni stromu **vzdy dosadi z katalogu** a to, co poslal klient,
zahodi (`normalizeTriggerFields` v `src/routes/dashboard.ts`),
zahodi (`normalizeTriggerFields` v `src/routes/dashboard/automations.ts`),
- dosazeni probiha **pred validaci**, jinak by podminky odkazujici na katalogova
ID vypadaly jako rozbite.
+6 -6
View File
@@ -219,14 +219,14 @@ a drzi se vsude, kde se neco zaklada:
| Co | Kdo smi | Kde se to kontroluje |
| -------------------------- | ------------------------------------------------------------ | ---------------------------------------- |
| firma | jen spravce platformy (`platformOnly` u CRUD firem) | `src/routes/settings.ts` |
| firma | jen spravce platformy (`platformOnly` u CRUD firem) | `src/routes/settings/tenants.ts` |
| firma z registru ARES | jen spravce platformy | `src/routes/ares.ts` |
| uzivatel | spravce platformy, nebo `user.manage` jen ve sve firme | `src/routes/settings.ts` |
| resitel (clen firmy, Lide) | `people.manage` jen ve sve firme, zaklada ucet s clenstvim | `src/routes/settings.ts` |
| uzivatel | spravce platformy, nebo `user.manage` jen ve sve firme | `src/routes/settings/users.ts` |
| resitel (clen firmy, Lide) | `people.manage` jen ve sve firme, zaklada ucet s clenstvim | `src/routes/settings/people.ts` |
| pozvanka | `user.manage`, role jen z te firmy | `src/routes/invites.ts` |
| konektor | `connector.manage` | `src/routes/connectors.ts`, `dashboard.ts` |
| automatizace | `automation.edit` za firmu automatizace | `src/routes/dashboard.ts` |
| stav incidentu | `incident.manage` za firmu incidentu, platformni jen spravce platformy | `src/routes/dashboard.ts` |
| konektor | `connector.manage` | `src/routes/connectors.ts`, `dashboard/misc.ts` |
| automatizace | `automation.edit` za firmu automatizace | `src/routes/dashboard/automations.ts` |
| stav incidentu | `incident.manage` za firmu incidentu, platformni jen spravce platformy | `src/routes/dashboard/incidents.ts` |
| akce nad ticketem | pravo akce za firmu ticketu a strop viditelnosti | `src/routes/ticketActions.ts` |
Spravce firmy s `user.manage` ma **jen svou firmu**: nenastavi `platformAdmin`,
+10
View File
@@ -99,6 +99,16 @@ Neulozit je tady spravne. Klient by dostal zpatky neco, co neumi vykreslit.
Widget, ktery mezitim z katalogu zmizel, se v prehledu ukaze jako cervena karta
s jeho ID. Nesmi tise vypadnout z rozlozeni.
## Kde to je na klientovi
Stranka `pages/dashboard/Overview.tsx` sklada `widgets/WidgetCard.tsx`
(vykresleni podle druhu), `widgets/WidgetPicker.tsx` (nabidka z katalogu),
`widgets/CustomWidget.tsx` (widgety, jejichz data pocita server)
a `widgets/EditBar.tsx` (lista rezimu uprav: ulozit, zahodit, obnovit
vychozi). Vstupy v rezimu uprav jsou z `components/ui/form`, stranka si zadny
vlastni nekresli. Server: `src/routes/dashboard/layout.ts`,
`src/data/widgets.ts`, `src/data/dashboardLayouts.ts`.
## Jak pridat widget
1. Zaznam do `widgets` v `src/data/widgets.ts`.
+4 -4
View File
@@ -1121,15 +1121,15 @@ Seznam mist, ktera navrh meni a je potreba je hlidat.
| `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 |
| `Ticket` dostane `typeId` a `fields` | `src/data/tickets/`, `src/openapi/paths/tickets.ts`, `src/shared/tickets.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 |
| `FlowStep` dostane `connectionId` | `src/data/automations/`, `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 |
| Log ticketu potrebuje redakci tajemstvi | `src/data/tickets/trace.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,
@@ -1138,5 +1138,5 @@ s AND na obou stranach (`src/data/conditions.ts` i `web/src/lib/flow.ts`, vzdy
obe), nebo AND drzet jen u akci a nemichat to do stromu. Druha varianta je
levnejsi, prvni upravnejsi.
A pri kazdem novem endpointu soucasne `src/openapi.ts` a tuhle dokumentaci.
A pri kazdem novem endpointu soucasne `src/openapi/paths/*.ts` a tuhle dokumentaci.
Swagger, ktery neodpovida chovani, je horsi nez zadny.
+39 -26
View File
@@ -17,22 +17,32 @@ zeptat na jeho vystupy.
## Kde to je
```
scripts/ soubory skriptu, obycejny JavaScript
_sablona.js sablona ke zkopirovani (podtrzitko = nenacita se)
connectors/ soubory skriptu, obycejny JavaScript
_sablona.js sablona ke zkopirovani (podtrzitko = nenacita se)
idoklad.get-issued-invoice.js
...
src/scripts/types.ts co je skript, zod schema manifestu
src/scripts/values.ts kontrola vstupu a vystupu
src/scripts/util.ts pomocne funkce pro skripty, redakce tajemstvi
src/scripts/connections.ts kam se vola a cim se to autorizuje (podle konektoru)
src/scripts/http.ts HTTP klient predany skriptu
src/scripts/manifest.ts overeni manifestu, prevod na operaci katalogu
src/scripts/registry.ts nacitani ze souboru, hot reload, ukladani
src/scripts/runner.ts spusteni jednoho skriptu
src/routes/scripts.ts API
web/src/pages/dashboard/Scripts.tsx stranka /dashboard/skripty
src/runtime/scripts/types.ts co je skript, zod schema manifestu
src/runtime/scripts/values.ts kontrola vstupu a vystupu
src/runtime/scripts/util.ts pomocne funkce pro skripty (`ctx.util`), redakce tajemstvi
src/runtime/scripts/connections.ts kam se vola a cim se to autorizuje (podle konektoru)
src/runtime/scripts/http.ts HTTP klient predany skriptu
src/runtime/scripts/manifest.ts overeni manifestu, prevod na operaci katalogu
src/runtime/scripts/registry.ts nacitani ze souboru, hot reload, ukladani
src/runtime/scripts/runner.ts spusteni jednoho skriptu
src/runtime/scripts/lookup.ts ktery skript obsluhuje operaci katalogu
src/runtime/scripts/mapping.ts engine transformaci, viz 13-transformace-dat.md
src/routes/scripts.ts API
web/src/pages/dashboard/Scripts.tsx stranka /dashboard/skripty
web/src/components/dashboard/scripts/ TestPanel (zkusebni spusteni), CodeEditor (uprava kodu)
```
Slozka se skriptu se jmenuje `connectors/`, protoze `scripts/` je podle zasad
misto pro pomocne skripty vyvoje, ne pro kod, ktery aplikace nacita za behu.
Cestu urcuje `config.scriptsDir` (promenna `SCRIPTS_DIR`, vychozi
`./connectors`), Dockerfile slozku kopiruje do image. Lint (`eslint.config.js`)
ji kontroluje jako obycejny JS **bez globalu**, takze `Buffer` nebo `process`
ve skriptu neprojde.
## Nic se neotaci
Soubory jsou zamerne **obycejny JavaScript, ne TypeScript**. TypeScript by se
@@ -58,13 +68,13 @@ Nesoulad je chyba, ne varovani - jinak by se skript ulozil pod jednim jmenem
a nacetl pod druhym.
```
scripts/idoklad.get-issued-invoice.js
connectors/idoklad.get-issued-invoice.js
\_____/ \________________/
sluzba operace
```
Z ID se dopocita, do ktere sluzby operace patri, takze se to nepise dvakrat.
Sluzba **musi existovat** v `src/data/services.ts`, jinak se skript ohlasi
Sluzba **musi existovat** v katalogu (`src/data/services/catalog/`), jinak se skript ohlasi
jako problem.
## Manifest
@@ -120,8 +130,8 @@ druhem (`output`).
## Co skript ma a co nema
Skript ma jen `ctx`. Zadny import, zadny pristup na sit mimo `ctx.http`
a **zadne pristupove udaje**.
Skript ma jen `ctx`. Zadny import, zadny global Node (`Buffer`, `process`),
zadny pristup na sit mimo `ctx.http` a **zadne pristupove udaje**.
```js
export async function run(inputs, ctx) { /* ... */ }
@@ -179,12 +189,15 @@ z nich by to resil spatne.
| `list(body, ...names)` | seznam z odpovedi: pole primo, nebo pod danym klicem, `data`, `content`, `items`, `results`. Jinak `null` |
| `addresses(value)` | adresy z pole "Prijemci" oddelene carkou nebo strednikem, bez prazdnych |
| `quote(value)` | hodnota v jednoduchych uvozovkach pro filtr OData nebo SQL, apostrof zdvojeny |
| `base64(value)` | text do Base64. Skript nema `Buffer` ani `btoa`, kodovani mu da runtime |
Ctyri posledni pribyly v zari 2026, kdyz se ukazalo, ze osm skriptu ma kazdy
svou verzi. Sablona `scripts/_sablona.js` je vsechny vypisuje, aby se nehledaly
v kodu serveru.
Ctyri z nich (`day`, `list`, `addresses`, `quote`) pribyly v zari 2026, kdyz
se ukazalo, ze osm skriptu ma kazdy svou verzi. `base64` pribyl pri prevodu
na zasady: `google.send-email` skladal zpravu pres `Buffer`, tedy pres global
Node, ktery skript mit nema. Sablona `connectors/_sablona.js` vsechny funkce
vypisuje, aby se nehledaly v kodu serveru.
`ctx.config` je slozene z `scriptConfig` v `src/scripts/connections.ts`:
`ctx.config` je slozene z `scriptConfig` v `src/runtime/scripts/connections.ts`:
z nastaveni napojeni vynecha kazde pole, jehoz hodnota je mezi tajnymi. Skript
tedy heslo SMTP ani tajemstvi OAuth nedostane ani omylem, i kdyz je runtime
(SMTP, MCP) potrebuje - ty si je berou z `serviceConfig`, ke kteremu skript
@@ -222,13 +235,13 @@ nastaveni:
| --------------------------- | ------------------------------------------------------ |
| `SERVICES_BASE_URL` | zaklad adres, vychozi `https://services.csbot.cz/apps` |
| `<SLUZBA>_BASE_URL` | presmerovani jedne sluzby, napr. `OPENAI_BASE_URL` |
| `SCRIPTS_DIR` | jina slozka se skripty |
| `SCRIPTS_DIR` | jina slozka se skripty, vychozi `./connectors` |
| `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`.
Co ktera sluzba vyzaduje, je v `credentials` u sluzby v `src/data/services/catalog/`.
Hodnoty patri konektoru a zadavaji se v portalu.
## Redakce tajemstvi
@@ -244,7 +257,7 @@ Redaktor (`createRedactor`) maskuje tajemstvi ve **ctyrech tvarech**:
prijaty token v chybe i uvnitr adresy nebo v zaescapovanem JSONu, a tam by
hola hodnota nesedela.
Zkracovani ma jednu konstantu, `DETAIL_BYTES` v `src/scripts/util.ts`
Zkracovani ma jednu konstantu, `DETAIL_BYTES` v `src/runtime/scripts/util.ts`
(`SCRIPT_ERROR_DETAIL_BYTES`, vychozi 8 kB). Detail chyby MCP mel driv vlastnich
600 znaku a prave u nej byla cela odpoved potreba nejvic.
@@ -290,7 +303,7 @@ Skript se domeri do katalogu sluzeb jako akce s `implementation: 'script'`
a `scriptId`. Kdyz nese ID operace, ktera uz v katalogu je, **skript vyhrava** -
staticky zapis je popis toho, co umime, skript je to, co se opravdu stane.
Prekryv drzi `src/data/services.ts` (`setScriptActions`, `actionsFor`).
Prekryv drzi `src/data/services/index.ts` (`setScriptActions`, `actionsFor`).
Je to zamerne tam, protoze vsechno ostatni se uz pta pres `findOperation`.
Tim se skripty naraz objevi ve validaci stromu, ve vypoctu toho, co je v kterem
kroku videt, i v sablonach - bez toho, aby se to psalo trikrat.
@@ -359,11 +372,11 @@ cele je.
## Jak pridat skript
1. Zkopirovat `scripts/_sablona.js` na `<sluzba>.<operace>.js`.
1. Zkopirovat `connectors/_sablona.js` na `<sluzba>.<operace>.js`.
2. Srovnat `manifest.id` s nazvem souboru.
3. Vyplnit `inputs` a `outputs`.
4. Napsat `run`.
5. Kdyz sluzba jeste neni v `src/data/services.ts`, pridat ji.
5. Kdyz sluzba jeste neni v katalogu (`src/data/services/catalog/<skupina>.ts`), pridat ji.
6. Kdyz potrebuje pristupove udaje, popsat je v `credentials` u te sluzby.
Hodnoty pak zada firma v konektoru.
+3 -3
View File
@@ -327,11 +327,11 @@ Cte se zvlast pres `/connectors/:id/checks`.
## Jak pridat sluzbu
1. Zaznam do `services` v `src/data/services.ts`: kategorie, ikona, `general`,
1. Zaznam do skupiny v `src/data/services/catalog/<skupina>.ts` (fasada `src/data/services.ts`): kategorie, ikona, `general`,
`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
3. Skripty operaci do `connectors/<sluzba>.<operace>.js`, viz
[11-skripty-konektoru.md](11-skripty-konektoru.md).
Katalog, builder, stranka Sluzby i zakladani konektoru si ji vezmou samy.
@@ -342,7 +342,7 @@ Kdo se v kodu orientoval podle stareho pojmenovani:
| Driv | Ted |
| --------------------------------- | ----------------------------- |
| `src/data/connectors.ts` | `src/data/services.ts` |
| `src/data/connectors.ts` | `src/data/services/` (fasada `services.ts`) |
| `Connector`, `ConnectorOperation` | `Service`, `ServiceOperation` |
| `connectorCategories` | `serviceCategories` |
| `findConnector` | `findService` |
+5 -5
View File
@@ -72,7 +72,7 @@ nemuze se v nem udelat preklep v zavorce.
## Dva rezimy
Obe moznosti stoji na tom samem enginu v `src/scripts/mapping.ts`.
Obe moznosti stoji na tom samem enginu v `src/runtime/scripts/mapping.ts`.
Volba je o tom, cehoz je vic:
| Rezim | Kdy | Skript |
@@ -271,10 +271,10 @@ mluvi o jejich schematu, ne o nasich datech.
## Kde to je
```
src/scripts/mapping.ts engine: cesty, prevody, pravidla, sablona
scripts/transform.map-fields.js rezim 1
scripts/transform.to-json.js rezim 2
scripts/idoklad.create-invoice-from-object.js druha polovina prikladu
src/runtime/scripts/mapping.ts engine: cesty, prevody, pravidla, sablona
connectors/transform.map-fields.js rezim 1
connectors/transform.to-json.js rezim 2
connectors/idoklad.create-invoice-from-object.js druha polovina prikladu
web/src/components/dashboard/flow/MappingEditor.tsx klikaci editor pravidel
web/src/components/dashboard/flow/StepInputs.tsx pole typu mapping, json a object
```
+3 -1
View File
@@ -114,7 +114,9 @@ v `src/data/refresh.ts`). `listByTenant(tenantIds, sortBy)` je jeden filtr
a razeni misto sedmi kopii v modulech.
**`withMirror`** pro provozni data: meni se v pameti, po zmene se zapise cely
zaznam. Zapisy tehoz ID jsou **serazene za sebou** retezem promise. Bez toho
zaznam. U ticketu a automatizaci je zapis a nacteni pri startu v modulu
`persist.ts` jejich slozky (`src/data/tickets/`, `src/data/automations/`);
`ticketStore.ts` a `automationStore.ts` jsou uz jen fasady. Zapisy tehoz ID jsou **serazene za sebou** retezem promise. Bez toho
mohl Postgres potvrdit dva `put` tehoz ticketu v opacnem poradi, nez prisly,
a v tabulce zustala starsi verze - v pameti to nebylo videt, po restartu ano.
Tickety navic slucuji vic zmen v jednom tiku do jednoho zapisu
+66 -26
View File
@@ -62,13 +62,48 @@ Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět.
| `personName(id)` | `src/data/people.ts` | Jméno účtu bez ohledu na firmu, pro popisky u záznamů, které už prošly filtrem na firmu. |
| `personIdFor(user, tenantId)` | `src/data/people.ts` | ID řešitele, kterým je uživatel ve firmě: ID účtu při členství, jinak `null`. Neptat se `user.id` přímo. |
| `findPersonByExternalId(value, tenantIds)` | `src/data/people.ts` | Řešitel podle ID z cizí aplikace, například voicebotId. Externí ID visí na členství. |
| `migratePeople()` | `src/data/migratePeople.ts` | Jednorázový převod starých záznamů řešitelů (`ppl_`) na účty při startu. Přepisuje odkazy přes `remapPersonIds` v `ticketStore.ts` a `automationStore.ts`. |
| `migratePeople()` | `src/data/migratePeople.ts` | Jednorázový převod starých záznamů řešitelů (`ppl_`) na účty při startu. Přepisuje odkazy přes `remapPersonIds` v `tickets/remap.ts` a `automations/remap.ts`. |
| `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. Hodnoty z `input.apply` zapíše v obou případech, prázdné nemaže. |
| `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. |
| `intakeEvent(input)` | `src/data/tickets/intake.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. Hodnoty z `input.apply` zapíše v obou případech, prázdné nemaže. |
| `getAgentStats(...)` | `src/data/tickets/stats.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/tickets/queries.ts` | Ticket firmy podle externího ID. Klíč je dvojice firma a ID. |
| `createApp()` | `src/app.ts` | Sestavi Express aplikaci: middleware, routery, health, Swagger, statika, SPA. Bez `listen` a bez nacteni dat, takze jde postavit v testu (supertest). `index.ts` ji jen spusti. |
| `config.serviceBaseUrlOverride(variable)` | `src/config.ts` | Jedine cteni `process.env` s dynamickym nazvem (`<SLUZBA>_BASE_URL`). Vraci normalizovanou adresu nebo `null`. Nikde jinde se `process.env` necte. |
| `buildOpenApiDocument()` | `src/openapi/index.ts` | Sklada OpenAPI z `components.ts` a `paths/*.ts`. Novy endpoint se popisuje v souboru sveho routeru, `crudPaths` v `helpers.ts` popise CRUD petici jednim radkem. |
| `pageFrom(query)`, `paginate(items, page)` | `src/routes/dashboard/shared.ts` | Strankovani seznamu ticketu a fronty behu: `limit`, `offset`, strop `MAX_PAGE_LIMIT`. Nepsat vlastni `slice` v route. |
| `memberOf(user, tenantId)` | `src/routes/settings/shared.ts` | Je ucet clenem firmy? Sdili sprava uctu a resitelu. |
### Moduly dat po rozdeleni
Puvodni soubory zustavaji jako fasady (`ticketStore.ts`, `automationStore.ts`,
`services.ts`), importy se nemeni. Kdo hleda, kde co je:
| Modul | Co drzi |
| ---------------------------------- | ------------------------------------------------------------------------------------------- |
| `tickets/index.ts` | verejne API slozky, seed a `initTickets` v poradi, ve kterem se maji volat |
| `tickets/model.ts` | `StoredTicket`, `defaultStatuses`, `channelLabels`, `toTicket` (doplneni vychozich hodnot) |
| `tickets/state.ts` | pole ticketu, indexy podle ID a externiho ID, log, udalosti, citace ID |
| `tickets/persist.ts` | `persist`, `touch` (`updatedAt` a zapis v jednom), `initTickets` |
| `tickets/queries.ts` | `listTickets` s `TicketFilter`, `getTicket`, `findTicket`, `findByExternalId`, `ticketWithinVisibility` |
| `tickets/store.ts` | zapisy: `createTicket`, `updateTicketStatus`, `assignTicket`, `setTicketType`, `setTicketTags`, `assignTicketGroup`, `claimTicket`, `addComment` |
| `tickets/intake.ts` | `intakeEvent`: udalost zvenku se stane ticketem nebo se navesi |
| `tickets/trace.ts` | `appendTrace`, `flattenTrace`, `lastTraceId`, `describePayload`: log prubehu |
| `tickets/stats.ts` | `getWorkload`, `getAgentStats` |
| `tickets/seed.ts`, `remap.ts` | ukazkova data (`SEED_DEMO=1`), preznaceni resitelu pri migraci |
| `automations/index.ts` | verejne API slozky, seed a `initAutomations` |
| `automations/model.ts` | `StoredAutomation`, `rulesOf`, `matchOf` (cteni podminky ve stare i nove podobe) |
| `automations/state.ts` | `Map` automatizaci, `nextId`, `findWritable` |
| `automations/persist.ts` | `save`, `initAutomations`, `mirror` |
| `automations/store.ts` | `listAutomations`, `getAutomation`, `createAutomation`, `updateAutomation`, `regenerateWebhookToken`, `findByWebhookToken`, `recordRun`, `deleteAutomation` |
| `automations/validation.ts` | `countSteps`, `collectFlowIssues`, `deriveKind`, `withDerived`: ciste funkce nad stromem |
| `automations/webhook.ts` | `generateWebhookToken`, `withWebhookToken`, `recordWebhookCall`, `recentWebhookCalls`, `bodyForCall` |
| `automations/runs.ts` | historie behu po dnech, `KEEP_DAYS`, `statsOf` |
| `automations/seed.ts`, `seedDemo.ts`, `remap.ts` | skutecne automatizace (vzdy), ukazkove (`SEED_DEMO=1`), preznaceni resitelu |
| `services/index.ts` | katalog za behu: `findService`, `findOperation`, `actionsFor`, `serviceCatalog`, `setScriptActions`, `setMcpOperations`, `withRuntimeOptions`, `visibleServices` |
| `services/catalog/index.ts` | `services` a `serviceCategories` slozene ze skupin; poradi tady je poradi v nabidce |
| `services/catalog/<skupina>.ts` | staticky zapis sluzeb jedne skupiny: `triggers`, `incident`, `ticket`, `crm`, `finance`, `logistics`, `email`, `messaging`, `social`, `office`, `analytics`, `ai`, `mcp`, `tools`, `polstryn` |
| `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()`, `refreshEntity(kind)` | `src/data/bootstrap.ts` | Obnoví všechny kopie v paměti, nebo jen jednu entitu. Route nastavení volá `bootstrapDataRefresh(route)` v `src/data/refresh.ts`, která vybere tu jednu. |
| `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. |
@@ -79,25 +114,25 @@ 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`, `day`, `list`, `addresses`, `quote`, `need`, `get`, `applyRules`, `fillJson`. Skript nemá sahat na nic jiného. |
| `pick`, `pickText`, `jwtExpiry`, `parseBool`, `parseNumber` | `src/scripts/util.ts` | Totéž pro server: pole bez ohledu na velikost písmen, `exp` z JWT, převody. Než napíšeš `Number(x)` s kontrolou `NaN`, je to tady. |
| `DETAIL_BYTES`, `truncate(value)` | `src/scripts/util.ts` | Jeden limit na zkracování detailu chyby pro všechny vrstvy. Žádné vlastní `slice(0, 600)`. |
| `createRedactor(...)` | `src/scripts/util.ts` | Vyškrtá tajemství z textu **před** logováním, i v URL-encoded a JSON-escaped tvaru. Používá se u všeho, co jde do logu. |
| `scriptConfig(target)` | `src/scripts/connections.ts` | Nastavení napojení bez tajných polí. Jediné, co skript dostane jako `ctx.config`. |
| `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. |
| `runScript(id, inputs, ctx)` | `src/runtime/scripts/runner.ts` | Spustí skript. **Nikdy nevyhodí výjimku**, chybu vrací jako výsledek s celým hlášením. |
| `validateValues(...)` | `src/runtime/scripts/values.ts` | Jedna kontrola pro vstupy i výstupy skriptu podle manifestu. |
| `scriptUtil` | `src/runtime/scripts/util.ts` | Nádobíčko pro skripty: `pick`, `first`, `num`, `date`, `day`, `list`, `addresses`, `quote`, `need`, `get`, `applyRules`, `fillJson`. Skript nemá sahat na nic jiného. |
| `pick`, `pickText`, `jwtExpiry`, `parseBool`, `parseNumber` | `src/runtime/scripts/util.ts` | Totéž pro server: pole bez ohledu na velikost písmen, `exp` z JWT, převody. Než napíšeš `Number(x)` s kontrolou `NaN`, je to tady. |
| `DETAIL_BYTES`, `truncate(value)` | `src/runtime/scripts/util.ts` | Jeden limit na zkracování detailu chyby pro všechny vrstvy. Žádné vlastní `slice(0, 600)`. |
| `createRedactor(...)` | `src/runtime/scripts/util.ts` | Vyškrtá tajemství z textu **před** logováním, i v URL-encoded a JSON-escaped tvaru. Používá se u všeho, co jde do logu. |
| `scriptConfig(target)` | `src/runtime/scripts/connections.ts` | Nastavení napojení bez tajných polí. Jediné, co skript dostane jako `ctx.config`. |
| `applyRules`, `fillJson` | `src/runtime/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/runtime/scripts/mapping.ts` | Čtení `zakaznik.adresa.mesto` z neznámého objektu. |
| `resolveTarget(...)` | `src/runtime/scripts/connections.ts` | Z konektoru poskládá adresu a hlavičky. Přístupové údaje nikam jinam nevedou. |
| `serviceBaseUrl(service)` | `src/runtime/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/runtime/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/runtime/scripts/http.ts` | HTTP se timeoutem, limitem odpovědi a rozlišením "zkusit znovu" a "marné". |
| `isPrivateHost(host)` | `src/runtime/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. |
| `ctx.http.postForm(...)` | `src/runtime/scripts/http.ts` | Odeslání souboru (`multipart/form-data`). Obsah přichází jako Base64, hranici dopisuje runtime. |
| `scriptIdFor(serviceId, operationId)` | `src/runtime/scripts/lookup.ts` | Který skript obsluhuje operaci z katalogu. |
## Klient
@@ -115,28 +150,33 @@ Viz [11-skripty-konektoru.md](11-skripty-konektoru.md).
| `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`, `refreshing` a `total`. S `body` pošle POST, s `enabled: false` se neptá, `patchOn` opraví data z události bez dotazu. |
| `useApiQuery<T>` | `hooks/useApiQuery.ts` | Načtení dat do stránky včetně `reload`, `refreshing` a `total`. S `body` pošle POST, s `enabled: false` se neptá, `patchOn` opraví data z události bez dotazu. |
| `useCollection(key)`, `useAccess()`, `useCollectionSelector` | `lib/collections.tsx` | Číselníky za firmu (lidé, skupiny, typy, služby, konektory, přístup) ze sdíleného skladu, opravované z událostí. Ne `apiFetch` na číselník ze stránky. |
| `patchTicketList(...)` | `lib/ticketEvents.ts` | Oprava seznamu ticketů z `payload.ticket` v události. Použít jako `patchOn`. |
| `apiFetchWithMeta<T>` | `lib/api.ts` | Jako `apiFetch`, ale vrací i `X-Total-Count`. Pro stránkované seznamy. |
| `useSubmit(fn)` | `lib/useSubmit.ts` | Odeslání formuláře: `saving`, chyba, reset. Dvanáct řádků, které si dřív psal každý formulář zvlášť. |
| `useUnsavedChanges(dirty)` | `lib/useUnsavedChanges.ts` | Varování při odchodu z rozepsaného formuláře nebo stromu. |
| `useSubmit(fn)` | `hooks/useSubmit.ts` | Odeslání formuláře: `saving`, chyba, reset. Dvanáct řádků, které si dřív psal každý formulář zvlášť. |
| `useUnsavedChanges(dirty)` | `hooks/useUnsavedChanges.ts` | Varování při odchodu z rozepsaného formuláře nebo stromu. |
| `priorities`, `priorityLabel` | `lib/options.ts` | Pevné číselníky. Stavy a kanály se berou ze serveru (`/widget-data/options`). |
| `plural(count, forms)` | `lib/format.ts` | Skloňování počtu (1 ticket, 2 tickety, 5 ticketů). |
| `Field`, `Input`, `Select`, `Textarea` | `components/ui/form/` | Formulářové prvky s jednou sadou tříd (`controlClass`). Vlastní `inputClass` ve stránce je chyba. |
| `Chip` | `components/ui/Chip.tsx` | Štítek. |
| `Table`, `TableHead`, `Th`, `TableRow`, `Td` | `components/ui/Table.tsx` | Tabulka seznamu v portalu (hlavicka verzalkami, radky s linkou). Ctyri stranky ji kreslily kazda jinak. Huste tabulky ticketu a vykonu zustavaji zvlast, jsou to jine tabulky. |
| `ServiceIcon` | `components/ui/ServiceIcon.tsx` | Ikona sluzby podle klice z katalogu. Misto `const Icon = serviceIcon(key)` v JSX, ktere lint hlasi jako komponentu vytvorenou pri vykresleni. |
| `EntityForm` | `components/dashboard/EntityForm.tsx` | Formular jedne entity v modalu, pouziva ho `EntityAdmin`. Pole z popisu sloupcu, hodnoty a chyby v propsech. |
| `useLatest(value)` | `hooks/useLatest.ts` | Ref s posledni hodnotou pro callbacky, ktere nemaji byt v zavislostech effectu. Zapis v layout effectu, aby vykresleni zustalo ciste. |
| `useSyncFromSource(source, apply)` | `hooks/useSyncFromSource.ts` | Prevzeti dat ze zdroje do rozepsaneho stavu uz pri vykresleni, ne v `useEffect`. Stara kopie neproblikne a stranka se nekresli dvakrat. |
| `TicketCard` | `components/dashboard/TicketCard.tsx` | Karta ticketu pro dlaždice a mobil, varianta `compact` pro widgety. |
| `useMediaQuery(query)` | `lib/useMediaQuery.ts` | Tabulka nebo karty podle šířky. `TicketTable` podle toho kreslí obojí, druhá komponenta není. |
| `useMediaQuery(query)`, `MD_UP` | `hooks/useMediaQuery.ts` | Tabulka nebo karty podle šířky pres `useSyncExternalStore`, prohlizec je zdroj pravdy. `TicketTable` podle toho kreslí obojí, druhá komponenta není. |
| `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. |
| `usePageMeta` | `hooks/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
1. **Nová entita v nastavení**: `defineStore` v modulu entity, řádek v
`bootstrap.ts`, `crudRouter` v `settings.ts`, popis v `Settings.tsx`.
`bootstrap.ts`, `crudRouter` v `src/routes/settings/<entita>.ts` a mount v `settings/index.ts`, popis v `Settings.tsx`.
Nic jiného se psát nemusí.
2. **Data, která se mění za provozu**, jdou přes `withMirror`. Data, která se
čtou při každém requestu a mění zřídka, přes `withCache`. Obojí nikdy.
+1 -1
View File
@@ -43,7 +43,7 @@ i vyúčtovaná částka.
Co k tomu je potřeba:
1. **Cena u operace.** Do katalogu (`src/data/services.ts`) přidat
1. **Cena u operace.** Do katalogu (`src/data/services/catalog/`) přidat
`priceCzk` k `ServiceOperation`. Chybějící cena znamená 0, ne chybu -
nová operace nesmí rozbít odhad.
2. **Očekávaný počet běhů.** Jedno číslo u automatizace, které zadá uživatel
+1 -1
View File
@@ -21,7 +21,7 @@ Proto tři vrstvy, každá napsaná jednou:
| 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
v `bootstrap.ts`, jeden `crudRouter` v `src/routes/settings/<entita>.ts` a mount v `settings/index.ts`, jeden popis v
`Settings.tsx`. Nic víc.
`crudRouter` navic s volbou `event` publikuje `<druh>.created`, `.updated`
+1 -1
View File
@@ -103,7 +103,7 @@ Medián, ne průměr: jeden ticket zapomenutý přes dovolenou by průměr úpln
rozhodil. Fronta se počítá vždycky celá, bez ohledu na období - leží tam bez
ohledu na to, na co se zrovna díváme.
Čísla počítá `getAgentStats` v `src/data/ticketStore.ts` a používá je widget
Čísla počítá `getAgentStats` v `src/data/tickets/stats.ts` a používá je widget
i detail osoby. Kdyby si je stránka počítala sama, na dvou místech by vyšlo
něco jiného.
+1 -1
View File
@@ -110,7 +110,7 @@ v návrhu, ne v provozu:
| 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,
Timeout a rozlišení "zkusit znovu" a "marné" už v `src/runtime/scripts/http.ts` je,
klíč proti dvojímu provedení taky. Chybí to, co je nad tím: fronta, opakování
a vypínání služby po sérii chyb.
+20
View File
@@ -113,6 +113,13 @@ behu poznat, co spustil clovek a co cas.
Pak beh skonci jako `failed` a zustane k nahlednuti. Nemaze se: bez zaznamu
by nikdo nezjistil, ze se neco nestalo.
Prodlevy jsou pole `BACKOFF_MS` v `src/runtime/queue.ts` a index do nej je
`attempts - 1`. Po neuspechu je `attempts` aspon 1, takze index sedi; kdyby
ale prisla nula, `BACKOFF_MS[-1]` je `undefined` a `new Date(NaN)` by beh
naplanoval na nikdy. `noUncheckedIndexedAccess` to odhalil, cteni ma proto
zalohu `MAX_BACKOFF_MS`. Podobna chyba byla v `rateLimit`: pri `max: 0` bylo
`Retry-After` `NaN`, ted je aspon 1 sekunda.
**Opakuje se jen to, co samo rekne `retryable`.** Vychozi je "ne". Pravidlo
je stejne ve vsech vrstvach a je napsane v komentari nad `StepResult`
v `executor.ts`:
@@ -302,6 +309,19 @@ k 7. 9. 2026.
Stara podoba (`fieldId` primo na kroku) se dal cte, prevadi ji `rulesOf`.
Prvni testy executoru (`tests/runtime/executor.test.ts`, zari 2026) nasly
v podminkach dve skutecne chyby, obe jsou opravene:
- **Prazdna hodnota se porovnavala jako nula.** `ordered()` delal
`Number('')`, a to je `0`, takze `castka <= 1000` platilo i pro castku,
ktera nikdy nedorazila. Ted prazdna strana znamena "neda se porovnat"
a `gt`, `gte`, `lt`, `lte` jsou nepravda. Pro "nedorazilo" plati dal
pravidlo vyse: ptat se `isNotEmpty`.
- **Hole jmeno vystupu vyhravalo nad `krok.jmeno`.** `conditionValue()`
hledal nejdriv hole jmeno, a to drzi vystup **prvniho** kroku, ktery ho
zapsal. Podminka nad druhym krokem se stejnym nazvem vystupu tak cetla
hodnotu z prvniho. Poradi je ted `fieldId`, `krok.jmeno`, hole jmeno.
Radek podminky v logu proto nese i to, s cim se porovnavalo, a rozlisuje
`nedorazilo` od `prázdné`:
+1 -1
View File
@@ -12,7 +12,7 @@ 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
Katalog v `src/data/services/catalog/` z toho vychazi. **Neni to totez**: jedna
aplikace muze nest vic sluzeb katalogu a nektere sluzby katalogu zatim zadnou
aplikaci nemaji.
+1 -1
View File
@@ -418,7 +418,7 @@ a krok to rekne misto toho, aby predstiral selhani.
| Tokeny EasyWebu | `src/mcp/easyweb/session.ts` |
| Prevod schemat | `src/mcp/schema.ts` |
| Nastroje v katalogu | `src/data/mcpTools.ts` |
| Obe sluzby | `src/data/services.ts` |
| Obe sluzby | `src/data/services/catalog/mcp.ts` |
| Nacteni nastroju | `src/routes/connectors.ts` |
| Vykonna cast kroku | `src/runtime/builtinSteps.ts`, `runMcpTool` |
| Ulozeni u konektoru | `src/data/connectors/*`, migrace `004` |
+9 -2
View File
@@ -180,7 +180,7 @@ coz je cil, to sedi.
| Navrh | Kde to je |
| ------------------------------- | ---------------------------------------------------------------- |
| `Field`, `Input`, `Select`, `Textarea` | `components/ui/form/`, tridy v `controlClass.ts` |
| `useSubmit` | `lib/useSubmit.ts` |
| `useSubmit` | `hooks/useSubmit.ts` |
| ciselniky ven | `lib/options.ts` (priority), stavy a kanaly z `/widget-data/options` |
| kompaktni karta ticketu | `components/dashboard/TicketCard.tsx`, varianta `compact` |
| stitek | `components/ui/Chip.tsx` |
@@ -264,6 +264,13 @@ jsou velke a s widgety nesouvisi. Prevest to, ceho se dotykame (ticket, helpdesk
plus verejne stranky, kde je drift videt nejvic, a zbytek nechat doputovat, jak
se k nemu bude sahat.
Stav v zari 2026: doputovalo to. Vstupy jsou z `components/ui/form` vsude
vcetne `Overview` a `TriggerConfig`, a tabulky seznamu (`EntityAdmin`,
`InvitePanel`, `People`, `AuditView`) kresli jedna `components/ui/Table.tsx`.
Sprava entit je rozdelena na `EntityAdmin` (tabulka, mazani) a `EntityForm`
(formular v modalu); nastaveni ma `settings/FeaturesAdmin` a `settings/AuditView`
jako vlastni komponenty, `Settings.tsx` je jen sklada.
---
## 3 - Hledani jako modal s kriterii
@@ -409,7 +416,7 @@ members: Array<{ personId: string; seesAll: boolean }>
```
Doporuceni je **b**. `personIds` se cte na sesti mistech (`people.ts`,
`dashboard.ts`, `settings.ts`, `builtinSteps.ts`, `People.tsx`, seed), takze je to
`routes/dashboard/`, `routes/settings/`, `builtinSteps.ts`, `People.tsx`, seed), takze je to
hodina prace a ne migrace, ktere by se clovek bal. U varianty a) vznikne za mesic
skupina, kde nekdo "vidi vse" a pritom v ni neni.
+113
View File
@@ -2,6 +2,119 @@
Nejnovejsi nahore.
## 2026-09-09 - Struktura podle zasad: rozdeleni souboru, lint, testy
`D:\GitHubRepository\CLAUDE.md` dostal zasady pro vsechny projekty (struktura
Node a React, jedno cteni `process.env`, lint a format v repu, testy
v `tests/`, soubor nad 500 radku je signal k rozdeleni). Projekt se od nich
lisil na nekolika mistech naraz: `index.ts` skladal aplikaci i poslouchal,
`routes/dashboard.ts` mel pres tisic radku a `openapi.ts` skoro tri tisice,
katalog sluzeb byl jeden soubor, lint ani testy neexistovaly a `process.env`
se cetl na sesti mistech. Tahle zmena to srovnava; nic z toho nemeni chovani
aplikace, az na dve skryte chyby nize a dve chyby, ktere nasly prvni testy.
### Co se presunulo
| Driv | Ted | Proc |
| --------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ |
| `scripts/*.js` | `connectors/*.js` | `scripts/` je podle zasad pro pomocne skripty vyvoje, ne pro kod nacitany za behu |
| `src/scripts/` | `src/runtime/scripts/` | je to runtime, ktery skripty spousti, patri k fronte a executoru |
| `src/index.ts` | `src/index.ts` (jen start) a `src/app.ts` (`createApp()`) | aplikace jde postavit v testu bez portu (supertest) |
| `src/routes/dashboard.ts` | `src/routes/dashboard/` (10 souboru po domenach a `shared.ts`) | jeden router = jedna domena, mount v `index.ts` |
| `src/routes/settings.ts` | `src/routes/settings/` (jedna entita = jeden soubor) | totez |
| `src/openapi.ts` | `src/openapi/{index,helpers,components}.ts` a `paths/*.ts` | popis endpointu lezi u sveho routeru; slozeny dokument ma stejnych 98 cest |
| `src/data/ticketStore.ts` | `src/data/tickets/` (model, state, persist, queries, store, intake, trace, stats, seed, remap) | fasada zustava, importy se nemeni |
| `src/data/automationStore.ts` | `src/data/automations/` (model, state, persist, store, validation, webhook, runs, seed, seedDemo, remap) | totez |
| `src/data/services.ts` | `src/data/services/index.ts` a `catalog/<skupina>.ts` | jeden soubor na skupinu sluzeb, `catalog/index.ts` drzi poradi |
| `web/src/lib/use*.ts` | `web/src/hooks/` | hooky maji vlastni slozku, `lib/` jsou ciste funkce |
`config.scriptsDir` ma vychozi `./connectors`, Dockerfile slozku kopiruje,
promenna `SCRIPTS_DIR` se nemeni. `process.env` se ted cte **jen**
v `src/config.ts`; pro `<SLUZBA>_BASE_URL` pribylo
`config.serviceBaseUrlOverride(variable)`, protoze nazev promenne sklada
katalog a vypisovat kazdou sluzbu do konfigurace by znamenalo dve mista.
Skripty konektoru dostaly `ctx.util.base64`, `google.send-email` uz nesaha na
`Buffer`.
### Web
Sdilene prvky: `components/ui/Table.tsx` (ctyri seznamy kreslily tabulku
kazdy jinak: `EntityAdmin`, `InvitePanel`, `People`, `AuditView`),
`components/ui/ServiceIcon.tsx`, vstupy v `Overview` a `TriggerConfig` z
`ui/form` misto vlastnich `<input>`. Rozdelene soubory:
`components/dashboard/scripts/{TestPanel,CodeEditor}`,
`dashboard/settings/{FeaturesAdmin,AuditView,types}`, `flow/{SampleBody,ModelTree,WebhookCalls}`,
`lib/exampleBody.ts`, `components/dashboard/EntityForm.tsx`, `widgets/EditBar.tsx`.
Nad 500 radku zustavaji `AutomationDetail`, `TicketDetail`, `MappingEditor`
a na serveru `data/services/catalog/ticket.ts`; duvody jsou
v [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md).
Magicka cisla na obou stranach dostala pojmenovane konstanty s komentarem
(`JSON_BODY_LIMIT`, `STATIC_MAX_AGE_SEC`, `MAX_PAGE_LIMIT`, `KEEP_DAYS`,
`WEBHOOK_TOKEN_BYTES`, `MD_UP`, ...), hodnoty se nezmenily.
### Nastroje
`eslint.config.js` (typescript-eslint, `react-hooks` v7 pro web, `connectors/`
jako obycejny JS bez globalu, zadne `any`, zadny prazdny `catch`),
`.prettierrc`, `.prettierignore`, `.editorconfig`, `.nvmrc` (20),
`.env.example` se vsemi promennymi a komentarem, `vitest.config.ts`. Skripty
`npm run lint`, `format`, `format:check`, `test`, `test:watch` vedle
`typecheck`.
**Lint je cisty na celem repu bez jedineho `eslint-disable`.** Pravidla hooku
Reactu v7 hlasi zapis do refu pri vykresleni, `setState` v effectu
a komponentu vytvorenou pri vykresleni. Reseni je vzdy zmena navrhu, ne
vyjimka: `hooks/useLatest.ts` (ref s posledni hodnotou, zapis v layout
effectu), `hooks/useSyncFromSource.ts` (prevzeti dat ze zdroje pri vykresleni
misto `useEffect`), odvozeny stav misto kopie ve stavu, dialogy jako vlastni
komponenty, ktere se pri zavreni odmontuji (stav se resetuje sam),
`useMediaQuery` pres `useSyncExternalStore`, `ServiceIcon` pres
`createElement`.
### Prisnejsi TypeScript
Oba `tsconfig` maji `noUncheckedIndexedAccess`. Opraveno 55 mist na serveru
a 29 na webu, vzdy osetrenim `undefined`, nikde `!`. Dve z nich byly skutecne
chyby:
- `BACKOFF_MS[attempts - 1]` v `src/runtime/queue.ts` je pri `attempts = 0`
`undefined` a `new Date(NaN)` by beh naplanoval na nikdy; cteni ma zalohu
`MAX_BACKOFF_MS`.
- `rateLimit` s `max: 0` posilal `Retry-After: NaN`; ted aspon 1 sekunda.
### Testy
`tests/` zrcadli `src/`: 8 souboru, 105 testu, vsechny prochazi.
`tests/setup.ts` nastavi rezim pameti (`DATA_DIR=''`, `DATABASE_URL=''`,
pevny `SECRETS_KEY` a `JWT_SECRET`, `SEED_DEMO=0`) a umlci `console.info`
a `console.warn`; test migrace si bere docasny `DATA_DIR`.
| Soubor | Co hlida |
| ----------------------------------- | ------------------------------------------------------------------------ |
| `data/access.test.ts` | `accessFor`, `visibilityFor`, `resolveScope`: pohledy, strop viditelnosti, cizi firma je 404 |
| `data/permissions.test.ts` | `permissionsOf` za firmu, `syncSystemRoles` |
| `data/tickets.test.ts` | `intakeEvent` (externi ID za firmu), `listTickets` s povinnym filtrem, `updateTicketStatus` |
| `data/migratePeople.test.ts` | prevod `ppl_` na ucty vcetne ticketu, skupin a stromu; zapis na disk |
| `net/guard.test.ts` | `urlProblem` (privatni rozsahy), `readBodyLimited`, `readJsonLimited` |
| `routes/health.test.ts` | `createApp()` pres supertest: health, 404 jako JSON, 401, prihlaseni, `/access` za firmu |
| `runtime/executor.test.ts` | podminky (retezce, cisla, data, seznamy, `all`/`any`), stara podoba, stropy `MAX_STEPS`, `MAX_ACTIONS`, `MAX_LOOP_ITEMS`, vystupy, `retryable` |
| `runtime/scripts/util.test.ts` | `createRedactor` ve ctyrech tvarech, `parseBool`, `parseNumber`, `pick`, `truncate` |
Prvni testy executoru nasly dve chyby v podminkach, obe opravene: prazdna
hodnota se pri `gt`/`lt` porovnavala jako nula (`Number('')` je `0`) a hole
jmeno vystupu melo prednost pred `krok.jmeno`, takze podminka nad druhym
krokem se stejnym vystupem cetla hodnotu z prvniho. Podrobne
v [20-fronta-a-runtime.md](20-fronta-a-runtime.md).
### Znamy stav proti zasadam
Jeden `package.json` pro server i web (workspaces az bude mit kazda strana
vlastni build), logovani `console.*` s prefixem modulu misto strukturovaneho
loggeru, zadny soubor CI. Prettier prosel celym kodem (`npm run format`,
se svolenim); markdown je z formatovani vyjmuty (`.prettierignore`), protoze
dokumentace ma vlastni styl tabulek. Zapsano v [01-prehled-a-stav.md](01-prehled-a-stav.md).
## 2026-09-09 - Incident jde otevrit a posunout do dalsiho stavu
Seznam incidentu ukazoval jen titulek, sluzbu a casy. Server pritom uz posilal
+48
View File
@@ -0,0 +1,48 @@
// Lint pro server (src), web (web/src), testy a skripty konektoru.
// Pravidla drzi to, co uz kod dodrzuje: zadne any, nepouzite promenne jen
// s podtrzitkem, hooky Reactu podle pravidel. Spousti se `npm run lint`.
import js from '@eslint/js';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['dist/**', 'node_modules/**', 'data/**', 'design/**', 'web/public/**'],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: [
'src/**/*.ts',
'tests/**/*.ts',
'vite.config.ts',
'vitest.config.ts',
'eslint.config.js',
],
languageOptions: { globals: globals.node },
},
{
files: ['web/src/**/*.{ts,tsx}'],
languageOptions: { globals: globals.browser },
plugins: { 'react-hooks': reactHooks },
rules: reactHooks.configs.recommended.rules,
},
{
// Skripty konektoru jsou obycejny JS bez importu; `ctx` dostanou od runtime.
files: ['connectors/**/*.js'],
languageOptions: { sourceType: 'module', globals: {} },
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
// Prazdny catch je tiche selhani, viz pravidlo 7 ve START.md.
'no-empty': ['error', { allowEmptyCatch: false }],
},
},
);
+1871 -4
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -12,7 +12,12 @@
"dev": "concurrently -n api,web -c magenta,cyan \"npm:dev:server\" \"npm:dev:web\"",
"dev:server": "tsx watch src/index.ts",
"dev:web": "vite",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p web/tsconfig.json --noEmit"
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p web/tsconfig.json --noEmit",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"bcryptjs": "^2.4.3",
@@ -25,6 +30,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
@@ -35,17 +41,25 @@
"@types/pg": "^8.21.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/supertest": "^7.2.1",
"@types/swagger-ui-express": "^4.1.7",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"eslint": "^10.10.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"lucide-react": "^0.469.0",
"prettier": "^3.9.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.1",
"supertest": "^7.2.2",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vite": "^6.0.7"
"typescript-eslint": "^8.70.0",
"vite": "^6.0.7",
"vitest": "^5.0.0"
},
"engines": {
"node": ">=20"
+297
View File
@@ -0,0 +1,297 @@
/**
* Sestaveni Express aplikace.
*
* Jen sklada middleware, routery, Swagger a SPA. Zadne `listen`, zadny
* bootstrap dat, zadne signaly - to vsechno je v `index.ts`. Diky tomu jde
* aplikaci postavit v testu (supertest) bez otevreneho portu.
*/
import cors from 'cors';
import express, { type NextFunction, type Request, type Response } from 'express';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import swaggerUi from 'swagger-ui-express';
import { config } from './config.js';
import { storageStatus } from './data/connectorStore.js';
import { databaseHealth } from './db/pool.js';
import { safeRouter } from './middleware/asyncHandler.js';
import { buildOpenApiDocument } from './openapi/index.js';
import { adminRouter } from './routes/admin.js';
import { authRouter } from './routes/auth.js';
import { contactRouter } from './routes/contact.js';
import { dashboardRouter } from './routes/dashboard/index.js';
import { publicInviteRouter } from './routes/invites.js';
import { webhookRouter } from './routes/webhook.js';
const here = path.dirname(fileURLToPath(import.meta.url));
/** Zbuildovana SPA. Vite ji zapisuje do dist/public, viz vite.config.ts. */
const webRoot = path.join(here, 'public');
/** Strop tela JSON. Nejvetsi bezne telo je ukazka spoustece (100 kB), zbytek je rezerva. */
const JSON_BODY_LIMIT = '256kb';
/** Rok. Soubory buildu maji hash v nazvu, takze se muzou cachovat na maximum. */
const STATIC_MAX_AGE_SEC = 31_536_000;
/**
* Token v adrese je pristupovy udaj. Do logu jde jen jeho zacatek, aby slo
* volani dohledat, ale ne zopakovat.
*/
function maskSecretsInUrl(url: string): string {
return url.replace(/(\/webhook\/(?:ticket\/)?|\/invites\/)([^/?#]{6})[^/?#]*/g, '$1$2...');
}
function isOriginAllowed(origin: string): boolean {
if (config.corsOrigins.includes(origin)) return true;
// V dev rezimu si Vite pri obsazenem portu vezme jiny, proto cely localhost.
if (!config.isProduction && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
return true;
}
return false;
}
/**
* Do index.html se za behu vklada base pro prohlizec.
*
* Prohlizec vidi adresu /apps/<app-id>/..., ale Vite build ma relativni cesty.
* Bez <base> by se soubory na vnorenych cestach hledaly ve spatne slozce.
* Prefix se bere z ROOT_PATH, nikdy neni v kodu natvrdo.
*/
function renderIndexHtml(): string {
const file = path.join(webRoot, 'index.html');
const html = fs.readFileSync(file, 'utf8');
const base = `${config.rootPath}/`;
const injected =
`<base href="${base}">\n` +
` <script>window.__BASE_PATH__ = ${JSON.stringify(config.rootPath)};</script>`;
return html.replace('<head>', `<head>\n ${injected}`);
}
/** Zakladni middleware: proxy, CORS, JSON, bezpecnostni hlavicky a log requestu. */
function applyBaseMiddleware(app: express.Express): void {
/*
* Aplikace bezi za reverse proxy (Caddy), jinak by req.ip a protokol byly
* containeru. Duveruje se **jednomu** skoku, ne vsem: pri `true` by si kazdy
* volajici mohl do X-Forwarded-For vepsat cizi adresu a obejit tak limit
* poctu pokusu, ktery je na adresu navazany.
*/
app.set('trust proxy', 1);
app.use(
cors({
origin(origin, callback) {
// Bez Origin (curl, server-to-server) i stejna domena projdou vzdy.
if (!origin || isOriginAllowed(origin)) return callback(null, true);
console.warn(`[cors] zablokovan origin: ${origin}`);
return callback(null, false);
},
credentials: true,
}),
);
app.use(express.json({ limit: JSON_BODY_LIMIT }));
/*
* Bezpecnostni hlavicky. Rucne a stridme: zadne CSP, ktere by rozbilo SPA
* nebo Swagger UI. Ramovani jen ze stejne domeny, zadne hadani typu obsahu,
* referer bez cesty pri odchodu jinam a vypnute senzory, ktere portal nepouziva.
*/
app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
});
app.use((req, _res, next) => {
// Loguje se jen metoda a cesta, nikdy hlavicky ani telo - obsahuji secrets.
console.info(`[req] ${req.method} ${maskSecretsInUrl(req.originalUrl)}`);
next();
});
}
/** Health, whoami, Swagger a vsechny API routery pod jednim routerem. */
function buildApiRouter(): express.Router {
/**
* strict: true je nutne. Bez nej by se cesta /docs shodovala i s /docs/
* a presmerovani nize by se zacyklilo.
*
* `safeRouter`: async handler, ktery spadne, dojde do error handleru
* misto toho, aby request visel a chyba skoncila jako unhandledRejection.
*/
const api = safeRouter({ strict: true });
/**
* Liveness. Zamerne **nezavisi na databazi**: kratky vypadek DB by jinak vedl
* k restartovani containeru, coz nic nespravi (AGENTS.md).
*/
api.get('/health', (_req, res) => {
res.json({ status: 'ok', uptimeSec: Math.round(process.uptime()) });
});
/**
* Readiness. Tady uz databaze zalezi, a proto je to zvlast.
* Vysledek se par sekund cachuje, aby monitoring nedelal dotaz pri kazdem pingu.
*/
api.get('/health/ready', async (_req, res) => {
const database = await databaseHealth();
const storage = storageStatus();
const ready = !database.enabled || database.ok;
res.status(ready ? 200 : 503).json({
status: ready ? 'ok' : 'degraded',
database,
storage,
});
});
/**
* Jak nas vidi ten, kdo nam vola.
*
* Vraci volajicimu jeho vlastni adresu tak, jak dorazila k serveru. Zni to
* zbytecne, ale je to jediny zpusob, jak zmerit, **s jakou zdrojovou adresou
* doruci reverse proxy volani, ktere vyslo z naseho containeru**. Container
* sam to nevidi, echo sluzba na internetu odpovi verejnou adresu, jenze
* volani na vlastni domenu se otaci zpatky na tentyz stroj a proxy pak muze
* videt adresu docker bridge, ne tu verejnou. A prave to rozhoduje o tom,
* jestli nas seznam povolenych IP pusti.
*
* Bez prihlaseni zamerne: neprozradi to nic, co by volajici uz nevedel,
* dostane svoji vlastni adresu. Stejne jako kterakoliv echo sluzba.
*/
api.get('/whoami', (req, res) => {
res.json({
// `req.ip` uz je po `trust proxy`, tedy hodnota z X-Forwarded-For.
ip: req.ip ?? null,
// Surove, aby bylo videt i to, co proxy pripsala nebo nepripsala.
forwardedFor: req.headers['x-forwarded-for'] ?? null,
remoteAddress: req.socket.remoteAddress ?? null,
});
});
/**
* Swagger UI. Cesta bez lomitka presmerujeme na variantu s lomitkem,
* jinak by se relativni odkazy na CSS a JS skladaly o uroven vys
* a za reverse proxy by se nenacetly.
*/
const openApiDocument = buildOpenApiDocument();
const swaggerOptions: swaggerUi.SwaggerUiOptions = {
customSiteTitle: `${config.brandName} API`,
swaggerOptions: { persistAuthorization: true },
};
api.get('/docs', (_req, res) => res.redirect(`${config.rootPath}/docs/`));
api.get('/openapi.json', (_req, res) => res.json(openApiDocument));
api.use(
'/docs',
swaggerUi.serveFiles(openApiDocument, swaggerOptions),
swaggerUi.setup(openApiDocument, swaggerOptions),
);
api.use('/api/auth', authRouter);
api.use('/api/dashboard', dashboardRouter);
// Verejne: kdo dostal odkaz na pozvanku, neni jeste prihlaseny.
api.use('/api/invites', publicInviteRouter);
api.use('/api/admin', adminRouter);
api.use('/api/contact', contactRouter);
api.use('/webhook', webhookRouter);
return api;
}
/** Staticke soubory buildu a SPA fallback. Bez buildu jen JSON s vysvetlenim. */
function applyWeb(app: express.Express): void {
let cachedIndexHtml: string | null = null;
const hasWebBuild = fs.existsSync(path.join(webRoot, 'index.html'));
if (!hasWebBuild) {
// Bez buildu webu nesmi aplikace tise vracet prazdno.
console.warn(`[start] build webu nenalezen v ${webRoot}, bezi jen API`);
app.get('/', (_req, res) => {
res.json({
name: 'csbot-prototype',
status: 'ok',
note: 'Build webu chybi, dostupne je jen API a /docs.',
});
});
return;
}
const serveStatic = express.static(webRoot, {
index: false,
// Soubory maji hash v nazvu, muzou se cachovat dlouho. index.html ne.
setHeaders(res, filePath) {
if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache');
else res.setHeader('Cache-Control', `public, max-age=${STATIC_MAX_AGE_SEC}, immutable`);
},
});
app.use(serveStatic);
if (config.rootPath) app.use(config.rootPath, serveStatic);
// Vsechny ostatni cesty obsluhuje SPA, routovani si resi React Router.
app.get('*', (_req, res) => {
if (!cachedIndexHtml) cachedIndexHtml = renderIndexHtml();
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.send(cachedIndexHtml);
});
}
// Centralni error handler - nic nesmi propadnout bez logu.
function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction): void {
/*
* Rozbite telo pozadavku neni nase chyba, je to spatne polozeny dotaz.
*
* `express.json` na nej vyhodi vyjimku, ta propadla sem a uzivatel videl
* "Interni chyba serveru" - hlasku, ktera rika, ze je neco spatne u nas,
* a poslala ho hledat na spatnou stranu. Stalo to jedno odpoledne.
*/
const status = (err as { status?: number } | null)?.status;
const type = (err as { type?: string } | null)?.type;
if (status === 400 && typeof type === 'string' && type.startsWith('entity.')) {
console.warn(`[error] ${req.method} ${req.path}: neplatne telo pozadavku (${type})`);
res.status(400).json({
error: 'bad_request',
message: 'Tělo požadavku není platný JSON objekt.',
});
return;
}
console.error('[error]', err);
const message = err instanceof Error ? err.message : 'Neznama chyba.';
res.status(500).json({
error: 'internal_error',
message: config.isProduction ? 'Interni chyba serveru.' : message,
});
}
/** Postavi aplikaci. Bez vedlejsich efektu: nic neposloucha a nic se nenacita. */
export function createApp(): express.Express {
const app = express();
applyBaseMiddleware(app);
const api = buildApiRouter();
// Mount na koren i na prefix proxy. Caddy prefix pres handle_path odstranuje,
// ale takhle aplikace funguje i kdyby ho nechal - a lokalne bez proxy taky.
app.use(api);
if (config.rootPath) app.use(config.rootPath, api);
// Neexistujici API cesta musi vratit JSON, ne HTML aplikace.
// Prefix se bere z ROOT_PATH, ne z tvaru `/apps/<id>` napsaneho natvrdo.
const apiPathPattern = new RegExp(
`^(${config.rootPath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')})?/(api|webhook)/`,
);
app.use((req, res, next) => {
if (apiPathPattern.test(req.path)) {
console.warn(`[404] ${req.method} ${req.originalUrl}`);
return res.status(404).json({ error: 'not_found', message: 'Endpoint neexistuje.' });
}
return next();
});
applyWeb(app);
app.use(errorHandler);
return app;
}
+14 -4
View File
@@ -104,7 +104,9 @@ function record(value: unknown): Record<string, unknown> | null {
}
function list(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.map(record).filter((v): v is Record<string, unknown> => v !== null) : [];
return Array.isArray(value)
? value.map(record).filter((v): v is Record<string, unknown> => v !== null)
: [];
}
async function call(path: string, init?: RequestInit): Promise<unknown> {
@@ -134,7 +136,8 @@ async function call(path: string, init?: RequestInit): Promise<unknown> {
response.status,
);
}
if (body.json === undefined) throw new AresError('ARES vrátil odpověď, která není JSON.', response.status);
if (body.json === undefined)
throw new AresError('ARES vrátil odpověď, která není JSON.', response.status);
return body.json;
}
@@ -170,7 +173,11 @@ export async function searchCompanies(name: string, limit = 10): Promise<AresCom
const json = await call('/ekonomicke-subjekty/vyhledat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ obchodniJmeno: query, start: 0, pocet: Math.min(Math.max(limit, 1), 50) }),
body: JSON.stringify({
obchodniJmeno: query,
start: 0,
pocet: Math.min(Math.max(limit, 1), 50),
}),
});
return list(record(json)?.ekonomickeSubjekty)
.map(toCompany)
@@ -181,7 +188,10 @@ export async function searchCompanies(name: string, limit = 10): Promise<AresCom
function properCase(value: string): string {
return value
.toLocaleLowerCase('cs-CZ')
.replace(/(^|[\s\-'])(\p{L})/gu, (_, sep: string, ch: string) => sep + ch.toLocaleUpperCase('cs-CZ'));
.replace(
/(^|[\s\-'])(\p{L})/gu,
(_, sep: string, ch: string) => sep + ch.toLocaleUpperCase('cs-CZ'),
);
}
/**
+15 -2
View File
@@ -133,9 +133,9 @@ export const config = {
/**
* Adresar se skripty konektoru. Relativne k adresari, ze ktereho aplikace
* bezi, aby to fungovalo v containeru (`/app/scripts`) i lokalne.
* bezi, aby to fungovalo v containeru (`/app/connectors`) i lokalne.
*/
scriptsDir: path.resolve(process.env.SCRIPTS_DIR ?? path.join(process.cwd(), 'scripts')),
scriptsDir: path.resolve(process.env.SCRIPTS_DIR ?? path.join(process.cwd(), 'connectors')),
/**
* Zaklad adres napojenych sluzeb, napr. "https://services.csbot.cz/apps".
* Konkretni konektor lze presmerovat pres `<KONEKTOR>_BASE_URL`.
@@ -230,6 +230,19 @@ export const config = {
* interval znamena jen vic dotazu do fronty.
*/
schedulerIntervalSec: positiveNumber(process.env.SCHEDULER_INTERVAL_SEC, 30),
/**
* Presmerovani jedne sluzby promennou `<SLUZBA>_BASE_URL`, napr.
* `OPENAI_BASE_URL`. K cemu to je, rika `runtime/scripts/connections.ts`.
*
* Jedine misto s dynamickym nazvem promenne: sluzby pribyvaji v katalogu
* a vypisovat kazdou sem by znamenalo dve mista, ktera se rozejdou. Nazev
* sklada volajici, tady se hodnota jen cte a normalizuje - bez mezer, bez
* lomitka na konci. Prazdna nebo chybejici promenna je `null`.
*/
serviceBaseUrlOverride(variable: string): string | null {
const value = (process.env[variable] ?? '').trim().replace(/\/+$/, '');
return value === '' ? null : value;
},
};
/** Zaklad verejne adresy aplikace vcetne prefixu proxy. */
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
/**
* Uloziste automatizaci vcetne jejich stromu akci (flow).
*
* Automatizace se drzi v pameti kvuli synchronnimu cteni a po kazde zmene se
* cela zapise do uloziste (`withMirror`). Kam - databaze, soubor, nebo nikam -
* rozhoduje `data/store/index.ts`; viz documentation/14-databaze.md.
*
* Slozka je rozdelena podle odpovednosti: `model` (tvar), `state` (pamet),
* `persist` (zapis a nacteni), `validation` (co se ze stromu dopocitava),
* `webhook` (token a volani), `runs` (historie behu), `store` (cteni a
* zapisy), `seed`, `seedDemo` a `remap`. Zvenku se importuje jen tenhle
* soubor, a to pres `data/automationStore.ts`.
*/
import { config } from '../../config.js';
import { seedRealAutomations } from './seed.js';
import { seedDemoAutomations } from './seedDemo.js';
export type {
Automation,
AutomationDetail,
AutomationFlow,
AutomationKind,
FlowStep,
FlowTrigger,
TriggerField,
WebhookCall,
WebhookCallField,
} from './model.js';
export { matchOf, rulesOf } from './model.js';
export {
bodyForCall,
generateWebhookToken,
recentWebhookCalls,
recordWebhookCall,
} from './webhook.js';
export { collectFlowIssues, countSteps } from './validation.js';
export { initAutomations } from './persist.js';
export {
createAutomation,
deleteAutomation,
findByWebhookToken,
getAutomation,
listAutomations,
recordRun,
regenerateWebhookToken,
updateAutomation,
} from './store.js';
export { remapPersonIds } from './remap.js';
seedRealAutomations();
if (config.seedDemo) {
seedDemoAutomations();
} else {
console.info('[automatizace] ukazkova data vypnuta (SEED_DEMO neni 1)');
}
if (!config.seedWebhookToken) {
console.warn(
'[automatizace] WEBHOOK_TOKEN_TEST neni nastaveny, adresa webhooku se pri kazdem ' +
'nasazeni zmeni. Nastavte ji jako promennou aplikace.',
);
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Tvar automatizace v ulozisti a cteni kroku podminky.
*
* Tvar automatizace a stromu je sdileny s webem, viz src/shared/automations.ts.
* Tady je jen to, co web nevidi: zaznam v ulozisti.
*/
import type { ConditionMatch, ConditionRule } from '../conditions.js';
import type {
Automation,
AutomationDetail,
AutomationFlow,
AutomationKind,
FlowStep,
FlowTrigger,
TriggerField,
WebhookCall,
WebhookCallField,
} from '../../shared/automations.js';
/**
* Tvar automatizace a stromu je sdileny s webem, viz src/shared/automations.ts.
* Tady zustava uloziste a prace se stromem.
*/
export type {
Automation,
AutomationDetail,
AutomationFlow,
AutomationKind,
FlowStep,
FlowTrigger,
TriggerField,
WebhookCall,
WebhookCallField,
};
/**
* Otazky podminky, at uz je krok v jakekoliv podobe.
*
* **Jedine misto, kde se stara podoba prevadi.** Kdyby se `fieldId` cetlo
* primo, krok ulozeny driv by po zmene modelu prisel o svou otazku a vetvil
* by vzdycky stejne - tise a bez chyby.
*/
export function rulesOf(step: Extract<FlowStep, { kind: 'condition' }>): ConditionRule[] {
if (step.rules && step.rules.length > 0) return step.rules;
if (!step.fieldId || !step.operator) return [];
return [{ fieldId: step.fieldId, operator: step.operator, value: step.value }];
}
/** Jak se otazky spoji. Jedna otazka nema co spojovat, bere se `all`. */
export function matchOf(step: Extract<FlowStep, { kind: 'condition' }>): ConditionMatch {
return step.match ?? 'all';
}
export interface StoredAutomation {
id: string;
tenantId: string;
name: string;
kind: AutomationKind;
enabled: boolean;
/**
* Behy po dnech, nejnovejsi nakonec. Drzi se poslednich `KEEP_DAYS`.
*
* Proc historie a ne jen citac pro dnesek: bez ni po pulnoci neni s cim
* srovnat a nikdo nepozna, jestli je dnesnich devet malo nebo hodne.
*/
days?: Array<{ day: string; runs: number; ok: number }>;
/** Behy od zalozeni. */
runsTotal?: number;
runsToday: number;
successRate: number;
avgDurationMs: number;
lastRunAt: string;
flow: AutomationFlow;
/**
* Nedodelky a pocet kroku **spocitane pri ulozeni**, ne pri kazdem cteni.
*
* Seznam automatizaci se cte pri kazdem nacteni prehledu i pri kazde zmene
* ticketu (spoustece), a prochazet kvuli tomu pokazde cely strom vsech
* automatizaci bylo znat. Nepovinne, protoze starsi zaznamy je nemaji -
* dopocitaji se pri startu.
*/
issues?: string[];
stepCount?: number;
createdAt: string;
updatedAt: string;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Zapis automatizace do uloziste a nacteni pri startu.
*
* Po kazde zmene se cela automatizace zapise (`withMirror`). Kam - databaze,
* soubor, nebo nikam - rozhoduje `data/store/index.ts`; viz
* documentation/14-databaze.md.
*/
import { defineStore } from '../store/index.js';
import { withMirror } from '../store/mirror.js';
import type { StoredAutomation } from './model.js';
import { continueCounter, store } from './state.js';
import { withDerived } from './validation.js';
/**
* Uloziste. Automatizace se drzi v pameti kvuli synchronnimu cteni (webhook
* hleda podle tokenu pri kazdem requestu) a po kazde zmene se cela zapise.
* Kam - databaze, soubor, nebo nikam - rozhoduje `data/store/index.ts`.
*/
export const mirror = withMirror(defineStore<StoredAutomation>('automation'));
/** Zapise do pameti i do uloziste. Kazda zmena jde skrz tohle. */
export function save(automation: StoredAutomation): void {
store.set(automation.id, automation);
mirror.save(automation);
}
/**
* Nacte automatizace z uloziste. Vola se pri startu, viz data/bootstrap.ts.
*
* Kdyz uloziste nic nema, ulozi se ukazkova sada, ktera je v tuhle chvili
* v pameti.
*/
export async function initAutomations(): Promise<void> {
const rows = await mirror.load(() => [...store.values()]);
store.clear();
/*
* Nedodelky se pri startu prepocitaji u vsech, ne jen u zaznamu bez nich:
* zavisi na katalogu sluzeb a ten se mezi nasazenimi meni (nove povinne
* pole kroku). Jednou pri startu je to levne, pri kazdem cteni ne.
*/
for (const row of rows) store.set(row.id, withDerived(row));
continueCounter();
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Preznaceni resitelu pri migraci, viz data/migratePeople.ts.
*/
import type { AutomationFlow } from '../../shared/automations.js';
import { save } from './persist.js';
import { store } from './state.js';
import { withDerived } from './validation.js';
/**
* Prepise ID resitelu ve stromech podle mapy stare -> nove. Vraci pocet
* zmenenych automatizaci.
*
* Jen pro migraci (data/migratePeople.ts). ID resitele muze byt v kroku
* `ticket/assign`, ve vstupu `assigneeId` u zalozeni ticketu i v podmince,
* proto se nahrazuje v JSON podobe celeho stromu, ne po znamych polich -
* nove pole by se jinak zapomnelo. Nahrazuje se jen cely retezec `"ppl_x"`,
* ne podretezec.
*/
export function remapPersonIds(map: Map<string, string>): number {
let changed = 0;
for (const automation of store.values()) {
const before = JSON.stringify(automation.flow);
let after = before;
for (const [oldId, newId] of map) {
after = after.split(JSON.stringify(oldId)).join(JSON.stringify(newId));
}
if (after === before) continue;
changed += 1;
save(withDerived({ ...automation, flow: JSON.parse(after) as AutomationFlow }));
}
return changed;
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Historie behu po dnech.
*
* Bez ni po pulnoci neni s cim srovnat a nikdo nepozna, jestli je dnesnich
* devet malo nebo hodne. Zapis behu je v `store.ts` (`recordRun`), tady je
* jen cteni a hranice, kolik se drzi.
*/
import type { StoredAutomation } from './model.js';
/** Jeden den v milisekundach. */
const DAY_MS = 86_400_000;
/** Kolik dni zpatky se drzi. Dva tydny staci na "je to dnes jinak nez obvykle". */
export const KEEP_DAYS = 14;
/** Dnesni den jako `2026-08-17`. Podle nej se pozna prelom dne. */
export function today(): string {
return new Date().toISOString().slice(0, 10);
}
function dayBefore(day: string): string {
return new Date(new Date(`${day}T00:00:00Z`).getTime() - DAY_MS).toISOString().slice(0, 10);
}
/**
* Statistika za dnesek z ulozene historie.
*
* Kdyz dnes jeste nic nebezelo, vraci nulu - **ne vcerejsi cislo**. Ukazat
* vcerejsi pocet jako dnesni je to, co bylo spatne.
*/
export function statsOf(stored: StoredAutomation): {
runsToday: number;
runsYesterday: number;
runsTotal: number;
successRate: number;
} {
const days = stored.days ?? [];
const now = today();
const mine = days.find((entry) => entry.day === now);
const before = days.find((entry) => entry.day === dayBefore(now));
/*
* Uspesnost dnesnich behu. Kdyz dnes zadny nebyl, bere se posledni den, kdy
* byly - nula procent u automatizace, ktera dnes jen nemela co delat, by
* vypadala jako porucha.
*/
const source = mine ?? [...days].reverse().find((entry) => entry.runs > 0);
return {
runsToday: mine?.runs ?? 0,
runsYesterday: before?.runs ?? 0,
runsTotal: stored.runsTotal ?? stored.runsToday,
successRate:
source && source.runs > 0
? Math.round((source.ok / source.runs) * 1000) / 10
: stored.successRate,
};
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Vychozi sada automatizaci pro prazdne uloziste.
*
* Skutecne automatizace (`seedRealAutomations`) se nasypou vzdy, ukazkove
* jsou v `seedDemo.ts` a zapinaji se pres `SEED_DEMO=1`. O obojim rozhoduje
* `index.ts`, tady je jen zapis do pameti bez uloziste.
*/
import { config } from '../../config.js';
import { minutesAgo } from '../store/index.js';
import type { StoredAutomation } from './model.js';
import { nextId, store } from './state.js';
import { deriveKind, withDerived } from './validation.js';
import { generateWebhookToken } from './webhook.js';
/** Ukazkova automatizace vznikla pred 90 dny a naposledy se menila pred 12 hodinami. */
const SEED_CREATED_MINUTES_AGO = 60 * 24 * 90;
const SEED_UPDATED_MINUTES_AGO = 60 * 12;
export function seed(
automation: Omit<StoredAutomation, 'id' | 'createdAt' | 'updatedAt' | 'kind' | 'tenantId'> & {
tenantId?: string;
},
) {
const id = nextId();
store.set(
id,
withDerived({
// Ukazkova data patri Automii, kdyz neni receno jinak.
tenantId: 'tnt_automia',
...automation,
id,
kind: deriveKind(automation.flow),
createdAt: minutesAgo(SEED_CREATED_MINUTES_AGO),
updatedAt: minutesAgo(SEED_UPDATED_MINUTES_AGO),
}),
);
}
/**
* Automatizace, ktere na instanci opravdu bezi.
*
* Je to **vychozi sada pro prazdne uloziste**, ne zdroj pravdy: pouzije se
* jen pri prvnim startu (nebo po ztrate dat, napr. redeploy bez databaze
* a bez svazku). Kdyz uloziste uz neco ma, tenhle kod se nepouzije a strom
* na instanci muze byt jiny. Zdrojem pravdy je uloziste, viz
* documentation/14-databaze.md.
*
* Token webhooku se bere z `WEBHOOK_TOKEN_TEST`, aby se adresa po ztrate dat
* nemenila a odesilatel ji nemusel prepisovat.
*
* Opsano z bezici instance 2026-09-02. Kdyz se strom na instanci zmeni a ma
* prezit i ztratu dat, patri ta zmena sem.
*/
export function seedRealAutomations(): void {
seed({
name: 'TEST',
enabled: true,
runsToday: 0,
successRate: 100,
avgDurationMs: 0,
lastRunAt: minutesAgo(0),
flow: {
trigger: {
serviceId: 'webhook',
operationId: 'received',
fields: [
{ id: 'f_callsid', name: 'callSid', type: 'string', required: true },
{ id: 'f_status', name: 'status', type: 'string', required: true },
{ id: 'f_voicebot', name: 'voicebotId', type: 'string', required: true },
{ id: 'f_mtjqv4qj_1', name: 'result', type: 'string', required: false, path: 'data.result' },
{ id: 'f_mtjqv4zn_2', name: 'rating', type: 'string', required: false, path: 'data.rating' },
// Objekt a nepovinne: telo ho posila jako strukturu a prvni zprava
// hovoru ho jeste nema. Deklarace `string` a povinny odmitala oboji.
{ id: 'f_mtjqvws7_3', name: 'data', type: 'object', required: false, path: 'data' },
],
webhookToken: config.seedWebhookToken || generateWebhookToken(),
},
steps: [
{
id: 'st_upsert',
kind: 'action',
serviceId: 'ticket',
operationId: 'upsert',
inputs: {
externalId: '{{callSid}}',
body: '{{voicebotId}}, {{data}}',
status: '{{result}}',
tags: '{{voicebotId}}',
priority: 'low',
},
},
/*
* **Nejdriv se ptame, jestli vysledek vubec prisel.**
*
* Jeden hovor posle vic zprav a ta prvni jen ohlasi, ze zacal:
* `data` je null, takze `{{result}}` je prazdne. Bez teto podminky
* spadlo prazdno rovnou do vetve "neni to Chybejici informace"
* a ticket se zavrel uz pri zvoneni. Na instanci to znamenalo, ze
* vsech 131 ticketu bylo vyrizenych a ve frontě nezustalo nic.
*/
{
id: 'st_mtjqwey5_4',
kind: 'condition',
fieldId: 'f_mtjqv4qj_1',
operator: 'isNotEmpty',
value: '',
yes: [
/*
* Az ted se rozhoduje podle vysledku, a **kladne**: hledame
* "Chybejici informace". Puvodni `neq` znamenalo "vsechno
* ostatni vcetne toho, co jeste nevime".
*/
{
id: 'st_mtml9001_2',
kind: 'condition',
fieldId: 'f_mtjqv4qj_1',
operator: 'eq',
value: 'Chybějící informace',
// Chybejici informace jde na servicedesk a s vyssi prioritou.
yes: [
{
id: 'st_mtjqxs41_6',
kind: 'action',
serviceId: 'ticket',
operationId: 'upsert',
inputs: {
externalId: '{{callSid}}',
priority: 'high',
groupId: 'grp_servicedesk',
},
},
{
id: 'st_mtml8hx0_1',
kind: 'action',
serviceId: 'ticket',
operationId: 'assign-group',
inputs: {
groupId: 'grp_servicedesk',
autoAssign: 'true',
},
},
],
// Hovor dopadl, ticket se zavira.
no: [
{
id: 'st_mtjqx6t1_5',
kind: 'action',
serviceId: 'ticket',
operationId: 'upsert',
inputs: {
externalId: '{{callSid}}',
closed: 'true',
},
},
],
},
],
// Vysledek jeste nedorazil. Ticket uz existuje, vic se ted delat nema.
no: [],
},
],
},
});
}
+467
View File
@@ -0,0 +1,467 @@
/**
* Ukazkove automatizace. Nasypou se jen se `SEED_DEMO=1`, viz `index.ts`.
*/
import { minutesAgo } from '../store/index.js';
import { seed } from './seed.js';
import { generateWebhookToken } from './webhook.js';
/**
* Ukazkove automatizace.
*
* Nasypou se **jen se `SEED_DEMO=1`**. Na instanci, kde uz nekdo pracuje,
* jsou to cizi zaznamy, ktere se po kazdem redeployi vraceji - a mazat je
* porad dokola nikoho nebavi.
*/
export function seedDemoAutomations(): void {
seed({
name: 'Objednávka, sklad a fakturace',
enabled: true,
runsToday: 428,
successRate: 99.3,
avgDurationMs: 1_240,
lastRunAt: minutesAgo(3),
flow: {
trigger: {
serviceId: 'eshop',
operationId: 'order-created',
fields: [
{ id: 'f_1', name: 'orderId', type: 'string', required: true },
{ id: 'f_2', name: 'total', type: 'number', required: true },
{ id: 'f_3', name: 'customerEmail', type: 'string', required: true },
],
},
steps: [
{ id: 'st_1', kind: 'action', serviceId: 'transform', operationId: 'map-fields' },
{ id: 'st_2', kind: 'action', serviceId: 'eshop', operationId: 'update-stock' },
{
id: 'st_3',
kind: 'condition',
fieldId: 'f_2',
operator: 'gte',
value: '5000',
yes: [
{ id: 'st_4', kind: 'action', serviceId: 'idoklad', operationId: 'create-proforma' },
{ id: 'st_5', kind: 'action', serviceId: 'email', operationId: 'send' },
],
no: [{ id: 'st_6', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' }],
},
{ id: 'st_7', kind: 'action', serviceId: 'ppl', operationId: 'create-shipment' },
],
},
});
seed({
name: 'Voicebot: příjem poptávek 24/7',
enabled: true,
runsToday: 137,
successRate: 96.1,
avgDurationMs: 74_000,
lastRunAt: minutesAgo(11),
flow: {
trigger: {
serviceId: 'voicebot',
operationId: 'call-received',
fields: [
{ id: 'f_11', name: 'callerNumber', type: 'string', required: true },
{ id: 'f_12', name: 'wantsOperator', type: 'boolean', required: false },
],
},
steps: [
{ id: 'st_11', kind: 'action', serviceId: 'voicebot', operationId: 'play-scenario' },
{ id: 'st_12', kind: 'action', serviceId: 'transcription', operationId: 'transcribe' },
{ id: 'st_13', kind: 'action', serviceId: 'openai', operationId: 'chat' },
{
id: 'st_14',
kind: 'condition',
fieldId: 'f_12',
operator: 'isTrue',
yes: [{ id: 'st_15', kind: 'action', serviceId: 'voicebot', operationId: 'transfer' }],
no: [
{ id: 'st_16', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' },
{ id: 'st_17', kind: 'action', serviceId: 'email', operationId: 'send' },
],
},
],
},
});
seed({
name: 'Synchronizace CRM a účetnictví',
enabled: true,
runsToday: 96,
successRate: 98.9,
avgDurationMs: 2_050,
lastRunAt: minutesAgo(26),
flow: {
trigger: {
serviceId: 'raynet',
operationId: 'company-changed',
fields: [{ id: 'f_21', name: 'companyId', type: 'string', required: true }],
},
steps: [
{ id: 'st_21', kind: 'action', serviceId: 'transform', operationId: 'deduplicate' },
{ id: 'st_22', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' },
{ id: 'st_23', kind: 'action', serviceId: 'log', operationId: 'write' },
],
},
});
seed({
name: 'Noční report pro management',
enabled: false,
runsToday: 0,
successRate: 100,
avgDurationMs: 18_400,
lastRunAt: minutesAgo(1_020),
flow: {
trigger: { serviceId: 'scheduler', operationId: 'interval', fields: [] },
steps: [
{ id: 'st_31', kind: 'action', serviceId: 'ga4', operationId: 'run-report' },
{ id: 'st_32', kind: 'action', serviceId: 'google-ads', operationId: 'campaign-report' },
{ id: 'st_33', kind: 'action', serviceId: 'sklik', operationId: 'campaign-report' },
{ id: 'st_34', kind: 'action', serviceId: 'openai', operationId: 'chat' },
{ id: 'st_35', kind: 'action', serviceId: 'email', operationId: 'send' },
],
},
});
// Ukazka webhooku s deklarovanymi parametry a podminkou nad cislem.
seed({
name: 'Webhook: hodnocení z dotazníku',
enabled: true,
runsToday: 61,
successRate: 100,
avgDurationMs: 640,
lastRunAt: minutesAgo(18),
flow: {
trigger: {
serviceId: 'webhook',
operationId: 'received',
webhookToken: generateWebhookToken(),
fields: [
{ id: 'f_41', name: 'customer', type: 'string', required: true },
{ id: 'f_42', name: 'score', type: 'number', required: true },
{ id: 'f_43', name: 'comment', type: 'string', required: false },
],
},
steps: [
{
id: 'st_41',
kind: 'condition',
fieldId: 'f_42',
operator: 'gte',
value: '15',
yes: [{ id: 'st_42', kind: 'action', serviceId: 'raynet', operationId: 'add-activity' }],
no: [
{
id: 'st_43',
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
inputs: {
subject: 'Nízké hodnocení od {{customer}}',
body: 'Hodnocení {{score}} z dotazníku. Komentář: {{comment}}',
company: '{{customer}}',
priority: 'high',
assigneeId: 'usr_2',
},
},
{ id: 'st_44', kind: 'action', serviceId: 'microsoft365', operationId: 'post-teams' },
],
},
],
},
});
/*
* Prijem z kanalu: jedna automatizace na kanal, zadne rozhodovani o resiteli.
* Smerovani resi jedna spolecna automatizace nize - viz documentation/06-tickety.md.
*/
seed({
name: 'WhatsApp: zpráva do ticketu',
enabled: true,
runsToday: 34,
successRate: 100,
avgDurationMs: 1_950,
lastRunAt: minutesAgo(7),
flow: {
trigger: {
serviceId: 'whatsapp',
operationId: 'message-received',
fields: [
{ id: 'whatsapp.phone', name: 'phone', type: 'string', required: true },
{ id: 'whatsapp.profileName', name: 'profileName', type: 'string', required: false },
{ id: 'whatsapp.text', name: 'text', type: 'string', required: true },
{ id: 'whatsapp.hasMedia', name: 'hasMedia', type: 'boolean', required: false },
{ id: 'whatsapp.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
steps: [
// Predvalidace: nejdriv se zeptame CRM, teprve podle odpovedi zakladame.
{
id: 'st_51',
kind: 'action',
serviceId: 'raynet',
operationId: 'find-company',
inputs: { phone: '{{phone}}' },
},
{
id: 'st_52',
kind: 'condition',
// Odkaz na vystup kroku st_51, ne na parametr spoustece.
fieldId: 'st_51.raynet.customerKnown',
operator: 'isTrue',
yes: [
{
id: 'st_53',
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
inputs: {
subject: 'WhatsApp od {{profileName}}',
body: '{{text}}',
company: '{{companyName}}',
contact: '{{profileName}}',
reply: '{{phone}}',
priority: 'normal',
assigneeId: '',
},
},
],
no: [
{
id: 'st_54',
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
inputs: {
subject: 'WhatsApp od neznámého čísla',
body: '{{text}}',
contact: '{{profileName}}',
reply: '{{phone}}',
priority: 'normal',
assigneeId: '',
},
},
{ id: 'st_55', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' },
],
},
],
},
});
seed({
name: 'Facebook: zpráva do ticketu',
enabled: true,
runsToday: 11,
successRate: 100,
avgDurationMs: 720,
lastRunAt: minutesAgo(52),
flow: {
trigger: {
serviceId: 'facebook',
operationId: 'message-received',
fields: [
{ id: 'facebook.senderId', name: 'senderId', type: 'string', required: true },
{ id: 'facebook.senderName', name: 'senderName', type: 'string', required: false },
{ id: 'facebook.text', name: 'text', type: 'string', required: true },
{ id: 'facebook.pageName', name: 'pageName', type: 'string', required: true },
{ id: 'facebook.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
// Bez predvalidace. Z Messengeru nemame e-mail ani telefon, podle ceho
// by se firma dohledala, takze zakladame rovnou a dohledani nechavame na cloveku.
steps: [
{
id: 'st_61',
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
inputs: {
subject: 'Facebook od {{senderName}}',
body: '{{text}}',
contact: '{{senderName}}',
reply: '{{senderId}}',
priority: 'normal',
assigneeId: '',
},
},
],
},
});
seed({
name: 'E-mail: požadavky do ticketu',
enabled: true,
runsToday: 58,
successRate: 99.1,
avgDurationMs: 2_310,
lastRunAt: minutesAgo(14),
flow: {
trigger: {
serviceId: 'email',
operationId: 'received',
fields: [
{ id: 'email.from', name: 'from', type: 'string', required: true },
{ id: 'email.subject', name: 'subject', type: 'string', required: true },
{ id: 'email.body', name: 'body', type: 'string', required: false },
{ id: 'email.hasAttachment', name: 'hasAttachment', type: 'boolean', required: false },
{ id: 'email.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
steps: [
{
id: 'st_65',
kind: 'action',
serviceId: 'raynet',
operationId: 'find-company',
inputs: { email: '{{from}}' },
},
{
id: 'st_66',
kind: 'condition',
fieldId: 'st_65.raynet.customerKnown',
operator: 'isTrue',
yes: [
{
id: 'st_67',
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
inputs: {
subject: '{{subject}}',
body: '{{body}}',
company: '{{companyName}}',
contact: '{{from}}',
reply: '{{from}}',
priority: 'normal',
assigneeId: '',
},
},
],
no: [
{
id: 'st_68',
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
inputs: {
subject: '{{subject}}',
body: '{{body}}',
contact: '{{from}}',
reply: '{{from}}',
priority: 'low',
assigneeId: '',
},
},
{
id: 'st_69',
kind: 'action',
serviceId: 'email',
operationId: 'send',
inputs: {
to: '{{from}}',
subject: 'Přijali jsme váš požadavek',
body: 'Dobrý den, požadavek jsme zaevidovali a ozveme se. Tým podpory.',
},
},
],
},
],
},
});
/*
* Jedna spolecna automatizace nad vsemi tickety, at vznikly odkudkoliv.
* Tohle je to misto, kde se dela logika zpracovani na miru zakaznikovi.
*/
seed({
name: 'Směrování ticketů na řešitele',
/*
* Ukazkova automatizace, ktera **meni data**, je vypnuta.
*
* Zapnuta by prebirala tickety, ktere uz nekomu patri podle skutecne
* automatizace zakaznika - ukazkova data nemaji sahat na zivy provoz.
* Kdo si ji chce vyzkouset, zapne si ji.
*/
enabled: false,
runsToday: 103,
successRate: 100,
avgDurationMs: 310,
lastRunAt: minutesAgo(4),
flow: {
trigger: {
serviceId: 'ticket',
operationId: 'created',
fields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ id: 'ticket.body', name: 'body', type: 'string', required: false },
{ id: 'ticket.channel', name: 'channel', type: 'string', required: true },
{ id: 'ticket.company', name: 'company', type: 'string', required: false },
{ id: 'ticket.contact', name: 'contact', type: 'string', required: false },
{ id: 'ticket.priority', name: 'priority', type: 'string', required: true },
{ id: 'ticket.knownCustomer', name: 'knownCustomer', type: 'boolean', required: true },
{ id: 'ticket.assigned', name: 'assigned', type: 'boolean', required: true },
],
},
steps: [
{
id: 'st_71',
kind: 'condition',
// Uz prirazeny ticket nepreberame, jinak bychom prepsali rucni rozhodnuti.
fieldId: 'ticket.assigned',
operator: 'isFalse',
yes: [
{
id: 'st_72',
kind: 'condition',
fieldId: 'ticket.body',
operator: 'contains',
value: 'faktur',
yes: [
{
id: 'st_73',
kind: 'action',
serviceId: 'ticket',
operationId: 'assign',
inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_3' },
},
],
no: [
{
id: 'st_74',
kind: 'condition',
fieldId: 'ticket.body',
operator: 'contains',
value: 'voicebot',
yes: [
{
id: 'st_75',
kind: 'action',
serviceId: 'ticket',
operationId: 'assign',
inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_novakova' },
},
],
no: [
{
id: 'st_76',
kind: 'action',
serviceId: 'ticket',
operationId: 'assign',
inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_2' },
},
],
},
],
},
],
no: [],
},
],
},
});
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Pamet automatizaci a citac ID.
*
* Automatizace se drzi v pameti kvuli synchronnimu cteni (webhook hleda
* podle tokenu pri kazdem requestu). Zapis do uloziste je v `persist.ts`.
*/
import { highestNumber, writableOrWarn } from '../store/index.js';
import type { StoredAutomation } from './model.js';
/** Na kolik mist se cislo v ID doplnuje nulami: `AUT-01`. */
const ID_DIGITS = 2;
export const store = new Map<string, StoredAutomation>();
let idCounter = 0;
export function nextId(): string {
idCounter += 1;
return `AUT-${String(idCounter).padStart(ID_DIGITS, '0')}`;
}
/**
* Po nacteni z uloziste. Citac musi pokracovat za nejvyssim ulozenym cislem,
* jinak by nova automatizace prepsala starou.
*/
export function continueCounter(): void {
idCounter = Math.max(idCounter, highestNumber(store.keys(), 'AUT'));
}
/** Automatizace z povolenych firem. Cizi se chova jako neexistujici. */
export function findWritable(id: string, tenantIds: string[]): StoredAutomation | undefined {
return writableOrWarn(store.get(id), id, tenantIds, 'automations');
}
+241
View File
@@ -0,0 +1,241 @@
/**
* Cteni a zapisy automatizaci: seznam, detail, zalozeni, uprava, token
* webhooku, zaznam behu, smazani.
*
* Kazdy zapis jde pres `save` a posle udalost na sbernici.
*/
import { publish } from '../../events/bus.js';
import { timingSafeEqualString } from '../../lib/secure.js';
import { describeModel } from '../model.js';
import type { Automation, AutomationDetail, AutomationFlow } from '../../shared/automations.js';
import type { StoredAutomation } from './model.js';
import { mirror, save } from './persist.js';
import { KEEP_DAYS, statsOf, today } from './runs.js';
import { findWritable, nextId, store } from './state.js';
import { collectFlowIssues, countSteps, deriveKind, withDerived } from './validation.js';
import { generateWebhookToken, recentWebhookCalls, withWebhookToken } from './webhook.js';
function toSummary(stored: StoredAutomation): Automation {
const {
flow: _flow,
createdAt: _createdAt,
updatedAt: _updatedAt,
days: _days,
...rest
} = stored;
return {
...rest,
/*
* Cisla se pocitaji z historie po dnech, ne z ulozeneho citace. Po pulnoci
* je "dnes" nula, dokud opravdu neco nebezi.
*/
...statsOf(stored),
// Spocitane pri ulozeni nebo pri startu, viz StoredAutomation. Zaloha
// pro pripad, ze by zaznam prisel jinudy nez pres `withDerived`.
stepCount: stored.stepCount ?? countSteps(stored.flow.steps),
configured: stored.flow.trigger !== null,
issues: stored.issues ?? collectFlowIssues(stored.flow),
};
}
function toDetail(stored: StoredAutomation): AutomationDetail {
return {
...toSummary(stored),
flow: stored.flow,
model: stored.flow.trigger?.sample === undefined
? []
: describeModel(stored.flow.trigger.sample),
recentCalls: recentWebhookCalls(stored.id),
createdAt: stored.createdAt,
updatedAt: stored.updatedAt,
};
}
/** Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse". */
export function listAutomations(tenantIds: string[]): Automation[] {
// Nejnovejsi nahoru, aby prave vytvorena automatizace byla hned videt.
return [...store.values()]
.filter((stored) => tenantIds.includes(stored.tenantId))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map(toSummary);
}
export function getAutomation(id: string, tenantIds: string[]): AutomationDetail | undefined {
const stored = store.get(id);
if (!stored) return undefined;
if (!tenantIds.includes(stored.tenantId)) {
console.warn(`[automations] pokus o cteni ${id} mimo povolene firmy`);
return undefined;
}
return toDetail(stored);
}
export function createAutomation(name: string, tenantId: string): AutomationDetail {
const id = nextId();
const now = new Date().toISOString();
const stored = withDerived({
id,
tenantId,
name,
kind: 'workflow',
enabled: false,
runsToday: 0,
successRate: 100,
avgDurationMs: 0,
lastRunAt: now,
flow: { trigger: null, steps: [] },
createdAt: now,
updatedAt: now,
});
save(stored);
console.info(`[automations] vytvorena automatizace ${id} "${name}"`);
publish('automation.created', `Vytvořena automatizace ${id}: ${name}`, { automationId: id }, tenantId);
return toDetail(stored);
}
export function updateAutomation(
id: string,
patch: { name?: string; enabled?: boolean; flow?: AutomationFlow },
tenantIds: string[],
): AutomationDetail | undefined {
const stored = findWritable(id, tenantIds);
if (!stored) {
console.warn(`[automations] pokus o upravu nedostupne automatizace: ${id}`);
return undefined;
}
const flow = withWebhookToken(patch.flow ?? stored.flow, stored.flow, id);
const updated = withDerived({
...stored,
name: patch.name ?? stored.name,
enabled: patch.enabled ?? stored.enabled,
flow,
kind: deriveKind(flow),
updatedAt: new Date().toISOString(),
});
// Nedokoncenou automatizaci nepustime do provozu - "aktivni" by nic nedelala
// nebo by delala neco jineho, nez uzivatel ceka.
const issues = updated.issues ?? [];
if (updated.enabled && issues.length > 0) {
console.warn(`[automations] ${id}: zapnuti odmitnuto - ${issues.join(' ')}`);
updated.enabled = false;
}
save(updated);
console.info(
`[automations] ulozena automatizace ${id} (kroku: ${updated.stepCount}, aktivni: ${updated.enabled}, nedodelku: ${issues.length})`,
);
publish('automation.updated', `Automatizace ${id} uložena: ${updated.name}`, {
automationId: id,
enabled: updated.enabled,
}, updated.tenantId);
return toDetail(updated);
}
/** Vygeneruje novy token - stara adresa okamzite prestane fungovat. */
export function regenerateWebhookToken(
id: string,
tenantIds: string[],
): AutomationDetail | undefined {
const stored = findWritable(id, tenantIds);
if (!stored) {
console.warn(`[automations] regenerace tokenu pro nedostupnou automatizaci: ${id}`);
return undefined;
}
if (stored.flow.trigger?.serviceId !== 'webhook') {
console.warn(`[automations] ${id}: regenerace tokenu, ale spoustec neni webhook`);
return undefined;
}
// Token je soucast nedodelku ("webhook nema adresu"), proto se dopocitava znovu.
const updated = withDerived({
...stored,
flow: {
...stored.flow,
trigger: { ...stored.flow.trigger, webhookToken: generateWebhookToken() },
},
updatedAt: new Date().toISOString(),
});
save(updated);
console.info(`[automations] ${id}: token webhooku pregenerovan, stara adresa neplati`);
return toDetail(updated);
}
/** Najde automatizaci podle tokenu v adrese webhooku. */
export function findByWebhookToken(token: string): AutomationDetail | undefined {
for (const stored of store.values()) {
if (stored.flow.trigger?.webhookToken && timingSafeEqualString(stored.flow.trigger.webhookToken, token)) return toDetail(stored);
}
return undefined;
}
/** Zapise beh automatizace - drzi metriky i graf zive. */
export function recordRun(id: string, ok = true, tenantIds?: string[]): AutomationDetail | undefined {
// Bez omezeni volá webhook, ktery se autorizuje tokenem, ne prihlasenim.
const stored = tenantIds ? findWritable(id, tenantIds) : store.get(id);
if (!stored) {
console.warn(`[automations] recordRun pro nedostupnou automatizaci: ${id}`);
return undefined;
}
/*
* Zapisuje se do dnesniho dne. Po pulnoci vznikne novy zaznam, takze citac
* nepokracuje pres den - to byla puvodni chyba.
*/
const now = today();
const days = [...(stored.days ?? [])];
const index = days.findIndex((entry) => entry.day === now);
const current = index === -1 ? undefined : days[index];
if (current === undefined) {
days.push({ day: now, runs: 1, ok: ok ? 1 : 0 });
} else {
days[index] = {
day: now,
runs: current.runs + 1,
ok: current.ok + (ok ? 1 : 0),
};
}
if (days.length > KEEP_DAYS) days.splice(0, days.length - KEEP_DAYS);
const total = (stored.runsTotal ?? stored.runsToday) + 1;
const dayEntry = days.find((entry) => entry.day === now)!;
const updated: StoredAutomation = {
...stored,
days,
runsTotal: total,
runsToday: dayEntry.runs,
successRate: Math.round((dayEntry.ok / dayEntry.runs) * 1000) / 10,
lastRunAt: new Date().toISOString(),
};
save(updated);
publish(
'automation.run',
ok
? `Automatizace ${id} proběhla: ${updated.name}`
: `Automatizace ${id} skončila chybou: ${updated.name}`,
{ automationId: id, ok },
updated.tenantId,
);
return toDetail(updated);
}
export function deleteAutomation(id: string, tenantIds: string[]): boolean {
const stored = findWritable(id, tenantIds);
const name = stored?.name;
const existed = stored !== undefined && store.delete(id);
if (existed) mirror.drop(id);
if (!existed) {
console.warn(`[automations] pokus o smazani nedostupne automatizace: ${id}`);
} else {
console.info(`[automations] smazana automatizace ${id}`);
publish('automation.deleted', `Automatizace ${id} smazána: ${name ?? ''}`, {
automationId: id,
}, stored?.tenantId ?? null);
}
return existed;
}
+227
View File
@@ -0,0 +1,227 @@
/**
* Co se ze stromu dopocitava: pocet kroku, nedodelky a druh automatizace.
*
* Vsechno jsou ciste funkce nad stromem. Uklada se to s automatizaci
* (`withDerived`), aby se strom neprochazel pri kazdem cteni seznamu.
*/
import { isUnary } from '../conditions.js';
import { collectScopes, duplicateNames, scopeFor } from '../flowScope.js';
import { rootsOf } from '../model.js';
import { actionInputsFor, findService } from '../services.js';
import { referencedFields, rootOf } from '../templates.js';
import type {
AutomationFlow,
AutomationKind,
FlowStep,
TriggerField,
} from '../../shared/automations.js';
import { rulesOf, type StoredAutomation } from './model.js';
/** Rekurzivne secte kroky vcetne obou vetvi podminek. */
export function countSteps(steps: FlowStep[]): number {
return steps.reduce((sum, step) => {
if (step.kind === 'condition') {
return sum + 1 + countSteps(step.yes) + countSteps(step.no);
}
if (step.kind === 'foreach') {
return sum + 1 + countSteps(step.steps);
}
return sum + 1;
}, 0);
}
/**
* Nedodelky v nastaveni jednoho kroku: nevyplnene povinne pole a odkaz
* na parametr, ktery u spoustece neexistuje.
*
* Druhy pripad nastane hlavne po prejmenovani parametru. Sablonu proto
* nezahazujeme ani tise neopravujeme, jen rekneme, kde se ma doplnit.
*/
function actionInputIssues(
step: Extract<FlowStep, { kind: 'action' }>,
available: TriggerField[],
/**
* Koreny, ktere jsou v poradku i kdyz nejsou deklarovanym parametrem:
* klice z ukazky tela, ID predchozich kroku, `item` a `index` uvnitr smycky.
*/
extraRoots: Set<string>,
): string[] {
const knownNames = new Set(available.map((field) => field.name));
const catalog = actionInputsFor(step.serviceId, step.operationId);
if (catalog.length === 0) return [];
const issues: string[] = [];
const operationName = findService(step.serviceId)?.name ?? step.serviceId;
for (const field of catalog) {
const value = step.inputs?.[field.id] ?? '';
if (field.required && value.trim().length === 0) {
issues.push(`Krok "${operationName}": chybí ${field.label.toLowerCase()}.`);
continue;
}
for (const reference of referencedFields(value)) {
/*
* Overuje se jen **prvni cast** odkazu. Zbytek je cesta do struktury
* a tu predem overit nejde - co presne prijde v tele, vime az pri behu.
*/
const root = rootOf(reference);
if (!knownNames.has(root) && !extraRoots.has(root)) {
issues.push(
`Krok "${operationName}", pole ${field.label.toLowerCase()}: parametr "${root}" u spouštěče neexistuje.`,
);
}
}
}
return issues;
}
/**
* Co brani zapnuti automatizace. Zamerne to NENI chyba pri ukladani -
* rozdelanou praci chceme ulozit, jen ji nesmime pustit do provozu.
*/
export function collectFlowIssues(flow: AutomationFlow): string[] {
const issues: string[] = [];
if (!flow.trigger) {
issues.push('Chybí spouštěč.');
return issues;
}
if (flow.steps.length === 0) {
issues.push('Automatizace nemá žádný krok.');
}
// Webhook bez registrovaneho tokenu nelze zavolat.
const isWebhook = flow.trigger.serviceId === 'webhook';
if (isWebhook && !flow.trigger.webhookToken) {
issues.push('Webhook nemá vygenerovanou adresu.');
}
const scopes = collectScopes(flow);
/*
* Co dalsiho smi stat na zacatku odkazu. Klice z ukazky tela, protoze prave
* kvuli nim se ukazka vlepuje, a `_body` s celym telem.
*/
const sampleRoots = rootsOf(flow.trigger.sample);
sampleRoots.add('_body');
const walk = (steps: FlowStep[], roots: Set<string>) => {
for (const step of steps) {
const available = scopeFor(scopes, step.id);
for (const name of duplicateNames(available)) {
issues.push(
`Parametr "${name}" je v tomto místě stromu dvakrát, v šabloně by nešlo poznat který.`,
);
}
if (step.kind === 'action') {
issues.push(...actionInputIssues(step, available, roots));
// ID kroku smi stat na zacatku odkazu: `{{st_faktura.invoiceId}}`.
roots.add(step.id);
continue;
}
if (step.kind === 'foreach') {
if (step.path.trim().length === 0) {
issues.push('Smyčka nemá vyplněnou cestu k seznamu.');
}
// Uvnitr smycky pribyva polozka a poradi, po ni vysledky za cely seznam.
walk(step.steps, new Set([...roots, 'item', 'index']));
roots.add(step.id);
continue;
}
// Kazda otazka podminky zvlast. Jedna spatna nesmi schovat ostatni.
const rules = rulesOf(step);
if (rules.length === 0) issues.push('Podmínka nemá žádnou otázku.');
for (const rule of rules) {
const field = available.find((candidate) => candidate.id === rule.fieldId);
if (!field) {
// Rozlisujeme "neexistuje" od "vznikne az pozdeji". Druhy pripad nastane
// po presunuti kroku a chce jinou radu nez smazat podminku.
issues.push(
scopes.all.has(rule.fieldId)
? 'Podmínka se ptá na parametr, který vzniká až v pozdějším kroku. Posuňte ji níž.'
: 'Podmínka se odkazuje na parametr, který už neexistuje.',
);
} else if (!isUnary(rule.operator) && (rule.value ?? '').trim().length === 0) {
issues.push(`Podmínka nad parametrem "${field.name}" nemá vyplněnou hodnotu.`);
} else if (field.type === 'number' && !isUnary(rule.operator)) {
if (Number.isNaN(Number(rule.value))) {
issues.push(`Podmínka nad parametrem "${field.name}" má nečíselnou hodnotu.`);
}
}
}
/*
* Koreny z vetvi se **nesou dal**. Vystup z vetve je za podminkou
* k dispozici, jen nepovinne - viz data/flowScope.ts.
*/
walk(step.yes, roots);
walk(step.no, roots);
}
};
walk(flow.steps, sampleRoots);
// Stejny nedodelek muze vyjit z vic kroku (typicky duplicitni jmeno parametru).
// Uzivateli staci rict jednou.
return [...new Set(issues)];
}
/** Pouziva strom nekde konektor z dane kategorie? */
function flowUsesCategory(steps: FlowStep[], category: string): boolean {
return steps.some((step) => {
if (step.kind === 'condition') {
return flowUsesCategory(step.yes, category) || flowUsesCategory(step.no, category);
}
if (step.kind === 'foreach') return flowUsesCategory(step.steps, category);
return findService(step.serviceId)?.category === category;
});
}
/**
* Druh automatizace se dopocitava ze stromu - klient ho nezadava.
* Je to jen stitek v seznamu, proto zamerne jednoducha heuristika:
* rozhoduje spoustec, u planovace jeste to, zda se ve krocich pracuje s analytikou.
*/
export function deriveKind(flow: AutomationFlow): AutomationKind {
if (!flow.trigger) return 'workflow';
const connector = findService(flow.trigger.serviceId);
if (!connector) {
console.warn(`[automations] spoustec odkazuje na neznama sluzba: ${flow.trigger.serviceId}`);
return 'workflow';
}
if (connector.id === 'voicebot') return 'voicebot';
if (connector.category === 'analytika') return 'report';
// Planovac + prace s analytikou = pravidelny report, ne obecne workflow.
if (connector.id === 'scheduler' && flowUsesCategory(flow.steps, 'analytika')) return 'report';
if (
connector.category === 'crm' ||
connector.category === 'ekonomika' ||
connector.category === 'logistika'
) {
return 'integrace';
}
return 'workflow';
}
/** Doplni to, co se ze stromu dopocitava a uklada s nim (nedodelky, pocet kroku). */
export function withDerived(automation: StoredAutomation): StoredAutomation {
return {
...automation,
stepCount: countSteps(automation.flow.steps),
issues: collectFlowIssues(automation.flow),
};
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Webhook automatizace: token v adrese a zaznamy poslednich volani.
*
* Token spravuje vyhradne server, viz `withWebhookToken`. Volani se drzi jen
* v pameti - je to napoveda pri ladeni, ne historie.
*/
import { randomBytes } from 'node:crypto';
import type { AutomationFlow, WebhookCall } from '../../shared/automations.js';
/** Delka tokenu v bajtech. 24 bajtu je 32 znaku base64url, dost na neuhodnutelnost. */
const WEBHOOK_TOKEN_BYTES = 24;
/** Neodhadnutelny token do adresy webhooku (32 znaku, base64url). */
export function generateWebhookToken(): string {
return randomBytes(WEBHOOK_TOKEN_BYTES).toString('base64url');
}
/** Kolik znaku tela se u volani drzi. Vic uz je v pameti zbytecne. */
const MAX_BODY = 8_000;
/** Telo do zaznamu: zmrazene na retezec, aby se pozdeji nezmenilo pod rukama. */
export function bodyForCall(body: unknown): { body: string; truncated: boolean } {
let text: string;
try {
text = JSON.stringify(body, null, 2) ?? '';
} catch {
// Cyklicka struktura nebo neco, co JSON neumi. Radeji nic nez pad.
text = '';
}
if (text.length <= MAX_BODY) return { body: text, truncated: false };
return { body: text.slice(0, MAX_BODY), truncated: true };
}
/** Kolik poslednich volani se u automatizace drzi. */
const MAX_CALLS = 10;
const calls = new Map<string, WebhookCall[]>();
/** Zapise, jak dopadlo jedno volani webhooku. Nejnovejsi je prvni. */
export function recordWebhookCall(automationId: string, call: WebhookCall): void {
const list = calls.get(automationId) ?? [];
list.unshift(call);
if (list.length > MAX_CALLS) list.length = MAX_CALLS;
calls.set(automationId, list);
}
/** Poslednich par volani. Cte se jen pres detail automatizace, ktery hlida firmu. */
export function recentWebhookCalls(automationId: string): WebhookCall[] {
return calls.get(automationId) ?? [];
}
/**
* Token webhooku spravuje VYHRADNE server:
* - webhook spoustec bez tokenu ho dostane vygenerovany,
* - existujici token se prevezme z ulozene verze (klient ho nemuze zmenit),
* - pri zmene spoustece na neco jineho se token zahodi.
*/
export function withWebhookToken(
next: AutomationFlow,
previous: AutomationFlow,
id: string,
): AutomationFlow {
if (!next.trigger) return next;
if (next.trigger.serviceId !== 'webhook') {
if (next.trigger.webhookToken) {
console.info(`[automations] ${id}: spoustec neni webhook, zahazuji token`);
}
return { ...next, trigger: { ...next.trigger, webhookToken: undefined } };
}
// Existujici token drzime, aby se uz zaregistrovana adresa nezmenila pod rukama.
const keptToken =
previous.trigger?.serviceId === 'webhook' ? previous.trigger.webhookToken : undefined;
if (keptToken) {
return { ...next, trigger: { ...next.trigger, webhookToken: keptToken } };
}
const token = generateWebhookToken();
console.info(`[automations] ${id}: vygenerovana adresa webhooku`);
return { ...next, trigger: { ...next.trigger, webhookToken: token } };
}
+13 -3
View File
@@ -41,6 +41,16 @@ interface ConnectorRow {
updated_at: Date;
}
/**
* Radek z `RETURNING`. Zapis, ktery nic nevrati, je chyba databaze, ne stav,
* ktery by mel volajici resit - proto vyjimka s jasnou hlaskou.
*/
function returnedRow(rows: ConnectorRow[], operation: string): ConnectorRow {
const row = rows[0];
if (row === undefined) throw new Error(`Databaze nevratila radek konektoru (${operation}).`);
return row;
}
function toConnector(row: ConnectorRow): Connector {
return {
id: row.id,
@@ -149,7 +159,7 @@ export const postgresConnectors: ConnectorRepository = {
],
);
return toConnector(row.rows[0]);
return toConnector(returnedRow(row.rows, 'create'));
});
},
@@ -205,7 +215,7 @@ export const postgresConnectors: ConnectorRepository = {
],
);
return toConnector(updated.rows[0]);
return toConnector(returnedRow(updated.rows, 'update'));
});
},
@@ -282,7 +292,7 @@ export const postgresConnectors: ConnectorRepository = {
RETURNING ${COLUMNS}`,
[id, JSON.stringify(sealAll(merged))],
);
return toConnector(updated.rows[0]);
return toConnector(returnedRow(updated.rows, 'setManagedValues'));
});
},
+1
View File
@@ -43,6 +43,7 @@ export function suggestRange(raw: string | null): string | null {
const parts = ip.split('.');
if (parts.length !== 4) return null;
const [first, second] = parts.map((part) => Number(part));
if (first === undefined || second === undefined) return null;
if (!Number.isInteger(first) || !Number.isInteger(second)) return null;
if (first === 127) return '127.0.0.0/8';
+3 -2
View File
@@ -34,8 +34,9 @@ export function getSummary(tenantIds: string[]): DashboardSummary {
// Dnesni sloupec grafu doplnujeme o skutecne behy automatizaci.
const runsToday = automations.reduce((sum, a) => sum + a.runsToday, 0);
if (series.length > 0) {
series[series.length - 1] = { ...series[series.length - 1], runs: runsToday };
const last = series.at(-1);
if (last !== undefined) {
series[series.length - 1] = { ...last, runs: runsToday };
}
return {
+4 -2824
View File
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
/**
* AI a hlas: voicebot, prepis hovoru, OpenAI.
*/
import type { Service } from '../../../shared/services.js';
export const aiServices: Service[] = [
{
id: 'voicebot',
name: 'Voicebot',
category: 'ai',
description: 'Hlasová linka: příjem hovorů, rozpoznání záměru, předání operátorovi.',
icon: 'PhoneCall',
status: 'available',
general: false,
appId: 'voicebot',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [
{
id: 'call-received',
name: 'Příchozí hovor',
description: 'Spustí se při přijetí hovoru na hlasovou linku.',
providedFields: [
{ id: 'voicebot.callerNumber', name: 'callerNumber', type: 'string', required: true },
{ id: 'voicebot.line', name: 'line', type: 'string', required: true },
{ id: 'voicebot.wantsOperator', name: 'wantsOperator', type: 'boolean', required: false },
{ id: 'voicebot.startedAt', name: 'startedAt', type: 'date', required: true },
],
},
{
id: 'call-ended',
name: 'Hovor ukončen',
description: 'Spustí se po skončení hovoru, k dispozici je přepis i záměr.',
},
],
actions: [
{
id: 'play-scenario',
name: 'Přehrát scénář',
description: 'Provede volajícího hlasovým scénářem a vrátí odpovědi.',
fields: ['Scénář', 'Jazyk'],
},
{
id: 'transfer',
name: 'Předat operátorovi',
description: 'Přepojí hovor na člověka a předá mu souhrn.',
fields: ['Skupina', 'Souhrn'],
},
{
id: 'outbound-call',
name: 'Zavolat zákazníkovi',
description: 'Zahájí odchozí hovor podle scénáře.',
fields: ['Telefon', 'Scénář'],
},
],
},
{
id: 'transcription',
name: 'Přepis hovoru',
category: 'ai',
description: 'Přepis zvuku na text (Deepgram + Whisper) se sloučením výsledků.',
icon: 'FileAudio',
status: 'available',
general: false,
appId: 'audio-transcription',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'deepgramApiKey',
label: 'Deepgram API klíč',
target: 'header',
name: 'X-Deepgram-Api-Key',
required: true,
secret: true,
hint: 'Deepgram Console, sekce API Keys.',
},
{
id: 'openaiApiKey',
label: 'OpenAI API klíč',
target: 'header',
name: 'X-OpenAI-Api-Key',
required: true,
secret: true,
hint: 'platform.openai.com, sekce API keys. Použije se na Whisper i na sloučení.',
},
],
// Sluzba nema zadne cteci volani s autorizaci: prepis se uctuje a jiny
// endpoint neni. Overi se proto jen dostupnost a rekne se to nahlas.
triggers: [],
actions: [
{
id: 'transcribe',
name: 'Přepsat nahrávku',
description:
'Přepíše nahrávku dvěma enginy naráz a oba přepisy sloučí do jednoho výsledku.',
fields: ['Nahrávka', 'Jazyk'],
},
],
},
{
id: 'openai',
name: 'OpenAI',
category: 'ai',
description:
'Dotazy na jazykové modely, práce se soubory a přepis zvuku pod vlastním API klíčem.',
icon: 'Bot',
status: 'available',
general: false,
appId: null,
baseUrl: 'https://api.openai.com/v1',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'apiKey',
label: 'API klíč',
target: 'header',
name: 'Authorization',
// Uzivatel vlepi klic tak, jak ho dostal. Slovo Bearer dopise runtime.
prefix: 'Bearer ',
required: true,
secret: true,
hint: 'platform.openai.com, sekce API keys. Vložte jen klíč, slovo Bearer doplní portál.',
},
{
id: 'organization',
label: 'ID organizace',
target: 'header',
name: 'OpenAI-Organization',
required: false,
secret: false,
hint: 'Jen když účet patří do víc organizací a útrata se má počítat konkrétní z nich.',
},
{
id: 'project',
label: 'ID projektu',
target: 'header',
name: 'OpenAI-Project',
required: false,
secret: false,
hint: 'Rozliší útratu mezi projekty jedné organizace.',
},
],
// Seznam modelu: nejlevnejsi cteci volani, ktere vyzaduje platny klic.
verifyPath: '/models',
triggers: [],
actions: [
{
id: 'chat',
name: 'Zeptat se modelu',
description: 'Pošle otázku vybranému modelu a vrátí odpověď i spotřebu tokenů.',
fields: ['Model', 'Instrukce', 'Otázka'],
},
{
id: 'ask-about-file',
name: 'Zeptat se na soubor',
description: 'Odpoví na otázku nad nahraným souborem nebo obrázkem.',
fields: ['Model', 'ID souboru', 'Otázka'],
},
{
id: 'upload-file',
name: 'Nahrát soubor',
description: 'Odešle soubor do OpenAI a vrátí jeho ID pro další kroky.',
fields: ['Název souboru', 'Obsah', 'Účel'],
},
{
id: 'transcribe-audio',
name: 'Přepsat zvuk',
description: 'Přepíše nahrávku na text jedním z přepisovacích modelů.',
fields: ['Nahrávka', 'Model', 'Jazyk'],
},
{
id: 'list-models',
name: 'Načíst seznam modelů',
description: 'Vrátí modely, na které účet dosáhne. Nic nemění a nic nestojí.',
},
],
},
];
+273
View File
@@ -0,0 +1,273 @@
/**
* Analytika a reklama: GA4, Search Console, Google Ads, Sklik, Meta Ads.
*/
import type { Service } from '../../../shared/services.js';
export const analyticsServices: Service[] = [
{
id: 'ga4',
name: 'Google Analytics 4',
category: 'analytika',
description: 'Návštěvnost, konverze a chování uživatelů.',
icon: 'BarChart3',
status: 'available',
general: false,
appId: 'analytics',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'credentials',
label: 'Klíč service accountu (Base64)',
target: 'header',
name: 'X-GA-Credentials',
required: false,
secret: true,
hint: 'JSON klíč service accountu zakódovaný do Base64. Vyplňte tohle, nebo access token.',
},
{
id: 'accessToken',
label: 'Access token',
target: 'header',
name: 'X-GA-Access-Token',
required: false,
secret: true,
hint: 'Hotový OAuth2 token. Platí zhruba hodinu, takže na provoz se hodí service account.',
},
{
id: 'quotaProject',
label: 'Projekt pro kvótu',
target: 'header',
name: 'X-GA-Quota-Project',
required: false,
secret: false,
hint: 'ID projektu v Google Cloud, na který se má počítat kvóta.',
},
],
// Seznam uctu: cteci volani, ktere bez platnych udaju neprojde.
verifyPath: '/ga/admin/accountSummaries',
triggers: [],
actions: [
{
id: 'run-report',
name: 'Načíst report',
description: 'Stáhne metriky za období pro další zpracování nebo report.',
fields: ['Property', 'Metriky', 'Dimenze', 'Období'],
},
],
},
{
id: 'search-console',
name: 'Search Console',
category: 'analytika',
description: 'Pozice ve vyhledávání, dotazy a prokliky.',
icon: 'Search',
status: 'available',
general: false,
appId: 'analytics',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'credentials',
label: 'Klíč service accountu (Base64)',
target: 'header',
name: 'X-GSC-Credentials',
required: false,
secret: true,
hint: 'JSON klíč service accountu zakódovaný do Base64. Vyplňte tohle, nebo access token.',
},
{
id: 'accessToken',
label: 'Access token',
target: 'header',
name: 'X-GSC-Access-Token',
required: false,
secret: true,
hint: 'Hotový OAuth2 token se scope webmasters.readonly.',
},
{
id: 'quotaProject',
label: 'Projekt pro kvótu',
target: 'header',
name: 'X-GSC-Quota-Project',
required: false,
secret: false,
},
],
// Seznam webu v uctu: cteci volani, ktere bez platnych udaju neprojde.
verifyPath: '/gsc/sites',
triggers: [],
actions: [
{
id: 'run-report',
name: 'Načíst výkon ve vyhledávání',
description: 'Vrátí dotazy, prokliky, zobrazení a průměrnou pozici.',
fields: ['Web', 'Období'],
},
],
},
{
id: 'google-ads',
name: 'Google Ads',
category: 'analytika',
description: 'Výkon kampaní a náklady na reklamu.',
icon: 'Megaphone',
status: 'available',
general: false,
appId: 'analytics',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'developerToken',
label: 'Developer token',
target: 'header',
name: 'X-GAds-Developer-Token',
required: true,
secret: true,
hint: 'Ze správcovského (MCC) účtu: Tools, API Center. Musí mít schválený přístup.',
},
{
id: 'accessToken',
label: 'Access token',
target: 'header',
name: 'X-GAds-Access-Token',
required: false,
secret: true,
hint: 'OAuth2 token se scope adwords. Vyplňte tohle, nebo klíč service accountu.',
},
{
id: 'credentials',
label: 'Klíč service accountu (Base64)',
target: 'header',
name: 'X-GAds-Credentials',
required: false,
secret: true,
hint: 'Funguje jen se zapnutou domain-wide delegation.',
},
{
id: 'loginCustomerId',
label: 'ID správcovského účtu',
target: 'header',
name: 'X-GAds-Login-Customer-Id',
required: false,
secret: false,
hint: 'MCC účet, přes který se přistupuje k podřízenému účtu. Bez pomlček.',
},
],
// Seznam uctu, na ktere udaje dosahnou. Nic nemeni.
verifyPath: '/googleads/customers:listAccessibleCustomers',
triggers: [],
actions: [
{
id: 'campaign-report',
name: 'Načíst výkon kampaní',
description: 'Stáhne náklady, konverze a ROAS podle kampaní.',
fields: ['Účet', 'Období'],
},
],
},
{
id: 'sklik',
name: 'Sklik',
category: 'analytika',
description: 'Kampaně a náklady v Skliku.',
icon: 'MousePointer',
status: 'available',
general: false,
appId: 'analytics',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'token',
label: 'Token API Drak',
target: 'header',
name: 'X-Sklik-Token',
required: true,
secret: true,
hint:
'Sklik: uživatelské jméno, Nastavení, Přístup k API Drak. ' +
'Vygenerování nového tokenu zneplatní ten předchozí.',
},
{
id: 'userId',
label: 'ID cizího účtu',
target: 'header',
name: 'X-Sklik-User-Id',
required: false,
secret: false,
hint: 'Jen pro agenturní přístup ke spravovanému účtu.',
},
],
// Kvoty a limity uctu: cteci volani, ktere bez platneho tokenu neprojde.
verifyPath: '/sklik/limits',
triggers: [],
actions: [
{
id: 'campaign-report',
name: 'Načíst výkon kampaní',
description: 'Stáhne statistiky kampaní za období.',
fields: ['Účet', 'Období'],
},
],
},
{
id: 'meta-ads',
name: 'Meta Ads',
category: 'analytika',
description: 'Výkon reklam na Facebooku a Instagramu: účty, kampaně, sestavy a insighty.',
icon: 'Facebook',
status: 'available',
general: false,
appId: 'meta',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'accessToken',
label: 'Access token',
target: 'header',
name: 'X-Meta-Access-Token',
required: true,
secret: true,
hint:
'Token systémového uživatele z Business Manageru. Nepřestane platit, ' +
'když někdo odejde z firmy, na rozdíl od uživatelského tokenu.',
},
{
id: 'appSecret',
label: 'App Secret',
target: 'header',
name: 'X-Meta-App-Secret',
required: false,
secret: true,
hint:
'Se zapnutým appsecret_proof je povinný, jinak Meta volání odmítne. ' +
'Služba z něj podpis dopočítá sama.',
},
{
id: 'apiVersion',
label: 'Verze Graph API',
target: 'header',
name: 'X-Meta-Api-Version',
required: false,
secret: false,
hint: 'Například v25.0. Bez vyplnění se použije verze nastavená ve službě.',
},
],
// Seznam reklamnich uctu, na ktere token dosahne. Nic nemeni.
verifyPath: '/ads/me/adaccounts',
triggers: [],
actions: [
{
id: 'list-accounts',
name: 'Načíst reklamní účty',
description: 'Vrátí účty, na které přihlašovací údaje dosáhnou.',
},
{
id: 'insights',
name: 'Načíst výkon reklam',
description: 'Stáhne útratu, prokliky a konverze za období pro účet, kampaň nebo sestavu.',
fields: ['Objekt', 'Období', 'Úroveň'],
},
],
},
];
+114
View File
@@ -0,0 +1,114 @@
/**
* CRM: Raynet.
*/
import type { Service } from '../../../shared/services.js';
export const crmServices: Service[] = [
{
id: 'raynet',
name: 'RAYNET CRM',
category: 'crm',
description: 'Firmy, kontakty, obchodní případy a aktivity v RAYNET CRM.',
icon: 'Users',
status: 'available',
general: false,
appId: 'raynet',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'apiKey',
label: 'API klíč',
target: 'header',
name: 'X-Api-Key',
required: true,
secret: true,
hint: 'RAYNET CRM: Nastavení, Klíč k API.',
},
{
id: 'email',
label: 'E-mail uživatele',
target: 'header',
name: 'X-Raynet-Email',
required: true,
secret: false,
hint: 'Přihlašovací e-mail. S API klíčem tvoří Basic Auth.',
},
{
id: 'instanceName',
label: 'Název instance',
target: 'header',
name: 'X-Instance-Name',
required: true,
secret: false,
hint: 'Subdoména účtu, tedy část před .raynetcrm.com.',
},
],
// Seznam firem o jedne polozce: nic nemeni a bez platnych udaju neprojde.
verifyPath: '/company?limit=1',
triggers: [
{
id: 'lead-created',
name: 'Nový obchodní případ',
description: 'Spustí se při založení nového obchodního případu.',
},
{
id: 'company-changed',
name: 'Změna firmy',
description: 'Spustí se při úpravě údajů firmy.',
},
],
actions: [
{
id: 'create-lead',
name: 'Založit obchodní případ',
description: 'Vytvoří nový obchodní případ včetně napojení na firmu.',
fields: ['Název', 'Firma', 'Vlastník', 'Fáze'],
outputFields: [{ id: 'raynet.leadId', name: 'leadId', type: 'string', required: true }],
},
{
id: 'find-company',
name: 'Dohledat firmu',
description:
'Zjistí, jestli odesílatele známe. Nic nezakládá. Podle výsledku se pak strom větví.',
inputs: [
{
id: 'email',
label: 'E-mail',
kind: 'text',
required: false,
hint: 'Například {{from}} u e-mailu.',
},
{
id: 'phone',
label: 'Telefon',
kind: 'text',
required: false,
hint: 'Například {{phone}} u WhatsApp.',
},
],
outputFields: [
{ id: 'raynet.customerKnown', name: 'customerKnown', type: 'boolean', required: true },
{ id: 'raynet.companyId', name: 'companyId', type: 'string', required: false },
{ id: 'raynet.companyName', name: 'companyName', type: 'string', required: false },
{ id: 'raynet.ownerName', name: 'ownerName', type: 'string', required: false },
],
},
{
id: 'upsert-contact',
name: 'Založit nebo aktualizovat kontakt',
description: 'Podle e-mailu kontakt najde a doplní, jinak vytvoří nový.',
fields: ['E-mail', 'Jméno', 'Telefon', 'Firma'],
outputFields: [
{ id: 'raynet.contactId', name: 'contactId', type: 'string', required: true },
],
},
{
id: 'add-activity',
name: 'Přidat aktivitu',
description: 'Zapíše hovor, e-mail nebo poznámku k záznamu.',
fields: ['Typ aktivity', 'Text', 'Vazba na záznam'],
},
],
},
];
+185
View File
@@ -0,0 +1,185 @@
/**
* E-mail: prijem i odeslani.
*/
import type { Service } from '../../../shared/services.js';
export const emailServices: Service[] = [
{
id: 'email',
name: 'E-mail',
category: 'komunikace',
description: 'Příjem i odesílání e-mailů včetně příloh.',
icon: 'Mail',
status: 'available',
general: false,
// Posmovni server neni nase aplikace za /apps a adresa je u konektoru,
// protoze kazda firma odesila ze sve schranky.
appId: null,
transport: 'smtp',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'host',
label: 'SMTP server',
target: 'config',
name: 'host',
required: true,
secret: false,
hint: 'Například smtp.seznam.cz nebo smtp.gmail.com.',
},
{
id: 'port',
label: 'Port',
target: 'config',
name: 'port',
required: true,
secret: false,
hint: '587 pro STARTTLS, 465 pro šifrované spojení od začátku, 25 bez šifrování.',
},
{
id: 'security',
label: 'Šifrování',
target: 'config',
name: 'security',
required: false,
secret: false,
hint:
'Prázdné se řídí portem: 465 je ssl, jinak starttls. ' +
'Přepsat jde hodnotou ssl, starttls nebo zadne.',
},
{
id: 'user',
label: 'Uživatel',
target: 'config',
name: 'user',
required: true,
secret: false,
hint: 'Přihlašovací jméno ke schránce, obvykle celá e-mailová adresa.',
},
{
id: 'password',
label: 'Heslo',
target: 'config',
name: 'password',
required: true,
secret: true,
hint:
'U schránek s dvoufázovým ověřením to musí být heslo pro aplikaci, ' +
'ne heslo k účtu.',
},
{
id: 'from',
label: 'Adresa odesílatele',
target: 'config',
name: 'from',
required: true,
secret: false,
hint: 'Server ji musí povolit. Obvykle stejná jako uživatel.',
},
{
id: 'fromName',
label: 'Jméno odesílatele',
target: 'config',
name: 'fromName',
required: false,
secret: false,
hint: 'Co uvidí příjemce místo holé adresy, například Podpora Automia.',
},
{
id: 'replyTo',
label: 'Adresa pro odpovědi',
target: 'config',
name: 'replyTo',
required: false,
secret: false,
hint: 'Kam mají chodit odpovědi, když jinam než na adresu odesílatele.',
},
],
triggers: [
{
id: 'received',
name: 'Přijat e-mail',
description:
'Spustí se při doručení e-mailu do sledované schránky. Typický začátek ticketu.',
providedFields: [
{ id: 'email.from', name: 'from', type: 'string', required: true },
{ id: 'email.subject', name: 'subject', type: 'string', required: true },
{ id: 'email.body', name: 'body', type: 'string', required: false },
{ id: 'email.hasAttachment', name: 'hasAttachment', type: 'boolean', required: false },
{ id: 'email.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
],
actions: [
{
id: 'send',
name: 'Odeslat e-mail',
description:
'Odešle zprávu ze schránky uvedené v konektoru. Předmět, příjemce ' +
'i tělo se skládají z parametrů spouštěče a výstupů předchozích kroků.',
inputs: [
{
id: 'to',
label: 'Příjemce',
kind: 'text',
required: true,
hint: 'Adresy oddělené čárkou. Například {{from}}, když odpovídáte na příchozí e-mail.',
},
{
id: 'cc',
label: 'Kopie',
kind: 'text',
required: false,
hint: 'Adresy oddělené čárkou.',
},
{
id: 'bcc',
label: 'Skrytá kopie',
kind: 'text',
required: false,
hint: 'Příjemci se navzájem neuvidí.',
},
{
id: 'subject',
label: 'Předmět',
kind: 'text',
required: true,
hint: 'Například Ticket {{ticketId}}: {{subject}}.',
},
{
id: 'html',
label: 'Tělo zprávy (HTML)',
kind: 'html',
required: true,
hint:
'Píše se jako HTML. Parametry se dosazují stejně jako jinde, ' +
'tedy {{jmeno}}, a dosazuje se bezpečně - ostré závorky v hodnotě ' +
'rozvržení nerozhodí.',
},
{
id: 'text',
label: 'Textová verze',
kind: 'longtext',
required: false,
hint:
'Pro klienty, kteří HTML nezobrazí. Bez vyplnění se vyrobí z HTML ' +
'odstraněním značek.',
},
{
id: 'replyTo',
label: 'Adresa pro odpovědi',
kind: 'text',
required: false,
hint: 'Přebije adresu z konektoru. Hodí se, když má odpověď zamířit do ticketu.',
},
],
outputFields: [
{ id: 'email.messageId', name: 'messageId', type: 'string', required: true },
{ id: 'email.accepted', name: 'accepted', type: 'number', required: true },
{ id: 'email.rejected', name: 'rejected', type: 'number', required: true },
],
},
],
},
];
+280
View File
@@ -0,0 +1,280 @@
/**
* Ekonomika a banky: iDoklad, CSOB, SAP Business One.
*/
import type { Service } from '../../../shared/services.js';
export const financeServices: Service[] = [
{
id: 'idoklad',
name: 'iDoklad',
category: 'ekonomika',
description: 'Fakturace: vydané i přijaté doklady, kontakty, úhrady.',
icon: 'Receipt',
status: 'available',
general: false,
appId: 'idoklad',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'clientId',
label: 'Client ID',
target: 'header',
name: 'X-ClientId',
required: true,
secret: false,
hint: 'Z vývojářského portálu iDokladu.',
},
{
id: 'clientSecret',
label: 'Client Secret',
target: 'header',
name: 'X-ClientSecret',
required: true,
secret: true,
hint: 'Uloží se jen pro odesílání a nikdy se nevrací zpátky.',
},
{
id: 'applicationId',
label: 'Application ID',
target: 'header',
name: 'X-ApplicationId',
required: false,
secret: false,
hint: 'Jen partnerské aplikace. Běžná aplikace ho nepotřebuje.',
},
{
id: 'language',
label: 'Jazyk odpovědí',
target: 'header',
name: 'X-Idoklad-Language',
required: false,
secret: false,
hint: 'Cz, Sk nebo En.',
},
],
// Vrati udaje o agende: nic nemeni a bez platnych udaju neprojde.
verifyPath: '/account/agenda',
triggers: [
{
id: 'invoice-paid',
name: 'Faktura uhrazena',
description: 'Spustí se, jakmile je vydaná faktura označená jako zaplacená.',
},
{
id: 'invoice-overdue',
name: 'Faktura po splatnosti',
description: 'Spustí se v den, kdy faktura překročí splatnost.',
},
],
actions: [
{
id: 'create-invoice',
name: 'Vystavit fakturu',
description: 'Vytvoří vydanou fakturu včetně položek a odešle ji odběrateli.',
fields: ['Odběratel', 'Položky', 'Splatnost', 'Odeslat e-mailem'],
},
{
id: 'create-proforma',
name: 'Vystavit proforma fakturu',
description: 'Vytvoří zálohovou fakturu.',
fields: ['Odběratel', 'Položky'],
},
{
id: 'mark-paid',
name: 'Označit jako uhrazenou',
description: 'Zapíše úhradu k existující faktuře.',
fields: ['Číslo faktury', 'Datum úhrady'],
},
],
},
{
id: 'csob',
name: 'ČSOB (PSD2)',
category: 'ekonomika',
description: 'Bankovní pohyby a zůstatky přes PSD2 rozhraní ČSOB.',
icon: 'Landmark',
status: 'available',
general: false,
appId: 'csob',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'certificate',
label: 'Certifikát QWAC (Base64 PFX)',
target: 'header',
name: 'X-CSOB-Certificate',
required: true,
secret: true,
hint: 'eIDAS certifikát jako PFX zakódovaný do Base64. Slouží k mutual TLS.',
},
{
id: 'certificatePassword',
label: 'Heslo k certifikátu',
target: 'header',
name: 'X-CSOB-Certificate-Password',
required: false,
secret: true,
hint: 'Jen když je PFX chráněný heslem.',
},
{
id: 'apiKey',
label: 'API klíč aplikace',
target: 'header',
name: 'X-API-Key',
required: true,
secret: true,
hint: 'Z vývojářského portálu ČSOB. Posílá se dál jako APIKEY.',
},
{
id: 'tppName',
label: 'Název TPP',
target: 'header',
name: 'X-TPP-Name',
required: true,
secret: false,
hint: 'Název registrované organizace, posílá se dál jako TPP-Name.',
},
{
id: 'accessToken',
label: 'Access token klienta',
target: 'header',
name: 'X-Access-Token',
required: true,
secret: true,
hint:
'OAuth2 token konkrétního klienta banky. Získá se přes /oauth/* a je ' +
'časově omezený, takže po vypršení se musí přepsat.',
},
{
id: 'clientId',
label: 'OAuth Client ID',
target: 'header',
name: 'X-CSOB-Client-Id',
required: false,
secret: false,
hint: 'Jen pro obnovu tokenu přes OAuth endpointy.',
},
{
id: 'clientSecret',
label: 'OAuth Client Secret',
target: 'header',
name: 'X-CSOB-Client-Secret',
required: false,
secret: true,
hint: 'Jen pro obnovu tokenu přes OAuth endpointy.',
},
],
// Seznam uctu klienta: cteci volani, ktere bez platneho tokenu neprojde.
verifyPath: '/accounts?size=1',
triggers: [
{
id: 'payment-received',
name: 'Přijatá platba',
description: 'Spustí se při nové příchozí platbě na účtu.',
},
],
actions: [
{
id: 'list-transactions',
name: 'Načíst pohyby',
description: 'Stáhne transakce za zvolené období pro další zpracování.',
fields: ['Účet', 'Období'],
},
{
id: 'match-payment',
name: 'Spárovat platbu s fakturou',
description: 'Podle variabilního symbolu a částky najde odpovídající fakturu.',
fields: ['Tolerance částky'],
},
],
},
{
id: 'sap-bo',
name: 'SAP Business One',
category: 'ekonomika',
description: 'Obchodní partneři, položky, objednávky a doklady v SAP Business One.',
icon: 'Database',
status: 'available',
general: false,
appId: 'sap-bo',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'serviceLayerUrl',
label: 'Adresa Service Layer',
target: 'header',
name: 'X-SAP-B1-BaseUrl',
required: true,
secret: false,
hint: 'Například https://sap.firma.cz:50000. Adresa musí být dostupná z internetu.',
},
{
id: 'companyDb',
label: 'Databáze firmy',
target: 'header',
name: 'X-SAP-B1-CompanyDB',
required: true,
secret: false,
hint: 'Název company databáze, například SBODEMOCZ.',
},
{
id: 'username',
label: 'Uživatel',
target: 'header',
name: 'X-SAP-B1-Username',
required: true,
secret: false,
},
{
id: 'password',
label: 'Heslo',
target: 'header',
name: 'X-SAP-B1-Password',
required: true,
secret: true,
},
{
id: 'language',
label: 'Jazyk',
target: 'header',
name: 'X-SAP-B1-Language',
required: false,
secret: false,
hint: 'Kód jazyka Service Layer, například cs-CZ.',
},
{
id: 'rejectUnauthorized',
label: 'Kontrolovat certifikát',
target: 'header',
name: 'X-SAP-B1-Reject-Unauthorized',
required: false,
secret: false,
hint: 'false povolí self-signed certifikát Service Layer. Výchozí je kontrolovat.',
},
],
// Prihlasi se a vrati verzi Service Layer. Nic nezaklada.
verifyPath: '/api/system/info',
triggers: [],
actions: [
{
id: 'find-business-partner',
name: 'Najít obchodního partnera',
description: 'Dohledá partnera podle kódu, IČO nebo názvu. Nic nezakládá.',
fields: ['Kód partnera', 'IČO', 'Název'],
},
{
id: 'list-orders',
name: 'Načíst objednávky',
description: 'Vrátí objednávky partnera nebo za období.',
fields: ['Partner', 'Období'],
},
{
id: 'create-order',
name: 'Založit objednávku',
description: 'Vytvoří prodejní objednávku včetně řádků.',
fields: ['Partner', 'Položky', 'Datum dodání'],
},
],
},
];
+55
View File
@@ -0,0 +1,55 @@
/**
* Incidenty. Zvlast od ticketu, protoze incident je udalost provozu, ne pozadavek cloveka.
*/
import type { Service } from '../../../shared/services.js';
export const incidentServices: Service[] = [
{
id: 'incident',
name: 'Incidenty',
category: 'obecne',
description:
'Výpadek nebo porucha, která se týká víc lidí najednou. Na rozdíl od ticketu ' +
'neřeší jednoho zákazníka, ale stav služby.',
icon: 'AlarmClock',
status: 'available',
general: true,
appId: null,
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [],
actions: [
{
id: 'create',
name: 'Založit incident',
description:
'Když se chyba netýká jednoho ticketu, ale celé služby. Typicky navazuje ' +
'na ticket typu chyba.',
implementation: 'script',
inputs: [
{ id: 'title', label: 'Název', kind: 'text', required: true },
{
id: 'service',
label: 'Čeho se týká',
kind: 'text',
required: false,
hint: 'Název služby nebo aplikace, například Web nebo Voicebot.',
},
{
id: 'severity',
label: 'Závažnost',
kind: 'choice',
required: false,
options: [
{ value: 'sev1', label: 'SEV1, kritická' },
{ value: 'sev2', label: 'SEV2, vážná' },
{ value: 'sev3', label: 'SEV3, menší' },
],
},
],
outputFields: [{ id: 'incidentId', name: 'incidentId', type: 'string', required: true }],
},
],
},
];
+50
View File
@@ -0,0 +1,50 @@
/**
* Katalog sluzeb slozeny ze skupin. Poradi tady je poradi v nabidce, proto
* se nemeni jen tak - a proto je zakazkova integrace az za MCP, kde byla.
*/
import type { Service, ServiceCategoryEntry } from '../../../shared/services.js';
import { triggerServices } from './triggers.js';
import { incidentServices } from './incident.js';
import { ticketServices } from './ticket.js';
import { crmServices } from './crm.js';
import { financeServices } from './finance.js';
import { logisticsServices } from './logistics.js';
import { emailServices } from './email.js';
import { socialServices } from './social.js';
import { officeServices } from './office.js';
import { messagingServices } from './messaging.js';
import { analyticsServices } from './analytics.js';
import { aiServices } from './ai.js';
import { mcpServices } from './mcp.js';
import { polstrynServices } from './polstryn.js';
import { toolServices } from './tools.js';
export const serviceCategories: ServiceCategoryEntry[] = [
{ id: 'obecne', label: 'Obecné' },
{ id: 'crm', label: 'CRM' },
{ id: 'ekonomika', label: 'Ekonomika a banky' },
{ id: 'logistika', label: 'Logistika' },
{ id: 'komunikace', label: 'Komunikace' },
{ id: 'analytika', label: 'Analytika' },
{ id: 'ai', label: 'AI a hlas' },
{ id: 'transformace', label: 'Transformace dat' },
];
export const services: Service[] = [
...triggerServices,
...incidentServices,
...ticketServices,
...crmServices,
...financeServices,
...logisticsServices,
...emailServices,
...socialServices,
...officeServices,
...messagingServices,
...analyticsServices,
...aiServices,
...mcpServices,
...polstrynServices,
...toolServices,
];
+125
View File
@@ -0,0 +1,125 @@
/**
* Logistika: PPL a e-shop.
*/
import type { Service } from '../../../shared/services.js';
export const logisticsServices: Service[] = [
{
id: 'ppl',
name: 'PPL CPL',
category: 'logistika',
description: 'Zásilky, štítky a svozy v systému PPL.',
icon: 'Truck',
status: 'available',
general: false,
appId: 'pplcplapi',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'clientId',
label: 'Client ID',
target: 'header',
name: 'X-Client-Id',
required: true,
secret: false,
hint: 'Z vývojářského portálu PPL CPL.',
},
{
id: 'clientSecret',
label: 'Client Secret',
target: 'header',
name: 'X-Client-Secret',
required: true,
secret: true,
},
{
id: 'environment',
label: 'Prostředí',
target: 'header',
name: 'X-Environment',
required: false,
secret: false,
hint: 'production (výchozí) nebo test. Test nevytváří skutečné zásilky.',
},
],
// Udaje o zakaznikovi: nic nezaklada a bez platnych udaju neprojde.
verifyPath: '/customer',
triggers: [
{
id: 'shipment-delivered',
name: 'Zásilka doručena',
description: 'Spustí se při změně stavu zásilky na doručeno.',
},
],
actions: [
{
id: 'create-shipment',
name: 'Vytvořit zásilku',
description: 'Založí zásilku a vrátí číslo balíku i štítek k tisku.',
fields: ['Příjemce', 'Adresa', 'Hmotnost', 'Služba'],
},
{
id: 'order-pickup',
name: 'Objednat svoz',
description: 'Objedná svoz na zvolený den a adresu.',
fields: ['Datum svozu', 'Adresa', 'Počet zásilek'],
},
{
id: 'track',
name: 'Zjistit stav zásilky',
description: 'Vrátí aktuální stav a historii zásilky.',
fields: ['Číslo zásilky'],
},
],
},
{
id: 'eshop',
name: 'E-shop',
category: 'logistika',
description: 'Objednávky, sklad a zákazníci z e-shopu (Shoptet, WooCommerce, vlastní).',
icon: 'ShoppingCart',
status: 'available',
general: false,
appId: 'eshop',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [
{
id: 'order-created',
name: 'Nová objednávka',
description:
'Spustí se při vytvoření objednávky v e-shopu. Celá objednávka projde ' +
'dál jako objekt, takže se dá přemapovat na doklad.',
providedFields: [
{ id: 'eshop.orderNumber', name: 'orderNumber', type: 'string', required: true },
{ id: 'eshop.orderTotal', name: 'orderTotal', type: 'number', required: true },
{ id: 'eshop.customerEmail', name: 'customerEmail', type: 'string', required: false },
// Cela objednavka. Do sablony se nedosazuje, predava se dalsimu kroku
// jako celek - typicky do transformace dat.
{ id: 'eshop.order', name: 'order', type: 'object', required: true },
{ id: 'eshop.items', name: 'items', type: 'list', required: true },
],
},
{
id: 'order-status-changed',
name: 'Změna stavu objednávky',
description: 'Spustí se při přechodu objednávky do jiného stavu.',
},
],
actions: [
{
id: 'update-order',
name: 'Změnit stav objednávky',
description: 'Nastaví objednávce nový stav a volitelně informuje zákazníka.',
fields: ['Číslo objednávky', 'Nový stav'],
},
{
id: 'update-stock',
name: 'Upravit stav skladu',
description: 'Naskladní nebo odepíše položky.',
fields: ['SKU', 'Množství'],
},
],
},
];
+160
View File
@@ -0,0 +1,160 @@
/**
* MCP servery. Nastroje se zjisti az od serveru, viz data/mcpTools.ts.
*/
import { MCP_EASYWEB_SERVICE_ID, MCP_SERVICE_ID } from '../../../mcp/dialect.js';
import type { Service } from '../../../shared/services.js';
export const mcpServices: Service[] = [
{
id: MCP_SERVICE_ID,
name: 'MCP server',
category: 'ai',
description:
'Napojení na libovolný MCP server. Portál si od něj vyžádá seznam nástrojů a ty se pak dají použít jako kroky automatizace.',
icon: 'Plug',
status: 'available',
general: false,
appId: null,
transport: 'mcp',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'serverUrl',
label: 'Adresa MCP serveru',
target: 'config',
name: 'serverUrl',
required: true,
secret: false,
hint: 'Celá adresa endpointu, například https://mcp.firma.cz/mcp. Musí být dostupná z internetu.',
},
{
id: 'token',
label: 'Token',
target: 'config',
name: 'token',
required: false,
secret: true,
hint: 'Když jste od provozovatele dostali hotový token. Portál ho pošle tak, jak je, a nic dalšího neřeší.',
},
{
id: 'clientId',
label: 'ID aplikace',
target: 'config',
name: 'clientId',
required: false,
secret: false,
hint: 'Druhá možnost: server má přihlášení přes OAuth. Portál si pak přístup vyzvedne sám a obnovuje ho.',
},
{
id: 'clientSecret',
label: 'Tajemství aplikace',
target: 'config',
name: 'clientSecret',
required: false,
secret: true,
hint: 'Patří k ID aplikace.',
},
{
id: 'tokenUrl',
label: 'Adresa pro přihlášení',
target: 'config',
name: 'tokenUrl',
required: false,
secret: false,
hint: 'Nechte prázdné. Vyplňuje se jen tehdy, když ji portál u serveru sám nenajde.',
},
{
id: 'scope',
label: 'Rozsah oprávnění',
target: 'config',
name: 'scope',
required: false,
secret: false,
hint: 'Nechte prázdné, pokud vám provozovatel serveru neřekl konkrétní hodnotu.',
},
],
triggers: [],
// Prazdne zamerne: vsechny operace jsou nastroje ze serveru.
actions: [],
},
{
id: MCP_EASYWEB_SERVICE_ID,
name: 'MCP EasyWeb',
category: 'ai',
description:
'Napojení na MCP server EasyWebu. Stačí adresa, jméno a heslo - portál si vyžádá seznam nástrojů a ty se dají použít jako kroky automatizace.',
icon: 'Plug',
status: 'available',
general: false,
appId: null,
transport: 'mcp',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'serverUrl',
label: 'Adresa MCP serveru',
target: 'config',
name: 'serverUrl',
required: true,
secret: false,
hint: 'Endpoint bez koncového lomítka, například https://web.firmy.cz/centaur/mcp. Přihlašovací adresy si portál odvodí sám.',
},
{
id: 'username',
label: 'Jméno',
target: 'config',
name: 'username',
required: true,
secret: false,
hint: 'Uživatel, pod kterým se má portál k serveru hlásit.',
},
{
id: 'password',
label: 'Heslo',
target: 'config',
name: 'password',
required: true,
secret: true,
hint: 'Použije se jednou, na registraci zařízení. Dál se portál hlásí klíčem, který si vyrobí sám.',
},
{
id: 'deviceName',
label: 'Název zařízení',
target: 'config',
name: 'deviceName',
required: false,
secret: false,
hint: 'Pod tímhle názvem uvidíte přihlášení v logu serveru. Prázdné znamená WorkNuke.',
},
/*
* Klic zarizeni. Vyrabi ho portal pri prvnim prihlaseni a od te chvile je
* to identita, kterou server pozna - jmeno a heslo uz se nepouziva.
*
* Je to pole konektoru, a ne zvlastni tabulka, protoze udaje konektoru se
* uz ukladaji zasifrovane a tohle je privatni klic. `managed` znamena, ze
* ho ve formulari nikdo nevidi a nevyplnuje.
*/
{
id: 'deviceJwk',
label: 'Klíč zařízení',
target: 'config',
name: 'deviceJwk',
required: false,
secret: true,
managed: true,
},
{
id: 'deviceFingerprint',
label: 'Otisk zařízení',
target: 'config',
name: 'deviceFingerprint',
required: false,
secret: false,
managed: true,
},
],
triggers: [],
actions: [],
},
];
+50
View File
@@ -0,0 +1,50 @@
/**
* Kratke zpravy: SMS a Slack.
*/
import type { Service } from '../../../shared/services.js';
export const messagingServices: Service[] = [
{
id: 'sms',
name: 'SMS',
category: 'komunikace',
description: 'Odesílání SMS zpráv zákazníkům nebo obsluze.',
icon: 'MessageSquare',
status: 'available',
general: false,
appId: 'sms',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [],
actions: [
{
id: 'send',
name: 'Odeslat SMS',
description: 'Odešle krátkou zprávu na telefonní číslo.',
fields: ['Telefon', 'Text'],
},
],
},
{
id: 'slack',
name: 'Slack',
category: 'komunikace',
description: 'Notifikace a interní komunikace v Slacku.',
icon: 'Hash',
status: 'planned',
general: false,
appId: 'slack',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [],
actions: [
{
id: 'post-message',
name: 'Poslat zprávu do kanálu',
description: 'Odešle zprávu do zvoleného kanálu.',
fields: ['Kanál', 'Text'],
},
],
},
];
+160
View File
@@ -0,0 +1,160 @@
/**
* Kancelarske baliky: Microsoft 365 a Google Workspace.
*/
import type { Service } from '../../../shared/services.js';
export const officeServices: Service[] = [
{
id: 'microsoft365',
name: 'Microsoft 365',
category: 'komunikace',
description: 'Outlook, kalendář, Teams, SharePoint a OneDrive.',
icon: 'Building2',
status: 'available',
general: false,
appId: 'microsoft-365-service',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'tenantId',
label: 'Tenant ID',
target: 'header',
name: 'X-MS365-Tenant-Id',
required: true,
secret: false,
hint: 'ID adresáře v Entra ID (dříve Azure AD).',
},
{
id: 'clientId',
label: 'Client ID',
target: 'header',
name: 'X-MS365-Client-Id',
required: true,
secret: false,
hint: 'ID registrované aplikace.',
},
{
id: 'clientSecret',
label: 'Client Secret',
target: 'header',
name: 'X-MS365-Client-Secret',
required: true,
secret: true,
hint: 'Tajný klíč aplikace. Má omezenou platnost, po vypršení se přepíše.',
},
],
// Stav napojeni na Graph: prihlasi se udaji z hlavicek, nic nemeni.
verifyPath: '/status',
triggers: [
{
id: 'calendar-event',
name: 'Nová schůzka v kalendáři',
description: 'Spustí se při založení schůzky ve sledovaném kalendáři.',
},
],
actions: [
{
id: 'create-event',
name: 'Vytvořit schůzku',
description: 'Založí schůzku a pozve účastníky.',
fields: ['Kalendář', 'Termín', 'Účastníci'],
},
{
id: 'upload-file',
name: 'Uložit soubor',
description: 'Nahraje dokument do SharePointu nebo OneDrive.',
fields: ['Knihovna', 'Cesta', 'Soubor'],
},
{
id: 'post-teams',
name: 'Poslat zprávu do Teams',
description: 'Odešle zprávu do kanálu nebo konkrétnímu člověku.',
fields: ['Kanál', 'Text zprávy'],
},
],
},
{
id: 'google',
name: 'Google Workspace',
category: 'komunikace',
description: 'Gmail, Kalendář, Disk, Tabulky, Dokumenty a Úkoly pod jedním napojením.',
icon: 'Chrome',
status: 'available',
general: false,
appId: 'google-service',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'serviceAccountJson',
label: 'JSON klíč service accountu',
target: 'header',
name: 'X-Google-Service-Account-Json',
required: false,
secret: true,
hint:
'Celý obsah staženého JSON souboru. Tohle je cesta pro provoz bez člověka: ' +
'službě z něj sama vznikne token. Alternativou je hotový access token.',
},
{
id: 'serviceAccountScopes',
label: 'Oprávnění (scopes)',
target: 'header',
name: 'X-Google-Service-Account-Scopes',
required: false,
secret: false,
hint:
'Oddělené mezerou. Ověření konektoru čte Disk, takže potřebuje aspoň ' +
'https://www.googleapis.com/auth/drive.readonly.',
},
{
id: 'serviceAccountSubject',
label: 'Zastupovaný uživatel',
target: 'header',
name: 'X-Google-Service-Account-Subject',
required: false,
secret: false,
hint: 'E-mail uživatele Workspace při domain-wide delegation. Bez něj jedná service account sám za sebe.',
},
{
id: 'accessToken',
label: 'Access token',
target: 'header',
name: 'X-Google-Access-Token',
required: false,
secret: true,
hint: 'Hotový OAuth2 token. Platí zhruba hodinu, takže na trvalý provoz se nehodí.',
},
],
// Seznam souboru na Disku: cteci volani, ktere bez platnych udaju neprojde.
// Predpoklada scope drive.readonly, viz napoveda u pole s opravnenimi.
verifyPath: '/google/drive/files',
triggers: [],
actions: [
{
id: 'send-email',
name: 'Odeslat e-mail',
description: 'Pošle e-mail přes Gmail účtu, pod kterým je napojení.',
fields: ['Příjemce', 'Předmět', 'Text'],
},
{
id: 'append-sheet-row',
name: 'Přidat řádek do tabulky',
description: 'Připíše řádek na konec listu v Google Tabulkách.',
fields: ['Tabulka', 'List', 'Hodnoty'],
},
{
id: 'create-event',
name: 'Vytvořit událost v kalendáři',
description: 'Založí událost a pozve účastníky.',
fields: ['Kalendář', 'Termín', 'Účastníci'],
},
{
id: 'find-file',
name: 'Najít soubor na Disku',
description: 'Dohledá soubor podle názvu nebo dotazu. Nic nemění.',
fields: ['Dotaz'],
},
],
},
];

Some files were not shown because too many files have changed in this diff Show More