Files
csbot-prototype/documentation/09-navrh-rozsireni.md
T
JiriUhlirandClaude Opus 5 6f6b287d7e Skripty konektoru: vykonna cast s manifestem a kontrolou parametru
Konektory dostaly vykonnou cast. Jeden skript je jeden soubor, ktery nese
manifest (vstupni a vystupni parametry) i kod. Diky manifestu s nim umi
pracovat strom automatizace, aniz by o kodu cokoliv vedel.

Soubory jsou zamerne obycejny JavaScript, ne TypeScript. TypeScript by se
musel prelozit a to je presne to otaceni, ktere tady nema byt. Registr
sleduje cas zmeny souboru, takze uprava v portalu, rucni uprava souboru
i novy soubor ve slozce funguji stejne a bez restartu.

Pridano:
- scripts/ se skripty konektoru, nazev souboru je zaroven ID operace
- kontrola vstupu i vystupu proti manifestu, jedna funkce pro obe strany.
  Chybejici povinny vystup je chyba skriptu, ne uzivatele - jinak by strom
  veril parametru, ktery nikdy nedosel
- ctx predavany skriptu: http nad adresou napojeni, util, log, config,
  idempotencyKey, fail a retry. Skript nedostane pristupove udaje
- rozliseni opakovatelne a koncove chyby. Runner nikdy nevyhodi vyjimku,
  vzdy vraci vysledek vcetne retryable
- redakce tajnych hodnot pred zapisem do logu. Cizi API rado vraci prijaty
  token v chybove zprave a log ticketu vidi klient
- napojeni z environment variables vcetne iDokladu
- sest ukazkovych skriptu pro iDoklad proti skutecnemu API sluzby
  services.csbot.cz/apps/idoklad, kazdy na jiny vzor
- stranka /dashboard/skripty: seznam, manifest, editor, zkusebni spusteni.
  Formular testu se sklada z manifestu, nepise se pro kazdy skript
- endpointy /api/dashboard/scripts vcetne Swaggeru

Zmeneno:
- katalog konektoru uz neni jen staticky seznam. Akce ze skriptu se domeruji
  prekryvem v src/data/connectors.ts, takze se naraz objevi ve validaci
  stromu, ve vypoctu scope i v sablonach. Pri stejnem ID vyhrava skript
- ConnectorOperation ma implementation a scriptId
- ApiError na klientovi nese cele telo odpovedi a umi z nej vytahnout issues
- Dockerfile kopiruje scripts/ do vysledneho image

Ukladani nemuze rozbit fungujici skript: kod se nejdriv zapise do docasneho
souboru, ten se nacte a overi, a az pak prepise puvodni.

K tomu tri dokumenty navrhu dalsich kroku: 09 datove modely a prava,
10 runtime a rozpocet na 150 klientu, 11 popis skriptu konektoru.

Overeno: npm run typecheck prochazi na serveru i webu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:37:58 +02:00

49 KiB

09 - Navrh rozsireni

Navrh, ne popis stavu. Nic z toho zatim neni naprogramovane. Kdyz se cast udela, prepise se do prislusneho souboru dokumentace a odsud zmizi.

Vychazi z toho, co uz v aplikaci je. Popis stavajiciho stavu je v 01-prehled-a-stav.md.

Jedno rozhodnuti nad vsim ostatnim

Nezakladat druhy system akci. Vsechno, co ma tlacitko na ticketu udelat, uz v katalogu konektoru existuje jako akce: zaloz doklad, posli e-mail, zapis do skladu, prirad cloveka. Ma to inputs, sablony {{parametr}}, deklarovane outputFields a validaci.

Akce ma proto vlastni definici, ale stoji na tom samem zakladu: tentyz katalog konektoru, tentyz model kroku, tentyz builder, tataz validace, tentyz log.

Kdyby se pro tlacitka postavil samostatny mechanismus, existovaly by dva zpusoby, jak zavolat Fakturoid, dva zpusoby, jak dosadit sablonu, a dva logy. Jeden by se casem opravoval a druhy ne.

Stejne pravidlo plati na widgety (katalog je zdroj pravdy) a na prava (access.ts je jedine misto).

Dve oddelene veci, ne jedna spolecna

Automatizacni stromy a akce jsou dve samostatne veci a nemaji spolecnou vrstvu mezi sebou:

Vec Spousti Kde je definovana
Automatizacni strom udalost, spoustec seznam automatizaci
Akce clovek kliknutim na ticketu seznam akci za firmu

Akce se vaze na typ nebo tag ticketu. Priklad: "Odeslat do iDokladu" pro objednavku. Na ticketu se pak vykresli CTA vsech akci, ktere na jeho typ nebo tag sedi a na ktere ma uzivatel pravo.

Automatizacni strom si to dela sam. Nevola akci a nesdili s ni nic. Kdyz ma po prijeti objednavky vzniknout doklad v iDokladu, ma strom ten krok v sobe.

Akce si svoje telo drzi sama. Muze to byt jeden krok konektoru, vlastni strom, nebo vlastni skript - podrobne nize u definice akce. Podstatne je, ze strom akce je jeji vlastni, ne odkaz nekam jinam.

Spolecne je pod obojim jen dvoje, a v tom je ta uspora: katalog konektoru a model kroku. Operace "vytvorit doklad" je napsana jednou a obe strany na ni odkazuji. Strom akce pouziva tentyz FlowStep, tentyz builder a tutez validaci jako automatizace. Duplikuje se tedy nastaveni, ne logika, a nevznika druhy builder, ktery by zaostaval.

Prvni verze tohoto navrhu mezi to vkladala treti entitu, sdileny postup volany z obou stran. Bylo to zbytecne: prinesla by verzovani napric, hloubku volani a jeden seznam navic, a to vsechno kvuli tomu, aby se nedvakrat vyplnilo mapovani poli.

Zbyva z toho jedna vedoma cena, kterou je potreba znat: kdyz se ma tataz vec dit automaticky i rucne, nastavuje se na dvou mistech. Zmena v iDokladu znamena upravit strom automatizace a upravit akci. Kdyby to nekdy zacalo bolet, nejlevnejsi zaplata je akce, kterou umi zavolat i krok stromu - ale az kdyz to bude potreba, ne dopredu.

1 a 2 - Typy ticketu a akce nad nimi

Body 1 a 2 jsou jedna vec. Bez typu ticketu neni na cem tlacitka rozlisovat, bez vlastnich poli neni s cim pracovat: akce "Potvrdit objednavku" potrebuje cislo objednavky, ne jen predmet a text.

Typ ticketu

Novy soubor src/data/ticketTypes.ts. Typ je nastavitelny za firmu, ne pevny v kodu.

interface TicketTypeField {
  id: string;        // 'fld_order_no', stabilni - odkazuji se na nej podminky
  key: string;       // 'orderNumber', pouziva se v sablonach: {{orderNumber}}
  label: string;     // 'Cislo objednavky', to co vidi uzivatel
  type: FieldType;   // uz existuje v conditions.ts
  required: boolean;
  options?: Array<{ value: string; label: string }>;
}

