Podminka se muze ptat na vic veci naraz

Podminka byla prave jedna otazka. Slozitejsi vetveni se muselo skladat
z vnorenych podminek, takze "vysledek dorazil" a v nem "vysledek je X" byly dve
urovne stromu misto jedne vety. U tri hodnot, ktere maji dopadnout stejne, to
byly tri urovne.

Model: rules (otazky) a match ('all' = a zaroven, 'any' = nebo). Stara podoba
fieldId/operator/value primo na kroku se dal cte, prevadi ji rulesOf - jedine
misto, kde se to deje. Kdyby se fieldId cetlo primo, krok ulozeny driv by po
zmene modelu prisel o svou otazku a vetvil by vzdycky stejne, tise a bez chyby.
Zapisuje se uz vzdycky rules.

fieldId je v typu nepovinne schvalne: prekladac tim ukazal vsech pet mist,
ktera podminku ctou (vyhodnoceni, obe hlasky do logu, kontrola pri ukladani,
kontrola nedodelku).

Proc jedna uroven a ne vyrazy se zavorkami: dve treti podminek jsou "vsechny
tohle" nebo "cokoliv z tohohle". Zavorky by v rozhrani znamenaly editor vyrazu,
ktery uz nikdo neuklika, a textovy zapis by navic zahodil odkaz na parametr pres
ID - diky nemu prejmenovani parametru podminku nerozbije a builder umi nabidnout
jen operatory, ktere na dany typ sedi. Az se ukaze, ze jedna uroven nestaci,
da se textovy zapis pridat nad tentyz vyhodnocovac. Opacne to nejde.

V builderu radek na otazku, tlacitko "Přidat otázku" a od druhe otazky prepinac
"sedí všechny" / "sedí aspoň jedna". Posledni otazka nejde smazat: podminka bez
otazky by tise nevetvila, runtime ji povazuje za nesplnenou a zaloguje to.

Radek podminky v logu nese vsechny otazky i s tim, ktera rozhodla - bez rozpadu
by u spojene podminky bylo videt jen "nesplneno".

