Runtime vykonava strom, prokliky z widgetu, oprava ukladani rozlozeni

Runtime: `src/runtime/executor.ts` jde krok po kroku, u podminky se vetvi,
do poli dosadi parametry, akci pusti pres runScript a vystupy pripise do
kontextu pro dalsi krok. Cely prubeh jde do logu ticketu vcetne toho, co
sluzba vratila. Pouzivaji ho obe cesty: akce na ticketu i webhook.

Opraveno: rozlozeni dashboardu s vlastnim widgetem se NEDALO ULOZIT.
`validateLayout` znala jen vestaveny katalog, takze kazdy pokus skoncil
hlaskou "widget v katalogu neexistuje" - presne to, co hlasil uzivatel.
Katalog je ted jedna funkce a pouziva ji nabidka i kontrola. Zaroven je
za konkretni firmu, driv slo polozit dlazdici jedne firmy na dashboard druhe.

Prokliky: z widgetu lidi na cloveka, ze seskupeni na vyfiltrovany seznam
ticketu. Odkazy sklada server, protoze on jediny zna filtr widgetu. Seznam
ticketu cte filtr z adresy a umi filtrovat na typ, tag a skupinu.

Tabulky: spolecna `TicketTable` pro seznam i detail osoby. Na mobilu se
neposouva do strany, uzka obrazovka dostane karty. Detail osoby ma velkou
tabulku se zalozkami "ma u sebe" a "vyresil" a prepinacem pohledu.

Odebrano: simulace vcetne tlacitka, dialogu i endpointu. Trojice pohledu
nad tickety - vyber firmy je select, "moje" je prepinac, driv to delalo
totez dvakrat.

Pridan zmereny rozbor kapacity pro 200 firem (19-kapacita-200-firem.md):
soucasny stav to nezvladne, protoze data jsou v pameti a vypis je linearni.
Zmereno na 5 000 ticketech, vcetne toho, co s tim a kolik serveru to chce.