interface TicketType {
  id: string;
  tenantId: string;
  key: string;                // 'order', 'complaint', 'request'
  name: string;               // 'Objednavka'
  icon: string;               // klic do connectorIcons.ts
  statuses: string[];         // vlastni workflow, prazdne = vychozi ctverice
  fields: TicketTypeField[];
}

Ticket dostane dve pole:

typeId: string | null;                  // null = ticket bez typu, jako dnes
fields: Record<string, string | number | boolean | null>;

Dve id u pole nejsou zbytecna. Je to totez rozdeleni, ktere uz v projektu je a je vysvetlene v 06-tickety.md: podminky odkazuji na id, aby prejmenovani nic nerozbilo, sablony odkazuji na key, aby si to clovek precetl.

Typ nebo tag

V zadani je otazka, jestli je "objednavka" tag. Odpoved je, ze jsou potreba oba a jsou to jine veci:

Vec Kolik na ticket K cemu
Typ prave jeden vlastni pole a vlastni workflow stavu
Tag libovolne mnoho volne oznaceni, filtry a widgety

Akce se muze vazat na oboji, ale nasledek se lisi:

  • Na typ. Za typem stoji pole, takze akce vi, ze existuje orderNumber, a muze ho dosadit do sablony. Kdyz akce potrebuje vlastni pole, musi byt na typu.
  • Na tag. Za tagem nestoji zadna pole, takze akce umi pracovat jen s vestavenymi polemi ticketu a s tim, na co se dopta formular. Zaroven tag muze kdokoliv pridat i odebrat, takze tlacitko se muze objevit u ticketu, ktery objednavka neni. U akce, ktera nekam neco odesle, je proto vhodne pridat visibleWhen nebo potvrzeni.

Tagy jsou hlavne spravna vec pro widgety a filtry, tam je volnost prinos. Ticket proto dostane i tags: string[].

Definice akce

src/data/ticketActions.ts.

interface TicketAction {
  id: string;
  tenantId: string;
  /** Na co se vaze. Aspon jedno z obojiho, jinak by se nabizela vsude. */
  ticketTypeIds: string[];       // prazdne = na typu nezalezi
  tags: string[];                // prazdne = na tagu nezalezi
  label: string;                 // 'Potvrdit objednavku'
  icon: string;
  style: 'primary' | 'default' | 'danger';
  order: number;
  /** Kdy se tlacitko vubec ukaze. Vsechny podminky musi platit. */
  visibleWhen: ActionCondition[];
  /** Text potvrzovaciho dialogu, null = spusti se hned. */
  confirm: string | null;
  /** Na co se doptat pred spustenim. Stejny typ jako inputs u akce konektoru. */
  form: OperationField[];
  /** Co akce dela. Tri moznosti, viz nize. */
  body: ActionBody;
  /** Verze definice. Uprava zaklada novou, bezici behy si drzi svou. */
  version: number;
  /** Pravo, ktere musi uzivatel mit. Vznika spolu s akci. Viz bod 5. */
  permission: string;            // 'action:tka_confirm_order'
  enabled: boolean;
}

type ActionBody =
  /** Jeden krok konektoru. Nejcastejsi pripad, nastavi se bez builderu. */
  | { kind: 'operation'; connectorId: string; operationId: string;
      connectionId: string | null; inputs: Record<string, string> }
  /** Vlastni strom. Tentyz model i tentyz builder jako u automatizaci. */
  | { kind: 'tree'; steps: FlowStep[] }
  /** Vlastni skript. Podminky a limity jsou v bodu 9. */
  | { kind: 'script'; scriptId: string; scriptVersion: number;
      inputs: Record<string, string> };

visibleWhen je nad ticket.* a ticket.fields.*, tedy nad uz existujicim modelem podminek. Typicky "stav je novy" nebo "objednavka jeste neni potvrzena". Tlacitko, ktere se ukaze vzdy a pak vyhodi chybu, je horsi nez tlacitko, ktere se neukaze.

Tri druhy tela akce

Akce neni jen jeden krok. ActionBody je proto union a kazdy druh je jina uroven slozitosti pro tehoz cloveka:

Druh Kdy UI
operation odesli tenhle doklad tam formular
tree zkus to, a kdyz se to nepovede, dej to Karlovi builder
script poskladej text v nasem formatu editor skriptu

tree pouziva tentyz model kroku, tentyz builder a tutez validaci jako automatizace. Neni to druhy strom, je to ten samy strom na jinem miste. Kdyby to byl jiny model, mel by builder dve verze a jedna by zaostavala.

operation je jen strom s jednim krokem, ale vyplati se ho mit zvlast: nejcasteji chce clovek presne tohle a nema chodit do builderu kvuli jednomu volani. Prevod z operation na tree musi jit jednim kliknutim, protoze potreba vetveni prijde az pri druhem selhani.

script je pro pripad, kdy jde o prevod dat, ne o volani sluzby. Plati pro nej vsechna omezeni z bodu 9: nema sit, nema tajemstvi, vystupy deklaruje dopredu.

Odkud akce vidi data ticketu

Akce spustena z ticketu dostane vstupy ve tri skupinach. Pro builder je to totez jako providedFields u spoustece, takze se menit nemusi:

Zdroj Priklad ID Priklad v sablone
vestavena pole ticketu ticket.subject {{subject}}
vlastni pole typu ticket.fld_order_no {{orderNumber}}
doptavaci formular form.reason {{reason}}

Nabidka vstupu se pocita za typ ticketu, ne staticky z katalogu. To je jediny novy pripad: katalog dnes vraci pevny seznam.

Akce navazana na tag ma jen prvni a treti skupinu. Za tagem nestoji zadna pole, takze sablona nema odkud vzit cislo objednavky. Je to podstatny prakticky rozdil: akce na tagu umi pracovat s predmetem, telem, zakaznikem a s tim, na co se dopta formular. Akce na typu umi navic vlastni pole. Kdyz akce potrebuje orderNumber, musi byt na typu.

Jak to vypada na ticketu

GET /api/dashboard/tickets/:id bude vracet navic:

"actions": [
  { "id": "tka_confirm_order", "label": "Potvrdit objednavku",
    "icon": "check-circle", "style": "primary",
    "confirm": "Objednavka se posle do skladu. Pokracovat?",
    "form": [{ "id": "note", "label": "Poznamka", "kind": "text", "required": false }] }
]

Seznam uz je filtrovany serverem podle visibleWhen, typu ticketu a prav uzivatele. Klient jen kresli tlacitka. Je to totez pravidlo jako u access - klient si nic nedovozuje, jinak se to pocita na dvou mistech a jednou se rozejde.

Spusteni:

POST /api/dashboard/tickets/:id/actions/:actionId
body:  { "form": { "note": "potvrzeno telefonicky" } }
odpoved: 202 { "runId": "run_..." }
Situace Odpoved
akce neexistuje nebo neni pro tento typ 404
uzivatel na ni nema pravo 403
visibleWhen neplati 409
chybi povinne pole formulare 400
automatizace je pozastavena 409

