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
+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: {