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:
co-authored by
Claude Opus 5
parent
52b190bfbc
commit
dd021b5f69
+368
-7
@@ -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
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user