409 u visibleWhen je zamer. Znamena "v tomhle stavu to nedava smysl", presne jako u ostatnich 409 v API. Uzivatel mel otevrenou starou stranku.

Audit je soucast, ne pristavek

Kazde stisknuti hned zapise zaznam do logu ticketu:

kind: 'action', status: 'info'
label: 'Rucne spustil Karel Vomacka: Potvrdit objednavku'

Log ticketu uz je strom a lide do nej chodi. Zvlastni evidence "kdo co zmackl" by znamenala dve casove osy a hledani ve dvou mistech. Vysledek behu se pak zavesi pod tento zaznam jako jeho deti.

Da se dodat pred runtimem

Rucni akce nemusi cekat na frontu z posledni sekce. Kratky strom lze na zacatku vykonat synchronne v requestu, s limitem kroku a timeoutem. Podminka je napsat vykonavani jako executeStep(step, context), kterou pozdeji zavola i worker. Ne jako kod uvnitr route handleru.

Kdyz se to napise takhle, je rucni akce prvni uzivatel vykonavace a runtime pak nepridava novy kod, jen frontu nad nim.

3 - Rucni prehozeni a vestavene akce

Cast uz existuje: POST /tickets/:id/assign, POST /tickets/:id/status, prava hlida canAssignOthers, v detailu ticketu jsou dva selecty.

Navrh je postavit vestavene akce do stejneho seznamu jako vlastni. Ne jako zvlastni kus UI vedle nich.

ID Co dela Pravo
builtin.assign.self vzit ticket na sebe ticket.assign.self
builtin.assign.other prehodit na kolegu ticket.assign.others
builtin.assign.group prehodit na skupinu ticket.assign.group
builtin.status zmenit stav ticket.status.change
builtin.priority zmenit prioritu ticket.priority.change
builtin.type zmenit typ ticketu ticket.type.change
builtin.reopen otevrit vyreseny ticket.reopen

Dva prinosy: pravo se resi jednim mechanismem pro vestavene i vlastni akce, a admin muze vestavenou akci pro nekoho vypnout, aniz by se menil kod.

Prehozeni na skupinu, ne jen na cloveka

Bod 5 mluvi o ucetni a skladnikovi. To znamena, ze prehazovani na konkretni jmeno nestaci - clovek chce rict "tohle je pro ucetni" bez toho, aby resil, kdo z nich ma dovolenou.

interface TicketGroup { id: string; tenantId: string; name: string; personIds: string[]; }

Ticket dostane assigneeGroupId: string | null vedle assigneeId. Fronta bez resitele se tim rozpadne na fronty skupin a prehled vytizeni dostane radek za skupinu. Je to vic prace nez zbytek bodu 3, takze druha vlna.

Co jeste chybi k prehazovani

  • Duvod prehozeni. Nepovinne pole, zapise se jako komentar do logu. Bez nej se z logu neda poznat, proc ticket obesel tri lidi.
  • Hromadne prehozeni ze seznamu. Zaskrtavatka v seznamu ticketu, jedna akce nad vyberem. Server to musi resit jako N samostatnych zapisu s dilcim vysledkem, ne jako vse nebo nic - jeden ticket bez prava nesmi zabit zbytek.
  • Notifikace. Dnes se prirazeni objevi jen v SSE toastu, kdyz je clovek prihlaseny. Prehozeni na nekoho, kdo se prave nekouka, se ztrati.

5 - Kazdy spousti jen svuj seznam akci

TenantRole = 'admin' | 'agent' na ucetni, skladnika a vedouciho nestaci. Pridavat dalsi hodnoty do unionu je slepa ulicka - kazdy klient chce jine.

Role a prava se stanou daty.

type PermissionKey = string;   // 'ticket.assign.others', 'action:tka_confirm_order'

interface Role {
  id: string;
  tenantId: string | null;     // null = systemova role, dostupna vsem firmam
  key: string;                 // 'admin', 'agent', 'accountant'
  name: string;                // 'Ucetni'
  permissions: PermissionKey[];
}

interface Membership {
  tenantId: string;
  roleIds: string[];           // misto role: 'admin' | 'agent'
}

admin a agent zustanou jako systemove role s prednastavenym seznamem prav. Nic se tim nerozbije a zadny existujici ucet se nemusi predelavat.

Katalog prav

Stejny princip jako katalog konektoru a widgetu: src/data/permissions.ts je zdroj pravdy o tom, jaka prava existuji, vcetne ceskych popisu a skupin.

GET /api/dashboard/permissions

Nastaveni role je pak zaskrtavatkova tabulka nad timto katalogem. Neznamy klic v roli se ignoruje a loguje, protoze pravo, ktere zmizelo z katalogu, nesmi shodit prihlaseni.

Pravo k vlastni akci vznika spolu s akci jako action:<id>. Admin pak nesestavuje prava, ale zaskrtava akce: "role Ucetni: Potvrdit objednavku, Vystavit fakturu". To je slovnik, ve kterem uvazuje.

Kontrola na dvou mistech, ale ne dvakrat pocitana

  • Server pri spusteni je autorita. Vzdy, i kdyz klient tlacitko nezobrazil.
  • Server pri vypisu rozhodne, ktera tlacitka klient dostane.

Klient nikdy prava nevyhodnocuje. Pri vypisu i pri spusteni se vola tataz funkce v access.ts.

Access se rozsiri:

interface Access {
  scopes: TicketScope[];
  tenants: Tenant[];
  defaultTenantId: string | null;
  personId: string | null;
  canAssignOthers: boolean;    // zustava, pocita se z permissions
  permissions: PermissionKey[]; // efektivni prava ve zvolene firme
  nav: NavItem[];               // viz bod 6
}

Pozor, tohle je nejvetsi zasah do stavajiciho kodu. accessFor(user) uz nemuze stacit, prava jsou az uvnitr firmy: accessFor(user, tenantId). Dotkne se to vsech rout, ktere accessFor volaji, a requireRole v src/middleware/auth.ts se nahradi requirePermission(key).

6 - Admin: prepinani a nastavovani zalozek nad klienty

Tri oddelene veci, ktere se snadno slijou do jedne.

a) Prepinac firem

Existuje. platformAdmin vidi vsechny firmy a pohled all. Beze zmeny.

b) Ktere zalozky firma vubec ma

interface TenantFeatures {
  tenantId: string;
  /** Ktere moduly firma ma. Klic odpovida zalozce v portalu. */
  tabs: string[];              // ['overview','tickets','incidents','automations','connectors']
  /** Ktere konektory smi pouzit. null = vsechny z katalogu. */
  connectorIds: string[] | null;
  limits: { automations: number; widgets: number; customActions: number };
}

Dve vrstvy s jinym vlastnikem, ktere se nesmi michat:

Vrstva Kdo nastavuje Znamena
TenantFeatures my, provozovatel co ma firma zaplacene a zapnute
Role admin te firmy kdo z jejich lidi to smi

Efektivni viditelnost je prunik. Vypnuty modul neexistuje ani pro admina te firmy - nema si ho jak zapnout, protoze ho nema. Kdyby to byla jedna vrstva, klientsky admin by si mohl zapnout to, co si nekoupil, nebo bychom my prenastavovali jejich interni prava.

Zpristupneni konektoru z bodu 9 je tataz vrstva. ConnectorGrant a TenantFeatures odpovidaji na tutez otazku, jen jednou za modul a jednou za konektor, a nastavuje je tentyz clovek. Ma to proto byt jedna obrazovka Nastaveni klienta: zalozky, konektory, skripty, limity. Rozdelit to na tri obrazovky by znamenalo, ze pri onboardingu klienta se na jednu zapomene.

Dusledek pro frontend: seznam zalozek v web/src/components/dashboard/DashboardLayout.tsx prestane byt konstanta a bude se brat z access.nav. Kompletni mapa cesta na komponentu zustane na klientovi, server posila jen klice a poradi.

c) Prepnout se a videt to jako konkretni clovek

To uz neni prepinac firem, ale impersonace, a je to citliva vec. Navrh:

POST /api/admin/impersonate  { "userId": "usr_..." }  -> kratkodoby token
POST /api/admin/impersonate/stop

Pravidla:

  • smi jen platformAdmin, a nikdy na jineho platformAdmin,
  • token plati nejvyse 30 minut a neda se obnovit, v payloadu nese act s ID skutecneho cloveka,
  • vychozi rezim je jen cteni. Zapis se musi vyslovne zapnout pri startu impersonace a kazdy zapis pak nese v auditu actedBy,
  • v portalu je po celou dobu vyrazny pruh s tim, za koho se uzivatel diva, a tlacitkem zpet. Bez nej clovek zapomene a smaze neco cizim jmenem,
  • start, konec i kazda akce jde do auditu.

Audit log

Bez nej nema impersonace smysl a 07-firmy-a-prava.md ho uz vede jako chybejici.

interface AuditEntry {
  id: string;
  at: string;
  tenantId: string | null;
  userId: string;
  actedBy: string | null;       // vyplnene pri impersonaci
  action: string;               // 'ticket.assign', 'role.update', 'impersonate.start'
  target: string | null;
  detail: Record<string, unknown>;
  result: 'ok' | 'denied';
}

Zapisuji se i odepreni. Dnes se jen loguji do konzole, takze opakovane pokusy o cizi firmu nikde nezustanou.

4 - Vlastni widgety

Dnes je widgets.ts pevny katalog, kde kind rika, kterou komponentu vykreslit. Pro vlastni widgety se to rozdeli na jak se to kresli a odkud jsou data.

type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge';

type WidgetSource =
  | { kind: 'ticketCount';  filter: TicketFilter; groupBy?: WidgetGroupBy }
  | { kind: 'ticketList';   filter: TicketFilter; limit: number }
  | { kind: 'ticketSeries'; filter: TicketFilter; bucket: 'day' | 'week' }
  | { kind: 'runCount';     filter: RunFilter; groupBy?: WidgetGroupBy }
  | { kind: 'workload';     groupIds?: string[] }
  | { kind: 'connectorMetric'; connectionId: string; metricId: string; period: Period };

type WidgetGroupBy = 'assignee' | 'group' | 'status' | 'type' | 'tag' | 'channel';

interface CustomWidget {
  id: string;
  tenantId: string;
  ownerId: string | null;      // null = firemni widget, jinak osobni
  name: string;
  render: WidgetRender;
  source: WidgetSource;
  target?: number;             // porovnavaci hodnota u gauge
  sizes: WidgetSize[];
  defaultSize: WidgetSize;
}

Uzivatel nepise dotazy

TicketFilter je tentyz filtr, ktery uz umi GET /api/dashboard/tickets: stav, typ, priorita, kanal, resitel, skupina, obdobi. Validuje ho totez schema.

Tim je bod "widgety skrz stavy ticketu" hotovy bez jedineho noveho dotazu do dat a bez jakekoliv moznosti napsat neco, co server polozi. Kdo si vymysli filtr, ktery seznam ticketu neumi, dostane 400, a je to zaroven signal, ze ten filtr patri nejdriv do seznamu.

Widgety skrz konektory

Katalog konektoru dostane u operaci treti druh vedle triggers a actions:

interface ConnectorMetric {
  id: string;
  name: string;
  /** number = jedno cislo, series = casova rada, list = radky */
  shape: 'number' | 'series' | 'list';
  unit?: string;
  periods: Period[];
}

Widget je pak "konektor Fakturoid, metrika Neuhrazene faktury, za 30 dni". Katalog zustava zdrojem pravdy presne jako u triggeru: pridani metriky je zaznam v katalogu, ne novy kod na webu.

Widget odkazuje na napojeni, ne na konektor. Duvod je v bodu 9: metrika se tahne z konkretniho uctu s konkretnimi pristupy. Firma bez napojeni na iDoklad si takovy widget nemuze postavit, i kdyz iDoklad v katalogu vidi. A kdyz se napojeni zrusi, widget musi na svem miste rict, ze napojeni chybi - ne zhasnout prehled a ne ukazovat posledni znamou hodnotu jako aktualni.

Seskupovani

groupBy je to, co dela z dlazdice pouzitelny prehled. "Tickety ve stavu selhalo vuci lidem, u kterych jsou" je ticketCount s filtrem na stav a groupBy: 'assignee'. Bez seskupovani by to byl jeden widget na cloveka a po prichodu noveho kolegy by chybel.

Vysledek seskupovani je seznam par nazev a cislo. Vykresli se jako pruhy, tabulka nebo kruh podle render - jsou to tataz data.

Poznamka k "selhalo"

Ticket dnes ma stavy novy, otevreny, ceka a vyresen. Zadne selhalo. Ta otazka se musi rozhodnout, protoze jsou to dve rozdilne veci:

  • selhal beh automatizace nebo akce. Pak je to runCount, ne ticketCount, a widget se pta na historii behu z bodu o runtimu.
  • ticket je v koncovem stavu, ktery znamena nezdar (objednavku neslo odeslat). Pak je to vlastni stav v TicketType.statuses, tedy vlastni workflow za typ.

Nejcasteji chce clovek prvni. Druhe je uzitecne az tam, kde typ ticketu ma opravdu jiny zivotni cyklus.

Dochazka neni widget

Dochazka v aplikaci neexistuje. Je to samostatna domena, ne druh dlazdice. Spravna cesta je konektor dochazky s metrikami (kdo je dnes v praci, hodiny za mesic, chybejici prichody). Widget pak vznikne sam, protoze connectorMetric uz bude umet. Delat pro dochazku zvlastni typ widgetu by znamenalo, ze pro kazdou dalsi oblast pribude dalsi.

Nacitani dat