Overeno na bezici instanci, strom se dvema spojenymi podminkami a peti volanimi:
vysledek nedorazil (nic), Chybějící informace (prirazeno), Přesměrování
a Mimo téma (zavreno pres any), Něco jiného (nic). Strom ulozeny ve stare
podobe se nacte a ulozi beze zmeny.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-07 09:16:19 +02:00
co-authored by Claude Opus 5
parent 1532ea0af7
commit 9f4b8c0596
9 changed files with 507 additions and 144 deletions
+63 -19
View File
@@ -11,7 +11,13 @@ import { config } from '../config.js';
import { publish } from '../events/bus.js';
import { defineStore } from './store/index.js';
import { withMirror } from './store/mirror.js';
import { isUnary, type ConditionOperator, type FieldType } from './conditions.js';
import {
isUnary,
type ConditionMatch,
type ConditionOperator,
type ConditionRule,
type FieldType,
} from './conditions.js';
import { actionInputsFor, findService } from './services.js';
import { collectScopes, duplicateNames, scopeFor } from './flowScope.js';
import { referencedFields, rootOf } from './templates.js';
@@ -97,10 +103,24 @@ export type FlowStep =
| {
id: string;
kind: 'condition';
/** id parametru z trigger.fields */
fieldId: string;
operator: ConditionOperator;
/** Chybi u operatoru, ktere hodnotu nepotrebuji (isEmpty, isTrue apod.). */
/**
* Otazky, ktere se ptaji naraz. Cte se **vzdycky pres `rulesOf`**.
*
* Driv byla podminka prave jedna otazka a slozitejsi vetveni se skladalo
* z vnorenych podminek: "vysledek dorazil" a v nem "vysledek je X" byly
* dve urovne stromu misto jedne vety.
*/
rules?: ConditionRule[];
/** `all` = a zaroven, `any` = nebo. Chybi u jedne otazky, tam nema co spojovat. */
match?: ConditionMatch;
/**
* Stara podoba: jedna otazka primo na kroku.
*
* Zaznamy ulozene driv ji maji misto `rules`. Prevadi ji `rulesOf`,
* zapisuje se uz vzdycky `rules`.
*/
fieldId?: string;
operator?: ConditionOperator;
value?: string;
yes: FlowStep[];
no: FlowStep[];
@@ -130,6 +150,24 @@ export interface AutomationFlow {
steps: FlowStep[];
}
/**
* Otazky podminky, at uz je krok v jakekoliv podobe.
*
* **Jedine misto, kde se stara podoba prevadi.** Kdyby se `fieldId` cetlo
* primo, krok ulozeny driv by po zmene modelu prisel o svou otazku a vetvil
* by vzdycky stejne - tise a bez chyby.
*/
export function rulesOf(step: Extract<FlowStep, { kind: 'condition' }>): ConditionRule[] {
if (step.rules && step.rules.length > 0) return step.rules;
if (!step.fieldId || !step.operator) return [];
return [{ fieldId: step.fieldId, operator: step.operator, value: step.value }];
}
/** Jak se otazky spoji. Jedna otazka nema co spojovat, bere se `all`. */
export function matchOf(step: Extract<FlowStep, { kind: 'condition' }>): ConditionMatch {
return step.match ?? 'all';
}
/** Neodhadnutelny token do adresy webhooku (32 znaku, base64url). */
export function generateWebhookToken(): string {
return randomBytes(24).toString('base64url');
@@ -463,20 +501,26 @@ export function collectFlowIssues(flow: AutomationFlow): string[] {
continue;
}
const field = available.find((candidate) => candidate.id === step.fieldId);
if (!field) {
// Rozlisujeme "neexistuje" od "vznikne az pozdeji". Druhy pripad nastane
// po presunuti kroku a chce jinou radu nez smazat podminku.
issues.push(
scopes.all.has(step.fieldId)
? 'Podmínka se ptá na parametr, který vzniká až v pozdějším kroku. Posuňte ji níž.'
: 'Podmínka se odkazuje na parametr, který už neexistuje.',
);
} else if (!isUnary(step.operator) && (step.value ?? '').trim().length === 0) {
issues.push(`Podmínka nad parametrem "${field.name}" nemá vyplněnou hodnotu.`);
} else if (field.type === 'number' && !isUnary(step.operator)) {
if (Number.isNaN(Number(step.value))) {
issues.push(`Podmínka nad parametrem "${field.name}" má nečíselnou hodnotu.`);
// Kazda otazka podminky zvlast. Jedna spatna nesmi schovat ostatni.
const rules = rulesOf(step);
if (rules.length === 0) issues.push('Podmínka nemá žádnou otázku.');
for (const rule of rules) {
const field = available.find((candidate) => candidate.id === rule.fieldId);
if (!field) {
// Rozlisujeme "neexistuje" od "vznikne az pozdeji". Druhy pripad nastane
// po presunuti kroku a chce jinou radu nez smazat podminku.
issues.push(
scopes.all.has(rule.fieldId)
? 'Podmínka se ptá na parametr, který vzniká až v pozdějším kroku. Posuňte ji níž.'
: 'Podmínka se odkazuje na parametr, který už neexistuje.',
);
} else if (!isUnary(rule.operator) && (rule.value ?? '').trim().length === 0) {
issues.push(`Podmínka nad parametrem "${field.name}" nemá vyplněnou hodnotu.`);
} else if (field.type === 'number' && !isUnary(rule.operator)) {
if (Number.isNaN(Number(rule.value))) {
issues.push(`Podmínka nad parametrem "${field.name}" má nečíselnou hodnotu.`);
}
}
}
+24
View File
@@ -70,6 +70,30 @@ export const operatorsByType: Record<FieldType, ConditionOperator[]> = {
list: ['contains', 'isEmpty', 'isNotEmpty'],
};
/**
* Jedna otazka v podmince.
*
* Driv byla podminka **prave jedna otazka** a slozitejsi vetveni se muselo
* skladat z vnorenych podminek. U tri hodnot, ktere maji dopadnout stejne, to
* znamenalo tri urovne stromu, ve kterych se nikdo nevyzna.
*/
export interface ConditionRule {
/** ID parametru spoustece, nebo `krok.vystup`. */
fieldId: string;
operator: ConditionOperator;
/** Chybi u operatoru, ktere hodnotu nepotrebuji (isEmpty, isTrue apod.). */
value?: string;
}
/**
* Jak se otazky spoji.
*
* `all` = a zaroven, `any` = nebo. Zamerne **jedna uroven**, ne strom se
* zavorkami: dve treti podminek jsou "vsechny tohle" nebo "cokoliv z tohohle"
* a zavorky by v rozhrani znamenaly editor vyrazu, ktery uz nikdo neuklika.
*/
export type ConditionMatch = 'all' | 'any';
/** Operatory, ktere nepotrebuji hodnotu k porovnani. */
export const unaryOperators: ConditionOperator[] = [
'isEmpty',
+42 -22
View File
@@ -14,6 +14,7 @@ import {
getAutomation,
listAutomations,
regenerateWebhookToken,
rulesOf,
updateAutomation,
type FlowStep,
} from '../data/automationStore.js';
@@ -1083,6 +1084,29 @@ dashboardRouter.get('/services', async (req, res) => {
// ------------------------------------------------------------- automatizace
/** Operator podminky. Vyctem, ne volnym retezcem - neznamy by tise nevetvil. */
const conditionOperatorSchema = z.enum([
'eq',
'neq',
'gt',
'gte',
'lt',
'lte',
'contains',
'startsWith',
'isEmpty',
'isNotEmpty',
'isTrue',
'isFalse',
]);
/** Jedna otazka podminky. Podminka jich muze mit vic, spojene pres `match`. */
const conditionRuleSchema = z.object({
fieldId: z.string().min(1, 'Podmínka musí mít vybraný parametr.'),
operator: conditionOperatorSchema,
value: z.string().optional(),
});
/**
* Rekurzivni schema kroku. z.lazy je nutne, protoze podminka obsahuje
* dalsi kroky - bez toho by se typ odkazoval sam na sebe drive, nez existuje.
@@ -1109,21 +1133,14 @@ const stepSchema: z.ZodType<FlowStep> = z.lazy(() =>
z.object({
id: z.string().min(1),
kind: z.literal('condition'),
fieldId: z.string().min(1, 'Podmínka musí mít vybraný parametr.'),
operator: z.enum([
'eq',
'neq',
'gt',
'gte',
'lt',
'lte',
'contains',
'startsWith',
'isEmpty',
'isNotEmpty',
'isTrue',
'isFalse',
]),
/*
* Otazky podminky. `rules` je dnesni podoba, `fieldId` a spol. stara -
* strom ulozeny driv musi jit ulozit znovu, aniz by se prepisoval.
*/
rules: z.array(conditionRuleSchema).max(10).optional(),
match: z.enum(['all', 'any']).optional(),
fieldId: z.string().min(1).optional(),
operator: conditionOperatorSchema.optional(),
value: z.string().optional(),
yes: z.array(stepSchema),
no: z.array(stepSchema),
@@ -1243,13 +1260,16 @@ async function validateFlowReferences(
const walk = async (steps: FlowStep[]): Promise<void> => {
for (const step of steps) {
if (step.kind === 'condition') {
const field = scopes.all.get(step.fieldId);
if (!field) {
problems.push(`Podmínka odkazuje na neexistující parametr (${step.fieldId}).`);
} else if (!operatorAllowedForType(step.operator, field.type)) {
problems.push(
`Operátor "${step.operator}" nelze použít na parametr "${field.name}" typu ${field.type}.`,
);
// Kazda otazka zvlast, jinak by prvni spatna schovala ostatni.
for (const rule of rulesOf(step)) {
const field = scopes.all.get(rule.fieldId);
if (!field) {
problems.push(`Podmínka odkazuje na neexistující parametr (${rule.fieldId}).`);
} else if (!operatorAllowedForType(rule.operator, field.type)) {
problems.push(
`Operátor "${rule.operator}" nelze použít na parametr "${field.name}" typu ${field.type}.`,
);
}
}
await walk(step.yes);
await walk(step.no);
+53 -25
View File
@@ -22,8 +22,8 @@
*/
import { defaultConnectorFor, getConnector, type Connector } from '../data/connectorStore.js';
import type { FlowStep } from '../data/automationStore.js';
import { isUnary, type ConditionOperator } from '../data/conditions.js';
import { matchOf, rulesOf, type FlowStep } from '../data/automationStore.js';
import { isUnary, type ConditionOperator, type ConditionRule } from '../data/conditions.js';
import { escapeHtml, renderTemplate } from '../data/templates.js';
import { actionInputsFor, type OperationField } from '../data/services.js';
import { getPath } from '../scripts/mapping.js';
@@ -543,33 +543,56 @@ function conditionDetail(
context: RunContext,
options: RunOptions,
): string {
const raw = conditionValue(step.fieldId, context, options);
const name = options.fieldNames?.[step.fieldId] ?? step.fieldId;
// Kazda otazka na svem radku, i s tim, jak dopadla. U spojenych podminek
// je jinak videt jen vysledek a ne to, ktera z nich rozhodla.
return rulesOf(step)
.map((rule) => {
const raw = conditionValue(rule.fieldId, context, options);
const name = options.fieldNames?.[rule.fieldId] ?? rule.fieldId;
const got =
raw === undefined
? 'nedorazilo'
: raw === null
? 'null'
: stringify(raw) === ''
? 'prázdné'
: JSON.stringify(stringify(raw));
const got =
raw === undefined
? 'nedorazilo'
: raw === null
? 'null'
: stringify(raw) === ''
? 'prázdné'
: JSON.stringify(stringify(raw));
if (isUnary(step.operator)) return `${name} = ${got}`;
return `${name} = ${got}, porovnáno s ${JSON.stringify(step.value ?? '')}`;
const sedi = matchRule(rule, context, options) ? 'sedí' : 'nesedí';
if (isUnary(rule.operator)) return `${name} = ${got}: ${sedi}`;
return `${name} = ${got}, porovnáno s ${JSON.stringify(rule.value ?? '')}: ${sedi}`;
})
.join(String.fromCharCode(10));
}
/** Vyhodnoti podminku nad kontextem. */
/** Jedna otazka podminky. */
function matchRule(rule: ConditionRule, context: RunContext, options: RunOptions): boolean {
const raw = conditionValue(rule.fieldId, context, options);
const value = raw === undefined || raw === null ? '' : stringify(raw);
return compare(rule.operator, value, rule.value ?? '');
}
/**
* Vyhodnoti podminku nad kontextem.
*
* `all` = musi sedet vsechny, `any` = staci jedna. Podminka **bez otazky**
* neplati: tise vetvit vzdycky stejne je horsi nez neudelat nic.
*/
function evaluate(
step: Extract<FlowStep, { kind: 'condition' }>,
context: RunContext,
options: RunOptions,
): boolean {
const raw = conditionValue(step.fieldId, context, options);
const value = raw === undefined || raw === null ? '' : stringify(raw);
const expected = step.value ?? '';
const rules = rulesOf(step);
if (rules.length === 0) {
console.warn(`[runtime] podminka ${step.id} nema zadnou otazku, nesplnena`);
return false;
}
return compare(step.operator, value, expected);
return matchOf(step) === 'any'
? rules.some((rule) => matchRule(rule, context, options))
: rules.every((rule) => matchRule(rule, context, options));
}
/**
@@ -622,13 +645,18 @@ function describeCondition(
context: RunContext,
options: RunOptions,
): string {
const raw = conditionValue(step.fieldId, context, options);
const value = raw === undefined || raw === null ? '(prázdné)' : stringify(raw);
// V logu ma stat jmeno, ktere clovek zna z builderu, ne vnitrni ID.
const name = options.fieldNames?.[step.fieldId] ?? nameOf(step.fieldId);
const rules = rulesOf(step);
if (rules.length === 0) return 'Podmínka bez otázky';
if (isUnary(step.operator)) return `Podmínka: ${name} ${step.operator} (${value})`;
return `Podmínka: ${name} ${step.operator} ${step.value ?? ''} (${value})`;
const parts = rules.map((rule) => {
// V logu ma stat jmeno, ktere clovek zna z builderu, ne vnitrni ID.
const name = options.fieldNames?.[rule.fieldId] ?? nameOf(rule.fieldId);
if (isUnary(rule.operator)) return `${name} ${rule.operator}`;
return `${name} ${rule.operator} ${rule.value ?? ''}`;
});
const spojka = matchOf(step) === 'any' ? ' nebo ' : ' a zároveň ';
return `Podmínka: ${parts.join(spojka)}`;
}
/** Co krok vratil, do logu. Delsi vystup se orizne, cely je ve vysledku. */