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:
@@ -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
|
||||
@@ -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=
|
||||
@@ -0,0 +1,7 @@
|
||||
dist
|
||||
node_modules
|
||||
data
|
||||
design
|
||||
package-lock.json
|
||||
# Dokumentace ma vlastni styl tabulek, prettier by je prelamal.
|
||||
*.md
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all",
|
||||
"semi": true,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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é`:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+1871
-4
File diff suppressed because it is too large
Load Diff
+16
-2
@@ -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
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
+4
-1345
File diff suppressed because it is too large
Load Diff
@@ -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.',
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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í.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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ň'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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í'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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í'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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: [],
|
||||
},
|
||||
];
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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
Reference in New Issue
Block a user