Dnesni pravidlo je "data si nacita prehled, ne widgety" a je spravne. S vlastnimi widgety uz ale nejde predpovedet, kolik dotazu je potreba, takze:

POST /api/dashboard/widgets/data
body:  { "scope": "tenant", "tenantId": "tnt_automia", "instanceIds": ["w1","w2","w3"] }
odpoved: { "w1": {...}, "w2": {...}, "w3": { "error": "..." } }

Jeden request na cely dashboard. Server si vysledky cachuje na par sekund podle podpisu zdroje, takze deset dlazdic nad stejnym filtrem je jeden dotaz. Widget, ktery selze, vraci chybu na sve pozici - jeden rozbity konektor nesmi zhasnout cely prehled. Obnovovani zustava na refetchOn a SSE.

Sdilene rozlozeni

Rozlozeni je dnes za dvojici uzivatel a firma. Pribude treti uroven:

  1. rozlozeni uzivatele, kdyz si ho upravil,
  2. jinak rozlozeni pro jeho roli ve firme, kdyz ho admin nastavil,
  3. jinak vychozi z katalogu.

Novy clovek v roli Skladnik tak hned uvidi dashboard skladnika, ne obecny.

7 - Napojeni na Postgres

Ano, a je to prvni vec, kterou udelat. Vsechno ostatni na ni stoji: role, akce, widgety, audit i historie behu jsou data, ktera nesmi zmizet restartem.

Aplikace je na to pripravena. src/data/ je oddelene od rout a 03-architektura-a-mapa-kodu.md s tim pocita. Cilem je zachovat signatury funkci ulozist a prepsat jim vnitrek. Routy se menit nemaji.

Volby

Vec Navrh Proc
Databaze PostgreSQL 16 JSONB, SKIP LOCKED, LISTEN/NOTIFY
Driver pg bez nadstavby, pool
Dotazy Drizzle (nebo Kysely) typovane SQL, ne skryty ORM
Migrace drizzle-kit, soubory v repu deterministicke, dohledatelne v gitu
Pripojeni DATABASE_URL jako AppFactory secret nikdy v kodu, nikdy v logu

Prisny ORM se nedoporucuje. Cely projekt je psany tak, ze server je autorita a filtr na firmu je povinny argument - to se hlida lip nad viditelnym SQL.

Schema, na cem zalezi

  • tenant_id NOT NULL na kazde business tabulce, indexy zacinaji tenant_id.
  • Povinny argument tenantIds v ulozistich zustava. Je to prvni obrana a funguje uz pri kompilaci. Row Level Security se da pridat pozdeji jako druha vrstva, ne jako nahrada.
  • JSONB na to, co se cte a zapisuje cele: strom automatizace, vlastni pole ticketu, zdroj widgetu, vstup a vystup kroku. Strom akci nerozkladat do tabulek, nikdo se nad nim nedotazuje po castech.
  • Cizi klice s ON DELETE RESTRICT u vsecho, co nese historii. Smazany resitel dnes ticket neshodi, to chovani musi zustat.

Poradi prevodu

  1. tenants, users, roles, people - identita, nejmensi objem, nejvic zavislosti.
  2. tickets, ticket_trace, ticket_types, ticket_tags, ticket_actions.
  3. connector_defs, connector_grants, connections, scripts - bod 9.
  4. automations, runs, run_steps, jobs - stromy a runtime.
  5. message_templates, dashboard_layouts, custom_widgets, audit.

Sifrovani pristupovych udaju z bodu 9 patri do treti skupiny a nesmi se odlozit na potom. Napojeni ulozene v plaintextu s tim, ze se to dosifruje pozdeji, je uz proteklo do zaloh.

Udalosti pres LISTEN/NOTIFY, ne Redis

src/events/bus.ts je dnes EventEmitter v pameti jedne instance a dokumentace u toho pocita s Redisem. S Postgresem to neni potreba: publish() zapise udalost do tabulky a zavola pg_notify, subscribe() si drzi jedno pripojeni s LISTEN. Signatury zustanou stejne, SSE stream se nemeni.

Dve veci k tomu:

  • pg_notify ma limit 8000 bajtu. Posilat jen typ a ID, ne cela data. Nevadi to, useApiQuery uz na udalost stejne dela nove nacteni.
  • Tabulka udalosti navic vyresi "poslednich par udalosti po pripojeni", ktere dnes zmizi restartem.

Health endpoint

/health musi podle AGENTS.md vracet 200, kdyz aplikace zvladne obsluhovat provoz. Nezavazovat ho na databazi. Kratky vypadek DB by AppFactory vedl k restartovani containeru, coz nic nespravi. Ping na databazi patri na /health/ready s vysledkem cachovanym par sekund.

8 - Cekani v automatizaci a sablony zprav

Krok "pockej"

Novy druh kroku vedle akce a podminky:

| { kind: 'wait'; mode: 'duration'; amount: number;
    unit: 'minutes' | 'hours' | 'days' }
| { kind: 'wait'; mode: 'business'; amount: number;
    unit: 'hours' | 'days' }            // v pracovni dobe firmy
| { kind: 'wait'; mode: 'until'; at: string }        // sablona, napr. {{dueDate}}
| { kind: 'wait'; mode: 'event'; eventType: string;
    matchOn: string; timeout: WaitDuration }         // pockej na odpoved

Implementacne je to skoro zdarma, a je to hlavni odmena za frontu v databazi z posledni sekce. Tabulka job uz ma run_after, takze "pockej 3 dny" je jeden UPDATE s posunutym casem. Zadny casovac v pameti, zadny setTimeout, restart containeru se behu nedotkne. S casovacem v pameti by tenhle bod nesel udelat vubec.

Ctyri veci, ktere se u cekani snadno zapomenou:

  • Pracovni doba. "Pockej 2 dny a posli upominku" u SLA znamena dva pracovni dny. Potrebuje to kalendar za firmu: pracovni doba, dny v tydnu, statni svatky, casova zona. Dodelat to potom znamena predelat vsechny existujici cekaci kroky, proto to ma byt v modelu od zacatku, i kdyby UI zpocatku nabizelo jen duration.
  • Zruseni. "Za 3 dny posli upominku" nesmi odejit, kdyz je ticket mezitim vyreseny. Nejlevnejsi a nejsrozumitelnejsi je podminka pri probuzeni: krok cekani ma cancelWhen, ktere se vyhodnoti az v okamziku probuzeni. K tomu tlacitko "zrusit beh" na ticketu.
  • Verze definice. Beh, ktery ceka tyden, musi dobehnout podle stromu, ktery platil pri jeho spusteni. Proto se verze automatizace nebo akce pinuje na zacatku behu (source_version v tabulce run). Bez toho by uprava stromu zmenila chovani nekolika tisic cekajicich behu.
  • Prehled cekajicich behu. Kdyz ceka 30 tisic behu, musi byt videt, na cem ceka a kdy se probudi, a musi jit hromadne zrusit. Jinak to je neviditelna bomba s casovym spinacem.