Overeno 7 kontrolami proti bezicimu serveru.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 15:54:17 +02:00
co-authored by Claude Opus 5
parent 29de584df8
commit 5d186dcd2e
27 changed files with 1357 additions and 1201 deletions
+14 -2
View File
@@ -127,12 +127,24 @@ export async function initConnectorStore(options: InitOptions = {}): Promise<Sto
flushRepository = () => local.flush();
if (useFile) {
reasons.push('Data se ukládají do JSON souboru, takže přežijí restart, ale ne redeploy.');
/*
* Soubory lezi uvnitr containeru. Restart je nechá byt, redeploy postavi
* novy container a slozka je zas prazdna. Aby data zustala, musi byt
* `DATA_DIR` na pripojenem svazku, nebo se musi nastavit `DATABASE_URL` -
* obojí je vec nasazeni, ne aplikace, proto se to rekne nahlas.
*/
reasons.push(
`Data se ukládají do souborů v ${config.dataDir}. Přežijí restart, ale ne redeploy, ` +
'protože složka je uvnitř kontejneru. Aby zůstala: nastavte DATABASE_URL, ' +
'nebo namiřte DATA_DIR na připojený svazek.',
);
if (!canSealSecrets()) {
reasons.push('Přístupové údaje se neukládají, protože není čím je zašifrovat.');
}
} else {
reasons.push('Data jsou jen v paměti procesu, restart je smaže.');
reasons.push(
'Data jsou jen v paměti procesu, restart je smaže. Nastavte DATA_DIR, nebo DATABASE_URL.',
);
}
status = {
+8 -3
View File
@@ -7,7 +7,8 @@
* prezije restart. Vychozi sada se neuklada - kdo si nic neupravil, nema zaznam.
*/
import { findWidget, type WidgetSize } from './widgets.js';
import type { CatalogWidget } from './widgets.js';
import type { WidgetSize } from './widgets.js';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withMirror } from './store/mirror.js';
@@ -88,8 +89,12 @@ export function resetLayout(userId: string, tenantId: string): LayoutItem[] {
* Overi rozlozeni proti katalogu. Vraci seznam problemu, prazdny znamena v poradku.
* Neznamy widget ani nepovolena sirka se neulozi - klient by pak dostal zpatky
* neco, co neumi vykreslit.
*
* Katalog je **parametr**, ne pevny seznam: patri do nej i vlastni widgety
* firmy a ty zavisi na tom, kdo se pta. Viz `widgetCatalog`.
*/
export function validateLayout(items: LayoutItem[]): string[] {
export function validateLayout(items: LayoutItem[], catalog: CatalogWidget[]): string[] {
const byId = new Map(catalog.map((widget) => [widget.id, widget]));
const problems: string[] = [];
if (items.length > MAX_WIDGETS) {
@@ -101,7 +106,7 @@ export function validateLayout(items: LayoutItem[]): string[] {
if (seen.has(item.id)) problems.push(`Widget s ID „${item.id}" je uvedený dvakrát.`);
seen.add(item.id);
const definition = findWidget(item.widgetId);
const definition = byId.get(item.widgetId);
if (!definition) {
problems.push(`Widget „${item.widgetId}" v katalogu neexistuje.`);
continue;
+1 -1
View File
@@ -155,7 +155,7 @@ export function updateIncidentStatus(id: string, status: IncidentStatus): Incide
return incident;
}
/** Prvni bezici incident - pouziva simulace, kdyz uzivatel neurci ktery. */
/** Prvni bezici incident. */
export function firstActiveIncident(): Incident | undefined {
return incidents.find((i) => i.status !== 'resolved');
}
+1 -1
View File
@@ -30,7 +30,7 @@ export function getSummary(tenantIds: string[]) {
const automations = listAutomations(tenantIds);
const series = getRunsSeries();
// Dnesni sloupec grafu doplnujeme o skutecne behy, aby se simulace projevila.
// 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 };
+21 -1
View File
@@ -814,6 +814,12 @@ export interface TicketFilter {
assignee?: string;
status?: TicketStatus;
channel?: TicketChannel;
/** Typ ticketu. `none` = tickety bez typu. */
typeId?: string;
/** Jeden tag. `none` = tickety bez tagu. */
tag?: string;
/** Skupina resitelu. `none` = bez skupiny. */
groupId?: string;
}
export function listTickets(filter: TicketFilter): Ticket[] {
@@ -821,6 +827,20 @@ export function listTickets(filter: TicketFilter): Ticket[] {
if (!filter.tenantIds.includes(ticket.tenantId)) return false;
if (filter.status && ticket.status !== filter.status) return false;
if (filter.channel && ticket.channel !== filter.channel) return false;
if (filter.typeId === 'none' ? ticket.typeId : filter.typeId && ticket.typeId !== filter.typeId)
return false;
if (filter.tag === 'none') {
if ((ticket.tags ?? []).length > 0) return false;
} else if (filter.tag && !(ticket.tags ?? []).includes(filter.tag)) {
return false;
}
if (
filter.groupId === 'none'
? ticket.assigneeGroupId
: filter.groupId && ticket.assigneeGroupId !== filter.groupId
) {
return false;
}
if (filter.assignee === 'unassigned') return ticket.assigneeId === null;
if (filter.assignee && ticket.assigneeId !== filter.assignee) return false;
return true;
@@ -851,7 +871,7 @@ export function getTicket(id: string, tenantIds: string[]): TicketDetail | undef
};
}
/** Prvni nevyrizeny ticket - pouziva simulace, kdyz uzivatel neurci ktery. */
/** Prvni nevyrizeny ticket. */
export function firstOpenTicket(tenantIds: string[]): Ticket | undefined {
const stored = tickets.find(
(t) => t.status !== 'resolved' && tenantIds.includes(t.tenantId),
+42 -1
View File
@@ -6,6 +6,8 @@
* i novou komponentu na webu, proto jich je zamerne malo a jsou obecne.
*/
import { listCustomWidgets, sizesFor, type WidgetRender, type WidgetSource } from './customWidgets.js';
export type WidgetSize = 'third' | 'half' | 'full';
export type WidgetKind =
@@ -15,7 +17,9 @@ export type WidgetKind =
| 'incidentList'
| 'workload'
/** Vykon resitelu. Data chodi z `/widget-data`, stejne jako u vlastnich. */
| 'agents';
| 'agents'
/** Vlastni widget: kresleni si urcuje jeho `render`. */
| WidgetRender;
export interface WidgetDefinition {
id: string;
@@ -144,3 +148,40 @@ export const builtinSources: Record<string, { kind: 'agentStats'; period: '30d'
export function findWidget(id: string): WidgetDefinition | undefined {
return widgets.find((widget) => widget.id === id);
}
/** Polozka katalogu tak, jak ji vidi klient. Vlastni widget nese i zdroj dat. */
export interface CatalogWidget extends WidgetDefinition {
/** true = widget si firma nadefinovala sama. */
custom: boolean;
render?: WidgetRender;
source?: WidgetSource;
target?: number;
}
/**
* Cely katalog: pevne widgety plus vlastni te firmy.
*
* **Jedina definice toho, co jde polozit na dashboard.** Pouziva ji nabidka
* widgetu i kontrola ukladaneho rozlozeni. Kdyz to byly dva seznamy, kontrola
* znala jen pevne widgety a ulozit rozlozeni s vlastnim widgetem vzdycky
* selhalo hlaskou, ze v katalogu neni.
*/
export function widgetCatalog(tenantIds: string[], userId: string): CatalogWidget[] {
const custom = listCustomWidgets(tenantIds, userId).map(
(widget): CatalogWidget => ({
id: widget.id,
name: widget.name,
description: widget.description,
// Klient kresli podle `kind`, u vlastniho widgetu je to jeho `render`.
kind: widget.render,
sizes: sizesFor(widget.render),
defaultSize: widget.size,
custom: true,
render: widget.render,
source: widget.source,
...(widget.target !== undefined ? { target: widget.target } : {}),
}),
);
return [...widgets.map((widget): CatalogWidget => ({ ...widget, custom: false })), ...custom];
}
-2
View File
@@ -14,7 +14,6 @@ import { buildOpenApiDocument } from './openapi.js';
import { authRouter } from './routes/auth.js';
import { contactRouter } from './routes/contact.js';
import { dashboardRouter } from './routes/dashboard.js';
import { simulateRouter } from './routes/simulate.js';
import { webhookRouter } from './routes/webhook.js';
import { bootstrapData } from './data/bootstrap.js';
import { flushConnectorStore, initConnectorStore, storageStatus } from './data/connectorStore.js';
@@ -114,7 +113,6 @@ api.use(
api.use('/api/auth', authRouter);
api.use('/api/dashboard', dashboardRouter);
api.use('/api/admin', adminRouter);
api.use('/api/simulate', simulateRouter);
api.use('/api/contact', contactRouter);
api.use('/webhook', webhookRouter);
-93
View File
@@ -136,7 +136,6 @@ export function buildOpenApiDocument() {
{ name: 'Skripty', description: 'Vykonna cast sluzby: manifest, kod a zkusebni beh' },
{ name: 'Nastaveni', description: 'Firmy, lide, role a prava, typy ticketu, akce, widgety' },
{ name: 'Sprava platformy', description: 'Audit a prepnuti na jiny ucet' },
{ name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' },
{ name: 'Webhook', description: 'Verejny prijem dat do automatizace' },
{ name: 'Kontakt', description: 'Poptavkovy formular z webu' },
],
@@ -1969,98 +1968,6 @@ export function buildOpenApiDocument() {
},
},
},
'/api/simulate': {
post: {
tags: ['Simulace'],
summary: 'Vyvolat provozni udalost',
description:
'Meni skutecna data, aby bylo videt, jak dashboard reaguje zive. ' +
'Nevyplnena pole server doplni ukazkovou hodnotou.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['action'],
properties: {
action: {
type: 'string',
enum: [
'ticket.created',
'ticket.resolved',
'incident.started',
'incident.resolved',
'automation.run',
],
},
subject: { type: 'string' },
body: { type: 'string', description: 'Cely text pozadavku.' },
channel: {
type: 'string',
enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'],
description: 'Odkud pozadavek prisel. Podle toho se poskladá i log ticketu.',
},
contact: { type: 'string' },
knownCustomer: {
type: 'boolean',
description:
'false = CRM firmu nedohleda, ticket zustane bez zakaznika i bez resitele.',
},
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
ticketId: { type: 'string' },
title: { type: 'string' },
service: { type: 'string' },
severity: { type: 'string', enum: ['sev1', 'sev2', 'sev3'] },
incidentId: { type: 'string' },
automationId: { type: 'string' },
ok: { type: 'boolean' },
},
},
},
},
},
responses: {
'200': { description: 'Udalost provedena' },
'201': { description: 'Zaznam vytvoren' },
'409': { description: 'Neni co provest' },
},
},
},
'/webhook/{token}': {
post: {
tags: ['Webhook'],
summary: 'Prijem dat do automatizace',
description:
'Verejny endpoint bez prihlaseni. Autorizuje neuhodnutelny token v adrese. ' +
'Telo se overuje proti parametrum deklarovanym u spoustece.',
parameters: [
{
name: 'token',
in: 'path',
required: true,
schema: { type: 'string' },
description: '32znakovy token vygenerovany serverem.',
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { type: 'object', additionalProperties: true },
example: { customer: 'Nordis', score: 18, comment: 'vse ok' },
},
},
},
responses: {
'202': { description: 'Prijato' },
'400': { description: 'Chybi parametr nebo nesedi typ' },
'404': { description: 'Neznamy token' },
'409': { description: 'Automatizace je pozastavena' },
},
},
},
'/api/contact': {
post: {
tags: ['Kontakt'],
+21 -19
View File
@@ -39,8 +39,7 @@ import {
validateLayout,
} from '../data/dashboardLayouts.js';
import { collectScopes } from '../data/flowScope.js';
import { widgets } from '../data/widgets.js';
import { listCustomWidgets, sizesFor } from '../data/customWidgets.js';
import { widgetCatalog } from '../data/widgets.js';
import { listIncidents } from '../data/incidentStore.js';
import { getSummary } from '../data/mock.js';
import { findPersonByEmail, listGroups, listPeople } from '../data/people.js';
@@ -125,23 +124,15 @@ dashboardRouter.get('/incidents', (_req, res) => {
* Diky tomu se rozlozeni dashboardu nemuselo menit.
*/
dashboardRouter.get('/widgets', (req, res) => {
const access = accessFor(req.user!);
const tenantIds = access.tenants.map((tenant) => tenant.id);
/*
* Katalog je za konkretni firmu, stejne jako rozlozeni. Kdyby vracel widgety
* vsech firem uzivatele, sla by polozit dlazdice Automie na dashboard
* Nordisu - v nabidce by byla, ale data by k ni nikdy neprisla.
*/
const tenantId = layoutTenant(req, res);
if (!tenantId) return;
const custom = listCustomWidgets(tenantIds, req.user!.id).map((widget) => ({
id: widget.id,
name: widget.name,
description: widget.description,
kind: widget.render,
sizes: sizesFor(widget.render),
defaultSize: widget.size,
custom: true,
render: widget.render,
source: widget.source,
...(widget.target !== undefined ? { target: widget.target } : {}),
}));
res.json({ items: [...widgets.map((widget) => ({ ...widget, custom: false })), ...custom] });
return res.json({ items: widgetCatalog([tenantId], req.user!.id) });
});
/**
@@ -198,7 +189,10 @@ dashboardRouter.put('/layout', (req, res) => {
});
}
const problems = validateLayout(parsed.data.items);
const problems = validateLayout(
parsed.data.items,
widgetCatalog([tenantId], req.user!.id),
);
if (problems.length > 0) {
console.warn(`[layout] ${req.user!.email}: neplatne rozlozeni - ${problems.join(' ')}`);
return res.status(400).json({ error: 'validation_error', message: problems[0] });
@@ -384,6 +378,14 @@ const ticketChannels: TicketChannel[] = [
function ticketFilterFrom(query: Record<string, unknown>, scope: ResolvedScope): TicketFilter {
const filter: TicketFilter = { tenantIds: scope.tenantIds };
// Filtry, na ktere se odkazuje z widgetu. `none` znamena "bez toho".
const typeId = query.typeId;
if (typeof typeId === 'string' && typeId !== '') filter.typeId = typeId;
const tag = query.tag;
if (typeof tag === 'string' && tag !== '') filter.tag = tag;
const groupId = query.groupId;
if (typeof groupId === 'string' && groupId !== '') filter.groupId = groupId;
// Pohled "moje" je silnejsi nez rucni filtr na resitele.
if (scope.scope === 'mine') {
filter.assignee = scope.personId ?? '__nikdo__';
-453
View File
@@ -1,453 +0,0 @@
import { Router } from 'express';
import { z } from 'zod';
import { listAutomations, recordRun } from '../data/automationStore.js';
import {
createIncident,
firstActiveIncident,
updateIncidentStatus,
} from '../data/incidentStore.js';
import {
createTicket,
firstOpenTicket,
updateTicketStatus,
type TicketChannel,
type TicketPriority,
type TraceInput,
} from '../data/ticketStore.js';
import { accessFor } from '../data/access.js';
import { requireAuth } from '../middleware/auth.js';
export const simulateRouter = Router();
simulateRouter.use(requireAuth);
/**
* Simulace provoznich udalosti pro nahled zivého dashboardu.
*
* Zamerne MENI skutecna data v ulozisti, ne jen posila falesnou notifikaci.
* Diky tomu se zmena projevi i v seznamech a v souhrnu, ne jen v bublinach.
*/
const schema = z.discriminatedUnion('action', [
z.object({
action: z.literal('ticket.created'),
/** Odkud pozadavek prisel. Podle toho se poskladá i log. */
channel: z
.enum(['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'])
.optional(),
subject: z.string().trim().min(3).optional(),
/** Cely text pozadavku. Bez nej se doplni z predmetu a kanalu. */
body: z.string().trim().min(3).optional(),
contact: z.string().trim().min(2).optional(),
priority: z.enum(['low', 'normal', 'high', 'critical']).optional(),
/** false = CRM firmu nedohleda, ticket zustane bez zakaznika a bez resitele. */
knownCustomer: z.boolean().optional(),
}),
z.object({
action: z.literal('ticket.resolved'),
ticketId: z.string().optional(),
}),
z.object({
action: z.literal('incident.started'),
title: z.string().trim().min(3).optional(),
service: z.string().trim().min(2).optional(),
severity: z.enum(['sev1', 'sev2', 'sev3']).optional(),
}),
z.object({
action: z.literal('incident.resolved'),
incidentId: z.string().optional(),
}),
z.object({
action: z.literal('automation.run'),
automationId: z.string().optional(),
ok: z.boolean().optional(),
}),
]);
/**
* Ukazkovy prichod pozadavku podle kanalu. Kanal urcuje nejen text,
* ale i to, jak vypada zaznam spoustece v logu ticketu.
*/
const channelPresets: Record<
TicketChannel,
{
serviceId: string;
operationId: string;
triggerLabel: string;
subjects: string[];
contact: string;
reply: string;
/** Odkaz na zdrojovou zpravu u poskytovatele. null tam, kde zadny neni. */
sourceRef: string | null;
/** Co spoustec vratil. `%s` se nahradi predmetem. */
payload: (contact: string, reply: string, subject: string) => string;
}
> = {
whatsapp: {
serviceId: 'whatsapp',
operationId: 'message-received',
triggerLabel: 'Přijata zpráva z WhatsApp',
subjects: [
'Nechodí ranní report objednávek',
'Potřebujeme přidat další číslo na hlášení',
'Zákazník si stěžuje na dvojí SMS',
],
contact: 'Bistro Kolektiv',
reply: '+420 774 902 331',
sourceRef: 'wamid.HBgLNDIwNzc0OTAyMzMx',
payload: (contact, reply, subject) =>
`{ "from": "${reply.replace(/\s/g, '')}", "profileName": "${contact}", "text": "${subject}" }`,
},
facebook: {
serviceId: 'facebook',
operationId: 'message-received',
triggerLabel: 'Přijata zpráva z Facebook Messengeru',
subjects: [
'Dotaz na otevírací dobu poboček',
'Nefunguje objednávkový formulář',
'Reklamace poškozené zásilky',
],
contact: 'Martin Dvořák',
reply: 'fb:1029384756',
sourceRef: 'mid.simulace.fb',
payload: (contact, reply, subject) =>
`{ "senderId": "${reply.replace('fb:', '')}", "senderName": "${contact}", "text": "${subject}" }`,
},
instagram: {
serviceId: 'instagram',
operationId: 'message-received',
triggerLabel: 'Přijata přímá zpráva na Instagramu',
subjects: [
'Máte to ještě skladem?',
'Kdy dorazí objednávka',
'Jde změnit adresa doručení',
],
contact: 'kavarna.uzastavky',
reply: 'ig:kavarna.uzastavky',
sourceRef: 'ig.simulace',
payload: (contact, _reply, subject) =>
`{ "username": "${contact}", "text": "${subject}" }`,
},
email: {
serviceId: 'email',
operationId: 'received',
triggerLabel: 'Přijat e-mail do schránky podpora@',
subjects: [
'Faktura se nespárovala s platbou',
'Žádost o změnu fakturačních údajů',
'Export do skladu skončil chybou',
],
contact: 'Tomáš Beran',
reply: 'tomas.beran@nordis.cz',
sourceRef: '<sim-4f8a@nordis.cz>',
payload: (_contact, reply, subject) =>
`{ "from": "${reply}", "subject": "${subject}", "attachments": 1 }`,
},
voice: {
serviceId: 'voicebot',
operationId: 'call-received',
triggerLabel: 'Příchozí hovor na linku 800 100 200',
subjects: [
'Voicebot nerozumí názvu ulice',
'Hovory se po 18:00 nepřepojují',
'Zákazník žádá zpětné volání',
],
contact: 'Petra Klientová',
reply: '+420 601 118 224',
sourceRef: 'cl_simulace',
payload: (_contact, reply, _subject) =>
`{ "callId": "cl_${Math.floor(Math.random() * 90_000) + 10_000}", "from": "${reply.replace(/\s/g, '')}", "durationSec": 74 }`,
},
form: {
serviceId: 'form',
operationId: 'submitted',
triggerLabel: 'Odeslán formulář Požadavek na úpravu',
subjects: [
'Rozšíření hlasového scénáře',
'Napojení dalšího e-shopu',
'Nový report pro management',
],
contact: 'Jana Sedláčková',
reply: 'jana.sedlackova@logitrans.cz',
sourceRef: null,
payload: (contact, _reply, subject) =>
`{ "company": "LogiTrans", "contact": "${contact}", "topic": "${subject}" }`,
},
portal: {
serviceId: 'ticket',
operationId: 'created',
triggerLabel: 'Založeno ručně z portálu',
subjects: [
'Interní kontrola nastavení zálohování',
'Revize oprávnění v CRM',
'Aktualizace hlasového scénáře',
],
contact: 'Interní požadavek',
reply: 'portal@automia.cz',
sourceRef: null,
payload: (contact, _reply, subject) => `{ "createdBy": "${contact}", "subject": "${subject}" }`,
},
};
/** Firmy, ktere CRM zna. Simulace z nich bere zakaznika, kdyz ma byt dohledany. */
const knownCompanies = [
{ id: 'crm_1042', company: 'Firma s.r.o.' },
{ id: 'crm_2210', company: 'Nordis a.s.' },
{ id: 'crm_3390', company: 'LogiTrans' },
];
/**
* Resitel podle kanalu. Zamerne primitivni pravidlo - jde o to ukazat,
* ze ticket nekonci na hromade, ale u konkretniho cloveka.
*/
const routingRule: Record<TicketChannel, { id: string; name: string; why: string }> = {
voice: { id: 'ppl_novakova', name: 'Eva Nováková', why: 'voice -> voiceboti' },
email: { id: 'ppl_kriz', name: 'Martin Kříž', why: 'email -> integrace' },
whatsapp: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'whatsapp -> servicedesk' },
facebook: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'facebook -> servicedesk' },
instagram: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'instagram -> servicedesk' },
form: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'form -> servicedesk' },
portal: { id: 'ppl_uhlir', name: 'Jiří Uhlíř', why: 'portal -> vedoucí týmu' },
};
/**
* Log prichodu pozadavku. Presne tohle chce clovek videt, kdyz se pta
* "proc ten ticket vypada takhle a co na to rekly jednotlive sluzby".
*/
function buildIntakeTrace(input: {
channel: TicketChannel;
subject: string;
contact: string;
reply: string;
priority: TicketPriority;
known: (typeof knownCompanies)[number] | null;
}): TraceInput[] {
const preset = channelPresets[input.channel];
const routing = routingRule[input.channel];
const trace: TraceInput[] = [
{
kind: 'trigger',
serviceId: preset.serviceId,
operationId: preset.operationId,
label: preset.triggerLabel,
status: 'ok',
response: preset.payload(input.contact, input.reply, input.subject),
durationMs: 60,
},
{
kind: 'action',
serviceId: 'ai-text',
operationId: 'classify',
label: 'Zařazení do kategorie',
status: 'ok',
response: `{ "category": "pozadavek", "priority": "${input.priority}", "confidence": 0.83 }`,
durationMs: 880,
},
{
kind: 'action',
serviceId: 'raynet',
operationId: 'upsert-contact',
label: 'Dohledání firmy v CRM',
status: input.known ? 'ok' : 'error',
response: input.known
? `{ "companyId": "${input.known.id}", "name": "${input.known.company}" }`
: `{ "matches": 0, "searchedBy": "reply", "value": "${input.reply}" }`,
durationMs: 590,
},
];
if (input.known) {
trace.push({
kind: 'condition',
label: 'knownCustomer je splněno',
status: 'ok',
response: 'true, pokračuje větev ANO',
children: [
{
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
label: 'Založení ticketu',
status: 'ok',
response: `{ "customerId": "${input.known.id}", "priority": "${input.priority}" }`,
durationMs: 70,
},
{
kind: 'action',
serviceId: 'ticket',
operationId: 'assign',
label: 'Přiřazení řešitele podle kanálu',
status: 'ok',
response: `{ "assignee": "${routing.name}", "rule": "${routing.why}" }`,
durationMs: 35,
},
],
});
return trace;
}
trace.push({
kind: 'condition',
label: 'knownCustomer není splněno',
status: 'skipped',
response: 'false, pokračuje větev NE',
children: [
{
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
label: 'Založení ticketu bez napojení na firmu',
status: 'ok',
response: `{ "customerId": null, "priority": "${input.priority}" }`,
durationMs: 68,
},
{
kind: 'action',
serviceId: 'raynet',
operationId: 'create-lead',
label: 'Založení obchodního případu k dohledání',
status: 'ok',
response: `{ "leadId": "lead_${Math.floor(Math.random() * 9_000) + 1_000}", "stage": "k overeni" }`,
durationMs: 910,
},
{
kind: 'action',
serviceId: 'ticket',
operationId: 'assign',
label: 'Přiřazení řešitele',
status: 'skipped',
response: 'Přeskočeno, bez známé firmy nelze určit garanta a ticket zůstal ve frontě',
},
],
});
return trace;
}
const defaultIncidents = [
{ title: 'Výpadek spojení s fakturačním API', service: 'Integrace / iDoklad' },
{ title: 'Zpoždění doručování webhooků', service: 'Webhook Router' },
{ title: 'Hlasová brána odmítá hovory', service: 'Voicebot Gateway' },
];
/** Nahodny prvek - jen pro rozmanitost ukazkovych dat. */
function pick<T>(items: T[]): T {
return items[Math.floor(Math.random() * items.length)];
}
simulateRouter.post('/', (req, res) => {
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup simulace.',
});
}
// Simulace pisi do dat, takze musi vedet, ci. Bez firmy nema kam.
const access = accessFor(req.user!);
const tenantId = access.defaultTenantId;
if (!tenantId) {
return res.status(403).json({
error: 'no_tenant',
message: 'Účet nepatří do žádné firmy, simulace nemá kam zapsat.',
});
}
const tenantIds = access.tenants.map((tenant) => tenant.id);
const input = parsed.data;
console.info(`[simulace] ${input.action} spustil ${req.user?.email} pro ${tenantId}`);
switch (input.action) {
case 'ticket.created': {
const channel = input.channel ?? 'whatsapp';
const preset = channelPresets[channel];
const priority = input.priority ?? 'normal';
const subject = input.subject ?? pick(preset.subjects);
const contact = input.contact ?? preset.contact;
// Vychozi chovani je "zakaznika zname" - nezname je zajimavejsi, ale vzacnejsi.
const known = (input.knownCustomer ?? true) ? pick(knownCompanies) : null;
const ticket = createTicket({
tenantId,
subject,
// Kanal posila cely text, predmet je jen jeho zkraceni.
body: input.body ?? `${subject}. Poslal ${contact} přes ${preset.triggerLabel}.`,
sourceRef: preset.sourceRef,
channel,
priority,
customer: {
id: known?.id ?? null,
company: known?.company ?? 'Neznámá firma',
contact,
reply: preset.reply,
},
// Bez znameho zakaznika neni podle ceho resitele urcit, ticket zustane ve fronte.
assigneeId: known ? routingRule[channel].id : null,
trace: buildIntakeTrace({ channel, subject, contact, reply: preset.reply, priority, known }),
});
return res.status(201).json({ ok: true, ticket });
}
case 'ticket.resolved': {
const target = input.ticketId ? { id: input.ticketId } : firstOpenTicket(tenantIds);
if (!target) {
return res.status(409).json({
error: 'nothing_to_resolve',
message: 'Není co vyřešit, všechny tickety jsou hotové.',
});
}
const ticket = updateTicketStatus(target.id, 'resolved', tenantIds);
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
return res.json({ ok: true, ticket });
}
case 'incident.started': {
const preset = pick(defaultIncidents);
const incident = createIncident({
title: input.title ?? preset.title,
service: input.service ?? preset.service,
severity: input.severity ?? 'sev2',
});
return res.status(201).json({ ok: true, incident });
}
case 'incident.resolved': {
const target = input.incidentId ? { id: input.incidentId } : firstActiveIncident();
if (!target) {
return res.status(409).json({
error: 'nothing_to_resolve',
message: 'Není co vyřešit, žádný incident neběží.',
});
}
const incident = updateIncidentStatus(target.id, 'resolved');
if (!incident) {
return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' });
}
return res.json({ ok: true, incident });
}
case 'automation.run': {
const automations = listAutomations(tenantIds).filter((a) => a.enabled);
const targetId = input.automationId ?? automations[0]?.id;
if (!targetId) {
return res.status(409).json({
error: 'no_automation',
message: 'Není co spustit, žádná automatizace není aktivní.',
});
}
const automation = recordRun(targetId, input.ok ?? true, tenantIds);
if (!automation) {
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
}
return res.json({ ok: true, automation });
}
default: {
// Vetve jsou vycerpane, tohle je jen pojistka pri rozsireni schematu.
console.error('[simulace] neosetrena akce:', input);
return res.status(400).json({ error: 'unknown_action', message: 'Neznámá akce.' });
}
}
});
+38 -10
View File
@@ -37,6 +37,7 @@ import {
import { findTicketType, validateTicketFields } from '../data/ticketTypes.js';
import { runScript } from '../scripts/runner.js';
import { scriptIdFor } from '../scripts/lookup.js';
import { runFlow } from '../runtime/executor.js';
export const ticketActionsRouter = Router();
@@ -235,9 +236,9 @@ interface ActionResult {
/**
* Vykonani tela akce.
*
* Zatim synchronne v requestu. Zamerne: rucni akce nemusi cekat na frontu
* a `runScript` je uz hotovy vstupni bod na kroku. Az bude runtime, zavola
* se odsud totez z workeru.
* Synchronne v requestu. Zamerne: rucni akci clovek odklikl a ceka na vysledek,
* fronta by mu ho jen oddalila. Az bude runtime na vic strojich, zavola se
* odsud totez z workeru - `runFlow` je stejny vstupni bod.
*/
async function runAction(
action: TicketAction,
@@ -250,14 +251,41 @@ async function runAction(
const body = action.body;
if (body.kind === 'tree') {
// Strom akce potrebuje runtime, ktery zatim neexistuje. Rekne se to nahlas,
// misto aby se tvarilo, ze se neco stalo.
if (body.steps.length === 0) {
return {
ok: false,
summary: 'akce nemá žádné kroky',
detail: 'Strom je prázdný. Doplňte kroky v definici akce.',
durationMs: Date.now() - startedAt,
outputs: {},
};
}
/*
* Kontext stromu: udaje ticketu a hodnoty z doptavaciho dialogu. Na obojí
* jde v krocích odkazovat pres `{{jmeno}}`, stejne jako u jedne operace.
*/
const context: Record<string, unknown> = { ...facts, ...form };
const run = await runFlow(body.steps, context, {
tenantId: ticket.tenantId,
ticketId: ticket.id,
idempotencyKey: `ticket:${ticket.id}:${action.id}`,
});
if (!run.ok) {
console.warn(`[actions] ${action.id} na ${ticket.id} od ${actorEmail}: ${run.error}`);
}
const failed = run.steps.find((step) => !step.ok);
return {
ok: false,
summary: 'strom akce zatím nejde vykonat, chybí runtime',
detail: 'Akce s vlastním stromem půjde spustit až s runtime automatizací.',
durationMs: Date.now() - startedAt,
outputs: {},
ok: run.ok,
summary: run.ok
? `proběhlo, ${run.steps.length} ${run.steps.length === 1 ? 'krok' : 'kroků'}`
: (run.error ?? 'selhalo'),
// Cele hlaseni toho kroku, ktery to zastavil. Zbytek je v logu ticketu.
detail: failed?.detail ?? null,
durationMs: run.durationMs,
outputs: run.context as Record<string, unknown>,
};
}
+50 -8
View File
@@ -3,6 +3,7 @@ import { z } from 'zod';
import { findByWebhookToken, recordRun, type TriggerField } from '../data/automationStore.js';
import type { FieldType } from '../data/conditions.js';
import { findByIntakeToken } from '../data/tenants.js';
import { runFlow } from '../runtime/executor.js';
import { intakeEvent } from '../data/ticketStore.js';
import { findTicketType, listTicketTypes } from '../data/ticketTypes.js';
import { publish } from '../events/bus.js';
@@ -191,18 +192,59 @@ webhookRouter.post('/:token', (req, res) => {
automationId: automation.id,
fields: Object.keys(payload),
});
recordRun(automation.id);
console.info(
`[webhook] ${automation.id}: prijato (${Object.keys(payload).join(', ') || 'bez dat'})`,
);
// PROTOTYP: strom se nevykonava, jen potvrdime prijem.
// Runtime je popsany v docs/08-automatizace-builder.md.
return res.status(202).json({
accepted: true,
automationId: automation.id,
message: 'Požadavek přijat. Prototyp strom akcí nevykonává.',
});
if (automation.flow.steps.length === 0) {
recordRun(automation.id);
return res.status(202).json({
accepted: true,
automationId: automation.id,
message: 'Přijato. Automatizace nemá žádné kroky.',
});
}
/*
* Strom se vykona hned v requestu. Odesilatel tim dostane skutecny vysledek
* misto "prijato" - u desitek kroku je to v poradku, u tisicu udalosti za
* minutu to patri do fronty, viz documentation/10-runtime-a-kapacita.md.
*/
return void runFlow(automation.flow.steps, payload, {
tenantId: automation.tenantId,
ticketId: null,
idempotencyKey: `webhook:${automation.id}:${token.slice(0, 8)}`,
})
.then((run) => {
recordRun(automation.id, run.ok);
const failed = run.steps.find((step) => !step.ok);
if (!run.ok) {
console.warn(`[webhook] ${automation.id}: beh selhal - ${run.error}`);
}
// Chyba stromu neni chyba prijmu. Data jsme prevzali, jen se nepovedlo
// je zpracovat - proto 200 s `ok: false`, ne 500.
return res.status(200).json({
accepted: true,
automationId: automation.id,
ok: run.ok,
steps: run.steps.length,
durationMs: run.durationMs,
error: run.error,
// Cele hlaseni toho kroku, ktery to zastavil.
detail: failed?.detail ?? null,
});
})
.catch((err: unknown) => {
// `runFlow` chyby nevyhazuje, tohle je posledni pojistka.
console.error(`[webhook] ${automation.id}: neocekavana chyba behu:`, err);
recordRun(automation.id, false);
return res.status(500).json({
error: 'internal_error',
message: err instanceof Error ? err.message : 'Běh selhal.',
});
});
});
/** Overi prichozi data proti deklarovanym parametrum spoustece. */
+84 -9
View File
@@ -55,13 +55,30 @@ const requestSchema = z.object({
widgetIds: z.array(z.string().min(1)).max(24),
});
/** Jedna hodnota, nebo seskupeny vysledek. Klient podle `render` vi, co s tim. */
/**
* Jedna hodnota, nebo seskupeny vysledek. Klient podle `render` vi, co s tim.
*
* U cisel a radku chodi `href`: kam se proklikne ten, kdo chce videt, co je
* za tim. Sklada ho **server**, protoze on jediny zna filtr widgetu - klient
* by si musel odvozovat, co ktery zdroj znamena, a jednou by se to rozeslo.
*/
type WidgetValue =
| { kind: 'number'; value: number }
| { kind: 'groups'; rows: Array<{ key: string; label: string; value: number }> }
| { kind: 'series'; points: Array<{ date: string; value: number }> }
| { kind: 'tickets'; items: Array<{ id: string; subject: string; status: string; assignee: string | null }> }
| { kind: 'workload'; rows: Array<{ name: string; open: number; overCapacity: boolean }> }
| { kind: 'number'; value: number; href?: string }
| {
kind: 'groups';
rows: Array<{ key: string; label: string; value: number; href?: string }>;
}
| { kind: 'series'; points: Array<{ date: string; value: number }>; href?: string }
| {
kind: 'tickets';
items: Array<{ id: string; subject: string; status: string; assignee: string | null }>;
href?: string;
}
| {
kind: 'workload';
rows: Array<{ personId: string; name: string; open: number; overCapacity: boolean; href: string }>;
href?: string;
}
| { kind: 'agents'; rows: AgentStatsRow[] }
/** Vysledek z konektoru. `stale` rika, ze je to z cache, ne cerstve zavolane. */
| {
@@ -303,6 +320,48 @@ function toExternal(
};
}
/**
* Adresa seznamu ticketu s dosazenym filtrem.
*
* Klic seskupeni se prevadi na parametr, ktery seznam umi. Prazdny klic
* znamena "bez toho", proto `none` - prazdna hodnota v adrese by se cetla
* jako "nefiltrovat".
*/
function ticketsHref(
filter: WidgetTicketFilter,
scope: ResolvedScope,
extra?: { groupBy?: WidgetGroupBy; key?: string },
): string {
const params = new URLSearchParams();
const tenantId = scope.tenantIds.length === 1 ? scope.tenantIds[0] : null;
if (tenantId) params.set('tenantId', tenantId);
if (filter.status?.length === 1) params.set('status', filter.status[0]);
if (filter.channel?.length === 1) params.set('channel', filter.channel[0]);
if (filter.typeIds?.length === 1) params.set('typeId', filter.typeIds[0]);
if (filter.tags?.length === 1) params.set('tag', filter.tags[0]);
if (filter.assignee?.length === 1) params.set('assignee', filter.assignee[0]);
if (filter.groupIds?.length === 1) params.set('groupId', filter.groupIds[0]);
if (extra?.groupBy) {
const key = extra.key === '' || extra.key === undefined ? 'none' : extra.key;
const byGroup: Record<WidgetGroupBy, string> = {
assignee: 'assignee',
group: 'groupId',
status: 'status',
type: 'typeId',
tag: 'tag',
channel: 'channel',
};
// Prazdna skupina u resitele je fronta, na to ma seznam vlastni hodnotu.
if (extra.groupBy === 'assignee' && key === 'none') params.set('assignee', 'unassigned');
else params.set(byGroup[extra.groupBy], key);
}
const query = params.toString();
return query ? `/dashboard/tickety?${query}` : '/dashboard/tickety';
}
function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue {
if (source.kind === 'agentStats') {
return {
@@ -316,10 +375,14 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
return {
kind: 'workload',
rows: workload.rows.map((row) => ({
personId: row.person.id,
name: row.person.name,
open: row.open,
overCapacity: row.overloaded,
// Z vytizeni se chce clovek dostat na toho cloveka, ne na cisla.
href: `/dashboard/lide/${row.person.id}`,
})),
href: '/dashboard/lide',
};
}
@@ -334,7 +397,13 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
);
if (source.kind === 'ticketCount') {
if (!source.groupBy) return { kind: 'number', value: tickets.length };
if (!source.groupBy) {
return {
kind: 'number',
value: tickets.length,
href: ticketsHref(source.filter, scope),
};
}
const counts = new Map<string, number>();
for (const ticket of tickets) {
@@ -345,7 +414,12 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
return {
kind: 'groups',
rows: [...counts.entries()]
.map(([key, value]) => ({ key, label: groupLabel(key, source.groupBy!), value }))
.map(([key, value]) => ({
key,
label: groupLabel(key, source.groupBy!),
value,
href: ticketsHref(source.filter, scope, { groupBy: source.groupBy, key }),
}))
.sort((a, b) => b.value - a.value),
};
}
@@ -353,6 +427,7 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
if (source.kind === 'ticketList') {
return {
kind: 'tickets',
href: ticketsHref(source.filter, scope),
items: tickets
.slice(0, source.limit)
.map((ticket) => ({
@@ -374,7 +449,7 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
value: tickets.filter((ticket) => ticket.createdAt.slice(0, 10) === day).length,
});
}
return { kind: 'series', points };
return { kind: 'series', points, href: ticketsHref(source.filter, scope) };
}
/** Zdroj vlastniho widgetu. Vestavene maji svuj v `builtinSources`. */
+335
View File
@@ -0,0 +1,335 @@
/**
* Vykonavani stromu kroku.
*
* Tohle je ta cast, ktera do ted chybela: strom se dal poskladat a ulozit, ale
* nic ho nespustilo. Pouziva se na dvou mistech a je zamerne jedno:
* - telo akce na ticketu, kterou spusti clovek tlacitkem,
* - automatizace spustena webhookem.
*
* Co dela:
* - jde krok po kroku, u podminky se vetvi,
* - do poli kroku dosadi `{{parametry}}` z kontextu,
* - akci vykona pres `runScript`, tedy tim samym skriptem jako vsude jinde,
* - vystupy kroku pripise do kontextu, aby na ne mohl dalsi krok odkazat,
* - kazdy krok zapise do logu vcetne toho, co sluzba vratila.
*
* Co nedela, a je to zamer:
* - **nebezi ve fronte**. Beh je synchronni v ramci jednoho requestu, takze
* dlouhy strom drzi spojeni. Na desitky kroku to staci, na statisice udalosti
* ne - popis fronty a rozdeleni na worker je v documentation/10-runtime-a-kapacita.md.
* - **neopakuje se sam**. Chyba krok zastavi a zapise se cela, misto aby se
* tise zkousela znovu.
*/
import { defaultConnectorFor, getConnector, type Connector } from '../data/connectorStore.js';
import type { FlowStep } from '../data/automationStore.js';
import { isUnary, type ConditionOperator } from '../data/conditions.js';
import { renderTemplate } from '../data/templates.js';
import { appendTrace, type TraceInput } from '../data/ticketStore.js';
import { scriptIdFor } from '../scripts/lookup.js';
import { runScript } from '../scripts/runner.js';
/**
* Hodnoty, na ktere jde ve strome odkazovat.
*
* Klic je jmeno, ktere se pise do sablony jako `{{jmeno}}`. Vystupy kroku se
* pridavaji pod `krok.vystup`, takze si dva stejne kroky neprepisou hodnoty.
*/
export type RunContext = Record<string, unknown>;
export interface RunOptions {
/** Firma, jejimz jmenem se to vola. Urcuje, ktery konektor se pouzije. */
tenantId: string;
/** Ticket, do jehoz logu se zapisuje. null = beh mimo ticket. */
ticketId: string | null;
/** Stabilni klic proti dvojimu provedeni. */
idempotencyKey: string | null;
/** Kolik kroku se nejvys vykona. Pojistka proti nechtene dlouhemu stromu. */
maxSteps?: number;
}
export interface StepResult {
stepId: string;
kind: 'action' | 'condition';
label: string;
ok: boolean;
/** U podminky, kterou vetvi se slo. */
branch?: 'yes' | 'no';
summary: string;
/** Cele hlaseni nebo cela odpoved sluzby. Nikdy se nezkracuje. */
detail: string | null;
durationMs: number;
}
export interface RunResult {
ok: boolean;
/** Kolik kroku se opravdu vykonalo, vcetne podminek. */
steps: StepResult[];
/** Kontext po behu, tedy i vystupy kroku. */
context: RunContext;
/** Prvni chyba, ktera beh zastavila. */
error: string | null;
durationMs: number;
}
/** Vychozi strop kroku. Vic uz neni strom, ale program. */
const MAX_STEPS = 50;
/**
* Vykona strom.
*
* **Nikdy nevyhodi vyjimku.** Chyba je vysledek, ne pad - stejna dohoda jako
* u `runScript`. Volajici tak nemusi resit try/catch a chyba se vzdycky dostane
* az k uzivateli i do logu ticketu.
*/
export async function runFlow(
steps: FlowStep[],
context: RunContext,
options: RunOptions,
): Promise<RunResult> {
const startedAt = Date.now();
const results: StepResult[] = [];
const working: RunContext = { ...context };
const limit = options.maxSteps ?? MAX_STEPS;
let error: string | null = null;
async function walk(list: FlowStep[]): Promise<boolean> {
for (const step of list) {
if (results.length >= limit) {
error = `Strom má víc než ${limit} kroků, běh se zastavil.`;
return false;
}
if (step.kind === 'condition') {
const branch = evaluate(step, working) ? 'yes' : 'no';
results.push({
stepId: step.id,
kind: 'condition',
label: describeCondition(step, working),
ok: true,
branch,
summary: branch === 'yes' ? 'splněno' : 'nesplněno',
detail: null,
durationMs: 0,
});
// Prochazi se jen jedna vetev, ta druha se nevykona.
if (!(await walk(branch === 'yes' ? step.yes : step.no))) return false;
continue;
}
const result = await runAction(step, working, options);
results.push(result);
if (!result.ok) {
error = result.summary;
return false;
}
}
return true;
}
await walk(steps);
// Log ticketu dostane cely prubeh naraz, ne po jednom zapisu.
if (options.ticketId && results.length > 0) {
appendTrace(options.ticketId, results.map(toTrace));
}
return {
ok: error === null,
steps: results,
context: working,
error,
durationMs: Date.now() - startedAt,
};
}
/** Vykona jeden krok pres skript sluzby. */
async function runAction(
step: Extract<FlowStep, { kind: 'action' }>,
context: RunContext,
options: RunOptions,
): Promise<StepResult> {
const startedAt = Date.now();
const label = `${step.serviceId}/${step.operationId}`;
const scriptId = scriptIdFor(step.serviceId, step.operationId);
if (!scriptId) {
return {
stepId: step.id,
kind: 'action',
label,
ok: false,
summary: 'operace nemá výkonnou část',
detail:
`Služba ${step.serviceId} nemá pro operaci ${step.operationId} skript, ` +
'takže ji nelze vykonat. Zbytek stromu se nespustil.',
durationMs: Date.now() - startedAt,
};
}
let connector: Connector | undefined;
try {
connector = step.connectorId
? await getConnector(step.connectorId, [options.tenantId])
: await defaultConnectorFor(options.tenantId, step.serviceId);
} catch (err) {
// Nedostupne uloziste konektoru nesmi vypadat jako chyba cizi sluzby.
return {
stepId: step.id,
kind: 'action',
label,
ok: false,
summary: 'napojení se nepodařilo načíst',
detail: err instanceof Error ? err.message : String(err),
durationMs: Date.now() - startedAt,
};
}
const inputs = fillTemplates(step.inputs ?? {}, context);
const result = await runScript(scriptId, inputs, {
connector: connector ?? null,
// Klic je stabilni na dvojici beh a krok, takze opakovane odeslani
// nevystavi druhou fakturu.
idempotencyKey: options.idempotencyKey ? `${options.idempotencyKey}:${step.id}` : undefined,
});
if (!result.ok) {
return {
stepId: step.id,
kind: 'action',
label,
ok: false,
summary: result.error?.message ?? 'krok selhal',
detail: result.error?.detail ?? null,
durationMs: Date.now() - startedAt,
};
}
// Vystupy pod jmenem kroku, aby si dva stejne kroky neprepsaly hodnoty.
context[step.id] = result.outputs;
for (const [key, value] of Object.entries(result.outputs)) {
context[`${step.id}.${key}`] = value;
}
return {
stepId: step.id,
kind: 'action',
label,
ok: true,
summary: 'hotovo',
detail: describe(result.outputs),
durationMs: Date.now() - startedAt,
};
}
/** Dosadi `{{jmeno}}` z kontextu do vsech poli kroku. */
function fillTemplates(
inputs: Record<string, string>,
context: RunContext,
): Record<string, string> {
const values: Record<string, string> = {};
for (const [key, value] of Object.entries(context)) {
values[key] = value === null || value === undefined ? '' : stringify(value);
}
const filled: Record<string, string> = {};
for (const [key, template] of Object.entries(inputs)) {
filled[key] = renderTemplate(template, values);
}
return filled;
}
function stringify(value: unknown): string {
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return JSON.stringify(value);
}
/** Vyhodnoti podminku nad kontextem. */
function evaluate(
step: Extract<FlowStep, { kind: 'condition' }>,
context: RunContext,
): boolean {
const raw = context[step.fieldId] ?? context[nameOf(step.fieldId)];
const value = raw === undefined || raw === null ? '' : stringify(raw);
const expected = step.value ?? '';
return compare(step.operator, value, expected);
}
/**
* Parametr se v kontextu drzi pod jmenem, ale podminka se odkazuje ID.
* `ticket.status` je pod `status`, `krok1.faktura` pod `krok1.faktura`.
*/
function nameOf(fieldId: string): string {
const dot = fieldId.indexOf('.');
return dot === -1 ? fieldId : fieldId.slice(dot + 1);
}
function compare(operator: ConditionOperator, value: string, expected: string): boolean {
const number = Number(value);
const expectedNumber = Number(expected);
switch (operator) {
case 'eq':
return value === expected;
case 'neq':
return value !== expected;
case 'contains':
return value.toLowerCase().includes(expected.toLowerCase());
case 'startsWith':
return value.toLowerCase().startsWith(expected.toLowerCase());
case 'gt':
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number > expectedNumber;
case 'gte':
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number >= expectedNumber;
case 'lt':
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number < expectedNumber;
case 'lte':
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number <= expectedNumber;
case 'isEmpty':
return value.trim() === '';
case 'isNotEmpty':
return value.trim() !== '';
case 'isTrue':
return value === 'true' || value === '1';
case 'isFalse':
return value === 'false' || value === '0' || value === '';
default:
// Neznamy operator radeji neprojde, nez aby tise vetvil vzdycky stejne.
console.warn(`[runtime] neznamy operator ${operator}, podminka nesplnena`);
return false;
}
}
function describeCondition(
step: Extract<FlowStep, { kind: 'condition' }>,
context: RunContext,
): string {
const raw = context[step.fieldId] ?? context[nameOf(step.fieldId)];
const value = raw === undefined || raw === null ? '(prázdné)' : stringify(raw);
const name = nameOf(step.fieldId);
if (isUnary(step.operator)) return `Podmínka: ${name} ${step.operator} (${value})`;
return `Podmínka: ${name} ${step.operator} ${step.value ?? ''} (${value})`;
}
/** Co krok vratil, do logu. Delsi vystup se orizne, cely je ve vysledku. */
function describe(outputs: Record<string, unknown>): string | null {
const keys = Object.keys(outputs);
if (keys.length === 0) return null;
const text = JSON.stringify(outputs);
return text.length > 4_000 ? `${text.slice(0, 4_000)}...` : text;
}
function toTrace(step: StepResult): TraceInput {
return {
kind: step.kind === 'condition' ? 'condition' : 'action',
status: step.ok ? 'ok' : 'error',
label: `${step.label}: ${step.summary}`,
response: step.detail,
durationMs: step.durationMs,
};
}