Obsah ticketu, nastaveni kroku a vystupy kroku

Ticket dostal telo (body) a odkaz na zdrojovou zpravu. Predmet je shrnuti,
telo je cely text pozadavku.

Akce maji nastavitelna pole (inputs) se sablonami {{parametr}}. Zatim ticket,
kanaly, CRM a AI, ostatni maji jen napovedu.

Krok vidi parametry spoustece plus vystupy kroku pred nim, takze jde vlozit
predvalidaci a vetvit se podle jejiho vysledku. Vetev podminky nepridava nic
do sekvence za podminkou.

Nove konektory Facebook Messenger a Instagram, nova akce RAYNET Dohledat firmu.

Ctyri vzorove automatizace v rozdeleni jedna na kanal pro prijem
a jedna spolecna pro smerovani na resitele.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-03 12:29:26 +02:00
co-authored by Claude Opus 5
parent 52b190bfbc
commit dd021b5f69
24 changed files with 1651 additions and 80 deletions
+368 -7
View File
@@ -9,7 +9,9 @@
import { randomBytes } from 'node:crypto';
import { publish } from '../events/bus.js';
import { isUnary, type ConditionOperator, type FieldType } from './conditions.js';
import { findConnector } from './connectors.js';
import { actionInputsFor, findConnector } from './connectors.js';
import { collectScopes, duplicateNames, scopeFor } from './flowScope.js';
import { referencedFields } from './templates.js';
export type AutomationKind = 'workflow' | 'voicebot' | 'integrace' | 'report';
@@ -44,6 +46,12 @@ export type FlowStep =
kind: 'action';
connectorId: string;
operationId: string;
/**
* Nastaveni akce: klic je `OperationField.id` z katalogu, hodnota je
* sablona s odkazy na parametry spoustece (`{{subject}}`).
* Chybi u akci, ktere zatim nemaji `inputs`.
*/
inputs?: Record<string, string>;
}
| {
id: string;
@@ -122,6 +130,44 @@ export function countSteps(steps: FlowStep[]): number {
}, 0);
}
/**
* Nedodelky v nastaveni jednoho kroku: nevyplnene povinne pole a odkaz
* na parametr, ktery u spoustece neexistuje.
*
* Druhy pripad nastane hlavne po prejmenovani parametru. Sablonu proto
* nezahazujeme ani tise neopravujeme, jen rekneme, kde se ma doplnit.
*/
function actionInputIssues(
step: Extract<FlowStep, { kind: 'action' }>,
available: TriggerField[],
): string[] {
const knownNames = new Set(available.map((field) => field.name));
const catalog = actionInputsFor(step.connectorId, step.operationId);
if (catalog.length === 0) return [];
const issues: string[] = [];
const operationName = findConnector(step.connectorId)?.name ?? step.connectorId;
for (const field of catalog) {
const value = step.inputs?.[field.id] ?? '';
if (field.required && value.trim().length === 0) {
issues.push(`Krok „${operationName}": chybí ${field.label.toLowerCase()}.`);
continue;
}
for (const name of referencedFields(value)) {
if (!knownNames.has(name)) {
issues.push(
`Krok „${operationName}", pole ${field.label.toLowerCase()}: parametr „${name}" u spouštěče neexistuje.`,
);
}
}
}
return issues;
}
/**
* Co brani zapnuti automatizace. Zamerne to NENI chyba pri ukladani -
* rozdelanou praci chceme ulozit, jen ji nesmime pustit do provozu.
@@ -144,15 +190,32 @@ export function collectFlowIssues(flow: AutomationFlow): string[] {
issues.push('Webhook nemá vygenerovanou adresu.');
}
const fieldById = new Map(flow.trigger.fields.map((field) => [field.id, field]));
const scopes = collectScopes(flow);
const walk = (steps: FlowStep[]) => {
for (const step of steps) {
if (step.kind !== 'condition') continue;
const available = scopeFor(scopes, step.id);
const field = fieldById.get(step.fieldId);
for (const name of duplicateNames(available)) {
issues.push(
`Parametr „${name}" je v tomto místě stromu dvakrát, v šabloně by nešlo poznat který.`,
);
}
if (step.kind === 'action') {
issues.push(...actionInputIssues(step, available));
continue;
}
const field = available.find((candidate) => candidate.id === step.fieldId);
if (!field) {
issues.push('Podmínka se odkazuje na parametr, který už neexistuje.');
// 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)) {
@@ -167,7 +230,9 @@ export function collectFlowIssues(flow: AutomationFlow): string[] {
};
walk(flow.steps);
return issues;
// Stejny nedodelek muze vyjit z vic kroku (typicky duplicitni jmeno parametru).
// Uzivateli staci rict jednou.
return [...new Set(issues)];
}
/** Pouziva strom nekde konektor z dane kategorie? */
@@ -370,7 +435,19 @@ seed({
value: '15',
yes: [{ id: 'st_42', kind: 'action', connectorId: 'raynet', operationId: 'add-activity' }],
no: [
{ id: 'st_43', kind: 'action', connectorId: 'ticket', operationId: 'create' },
{
id: 'st_43',
kind: 'action',
connectorId: 'ticket',
operationId: 'create',
inputs: {
subject: 'Nízké hodnocení od {{customer}}',
body: 'Hodnocení {{score}} z dotazníku. Komentář: {{comment}}',
company: '{{customer}}',
priority: 'high',
assigneeId: 'ppl_vomacka',
},
},
{ id: 'st_44', kind: 'action', connectorId: 'microsoft365', operationId: 'post-teams' },
],
},
@@ -378,6 +455,290 @@ seed({
},
});
/*
* Prijem z kanalu: jedna automatizace na kanal, zadne rozhodovani o resiteli.
* Smerovani resi jedna spolecna automatizace nize - viz documentation/06-tickety.md.
*/
seed({
name: 'WhatsApp: zpráva do ticketu',
enabled: true,
runsToday: 34,
successRate: 100,
avgDurationMs: 1_950,
lastRunAt: minutesAgo(7),
flow: {
trigger: {
connectorId: 'whatsapp',
operationId: 'message-received',
fields: [
{ id: 'whatsapp.phone', name: 'phone', type: 'string', required: true },
{ id: 'whatsapp.profileName', name: 'profileName', type: 'string', required: false },
{ id: 'whatsapp.text', name: 'text', type: 'string', required: true },
{ id: 'whatsapp.hasMedia', name: 'hasMedia', type: 'boolean', required: false },
{ id: 'whatsapp.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
steps: [
// Predvalidace: nejdriv se zeptame CRM, teprve podle odpovedi zakladame.
{
id: 'st_51',
kind: 'action',
connectorId: 'raynet',
operationId: 'find-company',
inputs: { phone: '{{phone}}' },
},
{
id: 'st_52',
kind: 'condition',
// Odkaz na vystup kroku st_51, ne na parametr spoustece.
fieldId: 'st_51.raynet.customerKnown',
operator: 'isTrue',
yes: [
{
id: 'st_53',
kind: 'action',
connectorId: 'ticket',
operationId: 'create',
inputs: {
subject: 'WhatsApp od {{profileName}}',
body: '{{text}}',
company: '{{companyName}}',
contact: '{{profileName}}',
reply: '{{phone}}',
priority: 'normal',
assigneeId: '',
},
},
],
no: [
{
id: 'st_54',
kind: 'action',
connectorId: 'ticket',
operationId: 'create',
inputs: {
subject: 'WhatsApp od neznámého čísla',
body: '{{text}}',
contact: '{{profileName}}',
reply: '{{phone}}',
priority: 'normal',
assigneeId: '',
},
},
{ id: 'st_55', kind: 'action', connectorId: 'raynet', operationId: 'create-lead' },
],
},
],
},
});
seed({
name: 'Facebook: zpráva do ticketu',
enabled: true,
runsToday: 11,
successRate: 100,
avgDurationMs: 720,
lastRunAt: minutesAgo(52),
flow: {
trigger: {
connectorId: 'facebook',
operationId: 'message-received',
fields: [
{ id: 'facebook.senderId', name: 'senderId', type: 'string', required: true },
{ id: 'facebook.senderName', name: 'senderName', type: 'string', required: false },
{ id: 'facebook.text', name: 'text', type: 'string', required: true },
{ id: 'facebook.pageName', name: 'pageName', type: 'string', required: true },
{ id: 'facebook.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
// Bez predvalidace. Z Messengeru nemame e-mail ani telefon, podle ceho
// by se firma dohledala, takze zakladame rovnou a dohledani nechavame na cloveku.
steps: [
{
id: 'st_61',
kind: 'action',
connectorId: 'ticket',
operationId: 'create',
inputs: {
subject: 'Facebook od {{senderName}}',
body: '{{text}}',
contact: '{{senderName}}',
reply: '{{senderId}}',
priority: 'normal',
assigneeId: '',
},
},
],
},
});
seed({
name: 'E-mail: požadavky do ticketu',
enabled: true,
runsToday: 58,
successRate: 99.1,
avgDurationMs: 2_310,
lastRunAt: minutesAgo(14),
flow: {
trigger: {
connectorId: 'email',
operationId: 'received',
fields: [
{ id: 'email.from', name: 'from', type: 'string', required: true },
{ id: 'email.subject', name: 'subject', type: 'string', required: true },
{ id: 'email.body', name: 'body', type: 'string', required: false },
{ id: 'email.hasAttachment', name: 'hasAttachment', type: 'boolean', required: false },
{ id: 'email.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
steps: [
{
id: 'st_65',
kind: 'action',
connectorId: 'raynet',
operationId: 'find-company',
inputs: { email: '{{from}}' },
},
{
id: 'st_66',
kind: 'condition',
fieldId: 'st_65.raynet.customerKnown',
operator: 'isTrue',
yes: [
{
id: 'st_67',
kind: 'action',
connectorId: 'ticket',
operationId: 'create',
inputs: {
subject: '{{subject}}',
body: '{{body}}',
company: '{{companyName}}',
contact: '{{from}}',
reply: '{{from}}',
priority: 'normal',
assigneeId: '',
},
},
],
no: [
{
id: 'st_68',
kind: 'action',
connectorId: 'ticket',
operationId: 'create',
inputs: {
subject: '{{subject}}',
body: '{{body}}',
contact: '{{from}}',
reply: '{{from}}',
priority: 'low',
assigneeId: '',
},
},
{
id: 'st_69',
kind: 'action',
connectorId: 'email',
operationId: 'send',
inputs: {
to: '{{from}}',
subject: 'Přijali jsme váš požadavek',
body: 'Dobrý den, požadavek jsme zaevidovali a ozveme se. Tým podpory.',
},
},
],
},
],
},
});
/*
* Jedna spolecna automatizace nad vsemi tickety, at vznikly odkudkoliv.
* Tohle je to misto, kde se dela logika zpracovani na miru zakaznikovi.
*/
seed({
name: 'Směrování ticketů na řešitele',
enabled: true,
runsToday: 103,
successRate: 100,
avgDurationMs: 310,
lastRunAt: minutesAgo(4),
flow: {
trigger: {
connectorId: 'ticket',
operationId: 'created',
fields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ id: 'ticket.body', name: 'body', type: 'string', required: false },
{ id: 'ticket.channel', name: 'channel', type: 'string', required: true },
{ id: 'ticket.company', name: 'company', type: 'string', required: false },
{ id: 'ticket.contact', name: 'contact', type: 'string', required: false },
{ id: 'ticket.priority', name: 'priority', type: 'string', required: true },
{ id: 'ticket.knownCustomer', name: 'knownCustomer', type: 'boolean', required: true },
{ id: 'ticket.assigned', name: 'assigned', type: 'boolean', required: true },
],
},
steps: [
{
id: 'st_71',
kind: 'condition',
// Uz prirazeny ticket nepreberame, jinak bychom prepsali rucni rozhodnuti.
fieldId: 'ticket.assigned',
operator: 'isFalse',
yes: [
{
id: 'st_72',
kind: 'condition',
fieldId: 'ticket.body',
operator: 'contains',
value: 'faktur',
yes: [
{
id: 'st_73',
kind: 'action',
connectorId: 'ticket',
operationId: 'assign',
inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_kriz' },
},
],
no: [
{
id: 'st_74',
kind: 'condition',
fieldId: 'ticket.body',
operator: 'contains',
value: 'voicebot',
yes: [
{
id: 'st_75',
kind: 'action',
connectorId: 'ticket',
operationId: 'assign',
inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_novakova' },
},
],
no: [
{
id: 'st_76',
kind: 'action',
connectorId: 'ticket',
operationId: 'assign',
inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_vomacka' },
},
],
},
],
},
],
no: [],
},
],
},
});
function toSummary(stored: StoredAutomation): Automation {
const { flow: _flow, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = stored;
return {
+286 -11
View File
@@ -10,6 +10,7 @@
*/
import type { FieldType } from './conditions.js';
import { people } from './people.js';
export type ConnectorCategory =
| 'spoustece'
@@ -40,12 +41,43 @@ export interface ProvidedField {
required: boolean;
}
/**
* Nastavitelne pole akce. Na rozdil od `fields` to neni napoveda, ale skutecna
* konfigurace - hodnota se uklada do kroku stromu (`FlowStep.inputs`).
*
* Hodnota je sablona. `{{nazev}}` se nahradi parametrem spoustece,
* viz `src/data/templates.ts`.
*/
export interface OperationField {
id: string;
label: string;
/** text = jeden radek, longtext = vice radku, choice = vyber z `options` */
kind: 'text' | 'longtext' | 'choice';
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
}
export interface ConnectorOperation {
id: string;
name: string;
description: string;
/** Popis toho, co bude potreba nastavit. Zatim jen informativni. */
/**
* Popis toho, co bude potreba nastavit - jen napoveda, nikam se neuklada.
* Cilovy stav je prevest vsechny akce na `inputs`, zatim je to udelane
* u ticketu a kanalu, ktere do nej ustuji.
*/
fields?: string[];
/** Skutecne nastavitelna pole akce. Kdyz chybi, akci nejde konfigurovat. */
inputs?: OperationField[];
/**
* Jen u akci: co krok vrati dalsim krokum. Diky tomu jde po kroku
* "dohledej firmu v CRM" postavit podminku na to, jestli se nasla.
*
* `required: true` znamena, ze sluzba hodnotu vraci vzdy.
* ID musi zustat stabilni, odkazuji se na nej podminky v ulozenych stromech.
*/
outputFields?: ProvidedField[];
/**
* Jen u triggeru: true = vstupni parametry si definuje uzivatel
* (webhook, formular). false/chybi = data urcuje sluzba.
@@ -71,6 +103,26 @@ export interface Connector {
actions: ConnectorOperation[];
}
/** Nabidka resitelu do vyberu u akci. Bere se ze seznamu lidi, aby se nerozesla. */
const assigneeOptions = [
{ value: '', label: 'Nechat ve frontě' },
...people.map((person) => ({ value: person.id, label: person.name })),
];
const priorityOptions = [
{ value: 'low', label: 'Nízká' },
{ value: 'normal', label: 'Běžná' },
{ value: 'high', label: 'Vysoká' },
{ value: 'critical', label: 'Kritická' },
];
const statusOptions = [
{ value: 'new', label: 'Nový' },
{ value: 'open', label: 'V řešení' },
{ value: 'waiting', label: 'Čeká na klienta' },
{ value: 'resolved', label: 'Vyřešeno' },
];
export const connectorCategories: Array<{ id: ConnectorCategory; label: string }> = [
{ id: 'spoustece', label: 'Spouštěče' },
{ id: 'servicedesk', label: 'Tickety a servicedesk' },
@@ -178,6 +230,7 @@ export const connectors: Connector[] = [
providedFields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ id: 'ticket.body', name: 'body', type: 'string', required: false },
{ id: 'ticket.channel', name: 'channel', type: 'string', required: true },
{ id: 'ticket.company', name: 'company', type: 'string', required: false },
{ id: 'ticket.contact', name: 'contact', type: 'string', required: false },
@@ -195,6 +248,7 @@ export const connectors: Connector[] = [
providedFields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ id: 'ticket.body', name: 'body', type: 'string', required: false },
{ id: 'ticket.channel', name: 'channel', type: 'string', required: true },
{ id: 'ticket.contact', name: 'contact', type: 'string', required: false },
{ id: 'ticket.reply', name: 'reply', type: 'string', required: true },
@@ -229,32 +283,102 @@ export const connectors: Connector[] = [
{
id: 'create',
name: 'Založit ticket',
description: 'Vytvoří požadavek včetně kanálu, firmy, priority a volitelně řešitele.',
fields: ['Předmět', 'Kanál', 'Firma', 'Kontakt', 'Adresa pro odpověď', 'Priorita', 'Řešitel'],
description: 'Vytvoří požadavek. Co se kam uloží, určíte v nastavení kroku.',
inputs: [
{
id: 'subject',
label: 'Předmět',
kind: 'text',
required: true,
hint: 'Krátké shrnutí. Typicky předmět e-mailu nebo začátek zprávy.',
},
{
id: 'body',
label: 'Obsah',
kind: 'longtext',
required: false,
hint: 'Celý text požadavku. Sem patří tělo e-mailu nebo zpráva z WhatsApp.',
},
{ id: 'company', label: 'Firma', kind: 'text', required: false },
{ id: 'contact', label: 'Kontakt', kind: 'text', required: false },
{
id: 'reply',
label: 'Adresa pro odpověď',
kind: 'text',
required: false,
hint: 'E-mail nebo telefon, odkud to přišlo.',
},
{
id: 'priority',
label: 'Priorita',
kind: 'choice',
required: true,
options: priorityOptions,
},
{
id: 'assigneeId',
label: 'Řešitel',
kind: 'choice',
required: false,
options: assigneeOptions,
},
],
outputFields: [{ id: 'ticket.newId', name: 'newTicketId', type: 'string', required: true }],
},
{
id: 'assign',
name: 'Přiřadit řešiteli',
description: 'Předá ticket konkrétnímu člověku. Objeví se mu mezi jeho tickety.',
fields: ['ID ticketu', 'Řešitel'],
inputs: [
{
id: 'ticketId',
label: 'ID ticketu',
kind: 'text',
required: true,
hint: 'Obvykle {{ticketId}} ze spouštěče.',
},
{
id: 'assigneeId',
label: 'Řešitel',
kind: 'choice',
required: true,
options: assigneeOptions,
},
],
},
{
id: 'set-status',
name: 'Změnit stav',
description: 'Posune ticket na nový, v řešení, čeká na klienta nebo vyřešeno.',
fields: ['ID ticketu', 'Nový stav'],
inputs: [
{ id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true },
{
id: 'status',
label: 'Nový stav',
kind: 'choice',
required: true,
options: statusOptions,
},
],
},
{
id: 'link-customer',
name: 'Napojit na zákazníka',
description: 'Doplní ticketu firmu z CRM. Používá se poté, co se zákazník dohledá.',
fields: ['ID ticketu', 'ID firmy v CRM'],
inputs: [
{ id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true },
{ id: 'companyId', label: 'ID firmy v CRM', kind: 'text', required: true },
],
},
{
id: 'comment',
name: 'Přidat komentář',
description: 'Zapíše komentář do logu ticketu, aby byl na stejné časové ose jako běh.',
fields: ['ID ticketu', 'Autor', 'Text'],
inputs: [
{ id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true },
{ id: 'author', label: 'Autor', kind: 'text', required: false },
{ id: 'text', label: 'Text', kind: 'longtext', required: true },
],
},
],
},
@@ -285,12 +409,44 @@ export const connectors: Connector[] = [
name: 'Založit obchodní případ',
description: 'Vytvoří nový obchodní případ včetně napojení na firmu.',
fields: ['Název', 'Firma', 'Vlastník', 'Fáze'],
outputFields: [{ id: 'raynet.leadId', name: 'leadId', type: 'string', required: true }],
},
{
id: 'find-company',
name: 'Dohledat firmu',
description:
'Zjistí, jestli odesílatele známe. Nic nezakládá. Podle výsledku se pak strom větví.',
inputs: [
{
id: 'email',
label: 'E-mail',
kind: 'text',
required: false,
hint: 'Například {{from}} u e-mailu.',
},
{
id: 'phone',
label: 'Telefon',
kind: 'text',
required: false,
hint: 'Například {{phone}} u WhatsApp.',
},
],
outputFields: [
{ id: 'raynet.customerKnown', name: 'customerKnown', type: 'boolean', required: true },
{ id: 'raynet.companyId', name: 'companyId', type: 'string', required: false },
{ id: 'raynet.companyName', name: 'companyName', type: 'string', required: false },
{ id: 'raynet.ownerName', name: 'ownerName', type: 'string', required: false },
],
},
{
id: 'upsert-contact',
name: 'Založit nebo aktualizovat kontakt',
description: 'Podle e-mailu kontakt najde a doplní, jinak vytvoří nový.',
fields: ['E-mail', 'Jméno', 'Telefon', 'Firma'],
outputFields: [
{ id: 'raynet.contactId', name: 'contactId', type: 'string', required: true },
],
},
{
id: 'add-activity',
@@ -470,8 +626,18 @@ export const connectors: Connector[] = [
{
id: 'send',
name: 'Odeslat e-mail',
description: 'Odešle zprávu podle šablony s daty z předchozích kroků.',
fields: ['Příjemce', 'Předmět', 'Šablona', 'Přílohy'],
description: 'Odešle zprávu. Text i příjemce sestavíte z parametrů spouštěče.',
inputs: [
{
id: 'to',
label: 'Příjemce',
kind: 'text',
required: true,
hint: 'Například {{from}}, když odpovídáte na příchozí e-mail.',
},
{ id: 'subject', label: 'Předmět', kind: 'text', required: true },
{ id: 'body', label: 'Text zprávy', kind: 'longtext', required: true },
],
},
],
},
@@ -501,7 +667,16 @@ export const connectors: Connector[] = [
id: 'send',
name: 'Odeslat zprávu',
description: 'Odpoví na číslo, ze kterého zpráva přišla, nebo na zadané číslo.',
fields: ['Telefon', 'Text'],
inputs: [
{
id: 'phone',
label: 'Telefon',
kind: 'text',
required: true,
hint: 'Například {{phone}} pro odpověď odesílateli.',
},
{ id: 'text', label: 'Text', kind: 'longtext', required: true },
],
},
{
id: 'send-template',
@@ -511,6 +686,83 @@ export const connectors: Connector[] = [
},
],
},
{
id: 'facebook',
name: 'Facebook Messenger',
category: 'komunikace',
description: 'Zprávy z firemní stránky na Facebooku.',
icon: 'Facebook',
status: 'connected',
triggers: [
{
id: 'message-received',
name: 'Přijata zpráva',
description: 'Spustí se při doručení zprávy do schránky stránky.',
providedFields: [
{ id: 'facebook.senderId', name: 'senderId', type: 'string', required: true },
{ id: 'facebook.senderName', name: 'senderName', type: 'string', required: false },
{ id: 'facebook.text', name: 'text', type: 'string', required: true },
{ id: 'facebook.pageName', name: 'pageName', type: 'string', required: true },
{ id: 'facebook.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
],
actions: [
{
id: 'send',
name: 'Odeslat zprávu',
description: 'Odpoví do konverzace, ze které zpráva přišla.',
inputs: [
{
id: 'recipientId',
label: 'Příjemce',
kind: 'text',
required: true,
hint: 'Například {{senderId}} pro odpověď odesílateli.',
},
{ id: 'text', label: 'Text', kind: 'longtext', required: true },
],
},
],
},
{
id: 'instagram',
name: 'Instagram',
category: 'komunikace',
description: 'Přímé zprávy na firemním účtu Instagramu.',
icon: 'Instagram',
status: 'available',
triggers: [
{
id: 'message-received',
name: 'Přijata zpráva',
description: 'Spustí se při doručení přímé zprávy.',
providedFields: [
{ id: 'instagram.senderId', name: 'senderId', type: 'string', required: true },
{ id: 'instagram.username', name: 'username', type: 'string', required: false },
{ id: 'instagram.text', name: 'text', type: 'string', required: true },
{ id: 'instagram.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
],
actions: [
{
id: 'send',
name: 'Odeslat zprávu',
description: 'Odpoví do konverzace, ze které zpráva přišla.',
inputs: [
{
id: 'recipientId',
label: 'Příjemce',
kind: 'text',
required: true,
hint: 'Například {{senderId}} pro odpověď odesílateli.',
},
{ id: 'text', label: 'Text', kind: 'longtext', required: true },
],
},
],
},
{
id: 'microsoft365',
name: 'Microsoft 365',
@@ -734,7 +986,20 @@ export const connectors: Connector[] = [
id: 'classify',
name: 'Zařadit do kategorie',
description: 'Rozhodne, do které kategorie text patří (např. téma ticketu).',
fields: ['Seznam kategorií', 'Text'],
inputs: [
{
id: 'categories',
label: 'Seznam kategorií',
kind: 'text',
required: true,
hint: 'Oddělte čárkou, například: porucha, dotaz, reklamace',
},
{ id: 'text', label: 'Text', kind: 'longtext', required: true },
],
outputFields: [
{ id: 'ai.category', name: 'category', type: 'string', required: true },
{ id: 'ai.confidence', name: 'confidence', type: 'number', required: true },
],
},
{
id: 'extract',
@@ -857,3 +1122,13 @@ export function providedFieldsFor(
): ProvidedField[] | undefined {
return findOperation(connectorId, operationId, 'trigger')?.providedFields;
}
/** Nastavitelna pole akce. Prazdne pole = akci zatim nejde konfigurovat. */
export function actionInputsFor(connectorId: string, operationId: string): OperationField[] {
return findOperation(connectorId, operationId, 'action')?.inputs ?? [];
}
/** Co akce vrati dalsim krokum. Prazdne pole = nic, na co by se dalo ptat. */
export function actionOutputsFor(connectorId: string, operationId: string): ProvidedField[] {
return findOperation(connectorId, operationId, 'action')?.outputFields ?? [];
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Co je videt v kterem miste stromu.
*
* Spoustec da nejake parametry. Kazda akce muze pridat dalsi - "dohledat firmu"
* vrati `customerKnown`, "zalozit ticket" vrati `newTicketId`. Podminka i sablona
* se pak muzou odkazat na cokoliv, co uz **pred nimi** vzniklo.
*
* Dve pravidla, na kterych to stoji:
* - krok vidi jen to, co je pred nim ve stejne sekvenci, a to, co videl jeho rodic,
* - vetev podminky NEPRIDAVA nic do sekvence za podminkou. Vetev totiz nemusela
* probehnout, takze spolehat se na jeji vystup by byla past.
*
* POZOR: stejna logika je na klientovi v web/src/lib/flow.ts.
* Server je autorita, kopie na klientovi existuje jen kvuli UI.
*/
import { actionOutputsFor } from './connectors.js';
import type { AutomationFlow, FlowStep, TriggerField } from './automationStore.js';
export interface FlowScopes {
/** Pole dostupna pri vstupu do kroku. Klic je ID kroku. */
byStep: Map<string, TriggerField[]>;
/**
* Vsechna pole, ktera ve strome nekdy vzniknou, vcetne vystupu z vetvi.
* Slouzi k rozliseni "parametr neexistuje" (chyba) od
* "parametr vznika az pozdeji" (nedodelek).
*/
all: Map<string, TriggerField>;
}
/** Vystupy akce jako pole se stabilnim ID v ramci celeho stromu. */
function outputsOf(step: Extract<FlowStep, { kind: 'action' }>): TriggerField[] {
return actionOutputsFor(step.connectorId, step.operationId).map((output) => ({
// Prefix ID krokem - dva stejne kroky ve strome se nesmi prekryt.
id: `${step.id}.${output.id}`,
name: output.name,
type: output.type,
required: output.required,
}));
}
export function collectScopes(flow: AutomationFlow): FlowScopes {
const byStep = new Map<string, TriggerField[]>();
const all = new Map<string, TriggerField>();
const triggerFields = flow.trigger?.fields ?? [];
for (const field of triggerFields) all.set(field.id, field);
const walk = (steps: FlowStep[], inherited: TriggerField[]) => {
let available = inherited;
for (const step of steps) {
byStep.set(step.id, available);
if (step.kind === 'condition') {
walk(step.yes, available);
walk(step.no, available);
continue;
}
const outputs = outputsOf(step);
if (outputs.length === 0) continue;
for (const output of outputs) all.set(output.id, output);
available = [...available, ...outputs];
}
};
walk(flow.steps, triggerFields);
return { byStep, all };
}
/** Pole dostupna v danem kroku. Prazdne, kdyz krok ve strome neni. */
export function scopeFor(scopes: FlowScopes, stepId: string): TriggerField[] {
return scopes.byStep.get(stepId) ?? [];
}
/**
* Jmena, ktera se ve scope opakuji. Sablona se odkazuje jmenem, takze
* dve stejna jmena znamenaji, ze nikdo nepozna, ktere se dosadi.
*/
export function duplicateNames(fields: TriggerField[]): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const field of fields) {
if (seen.has(field.name)) duplicates.add(field.name);
seen.add(field.name);
}
return [...duplicates];
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Sablony hodnot v krocich stromu.
*
* Hodnota nastavitelneho pole akce muze obsahovat odkazy na parametry
* spoustece: `Reklamace od {{profileName}}`. Pri behu se `{{...}}` nahradi
* skutecnou hodnotou.
*
* Odkazuje se **jmenem** parametru, ne jeho ID. Uzivatel jmeno vidi a pise,
* `{{f_42}}` by nikdo neprecetl. Cenou je, ze prejmenovani parametru sablonu
* rozbije - proto se to hlida a hlasi jako nedodelek, ne jako tichá chyba.
*
* POZOR: stejna logika je na klientovi v web/src/lib/flow.ts.
* Server je autorita, kopie na klientovi existuje jen kvuli napovede v UI.
*/
/** `{{nazev}}`, mezery kolem nazvu jsou povolene. */
const REFERENCE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
/** Jmena parametru, na ktere sablona odkazuje. Bez duplicit, v poradi vyskytu. */
export function referencedFields(template: string): string[] {
const found: string[] = [];
for (const match of template.matchAll(REFERENCE)) {
const name = match[1];
if (name && !found.includes(name)) found.push(name);
}
return found;
}
/**
* Dosadi hodnoty za odkazy. Chybejici parametr se nahradi prazdnym retezcem
* a zaloguje - nechat v textu `{{neco}}` by znamenalo poslat to zakaznikovi.
*/
export function renderTemplate(template: string, values: Record<string, string>): string {
return template.replace(REFERENCE, (_match, name: string) => {
const value = values[name];
if (value === undefined) {
console.warn(`[template] odkaz na neznamy parametr "${name}", dosazuji prazdno`);
return '';
}
return value;
});
}
+40 -1
View File
@@ -20,7 +20,14 @@ export type TicketStatus = 'new' | 'open' | 'waiting' | 'resolved';
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
/** Odkud pozadavek prisel. Podle toho se pozna, co ma smysl hledat v logu. */
export type TicketChannel = 'whatsapp' | 'email' | 'voice' | 'form' | 'portal';
export type TicketChannel =
| 'whatsapp'
| 'facebook'
| 'instagram'
| 'email'
| 'voice'
| 'form'
| 'portal';
export interface TicketCustomer {
/**
@@ -67,6 +74,13 @@ export interface TicketTraceEntry {
export interface Ticket {
id: string;
subject: string;
/**
* Cely text pozadavku - telo e-mailu, zprava z WhatsApp, prepis hovoru.
* Prazdny retezec znamena, ze krok "Zalozit ticket" obsah nenaplnil.
*/
body: string;
/** Odkaz na zdrojovou zpravu u poskytovatele. null u rucne zalozenych. */
sourceRef: string | null;
channel: TicketChannel;
customer: TicketCustomer;
status: TicketStatus;
@@ -88,6 +102,8 @@ interface StoredTicket extends Omit<Ticket, 'assignee'> {
export const channelLabels: Record<TicketChannel, string> = {
whatsapp: 'WhatsApp',
facebook: 'Facebook Messenger',
instagram: 'Instagram',
email: 'E-mail',
voice: 'Hlasová linka',
form: 'Webový formulář',
@@ -179,6 +195,10 @@ seed(
{
id: 'TK-4821',
subject: 'Voicebot neodpovídá na volání po 18:00',
body:
'Dobrý den, po šesté hodině to nikdo nebere. Zkoušeli jsme to včera i dnes, ' +
'linka jen vyzvání a pak to spadne. Přes den to funguje normálně.',
sourceRef: 'cl_88213',
channel: 'voice',
customer: {
id: 'crm_1042',
@@ -287,6 +307,11 @@ seed(
{
id: 'TK-4820',
subject: 'Přidat pole IČO do synchronizace CRM a fakturace',
body:
'Zdravím, potřebovali bychom, aby se při synchronizaci přenášelo i IČO. ' +
'Teď ho musíme do faktur doplňovat ručně a občas se na to zapomene. ' +
'Kolik by to bylo práce?',
sourceRef: '<9f21c4@nordis.cz>',
channel: 'email',
customer: {
id: 'crm_2210',
@@ -365,6 +390,8 @@ seed(
{
id: 'TK-4819',
subject: 'Chybí denní report objednávek v e-mailu',
body: 'Dobrý den, už třetí den nechodí ranní report. Můžete se na to prosím podívat?',
sourceRef: 'wamid.HBgLNDIwNzc0OTAyMzMx',
channel: 'whatsapp',
customer: {
id: null,
@@ -456,6 +483,10 @@ seed(
{
id: 'TK-4817',
subject: 'Rozšíření hlasového scénáře o objednávku svozu',
body:
'Chtěli bychom, aby si zákazník mohl objednat svoz rovnou po telefonu, ' +
'bez přepojení na dispečink. Rozpočet do 40 000. Prosím o odhad.',
sourceRef: null,
channel: 'form',
customer: {
id: 'crm_3390',
@@ -510,6 +541,10 @@ seed(
{
id: 'TK-4812',
subject: 'Duplicitní zápis kontaktů z webového formuláře',
body:
'Každé odeslání formuláře zakládá nový kontakt, i když stejný e-mail už v CRM je. ' +
'Máme tam desítky duplicit.',
sourceRef: null,
channel: 'portal',
customer: {
id: 'crm_1042',
@@ -665,6 +700,8 @@ export function getWorkload(everyone: Person[]): Workload {
export interface CreateTicketInput {
subject: string;
body?: string;
sourceRef?: string | null;
channel: TicketChannel;
customer: TicketCustomer;
priority: TicketPriority;
@@ -688,6 +725,8 @@ export function createTicket(input: CreateTicketInput): Ticket {
const stored: StoredTicket = {
id: `TK-${ticketCounter}`,
subject: input.subject,
body: input.body ?? '',
sourceRef: input.sourceRef ?? null,
channel: input.channel,
customer: input.customer,
status: 'new',
+22 -2
View File
@@ -108,9 +108,20 @@ export function buildOpenApiDocument() {
properties: {
id: { type: 'string', example: 'TK-4821' },
subject: { type: 'string' },
body: {
type: 'string',
description:
'Cely text pozadavku. Prazdny retezec = krok "Zalozit ticket" obsah nenaplnil.',
},
sourceRef: {
type: 'string',
nullable: true,
description: 'Odkaz na zdrojovou zpravu u poskytovatele.',
example: 'wamid.HBgLNDIwNzc0OTAyMzMx',
},
channel: {
type: 'string',
enum: ['whatsapp', 'email', 'voice', 'form', 'portal'],
enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'],
description: 'Odkud pozadavek prisel.',
},
customer: { $ref: '#/components/schemas/TicketCustomer' },
@@ -234,6 +245,14 @@ export function buildOpenApiDocument() {
kind: { type: 'string', enum: ['action', 'condition'] },
connectorId: { type: 'string', example: 'email' },
operationId: { type: 'string', example: 'send' },
inputs: {
type: 'object',
additionalProperties: { type: 'string' },
description:
'Nastaveni akce. Klic je ID pole z katalogu, hodnota je sablona - ' +
'{{nazev}} se nahradi parametrem spoustece. Neznamy klic vraci 400.',
example: { subject: 'Reklamace od {{profileName}}', body: '{{text}}' },
},
fieldId: { type: 'string', example: 'f_42' },
operator: {
type: 'string',
@@ -800,9 +819,10 @@ export function buildOpenApiDocument() {
],
},
subject: { type: 'string' },
body: { type: 'string', description: 'Cely text pozadavku.' },
channel: {
type: 'string',
enum: ['whatsapp', 'email', 'voice', 'form', 'portal'],
enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'],
description: 'Odkud pozadavek prisel. Podle toho se poskladá i log ticketu.',
},
contact: { type: 'string' },
+30 -4
View File
@@ -17,6 +17,7 @@ import {
findOperation,
providedFieldsFor,
} from '../data/connectors.js';
import { collectScopes } from '../data/flowScope.js';
import { listIncidents } from '../data/incidentStore.js';
import { getSummary } from '../data/mock.js';
import { findPersonByEmail, listPeople } from '../data/people.js';
@@ -56,7 +57,15 @@ dashboardRouter.get('/people', (req, res) => {
});
const ticketStatuses: TicketStatus[] = ['new', 'open', 'waiting', 'resolved'];
const ticketChannels: TicketChannel[] = ['whatsapp', 'email', 'voice', 'form', 'portal'];
const ticketChannels: TicketChannel[] = [
'whatsapp',
'facebook',
'instagram',
'email',
'voice',
'form',
'portal',
];
/**
* Filtr ze query parametru. Nesmyslnou hodnotu zahodime a zalogujeme -
@@ -198,6 +207,8 @@ const stepSchema: z.ZodType<FlowStep> = z.lazy(() =>
kind: z.literal('action'),
connectorId: z.string().min(1),
operationId: z.string().min(1),
// Klic je ID pole z katalogu, hodnota sablona. Overuje se nize.
inputs: z.record(z.string()).optional(),
}),
z.object({
id: z.string().min(1),
@@ -298,12 +309,14 @@ function validateFlowReferences(flow: z.infer<typeof flowSchema>): string[] {
names.add(field.name);
}
const fieldById = new Map(flow.trigger.fields.map((field) => [field.id, field]));
// Scope rika, co je v kterem miste stromu videt. Podminka se smi ptat
// jen na parametry, ktere pred ni uz vznikly.
const scopes = collectScopes(flow);
const walk = (steps: FlowStep[]) => {
for (const step of steps) {
if (step.kind === 'condition') {
const field = fieldById.get(step.fieldId);
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)) {
@@ -315,8 +328,21 @@ function validateFlowReferences(flow: z.infer<typeof flowSchema>): string[] {
walk(step.no);
continue;
}
if (!findOperation(step.connectorId, step.operationId, 'action')) {
const action = findOperation(step.connectorId, step.operationId, 'action');
if (!action) {
problems.push(`Akce ${step.connectorId}/${step.operationId} neexistuje v katalogu.`);
continue;
}
// Nastaveni musi sedet na katalog. Neznamy klic je rozbity strom,
// ne nedodelek - ulozit ho by znamenalo drzet data, ktera nikdo neprecte.
const allowed = new Set((action.inputs ?? []).map((input) => input.id));
for (const key of Object.keys(step.inputs ?? {})) {
if (!allowed.has(key)) {
problems.push(
`Akce ${step.connectorId}/${step.operationId} nemá nastavitelné pole „${key}".`,
);
}
}
}
};
+47 -1
View File
@@ -30,8 +30,12 @@ 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', 'email', 'voice', 'form', 'portal']).optional(),
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. */
@@ -71,6 +75,8 @@ const channelPresets: Record<
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;
}
@@ -86,9 +92,40 @@ const channelPresets: Record<
],
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: {
connectorId: '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: {
connectorId: '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: {
connectorId: 'email',
operationId: 'received',
@@ -100,6 +137,7 @@ const channelPresets: Record<
],
contact: 'Tomáš Beran',
reply: 'tomas.beran@nordis.cz',
sourceRef: '<sim-4f8a@nordis.cz>',
payload: (_contact, reply, subject) =>
`{ "from": "${reply}", "subject": "${subject}", "attachments": 1 }`,
},
@@ -114,6 +152,7 @@ const channelPresets: Record<
],
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 }`,
},
@@ -128,6 +167,7 @@ const channelPresets: Record<
],
contact: 'Jana Sedláčková',
reply: 'jana.sedlackova@logitrans.cz',
sourceRef: null,
payload: (contact, _reply, subject) =>
`{ "company": "LogiTrans", "contact": "${contact}", "topic": "${subject}" }`,
},
@@ -142,6 +182,7 @@ const channelPresets: Record<
],
contact: 'Interní požadavek',
reply: 'portal@automia.cz',
sourceRef: null,
payload: (contact, _reply, subject) => `{ "createdBy": "${contact}", "subject": "${subject}" }`,
},
};
@@ -161,6 +202,8 @@ 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' },
};
@@ -314,6 +357,9 @@ simulateRouter.post('/', (req, res) => {
const ticket = createTicket({
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: {