Casy se ukladaji v UTC. Probuzeni se pocita jako absolutni okamzik dopredu, v casove zone firmy, aby prechod na letni cas neposunul vsechno o hodinu.

Sablony zprav

Dnes se text pise do pole akce ve strome. To staci na jeden radek, ne na e-mail. Sablona je proto samostatna vec:

interface MessageTemplate {
  id: string;
  tenantId: string;
  key: string;                 // 'order-confirmed'
  name: string;                // 'Potvrzeni objednavky'
  channel: 'email' | 'whatsapp' | 'sms';
  subject: string;             // sablona
  bodyText: string;            // sablona, vzdy povinna
  bodyHtml: string | null;     // jen u e-mailu, nepovinna
  /** Ktere parametry sablona potrebuje. Klic k validaci, viz nize. */
  expects: string[];
  locale: string;
}

Zadny novy sablonovaci jazyk. Tentyz {{parametr}} a tentyz kod v src/data/templates.ts. Dva sablonovaci systemy v jedne aplikaci znamenaji, ze v jednom pujde {{a.b}} a v druhem ne, a nikdo nebude vedet, ve kterem prave je.

expects je to podstatne. Sablona sama nevi, odkud se pouzije, takze pri ulozeni nema proti cemu overit {{orderNumber}}. Deklaruje proto, co potrebuje, a krok, ktery ji pouzije, si overi, ze to jeho misto ve strome nabizi. Tim se z prekvapeni pri behu stane issue v builderu, presne v tom rezimu, ktery uz projekt ma: nedodelek se ulozi, jen automatizaci nepusti.

Dal k tomu patri:

  • Rozvrzeni za firmu. Hlavicka, logo, paticka a podpis jednou, ne v kazde sablone. Sablona nese jen telo.
  • Nahled s ukazkovymi daty. POST /api/dashboard/templates/:id/preview s hodnotami parametru. Bez nahledu se sablony ladi odesilanim skutecnych e-mailu zakaznikum.
  • Cisteni HTML. Telo od uzivatele projde sanitizaci, styly inline. Jinak je to XSS na kazdem, kdo si e-mail otevre ve webmailu.

Odesilani e-mailu vubec neexistuje

01-prehled-a-stav.md vede odesilani e-mailu jako chybejici, dnes se poptavka jen loguje. Bod 8 tedy potrebuje i transport: napojeni na SMTP nebo poskytovatele za firmu, jako kterekoliv jine napojeni z bodu 9.

Dve veci, ktere se resi az bolestive pozde:

  • Odpoved zakaznika se ma vratit do ticketu, ne zalozit novy. Znamena to adresu s tokenem (ticket+<token>@...) a parovani pri prijmu. Kdyz se to nezavede hned, vznikne z kazde e-mailove konverzace pet ticketu.
  • Doruditelnost. SPF, DKIM a DMARC na domene klienta, jinak upominky konci ve spamu a nikdo se to nedozvi. Patri to do onboardingu klienta, ne do kodu.

9 - Jak jsou postavene konektory a skripty

Tohle je nejdulezitejsi cast celeho navrhu, protoze rozhoduje o bezpecnostnim modelu. Dnes je katalog v src/data/connectors.ts spolecny pro vsechny firmy a 07-firmy-a-prava.md vede "Tenant u konektoru" jako chybejici.

Tri vrstvy, ktere se nesmi slit do jedne

Priklad ze zadani (vsichni mohou iDoklad, jen firma C vidi Polstryn SAP, firma B iDoklad vidi ale nema napojeni) nejde zapsat mene nez tremi vrstvami:

Vrstva Co to je Kdo to vlastni
Definice ze iDoklad existuje a co umi my, nebo firma
Zpristupneni kdo ho vubec smi videt my
Napojeni ucet firmy s jejimi pristupy firma
interface ConnectorDefinition {
  id: string;
  key: string;                  // 'idoklad'
  name: string;
  category: ConnectorCategory;
  icon: string;
  /** platform = udelali jsme my, tenant = udelala si firma sama. */
  ownerScope: 'platform' | 'tenant';
  ownerTenantId: string | null;
  /** public = vidi ho vsichni. restricted = jen komu je zpristupneny. */
  visibility: 'public' | 'restricted';
  status: 'available' | 'planned' | 'deprecated';
  /** Jak se autorizuje: co se vyplnuje pri zakladani napojeni. */
  auth: ConnectorAuth;
  triggers: ConnectorOperation[];
  actions: ConnectorOperation[];
  metrics: ConnectorMetric[];
  version: number;
}

/** Zpristupneni definice konkretni firme. Jen u visibility: 'restricted'. */
interface ConnectorGrant {
  connectorDefId: string;
  tenantId: string;
  grantedBy: string;
  grantedAt: string;
}

interface ConnectorConnection {
  id: string;
  tenantId: string;
  connectorDefId: string;
  name: string;                 // 'iDoklad Celo' - firma muze mit dve
  /** Sifrovane, nikdy se nevraci z API. Viz nize. */
  secrets: EncryptedBlob;
  /** Necitliva cast nastaveni: URL instance, cislo eshopu, vychozi rada. */
  config: Record<string, unknown>;
  status: 'active' | 'error' | 'expired';
  isDefault: boolean;
  lastCheckAt: string | null;
  lastError: string | null;
}

Stav konektoru se prestane cist a zacne pocitat

Dnes je ConnectorStatus = 'connected' | 'available' | 'planned' pevne pole v katalogu. Ty tri hodnoty jsou ale presne to, co zadani popisuje, takze staci je pocitat za firmu:

Stav Kdy
connected firma ma aktivni napojeni
available vidi definici, napojeni nema, muze si ho udelat
planned definice je na roadmape
neviditelny restricted bez zpristupneni - v odpovedi vubec neni

GET /api/dashboard/connectors tim prestava byt spolecny a zacina byt za firmu. Neviditelna definice se nevraci se stavem, ale nevraci se vubec. Firma A nesmi z odpovedi poznat, ze Polstryn SAP existuje.

Krok ve strome odkazuje na napojeni

FlowStep dnes nese connectorId a operationId. Pribude connectionId:

{ kind: 'action'; connectorId: string; operationId: string;
  connectionId: string | null;   // null = vychozi napojeni firmy pro tento konektor
  inputs?: Record<string, string> }

Proc oboji a proc nullable:

  • Explicitni connectionId je potreba, kdyz firma ma dva ucty teze sluzby.
  • null jako vychozi dela strom prenositelnym. Vzorovy strom se da nabidnout vsem firmam a kazde se dosadi jeji vlastni napojeni.

Validace pri ulozeni: napojeni musi patrit te same firme a te same definici. Cizi connectionId se chova jako neexistujici, tedy 404, ne 403 - stejne pravidlo jako u ticketu v 07-firmy-a-prava.md. Chybejici vychozi napojeni je nedodelek, ne chyba: strom se ulozi, automatizace nepujde zapnout.

Pristupove udaje

Tohle je nejcitlivejsi misto cele aplikace.

  • Sifrovane v Postgresu, AES-256-GCM, klic z AppFactory secretu, nahodne IV za zaznam a keyVersion u kazdeho radku, aby sla vymena klice.
  • Pole jsou jen pro zapis. API umi clientSecret nastavit a nikdy ho nevrati. Odpoved nese { "clientSecret": { "isSet": true } }, ne hodnotu. To je presne to, co AGENTS.md zakazuje: secrets se nevraci z beznych endpointu.
  • Redakce v logu ticketu. Log ukazuje, co sluzba vratila, a to je jinak jista cesta, jak se token dostane do trace, kdyz ho cizi API vrati v odpovedi nebo v chybove zprave. Pred zapisem musi projit nahrada znamych tajnych hodnot za hvezdicky. Neni to volitelne dolazeni, je to soucast zapisu do logu.
  • OAuth spravuje runtime. Definice deklaruje token endpoint, runtime drzi access token s expiraci a obnovuje ho. Skript ani sablona se k tokenu nedostanou.
  • Test napojeni jako samostatny endpoint. Firma po zadani udaju zmackne Overit a hned vi, jestli to funguje, misto aby to zjistila z padle automatizace v noci.

Definice v DB, ale nase definice patri do repa

Zadani rika dat konektory do DB. Ano, s jednou vyhradou, ktera se vyplati:

  • Definice, ktere delame my (iDoklad, Shoptet, WhatsApp) zijou v repu a do DB se nasazuji migraci nebo seedem. Repo je zdroj pravdy. Jinak zmizi code review, historie v gitu i moznost vratit zmenu, a vyvoj se rozejde s produkci. Rucne upraveny radek v produkci nikdo za tri mesice nedohleda.
  • Definice, ktere si udela firma, zijou jen v DB. Tam repo neni od ceho, vlastnikem je zakaznik.

Runtime v obou pripadech cte z DB, takze se kod nemusi rozdvojovat.

Jak se konektor stavi

Definice ma u kazde operace implementaci, a jsou tri druhy:

Druh Kde je kod Pro co
builtin v repu, TypeScript nase konektory, kde potrebujeme plnou moc
http zadny kod vetsina REST API, i to, co si udela firma
script sandbox prevod dat a divne protokoly

http ma byt vychozi, i pro nase konektory. Operace je pak zaznam:

interface HttpImplementation {
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
  path: string;                        // sablona nad inputs a config
  headers: Record<string, string>;     // sablony, tajemstvi jen odkazem
  body: string | null;                 // sablona
  /** Mapovani odpovedi na outputFields: cesta v JSONu na nazev vystupu. */
  outputMap: Record<string, string>;   // { customerId: 'data.id' }
  pagination?: { kind: 'page' | 'cursor'; ... };
  /** Ktere HTTP kody jsou opakovatelne. Zbytek je koncova chyba. */
  retryOn: number[];
}

V portalu je to formular: adresa, metoda, hlavicky, telo, mapovani vystupu a tlacitko Vyzkouset s ukazkovymi daty. Tim je odpovezena i puvodni otazka "vytvorit produkt skrz custom skripty" - vetsinou to neni skript, ale jedno POST volani a mapovani odpovedi. A co je dulezitejsi, plati pro nej audit, retry i rate limit jako pro kazdy jiny krok.

U http se musi ohlidat SSRF: povolene domeny za firmu, zakaz privatnich rozsahu IP a localhostu, timeout, limit velikosti odpovedi, zadne nasledovani presmerovani na jinou domenu.

Skripty

Zbyde potreba prepocitat, spojit nebo preformatovat data mezi dvema kroky.

interface ScriptDef {
  id: string;
  name: string;
  ownerScope: 'platform' | 'tenant';
  ownerTenantId: string | null;
  visibility: 'public' | 'restricted';   // stejny grant jako u konektoru
  code: string;
  version: number;
  status: 'draft' | 'published' | 'disabled';
  inputs: OperationField[];
  outputFields: ProvidedField[];         // deklarovane dopredu
}

Pravidla, na kterych to stoji:

  • Skript nema sit ani tajemstvi. Vstup JSON, vystup JSON, nic jineho. Cizi sluzby vola runtime pres konektory. Jinak zmizi audit, retry i rate limit a padly skript necha rozdelanou praci, kterou nikdo nedohleda.
  • node:vm neni bezpecnostni hranice a nesmi se pouzit. Bud isolated-vm, nebo QuickJS ve WASM.
  • Limity: CPU radove desitky ms, pamet jednotky MB, velikost vystupu, zadne require, zadne fetch, zadne casovace.
  • Vystupy se deklaruji dopredu, aby na ne sla postavit podminka jako na kazdy jiny outputFields. Skript vracejici cokoliv by se v builderu nedal pouzit.
  • Publikovana verze je nemenna, uprava zaklada novou. Totez jako u definice akce a ze stejneho duvodu.

Kdo potrebuje libovolne IO, nedostane skript, ale vlastni sluzbu v AppFactory - presne k tomu AppFactory je - a v katalogu definici konektoru, ktera ji vola. Tim se z vyjimky stava normalni konektor.

Ctyri ruzne otazky, ktere se pletou do jedne

U konektoru i skriptu se musi rozlisit, a kazda ma jineho vlastnika:

Otazka Mechanismus Nastavuje
Kdo to vidi visibility plus grant my
Kdo si smi udelat napojeni pravo connector.manage admin firmy
Kdo to smi pouzit ve strome pravo automation.edit admin firmy
Kdo smi spustit rucni akci pravo action:<id> admin firmy
Kdo smi upravit skript pravo script.edit my

Zvlast posledni radek: uprava skriptu zmeni chovani vseho, co ho pouziva. To neni pravo, ktere se dava vedle prava zakladat tickety.

Cele flow z prikladu

Kontrola navrhu proti zadani. Karel Novak, firmy Celo a Delo.

Zaznamy, ktere vzniknou

users            usr_novak  Karel Novak
                            memberships: [ (tnt_celo, admin), (tnt_delo, admin) ]

connector_def    cd_idoklad   iDoklad     platform  public
                 cd_easyweb   EasyWeb     platform  public
                 cd_shoptet   Shoptet     platform  public
                 cd_sap_pol   Polstryn SAP platform restricted
connector_grant  (cd_sap_pol, tnt_firmac)

connection       cn_1  tnt_celo  cd_idoklad  'iDoklad Celo'   secrets: clientId+secret
                 cn_2  tnt_celo  cd_easyweb  'EasyWeb Celo'
                 cn_3  tnt_delo  cd_shoptet  'Shoptet Delo'

ticket_type      tt_order  tnt_celo  key 'order'  'Objednavka'
                           fields: orderNumber, customerName, total

ticket_action    tka_send  tnt_celo  [tt_order]  'Odeslat do iDokladu'
                           body: operation  cd_idoklad/create-invoice  cn_1
                                 inputs: { number: '{{orderNumber}}', ... }

                 tka_txt   tnt_celo  [tt_order]  'Vytvorit textak'
                           body: tree  [ skript poskladej text, Soubory/create ]

automation       au_order  tnt_celo  trigger: EasyWeb objednavka prijata (cn_2)
                           steps: [ Zalozit ticket typu order,
                                    iDoklad/create-invoice pres cn_1 ]

Co Karel vidi ve firme Celo

Katalog konektoru: iDoklad connected, EasyWeb connected, Shoptet available (vidi, ze si ho muze napojit, ale ve strome ho pouzit nejde). Polstryn SAP v odpovedi neni vubec.

Ticket typu Objednavka: dve tlacitka, Odeslat do iDokladu a Vytvorit textak. Obe smi zmacknout, protoze ma roli s pravy action:tka_send a action:tka_txt.

Automatizace au_order dela to same odesilani sama, ma ten krok ve svem strome. Odesilani do iDokladu je tedy nastavene na dvou mistech: v akci tka_send a ve strome au_order. To je ta vedoma cena z uvodni sekce. Obe mista pritom volaji tutez operaci katalogu pres totez napojeni cn_1, takze co se duplikuje, je mapovani poli, ne logika.

Co Karel vidi ve firme Delo

Prepne firmu. Katalog: Shoptet connected, iDoklad a EasyWeb available (neni napojeni, jde si ho udelat). Polstryn SAP opet vubec.

Typ ticketu Objednavka neexistuje, patri firme Celo. Tlacitka tka_send a tka_txt taky ne, a to ani kdyby si typ zalozil - definice akce nese tenantId.

To je ten vysledek, o ktery v zadani jde: tentyz clovek, tytez prihlaseni, uplne jina nabidka. Drzi to jedina vec, a to tenantId na definici akce, typu ticketu, automatizace i napojeni. Kdyby kterakoliv z nich tenantId nemela, prosakne firma Celo do Dela.

Textovy soubor: pozor na jednu past

"Vlastni akce mi ulozi textak s nejakym formatem" nejde udelat skriptem, protoze skript nema IO. A hlavne: container ma docasny disk. Soubor zapsany na disk zmizi pri kazdem nasazeni.

Spravne je vestaveny konektor Soubory s akcemi create (obsah je sablona, vystup je odkaz), attach-to-ticket a send. Ulozeni jde do objektoveho uloziste, ne na disk containeru, a soubor se objevi jako priloha ticketu nebo ke stazeni. Skript, kdyz je vubec potreba, jen poskladej text a vrati ho jako hodnotu.

Znamena to jednu infrastrukturni vec navic: S3 kompatibilni uloziste, treba MinIO. Je to jediny bod celeho navrhu, ktery si rika o dalsi sluzbu vedle Postgresu.

Widgety z prikladu

Widget Zdroj
Pocet objednavek od-do connectorMetric nad cn_1, metrika a obdobi
Pocet novych klientu od-do connectorMetric nad cn_1
Pocet ticketu typu Objednavka ticketCount, filtr typeIds: [tt_order]
Pocet padlych behu runCount, filtr status: failed
Padle tickety vuci lidem ticketCount plus groupBy: 'assignee'

Prvni dva se ve firme Delo postavit nedaji, protoze cn_1 do ni nepatri. Az bude mit Delo svuj iDoklad, postavi si je nad svym napojenim a cisla budou jeji.

Runtime a kapacita

Vyhodnocovani kroku, prijem udalosti a co to znamena pri 150 klientech ma vlastni soubor: 10-runtime-a-kapacita.md. Odsud je dulezite jen tolik, ze prubeh behu drzi radek v databazi, ne pamet procesu, a ze diky tomu je cekaci krok z bodu 8 skoro zdarma.

Poradi prace

Vlna Co Zavisi na
0 Postgres, prevod ulozist, LISTEN/NOTIFY (bod 7) -
1 Konektory do DB: definice, zpristupneni, napojeni, sifrovani udaju (bod 9) 0
2 Definice akci: telo jako operace, strom nebo skript, verzovani 0, 1
3 Typy ticketu, tagy, vlastni pole, rucni akce, vestavene akce (1, 2, 3) 0, 2
4 Role a prava jako data, zalozky ze serveru, Nastaveni klienta, audit, impersonace (5, 6) 0, 1
5 Runtime: fronta, retry, idempotence, fairness, krok wait (bod 8) 0, 2
6 Sablony zprav a odesilani e-mailu (bod 8.1) 0, 5
7 Vlastni widgety, seskupovani, sdilene rozlozeni (bod 4) 0, 1
8 Skripty v sandboxu 1, 5

Zmena proti prvni verzi: konektory se posunuly na zacatek. Bez rozdeleni na definici, zpristupneni a napojeni nema smysl delat typy ticketu ani widgety, protoze oboji uz na napojeni odkazuje. Prvni verze mela konektory az v posledni vlne a byla to chyba.

Vlna 7 je nezavisla na 2 az 6 a da se zaradit kdykoliv po 1, kdyz je potreba neco ukazat. Vlna 3 nemusi cekat na 5, protoze rucni akce se zpocatku vykona synchronne - viz konec sekce 1 a 2.

Vlna 8 je posledni zamerne. Az bude hotovy http druh implementace z bodu 9, casto se ukaze, ze skripty nikdo nepotrebuje.

Co se tim rozbije

Seznam mist, ktera navrh meni a je potreba je hlidat.

Zmena Dotkne se
accessFor(user) -> accessFor(user, tenantId) vsechny routy dashboardu, prava jsou az uvnitr firmy
Membership.role -> roleIds types.ts, users.ts, access.ts, middleware/auth.ts
requireRole -> requirePermission src/middleware/auth.ts a vsechna jeho pouziti
Ticket dostane typeId a fields ticketStore.ts, openapi.ts, web/src/types/dashboard.ts, seznam, detail, simulace
Katalog konektoru prestane byt spolecny connectors.ts, GET /connectors, Connectors.tsx - vraci se za firmu
ConnectorStatus se prestane cist z katalogu pocita se z napojeni, dnes je to pevne pole
FlowStep dostane connectionId automationStore.ts, flow.ts, validace stromu, builder
Zalozky ze serveru web/src/components/dashboard/DashboardLayout.tsx, dnes konstanta
WidgetKind -> render plus source widgets.ts, WidgetCard.tsx, ulozena rozlozeni potrebuji prevod ID
Novy druh kroku wait a call flow.ts, flowScope.ts, FlowCanvas.tsx, validace
visibleWhen potrebuje AND vice podminek model podminek dnes umi jedno porovnani, viz conditions.ts
Log ticketu potrebuje redakci tajemstvi ticketStore.ts, zapis response do trace
Data z pameti do Postgresu cele src/data/, routy zustavaji

Dve veci k modelu podminek. visibleWhen u akce potrebuje spojit vic porovnani, zatim to jde jen vnorenim ve strome. Bud model podminek rozsirit o seznam 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. Swagger, ktery neodpovida chovani, je horsi nez zadny.