updated tickets

This commit is contained in:
JiriUhlir
2026-08-03 11:45:35 +02:00
parent 9a0c67af08
commit 52b190bfbc
28 changed files with 3228 additions and 241 deletions
+193 -33
View File
@@ -6,11 +6,14 @@
* - actions: co se s nim da UDELAT uprostred behu
*
* Konektor muze mit jen triggery (webhook), jen akce (odeslani e-mailu), nebo obojí.
* Jak pridat novy konektor: docs/08-automatizace-builder.md
* Jak pridat novy konektor: documentation/05-dashboard-a-builder.md
*/
import type { FieldType } from './conditions.js';
export type ConnectorCategory =
| 'spoustece'
| 'servicedesk'
| 'crm'
| 'ekonomika'
| 'logistika'
@@ -22,6 +25,21 @@ export type ConnectorCategory =
/** connected = klient ho ma napojeny, available = umime napojit, planned = na roadmape */
export type ConnectorStatus = 'connected' | 'available' | 'planned';
/**
* Parametr, ktery spoustec sam preda do stromu. Tvarem odpovida `TriggerField`
* v automationStore, ale definuje se tady - katalog je zdroj pravdy o tom,
* co ktera sluzba posila.
*
* ID musi byt stabilni, odkazuji se na nej podminky v ulozenych stromech.
* Prejmenovani ID rozbije existujici automatizace.
*/
export interface ProvidedField {
id: string;
name: string;
type: FieldType;
required: boolean;
}
export interface ConnectorOperation {
id: string;
name: string;
@@ -33,6 +51,12 @@ export interface ConnectorOperation {
* (webhook, formular). false/chybi = data urcuje sluzba.
*/
customPayload?: boolean;
/**
* Jen u triggeru: parametry, ktere sluzba predava sama. Uzivatel je nemeni,
* server je pri ukladani stromu dosadi misto toho, co poslal klient.
* Diky nim jde stavet podminky nad daty, ktera si nikdo nevymyslel.
*/
providedFields?: ProvidedField[];
}
export interface Connector {
@@ -49,6 +73,7 @@ export interface Connector {
export const connectorCategories: Array<{ id: ConnectorCategory; label: string }> = [
{ id: 'spoustece', label: 'Spouštěče' },
{ id: 'servicedesk', label: 'Tickety a servicedesk' },
{ id: 'crm', label: 'CRM' },
{ id: 'ekonomika', label: 'Ekonomika a banky' },
{ id: 'logistika', label: 'Logistika' },
@@ -129,6 +154,111 @@ export const connectors: Connector[] = [
actions: [],
},
// -------------------------------------------------------------- servicedesk
/**
* Tickety nejsou jen akce na konci stromu. Jsou to obe strany:
* kanaly do nich ustuji (WhatsApp, e-mail, hlas) a zalozeny ticket
* je zase spoustecem navazne automatizace - typicky "mame zakaznika?".
*/
{
id: 'ticket',
name: 'Tickety',
category: 'servicedesk',
description:
'Servicedesk. Požadavek od zákazníka, který má svého řešitele a dohledatelný průběh.',
icon: 'LifeBuoy',
status: 'connected',
triggers: [
{
id: 'created',
name: 'Založen ticket',
description:
'Spustí se při každém novém ticketu, ať vznikl z kanálu nebo ručně. ' +
'Podle parametru knownCustomer se pozná, jestli se firma dohledala v CRM.',
providedFields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ 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 },
],
},
{
id: 'unknown-customer',
name: 'Ticket bez zákazníka',
description:
'Spustí se, když se k ticketu nepodařilo dohledat firmu. Sem patří založení ' +
'obchodního případu nebo dotaz zpět na zadavatele.',
providedFields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ 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 },
],
},
{
id: 'assigned',
name: 'Ticket přiřazen řešiteli',
description: 'Spustí se, jakmile ticket dostane konkrétního člověka.',
providedFields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ id: 'ticket.assignee', name: 'assignee', type: 'string', required: true },
{ id: 'ticket.assigneeEmail', name: 'assigneeEmail', type: 'string', required: true },
{ id: 'ticket.priority', name: 'priority', type: 'string', required: true },
],
},
{
id: 'status-changed',
name: 'Změna stavu ticketu',
description: 'Spustí se při přechodu do jiného stavu, včetně vyřešení.',
providedFields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ id: 'ticket.status', name: 'status', type: 'string', required: true },
{ id: 'ticket.previousStatus', name: 'previousStatus', type: 'string', required: true },
{ id: 'ticket.assignee', name: 'assignee', type: 'string', required: false },
],
},
],
actions: [
{
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'],
},
{
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'],
},
{
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'],
},
{
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'],
},
{
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'],
},
],
},
// --------------------------------------------------------------------- crm
{
id: 'raynet',
@@ -325,8 +455,15 @@ export const connectors: Connector[] = [
{
id: 'received',
name: 'Přijat e-mail',
description: 'Spustí se při doručení e-mailu do sledované schránky.',
fields: ['Schránka', 'Filtr odesílatele nebo předmětu'],
description:
'Spustí se při doručení e-mailu do sledované schránky. Typický začátek ticketu.',
providedFields: [
{ 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 },
],
},
],
actions: [
@@ -338,6 +475,42 @@ export const connectors: Connector[] = [
},
],
},
{
id: 'whatsapp',
name: 'WhatsApp',
category: 'komunikace',
description: 'Příjem a odesílání zpráv přes WhatsApp Business. Nejrychlejší cesta k ticketu.',
icon: 'MessageCircle',
status: 'connected',
triggers: [
{
id: 'message-received',
name: 'Přijata zpráva',
description: 'Spustí se při doručení zprávy na firemní číslo.',
providedFields: [
{ 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 },
],
},
],
actions: [
{
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'],
},
{
id: 'send-template',
name: 'Odeslat schválenou šablonu',
description: 'Pošle předschválenou šablonu. Nutné mimo 24hodinové okno konverzace.',
fields: ['Telefon', 'Šablona', 'Proměnné'],
},
],
},
{
id: 'microsoft365',
name: 'Microsoft 365',
@@ -491,7 +664,12 @@ export const connectors: Connector[] = [
id: 'call-received',
name: 'Příchozí hovor',
description: 'Spustí se při přijetí hovoru na hlasovou linku.',
fields: ['Linka', 'Jazyk'],
providedFields: [
{ id: 'voicebot.callerNumber', name: 'callerNumber', type: 'string', required: true },
{ id: 'voicebot.line', name: 'line', type: 'string', required: true },
{ id: 'voicebot.wantsOperator', name: 'wantsOperator', type: 'boolean', required: false },
{ id: 'voicebot.startedAt', name: 'startedAt', type: 'date', required: true },
],
},
{
id: 'call-ended',
@@ -631,35 +809,6 @@ export const connectors: Connector[] = [
},
],
},
{
id: 'ticket',
name: 'Tickety',
category: 'nastroje',
description: 'Servicedesk — zakládání a aktualizace požadavků.',
icon: 'LifeBuoy',
status: 'connected',
triggers: [
{
id: 'created',
name: 'Nový ticket',
description: 'Spustí se při založení ticketu.',
},
],
actions: [
{
id: 'create',
name: 'Založit ticket',
description: 'Vytvoří požadavek včetně priority a přiřazení.',
fields: ['Předmět', 'Popis', 'Priorita', 'Řešitel'],
},
{
id: 'comment',
name: 'Přidat komentář',
description: 'Zapíše komentář k existujícímu ticketu.',
fields: ['ID ticketu', 'Text'],
},
],
},
{
id: 'log',
name: 'Zápis do logu',
@@ -697,3 +846,14 @@ export function findOperation(
const pool = type === 'trigger' ? connector.triggers : connector.actions;
return pool.find((op) => op.id === operationId);
}
/**
* Parametry, ktere spoustec predava sam. `undefined` znamena, ze si je
* deklaruje uzivatel (webhook, formular) - katalog do toho nemluvi.
*/
export function providedFieldsFor(
connectorId: string,
operationId: string,
): ProvidedField[] | undefined {
return findOperation(connectorId, operationId, 'trigger')?.providedFields;
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Resitele ticketu - lide, kteri maji pozadavek "u sebe".
*
* Zamerne oddelene od `users.ts`. Uzivatel je ten, kdo se prihlasi do portalu,
* resitel je ten, na koho jde ticket. Casto je to tyz clovek, ale ne vzdy:
* technik muze mit tickety a do portalu se nikdy neprihlasit.
* Spojka mezi obojim je e-mail.
*
* POZOR: data jsou v pameti procesu, restart je vrati na vychozi sadu.
*/
export interface Person {
id: string;
name: string;
email: string;
/** Cim se v tymu zabyva. Jen popisek, nic nerozhoduje. */
role: string;
/** Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez. */
capacity: number;
}
export const people: Person[] = [
{
id: 'ppl_vomacka',
name: 'Karel Vomáčka',
email: 'karel.vomacka@automia.cz',
role: 'Servicedesk',
capacity: 8,
},
{
id: 'ppl_uhlir',
name: 'Jiří Uhlíř',
email: 'admin@automia.cz',
role: 'Vedoucí týmu',
capacity: 5,
},
{
id: 'ppl_kriz',
name: 'Martin Kříž',
email: 'martin.kriz@automia.cz',
role: 'Integrace a API',
capacity: 6,
},
{
id: 'ppl_novakova',
name: 'Eva Nováková',
email: 'eva.novakova@automia.cz',
role: 'Voiceboti',
capacity: 6,
},
];
export function listPeople(): Person[] {
return [...people].sort((a, b) => a.name.localeCompare(b.name, 'cs'));
}
export function findPerson(id: string): Person | undefined {
return people.find((p) => p.id === id);
}
/** Spojka na prihlaseneho uzivatele - podle ni funguje filtr "moje tickety". */
export function findPersonByEmail(email: string): Person | undefined {
const normalized = email.trim().toLowerCase();
return people.find((p) => p.email.toLowerCase() === normalized);
}
+684 -40
View File
@@ -2,121 +2,711 @@
* Uloziste ticketu. Zmeny posilaji udalost na sbernici, takze se projevi
* v dashboardu okamzite bez obnoveni stranky.
*
* Ticket je prichozi pozadavek odkudkoliv (WhatsApp, e-mail, hlasova linka,
* formular, portal). NENI to bug ani wish - vyvojarska agenda ma vlastni
* evidenci a s ticketem se plete jen v hlave.
*
* Dve veci, na kterych model stoji:
* - ticket ma vzdy jednoho resitele (nebo zadneho), aby slo rict "mas to u sebe",
* - ticket si nese strom zaznamu o tom, co se s nim delo a co ktera sluzba vratila.
*
* POZOR: data jsou v pameti procesu, restart API je vrati na vychozi sadu.
*/
import { publish } from '../events/bus.js';
import { findPerson, type Person } from './people.js';
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 interface TicketCustomer {
/**
* ID firmy v CRM. null = zakaznika se nepodarilo dohledat.
* Prave na tohle se pta podminka "mame zakaznika?" ve stromu automatizace.
*/
id: string | null;
company: string;
/** Konkretni clovek, ktery pozadavek poslal. */
contact: string;
/** Adresa nebo cislo, odkud to prislo a kam se odpovida. */
reply: string;
}
/** Odkaz na resitele v odpovedi API. Uvnitr se drzi jen jeho ID. */
export interface TicketAssignee {
id: string;
name: string;
}
export type TraceStatus = 'ok' | 'error' | 'skipped' | 'info';
export type TraceKind = 'trigger' | 'action' | 'condition' | 'note';
/**
* Jeden radek v logu ticketu. Strom se sklada pres `parentId` -
* vetev podminky visi na zaznamu te podminky.
*/
export interface TicketTraceEntry {
id: string;
/** null = zaznam v hlavni sekvenci. */
parentId: string | null;
kind: TraceKind;
/** Ktera sluzba to byla. null u poznamek a podminek. */
connectorId: string | null;
operationId: string | null;
label: string;
status: TraceStatus;
/** Co sluzba vratila. Tohle je duvod, proc log existuje. */
response: string | null;
durationMs: number | null;
at: string;
}
export interface Ticket {
id: string;
subject: string;
requester: string;
channel: TicketChannel;
customer: TicketCustomer;
status: TicketStatus;
priority: TicketPriority;
assignee: string | null;
assignee: TicketAssignee | null;
/** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */
automationId: string | null;
createdAt: string;
updatedAt: string;
}
export interface TicketDetail extends Ticket {
trace: TicketTraceEntry[];
}
interface StoredTicket extends Omit<Ticket, 'assignee'> {
assigneeId: string | null;
}
export const channelLabels: Record<TicketChannel, string> = {
whatsapp: 'WhatsApp',
email: 'E-mail',
voice: 'Hlasová linka',
form: 'Webový formulář',
portal: 'Portál',
};
function minutesAgo(minutes: number): string {
return new Date(Date.now() - minutes * 60_000).toISOString();
}
const tickets: Ticket[] = [
// ------------------------------------------------------------------- uloziste
const tickets: StoredTicket[] = [];
/** Log ticketu drzime zvlast - je to jina zivotnost i jiny objem dat. */
const traces = new Map<string, TicketTraceEntry[]>();
let ticketCounter = 4_821;
let traceCounter = 0;
function nextTraceId(): string {
traceCounter += 1;
return `tr_${traceCounter}`;
}
/** Zaznam v logu tak, jak se zapisuje - strom je zanoreny, ulozeni ho zplosti. */
export interface TraceInput {
kind: TraceKind;
label: string;
status: TraceStatus;
connectorId?: string | null;
operationId?: string | null;
response?: string | null;
durationMs?: number | null;
/** Posun proti "ted" v minutach. Pouziva jen ukazkova data. */
agoMinutes?: number;
children?: TraceInput[];
}
/**
* Zplosti zanoreny zapis do seznamu s `parentId`.
* Poradi se zachovava, aby se strom dal vykreslit jednim pruchodem.
*/
function flattenTrace(
inputs: TraceInput[],
parentId: string | null,
into: TicketTraceEntry[],
): TicketTraceEntry[] {
for (const input of inputs) {
const entry: TicketTraceEntry = {
id: nextTraceId(),
parentId,
kind: input.kind,
connectorId: input.connectorId ?? null,
operationId: input.operationId ?? null,
label: input.label,
status: input.status,
response: input.response ?? null,
durationMs: input.durationMs ?? null,
at: minutesAgo(input.agoMinutes ?? 0),
};
into.push(entry);
if (input.children && input.children.length > 0) {
flattenTrace(input.children, entry.id, into);
}
}
return into;
}
/** Prida zaznamy do logu ticketu. Vraci, kolik radku pribylo. */
export function appendTrace(ticketId: string, inputs: TraceInput[]): number {
const existing = traces.get(ticketId);
if (!existing) {
console.warn(`[tickets] zapis do logu neexistujiciho ticketu: ${ticketId}`);
return 0;
}
const before = existing.length;
flattenTrace(inputs, null, existing);
return existing.length - before;
}
// --------------------------------------------------------------- ukazkova data
function seed(ticket: StoredTicket, trace: TraceInput[]) {
tickets.push(ticket);
traces.set(ticket.id, flattenTrace(trace, null, []));
}
seed(
{
id: 'TK-4821',
subject: 'Voicebot neodpovídá na volání po 18:00',
requester: 'Firma s.r.o.',
channel: 'voice',
customer: {
id: 'crm_1042',
company: 'Firma s.r.o.',
contact: 'Petra Klientová',
reply: '+420 601 118 224',
},
status: 'open',
priority: 'high',
assignee: 'Jiří U.',
assigneeId: 'ppl_novakova',
automationId: 'AUT-02',
createdAt: minutesAgo(310),
updatedAt: minutesAgo(42),
},
[
{
kind: 'trigger',
connectorId: 'voicebot',
operationId: 'call-received',
label: 'Příchozí hovor na linku 800 100 200',
status: 'ok',
response: '{ "callId": "cl_88213", "from": "+420601118224", "durationSec": 96 }',
durationMs: 120,
agoMinutes: 310,
},
{
kind: 'action',
connectorId: 'transcription',
operationId: 'transcribe',
label: 'Přepis nahrávky',
status: 'ok',
response:
'{ "language": "cs", "confidence": 0.94, "text": "Dobrý den, po šesté hodině to nikdo nebere..." }',
durationMs: 4_180,
agoMinutes: 309,
},
{
kind: 'action',
connectorId: 'ai-text',
operationId: 'classify',
label: 'Zařazení do kategorie',
status: 'ok',
response: '{ "category": "porucha", "priority": "high", "confidence": 0.88 }',
durationMs: 910,
agoMinutes: 309,
},
{
kind: 'action',
connectorId: 'raynet',
operationId: 'upsert-contact',
label: 'Dohledání firmy podle telefonu',
status: 'ok',
response: '{ "companyId": "crm_1042", "name": "Firma s.r.o.", "matchedBy": "phone" }',
durationMs: 640,
agoMinutes: 309,
},
{
kind: 'condition',
label: 'knownCustomer je splněno',
status: 'ok',
response: 'true, pokračuje větev ANO',
agoMinutes: 309,
children: [
{
kind: 'action',
connectorId: 'ticket',
operationId: 'create',
label: 'Založení ticketu',
status: 'ok',
response: '{ "ticketId": "TK-4821", "priority": "high" }',
durationMs: 85,
agoMinutes: 309,
},
{
kind: 'action',
connectorId: 'ticket',
operationId: 'assign',
label: 'Přiřazení řešitele podle služby',
status: 'ok',
response: '{ "assignee": "Eva Nováková", "rule": "voicebot -> voiceboti" }',
durationMs: 40,
agoMinutes: 309,
},
],
},
{
kind: 'action',
connectorId: 'microsoft365',
operationId: 'post-teams',
label: 'Upozornění do Teams',
status: 'error',
response: 'HTTP 429 Too Many Requests, kanál "Servicedesk" překročil limit, zpráva neodešla',
durationMs: 2_400,
agoMinutes: 308,
},
{
kind: 'note',
label: 'Eva Nováková: Reprodukováno, chyba je v nočním režimu scénáře.',
status: 'info',
agoMinutes: 42,
},
],
);
seed(
{
id: 'TK-4820',
subject: 'Přidat pole IČO do synchronizace CRM a fakturace',
requester: 'Nordis a.s.',
channel: 'email',
customer: {
id: 'crm_2210',
company: 'Nordis a.s.',
contact: 'Tomáš Beran',
reply: 'tomas.beran@nordis.cz',
},
status: 'waiting',
priority: 'normal',
assignee: 'Martin K.',
assigneeId: 'ppl_kriz',
automationId: 'AUT-03',
createdAt: minutesAgo(1_180),
updatedAt: minutesAgo(190),
},
[
{
kind: 'trigger',
connectorId: 'email',
operationId: 'received',
label: 'Přijat e-mail do schránky podpora@',
status: 'ok',
response: '{ "from": "tomas.beran@nordis.cz", "subject": "IČO v synchronizaci", "attachments": 0 }',
durationMs: 60,
agoMinutes: 1_180,
},
{
kind: 'action',
connectorId: 'ai-text',
operationId: 'extract',
label: 'Vytažení údajů z textu',
status: 'ok',
response: '{ "type": "pozadavek na zmenu", "system": "CRM + iDoklad", "urgent": false }',
durationMs: 1_120,
agoMinutes: 1_180,
},
{
kind: 'action',
connectorId: 'raynet',
operationId: 'upsert-contact',
label: 'Dohledání firmy podle e-mailu',
status: 'ok',
response: '{ "companyId": "crm_2210", "name": "Nordis a.s.", "matchedBy": "emailDomain" }',
durationMs: 520,
agoMinutes: 1_180,
},
{
kind: 'action',
connectorId: 'ticket',
operationId: 'create',
label: 'Založení ticketu',
status: 'ok',
response: '{ "ticketId": "TK-4820", "priority": "normal" }',
durationMs: 74,
agoMinutes: 1_179,
},
{
kind: 'action',
connectorId: 'email',
operationId: 'send',
label: 'Potvrzení zadavateli',
status: 'ok',
response: '{ "messageId": "<a41c@automia.cz>", "to": "tomas.beran@nordis.cz" }',
durationMs: 380,
agoMinutes: 1_179,
},
{
kind: 'note',
label: 'Martin Kříž: Čekáme na potvrzení rozsahu od zákazníka.',
status: 'info',
agoMinutes: 190,
},
],
);
seed(
{
id: 'TK-4819',
subject: 'Chybí denní report objednávek v e-mailu',
requester: 'Bistro Kolektiv',
channel: 'whatsapp',
customer: {
id: null,
company: 'Neznámá firma',
contact: 'Bistro Kolektiv',
reply: '+420 774 902 331',
},
status: 'new',
priority: 'normal',
assignee: null,
assigneeId: null,
automationId: 'AUT-01',
createdAt: minutesAgo(95),
updatedAt: minutesAgo(95),
},
[
{
kind: 'trigger',
connectorId: 'whatsapp',
operationId: 'message-received',
label: 'Přijata zpráva z WhatsApp',
status: 'ok',
response:
'{ "from": "+420774902331", "profileName": "Bistro Kolektiv", "text": "Dobrý den, už třetí den nechodí ranní report." }',
durationMs: 55,
agoMinutes: 95,
},
{
kind: 'action',
connectorId: 'ai-text',
operationId: 'classify',
label: 'Zařazení do kategorie',
status: 'ok',
response: '{ "category": "vypadek reportu", "priority": "normal", "confidence": 0.79 }',
durationMs: 870,
agoMinutes: 95,
},
{
kind: 'action',
connectorId: 'raynet',
operationId: 'upsert-contact',
label: 'Dohledání firmy podle telefonu',
status: 'error',
response: '{ "matches": 0, "searchedBy": "phone", "value": "+420774902331" }',
durationMs: 610,
agoMinutes: 95,
},
{
kind: 'condition',
label: 'knownCustomer není splněno',
status: 'skipped',
response: 'false, pokračuje větev NE',
agoMinutes: 95,
children: [
{
kind: 'action',
connectorId: 'ticket',
operationId: 'create',
label: 'Založení ticketu bez napojení na firmu',
status: 'ok',
response: '{ "ticketId": "TK-4819", "customerId": null }',
durationMs: 68,
agoMinutes: 95,
},
{
kind: 'action',
connectorId: 'raynet',
operationId: 'create-lead',
label: 'Založení obchodního případu k dohledání',
status: 'ok',
response: '{ "leadId": "lead_7781", "stage": "k overeni" }',
durationMs: 940,
agoMinutes: 95,
},
{
kind: 'action',
connectorId: 'ticket',
operationId: 'assign',
label: 'Přiřazení řešitele',
status: 'skipped',
response: 'Přeskočeno, bez známé firmy nelze určit garanta a ticket zůstal nepřiřazený',
agoMinutes: 95,
},
],
},
],
);
seed(
{
id: 'TK-4817',
subject: 'Rozšíření hlasového scénáře o objednávku svozu',
requester: 'LogiTrans',
channel: 'form',
customer: {
id: 'crm_3390',
company: 'LogiTrans',
contact: 'Jana Sedláčková',
reply: 'jana.sedlackova@logitrans.cz',
},
status: 'open',
priority: 'low',
assignee: 'Eva N.',
assigneeId: 'ppl_vomacka',
automationId: null,
createdAt: minutesAgo(2_600),
updatedAt: minutesAgo(420),
},
[
{
kind: 'trigger',
connectorId: 'form',
operationId: 'submitted',
label: 'Odeslán formulář Požadavek na úpravu',
status: 'ok',
response: '{ "company": "LogiTrans", "topic": "voicebot", "budget": "do 40 000" }',
durationMs: 45,
agoMinutes: 2_600,
},
{
kind: 'action',
connectorId: 'ticket',
operationId: 'create',
label: 'Založení ticketu',
status: 'ok',
response: '{ "ticketId": "TK-4817", "priority": "low" }',
durationMs: 71,
agoMinutes: 2_600,
},
{
kind: 'note',
label: 'Ticket ručně přiřazen na Karla Vomáčku.',
status: 'info',
agoMinutes: 2_580,
},
{
kind: 'note',
label: 'Karel Vomáčka: Odhad odeslán, čeká se na objednávku.',
status: 'info',
agoMinutes: 420,
},
],
);
seed(
{
id: 'TK-4812',
subject: 'Duplicitní zápis kontaktů z webového formuláře',
requester: 'Firma s.r.o.',
channel: 'portal',
customer: {
id: 'crm_1042',
company: 'Firma s.r.o.',
contact: 'Petra Klientová',
reply: 'petra.klientova@firma.cz',
},
status: 'resolved',
priority: 'critical',
assignee: 'Jiří U.',
assigneeId: 'ppl_uhlir',
automationId: null,
createdAt: minutesAgo(5_100),
updatedAt: minutesAgo(1_500),
},
];
[
{
kind: 'trigger',
connectorId: 'form',
operationId: 'submitted',
label: 'Nahlášeno z portálu',
status: 'ok',
response: '{ "reportedBy": "petra.klientova@firma.cz" }',
durationMs: 38,
agoMinutes: 5_100,
},
{
kind: 'action',
connectorId: 'transform',
operationId: 'deduplicate',
label: 'Kontrola duplicit v CRM',
status: 'ok',
response: '{ "scanned": 1284, "duplicates": 37, "key": "email" }',
durationMs: 6_700,
agoMinutes: 4_900,
},
{
kind: 'note',
label: 'Jiří Uhlíř: Do formuláře doplněna kontrola podle e-mailu, duplicity sloučeny.',
status: 'info',
agoMinutes: 1_500,
},
],
);
let counter = 4_821;
// ------------------------------------------------------------------ prevody
export function listTickets(): Ticket[] {
// Nejdriv nevyrizene, uvnitr od nejnovejsi upravy.
return [...tickets].sort((a, b) => {
if (a.status === 'resolved' && b.status !== 'resolved') return 1;
if (b.status === 'resolved' && a.status !== 'resolved') return -1;
return b.updatedAt.localeCompare(a.updatedAt);
function toTicket(stored: StoredTicket): Ticket {
const { assigneeId, ...rest } = stored;
if (!assigneeId) return { ...rest, assignee: null };
const person = findPerson(assigneeId);
if (!person) {
// Resitel zmizel ze seznamu - ticket nesmi spadnout, ale chceme o tom vedet.
console.warn(`[tickets] ${stored.id}: resitel ${assigneeId} uz neexistuje`);
return { ...rest, assignee: null };
}
return { ...rest, assignee: { id: person.id, name: person.name } };
}
// -------------------------------------------------------------------- dotazy
export interface TicketFilter {
/** ID resitele, nebo 'unassigned' pro nepridelene. */
assignee?: string;
status?: TicketStatus;
channel?: TicketChannel;
}
export function listTickets(filter: TicketFilter = {}): Ticket[] {
const selected = tickets.filter((ticket) => {
if (filter.status && ticket.status !== filter.status) return false;
if (filter.channel && ticket.channel !== filter.channel) return false;
if (filter.assignee === 'unassigned') return ticket.assigneeId === null;
if (filter.assignee && ticket.assigneeId !== filter.assignee) return false;
return true;
});
// Nejdriv nevyrizene, uvnitr od nejnovejsi upravy.
return selected
.sort((a, b) => {
if (a.status === 'resolved' && b.status !== 'resolved') return 1;
if (b.status === 'resolved' && a.status !== 'resolved') return -1;
return b.updatedAt.localeCompare(a.updatedAt);
})
.map(toTicket);
}
export function getTicket(id: string): Ticket | undefined {
return tickets.find((t) => t.id === id);
export function getTicket(id: string): TicketDetail | undefined {
const stored = tickets.find((t) => t.id === id);
if (!stored) return undefined;
return { ...toTicket(stored), trace: traces.get(id) ?? [] };
}
export function createTicket(input: {
/** Prvni nevyrizeny ticket - pouziva simulace, kdyz uzivatel neurci ktery. */
export function firstOpenTicket(): Ticket | undefined {
const stored = tickets.find((t) => t.status !== 'resolved');
return stored ? toTicket(stored) : undefined;
}
/**
* Kdo co ma u sebe. Prehled pres cely tym, ne jen pres prihlaseneho cloveka -
* bez nej nejde poznat, ze jeden clovek utahuje polovinu servicedesku.
*/
export interface WorkloadRow {
person: Person;
/** Nevyresene tickety. Tohle je to cislo, na ktere se lidi divaji. */
open: number;
/** Vcetne vyresenych, aby bylo videt, kolik uz ma za sebou. */
total: number;
critical: number;
/** Nejstarsi nevyreseny ticket. null = nic nevisi. */
oldestOpenAt: string | null;
/** true = ma vic nevyrizenych, nez je jeho kapacita. */
overloaded: boolean;
}
export interface Workload {
rows: WorkloadRow[];
unassigned: number;
openTotal: number;
}
export function getWorkload(everyone: Person[]): Workload {
const rows = everyone.map((person) => {
const mine = tickets.filter((t) => t.assigneeId === person.id);
const open = mine.filter((t) => t.status !== 'resolved');
const oldest = open.reduce<string | null>(
(acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc),
null,
);
return {
person,
open: open.length,
total: mine.length,
critical: open.filter((t) => t.priority === 'critical').length,
oldestOpenAt: oldest,
overloaded: open.length > person.capacity,
};
});
// Nejvytizenejsi nahoru - prehled ma odpovedet na "kdo toho ma nejvic".
rows.sort((a, b) => b.open - a.open || a.person.name.localeCompare(b.person.name, 'cs'));
return {
rows,
unassigned: tickets.filter((t) => t.assigneeId === null && t.status !== 'resolved').length,
openTotal: tickets.filter((t) => t.status !== 'resolved').length,
};
}
// --------------------------------------------------------------------- zmeny
export interface CreateTicketInput {
subject: string;
requester: string;
channel: TicketChannel;
customer: TicketCustomer;
priority: TicketPriority;
}): Ticket {
counter += 1;
assigneeId?: string | null;
automationId?: string | null;
/** Log toho, jak ticket vznikl. Bez nej je ticket nedohledatelny. */
trace?: TraceInput[];
}
export function createTicket(input: CreateTicketInput): Ticket {
ticketCounter += 1;
const now = new Date().toISOString();
const ticket: Ticket = {
id: `TK-${counter}`,
// Neexistujiciho resitele radeji zahodime, nez abychom ulozili mrtvy odkaz.
let assigneeId = input.assigneeId ?? null;
if (assigneeId && !findPerson(assigneeId)) {
console.warn(`[tickets] neznamy resitel ${assigneeId}, ticket zustava neprirazeny`);
assigneeId = null;
}
const stored: StoredTicket = {
id: `TK-${ticketCounter}`,
subject: input.subject,
requester: input.requester,
channel: input.channel,
customer: input.customer,
status: 'new',
priority: input.priority,
assignee: null,
assigneeId,
automationId: input.automationId ?? null,
createdAt: now,
updatedAt: now,
};
tickets.unshift(ticket);
tickets.unshift(stored);
traces.set(stored.id, flattenTrace(input.trace ?? [], null, []));
publish('ticket.created', `Nový ticket ${ticket.id}: ${ticket.subject}`, {
ticketId: ticket.id,
priority: ticket.priority,
publish('ticket.created', `Nový ticket ${stored.id}: ${stored.subject}`, {
ticketId: stored.id,
channel: stored.channel,
priority: stored.priority,
knownCustomer: stored.customer.id !== null,
});
return ticket;
return toTicket(stored);
}
export function updateTicketStatus(id: string, status: TicketStatus): Ticket | undefined {
@@ -126,9 +716,18 @@ export function updateTicketStatus(id: string, status: TicketStatus): Ticket | u
return undefined;
}
const previous = ticket.status;
ticket.status = status;
ticket.updatedAt = new Date().toISOString();
appendTrace(id, [
{
kind: 'note',
label: `Stav změněn z „${previous}" na „${status}".`,
status: 'info',
},
]);
if (status === 'resolved') {
publish('ticket.resolved', `Ticket ${ticket.id} vyřešen: ${ticket.subject}`, {
ticketId: ticket.id,
@@ -139,10 +738,55 @@ export function updateTicketStatus(id: string, status: TicketStatus): Ticket | u
status,
});
}
return ticket;
return toTicket(ticket);
}
/** Prvni nevyrizeny ticket - pouziva simulace, kdyz uzivatel neurci ktery. */
export function firstOpenTicket(): Ticket | undefined {
return tickets.find((t) => t.status !== 'resolved');
/** Prirazeni resitele. `null` ticket vrati zpatky do fronty. */
export function assignTicket(id: string, assigneeId: string | null): Ticket | undefined {
const ticket = tickets.find((t) => t.id === id);
if (!ticket) {
console.warn(`[tickets] prirazeni neexistujiciho ticketu: ${id}`);
return undefined;
}
const person = assigneeId ? findPerson(assigneeId) : null;
if (assigneeId && !person) {
console.warn(`[tickets] ${id}: prirazeni na neznameho resitele ${assigneeId}`);
return undefined;
}
ticket.assigneeId = person?.id ?? null;
ticket.updatedAt = new Date().toISOString();
appendTrace(id, [
{
kind: 'note',
label: person ? `Ticket přiřazen: ${person.name}.` : 'Ticket vrácen do fronty.',
status: 'info',
},
]);
publish(
'ticket.assigned',
person
? `Ticket ${ticket.id} přiřazen: ${person.name}`
: `Ticket ${ticket.id} vrácen do fronty`,
{ ticketId: ticket.id, assigneeId: ticket.assigneeId },
);
return toTicket(ticket);
}
/** Komentar je jen dalsi radek logu - at je vsechno na jedne casove ose. */
export function addComment(id: string, author: string, text: string): Ticket | undefined {
const ticket = tickets.find((t) => t.id === id);
if (!ticket) {
console.warn(`[tickets] komentar k neexistujicimu ticketu: ${id}`);
return undefined;
}
ticket.updatedAt = new Date().toISOString();
appendTrace(id, [{ kind: 'note', label: `${author}: ${text}`, status: 'info' }]);
publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id });
return toTicket(ticket);
}
+1
View File
@@ -12,6 +12,7 @@ import { randomUUID } from 'node:crypto';
export type DashboardEventType =
| 'ticket.created'
| 'ticket.updated'
| 'ticket.assigned'
| 'ticket.resolved'
| 'incident.started'
| 'incident.updated'
+312 -4
View File
@@ -24,6 +24,7 @@ export function buildOpenApiDocument() {
{ name: 'Provoz', description: 'Health a zakladni informace' },
{ name: 'Autentizace', description: 'Prihlaseni do portalu' },
{ name: 'Dashboard', description: 'Data klientskeho portalu' },
{ name: 'Tickety', description: 'Pozadavky, jejich resitele a log prubehu' },
{ name: 'Automatizace', description: 'Sprava automatizaci a stromu akci' },
{ name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' },
{ name: 'Webhook', description: 'Verejny prijem dat do automatizace' },
@@ -71,19 +72,130 @@ export function buildOpenApiDocument() {
user: { $ref: '#/components/schemas/User' },
},
},
Person: {
type: 'object',
description: 'Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail.',
properties: {
id: { type: 'string', example: 'ppl_vomacka' },
name: { type: 'string', example: 'Karel Vomacka' },
email: { type: 'string', format: 'email' },
role: { type: 'string', example: 'Servicedesk' },
capacity: {
type: 'integer',
description: 'Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez.',
},
},
},
TicketCustomer: {
type: 'object',
properties: {
id: {
type: 'string',
nullable: true,
description: 'ID firmy v CRM. null = zakaznika se nepodarilo dohledat.',
example: 'crm_1042',
},
company: { type: 'string', example: 'Firma s.r.o.' },
contact: { type: 'string', example: 'Petra Klientova' },
reply: {
type: 'string',
description: 'Adresa nebo cislo, odkud pozadavek prisel a kam se odpovida.',
},
},
},
Ticket: {
type: 'object',
properties: {
id: { type: 'string', example: 'TK-4821' },
subject: { type: 'string' },
requester: { type: 'string' },
channel: {
type: 'string',
enum: ['whatsapp', 'email', 'voice', 'form', 'portal'],
description: 'Odkud pozadavek prisel.',
},
customer: { $ref: '#/components/schemas/TicketCustomer' },
status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] },
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
assignee: { type: 'string', nullable: true },
assignee: {
type: 'object',
nullable: true,
description: 'Kdo ma ticket u sebe. null = ceka ve fronte.',
properties: {
id: { type: 'string', example: 'ppl_vomacka' },
name: { type: 'string', example: 'Karel Vomacka' },
},
},
automationId: {
type: 'string',
nullable: true,
description: 'Automatizace, ktera ticket zalozila. null = zalozeno rucne.',
},
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
TicketTraceEntry: {
type: 'object',
description:
'Jeden radek logu ticketu. Strom se sklada pres parentId - vetev podminky ' +
'visi na zaznamu te podminky.',
properties: {
id: { type: 'string', example: 'tr_12' },
parentId: {
type: 'string',
nullable: true,
description: 'null = zaznam v hlavni sekvenci.',
},
kind: { type: 'string', enum: ['trigger', 'action', 'condition', 'note'] },
connectorId: { type: 'string', nullable: true, example: 'raynet' },
operationId: { type: 'string', nullable: true, example: 'upsert-contact' },
label: { type: 'string' },
status: { type: 'string', enum: ['ok', 'error', 'skipped', 'info'] },
response: {
type: 'string',
nullable: true,
description: 'Co sluzba vratila. Kvuli tomuhle log existuje.',
},
durationMs: { type: 'integer', nullable: true },
at: { type: 'string', format: 'date-time' },
},
},
TicketDetail: {
allOf: [
{ $ref: '#/components/schemas/Ticket' },
{
type: 'object',
properties: {
trace: {
type: 'array',
items: { $ref: '#/components/schemas/TicketTraceEntry' },
},
},
},
],
},
Workload: {
type: 'object',
description: 'Prehled nad firmou - kdo ma kolik ticketu u sebe.',
properties: {
rows: {
type: 'array',
items: {
type: 'object',
properties: {
person: { $ref: '#/components/schemas/Person' },
open: { type: 'integer', description: 'Nevyresene tickety.' },
total: { type: 'integer' },
critical: { type: 'integer' },
oldestOpenAt: { type: 'string', format: 'date-time', nullable: true },
overloaded: { type: 'boolean' },
},
},
},
unassigned: { type: 'integer', description: 'Nevyresene tickety bez resitele.' },
openTotal: { type: 'integer' },
},
},
Incident: {
type: 'object',
properties: {
@@ -288,11 +400,58 @@ export function buildOpenApiDocument() {
responses: { '200': { description: 'Souhrnne metriky a casova rada' } },
},
},
'/api/dashboard/people': {
get: {
tags: ['Tickety'],
summary: 'Seznam resitelu',
description:
'Lide, na ktere jde ticket priradit. `meId` je resitel odpovidajici ' +
'prihlasenemu uzivateli, nebo null, pokud zadny neni.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Resitele',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/Person' } },
meId: { type: 'string', nullable: true },
},
},
},
},
},
},
},
},
'/api/dashboard/tickets': {
get: {
tags: ['Dashboard'],
tags: ['Tickety'],
summary: 'Seznam ticketu',
description: 'Neznama hodnota filtru se ignoruje a zaloguje, seznam se nezuzi.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'assignee',
in: 'query',
schema: { type: 'string' },
description:
'ID resitele, `me` pro prihlaseneho uzivatele, nebo `unassigned` pro frontu.',
example: 'me',
},
{
name: 'status',
in: 'query',
schema: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] },
},
{
name: 'channel',
in: 'query',
schema: { type: 'string', enum: ['whatsapp', 'email', 'voice', 'form', 'portal'] },
},
],
responses: {
'200': {
description: 'Tickety',
@@ -302,6 +461,7 @@ export function buildOpenApiDocument() {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/Ticket' } },
meId: { type: 'string', nullable: true },
},
},
},
@@ -310,6 +470,144 @@ export function buildOpenApiDocument() {
},
},
},
'/api/dashboard/tickets/workload': {
get: {
tags: ['Tickety'],
summary: 'Kdo co ma u sebe',
description: 'Prehled zateze pres cely tym vcetne poctu ticketu ve fronte.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Vytizeni resitelu',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Workload' } },
},
},
},
},
},
'/api/dashboard/tickets/{id}': {
get: {
tags: ['Tickety'],
summary: 'Detail ticketu vcetne logu',
description: 'Log obsahuje i to, co ktera volana sluzba vratila.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
example: 'TK-4821',
},
],
responses: {
'200': {
description: 'Detail',
content: {
'application/json': { schema: { $ref: '#/components/schemas/TicketDetail' } },
},
},
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/assign': {
post: {
tags: ['Tickety'],
summary: 'Priradit resitele',
description: 'Poslete null pro vraceni ticketu do fronty.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['assigneeId'],
properties: {
assigneeId: { type: 'string', nullable: true, example: 'ppl_vomacka' },
},
},
},
},
},
responses: {
'200': {
description: 'Prirazeno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'400': { description: 'Chybi assigneeId' },
'404': { description: 'Ticket nebo resitel neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/status': {
post: {
tags: ['Tickety'],
summary: 'Zmenit stav ticketu',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['status'],
properties: {
status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] },
},
},
},
},
},
responses: {
'200': {
description: 'Zmeneno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'400': { description: 'Neplatny stav' },
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/comment': {
post: {
tags: ['Tickety'],
summary: 'Pridat komentar',
description: 'Komentar je dalsi radek logu, aby bylo vse na jedne casove ose.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['text'],
properties: { text: { type: 'string', minLength: 2 } },
},
},
},
},
responses: {
'200': {
description: 'Zapsano',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'400': { description: 'Prazdny komentar' },
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/incidents': {
get: {
tags: ['Dashboard'],
@@ -502,7 +800,17 @@ export function buildOpenApiDocument() {
],
},
subject: { type: 'string' },
requester: { type: 'string' },
channel: {
type: 'string',
enum: ['whatsapp', 'email', 'voice', 'form', 'portal'],
description: 'Odkud pozadavek prisel. Podle toho se poskladá i log ticketu.',
},
contact: { type: 'string' },
knownCustomer: {
type: 'boolean',
description:
'false = CRM firmu nedohleda, ticket zustane bez zakaznika i bez resitele.',
},
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
ticketId: { type: 'string' },
title: { type: 'string' },
+159 -9
View File
@@ -11,10 +11,26 @@ import {
type FlowStep,
} from '../data/automationStore.js';
import { operatorAllowedForType, operatorsByType } from '../data/conditions.js';
import { connectorCategories, connectors, findOperation } from '../data/connectors.js';
import {
connectorCategories,
connectors,
findOperation,
providedFieldsFor,
} from '../data/connectors.js';
import { listIncidents } from '../data/incidentStore.js';
import { getSummary } from '../data/mock.js';
import { listTickets } from '../data/ticketStore.js';
import { findPersonByEmail, listPeople } from '../data/people.js';
import {
addComment,
assignTicket,
getTicket,
getWorkload,
listTickets,
updateTicketStatus,
type TicketChannel,
type TicketFilter,
type TicketStatus,
} from '../data/ticketStore.js';
import { requireAuth } from '../middleware/auth.js';
import { streamRouter } from './stream.js';
@@ -27,14 +43,132 @@ dashboardRouter.get('/summary', (_req, res) => {
res.json(getSummary());
});
dashboardRouter.get('/tickets', (_req, res) => {
res.json({ items: listTickets() });
});
dashboardRouter.get('/incidents', (_req, res) => {
res.json({ items: listIncidents() });
});
// ------------------------------------------------------------------- tickety
/** Resitele. Klient je potrebuje do nabidky prirazeni i do prehledu firmy. */
dashboardRouter.get('/people', (req, res) => {
const me = req.user ? findPersonByEmail(req.user.email) : undefined;
res.json({ items: listPeople(), meId: me?.id ?? null });
});
const ticketStatuses: TicketStatus[] = ['new', 'open', 'waiting', 'resolved'];
const ticketChannels: TicketChannel[] = ['whatsapp', 'email', 'voice', 'form', 'portal'];
/**
* Filtr ze query parametru. Nesmyslnou hodnotu zahodime a zalogujeme -
* je lepsi ukazat vic ticketu nez prazdny seznam bez vysvetleni.
*/
function ticketFilterFrom(query: Record<string, unknown>, myPersonId: string | null): TicketFilter {
const filter: TicketFilter = {};
const assignee = typeof query.assignee === 'string' ? query.assignee : undefined;
if (assignee === 'me') {
// Prihlaseny uzivatel nemusi byt resitel - pak nema smysl nic vracet.
filter.assignee = myPersonId ?? '__nikdo__';
} else if (assignee) {
filter.assignee = assignee;
}
const status = typeof query.status === 'string' ? query.status : undefined;
if (status) {
if (ticketStatuses.includes(status as TicketStatus)) filter.status = status as TicketStatus;
else console.warn(`[tickets] neznamy stav ve filtru: ${status}`);
}
const channel = typeof query.channel === 'string' ? query.channel : undefined;
if (channel) {
if (ticketChannels.includes(channel as TicketChannel)) filter.channel = channel as TicketChannel;
else console.warn(`[tickets] neznamy kanal ve filtru: ${channel}`);
}
return filter;
}
dashboardRouter.get('/tickets', (req, res) => {
const me = req.user ? findPersonByEmail(req.user.email) : undefined;
const filter = ticketFilterFrom(req.query as Record<string, unknown>, me?.id ?? null);
res.json({ items: listTickets(filter), meId: me?.id ?? null });
});
/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */
dashboardRouter.get('/tickets/workload', (_req, res) => {
res.json(getWorkload(listPeople()));
});
dashboardRouter.get('/tickets/:id', (req, res) => {
const ticket = getTicket(req.params.id);
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
return res.json(ticket);
});
const assignSchema = z.object({
/** null = vratit do fronty. */
assigneeId: z.string().min(1).nullable(),
});
dashboardRouter.post('/tickets/:id/assign', (req, res) => {
const parsed = assignSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: 'Chybí ID řešitele, nebo poslete null pro vrácení do fronty.',
});
}
const ticket = assignTicket(req.params.id, parsed.data.assigneeId);
if (!ticket) {
return res.status(404).json({
error: 'not_found',
message: 'Ticket nebo řešitel neexistuje.',
});
}
return res.json(ticket);
});
const statusSchema = z.object({
status: z.enum(['new', 'open', 'waiting', 'resolved']),
});
dashboardRouter.post('/tickets/:id/status', (req, res) => {
const parsed = statusSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'validation_error', message: 'Neplatný stav ticketu.' });
}
const ticket = updateTicketStatus(req.params.id, parsed.data.status);
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
return res.json(ticket);
});
const commentSchema = z.object({
text: z.string().trim().min(2, 'Komentář musí mít alespoň 2 znaky.'),
});
dashboardRouter.post('/tickets/:id/comment', (req, res) => {
const parsed = commentSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný komentář.',
});
}
const author = req.user?.name ?? 'Portál';
const ticket = addComment(req.params.id, author, parsed.data.text);
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
return res.json(ticket);
});
// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo.
dashboardRouter.use('/stream', streamRouter);
@@ -125,6 +259,20 @@ const updateSchema = z.object({
flow: flowSchema.optional(),
});
/**
* Spoustec, ktery si data urcuje sam (e-mail, WhatsApp, ticket), nesmi mit
* parametry od klienta. Dosadime katalogovou verzi, a to jeste PRED validaci -
* jinak by podminky odkazujici na katalogova ID vypadaly jako rozbite.
*/
function normalizeTriggerFields(flow: z.infer<typeof flowSchema>): z.infer<typeof flowSchema> {
if (!flow.trigger) return flow;
const provided = providedFieldsFor(flow.trigger.connectorId, flow.trigger.operationId);
if (!provided) return flow;
return { ...flow, trigger: { ...flow.trigger, fields: provided.map((field) => ({ ...field })) } };
}
/**
* Overi, ze kazdy krok odkazuje na existujici konektor a operaci.
* Vraci seznam problemu - prazdny znamena, ze je strom v poradku.
@@ -212,8 +360,10 @@ dashboardRouter.put('/automations/:id', (req, res) => {
});
}
if (parsed.data.flow) {
const problems = validateFlowReferences(parsed.data.flow);
const flow = parsed.data.flow ? normalizeTriggerFields(parsed.data.flow) : undefined;
if (flow) {
const problems = validateFlowReferences(flow);
if (problems.length > 0) {
console.warn(`[automations] ${req.params.id}: neplatny strom - ${problems.join(' ')}`);
return res.status(400).json({
@@ -224,7 +374,7 @@ dashboardRouter.put('/automations/:id', (req, res) => {
}
}
const updated = updateAutomation(req.params.id, parsed.data);
const updated = updateAutomation(req.params.id, { ...parsed.data, flow });
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
}
+252 -11
View File
@@ -6,7 +6,14 @@ import {
firstActiveIncident,
updateIncidentStatus,
} from '../data/incidentStore.js';
import { createTicket, firstOpenTicket, updateTicketStatus } from '../data/ticketStore.js';
import {
createTicket,
firstOpenTicket,
updateTicketStatus,
type TicketChannel,
type TicketPriority,
type TraceInput,
} from '../data/ticketStore.js';
import { requireAuth } from '../middleware/auth.js';
export const simulateRouter = Router();
@@ -22,9 +29,13 @@ simulateRouter.use(requireAuth);
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(),
subject: z.string().trim().min(3).optional(),
requester: z.string().trim().min(2).optional(),
contact: z.string().trim().min(2).optional(),
priority: z.enum(['low', 'normal', 'high', 'critical']).optional(),
/** false = CRM firmu nedohleda, ticket zustane bez zakaznika a bez resitele. */
knownCustomer: z.boolean().optional(),
}),
z.object({
action: z.literal('ticket.resolved'),
@@ -47,14 +58,227 @@ const schema = z.discriminatedUnion('action', [
}),
]);
const defaultSubjects = [
'Nefunguje export objednávek do skladu',
'Voicebot nerozumí názvu ulice',
'Faktura se nespárovala s platbou',
'Chybí notifikace o nové poptávce',
'Zákazník žádá změnu fakturačních údajů',
/**
* Ukazkovy prichod pozadavku podle kanalu. Kanal urcuje nejen text,
* ale i to, jak vypada zaznam spoustece v logu ticketu.
*/
const channelPresets: Record<
TicketChannel,
{
connectorId: string;
operationId: string;
triggerLabel: string;
subjects: string[];
contact: string;
reply: string;
/** Co spoustec vratil. `%s` se nahradi predmetem. */
payload: (contact: string, reply: string, subject: string) => string;
}
> = {
whatsapp: {
connectorId: 'whatsapp',
operationId: 'message-received',
triggerLabel: 'Přijata zpráva z WhatsApp',
subjects: [
'Nechodí ranní report objednávek',
'Potřebujeme přidat další číslo na hlášení',
'Zákazník si stěžuje na dvojí SMS',
],
contact: 'Bistro Kolektiv',
reply: '+420 774 902 331',
payload: (contact, reply, subject) =>
`{ "from": "${reply.replace(/\s/g, '')}", "profileName": "${contact}", "text": "${subject}" }`,
},
email: {
connectorId: 'email',
operationId: 'received',
triggerLabel: 'Přijat e-mail do schránky podpora@',
subjects: [
'Faktura se nespárovala s platbou',
'Žádost o změnu fakturačních údajů',
'Export do skladu skončil chybou',
],
contact: 'Tomáš Beran',
reply: 'tomas.beran@nordis.cz',
payload: (_contact, reply, subject) =>
`{ "from": "${reply}", "subject": "${subject}", "attachments": 1 }`,
},
voice: {
connectorId: 'voicebot',
operationId: 'call-received',
triggerLabel: 'Příchozí hovor na linku 800 100 200',
subjects: [
'Voicebot nerozumí názvu ulice',
'Hovory se po 18:00 nepřepojují',
'Zákazník žádá zpětné volání',
],
contact: 'Petra Klientová',
reply: '+420 601 118 224',
payload: (_contact, reply, _subject) =>
`{ "callId": "cl_${Math.floor(Math.random() * 90_000) + 10_000}", "from": "${reply.replace(/\s/g, '')}", "durationSec": 74 }`,
},
form: {
connectorId: 'form',
operationId: 'submitted',
triggerLabel: 'Odeslán formulář Požadavek na úpravu',
subjects: [
'Rozšíření hlasového scénáře',
'Napojení dalšího e-shopu',
'Nový report pro management',
],
contact: 'Jana Sedláčková',
reply: 'jana.sedlackova@logitrans.cz',
payload: (contact, _reply, subject) =>
`{ "company": "LogiTrans", "contact": "${contact}", "topic": "${subject}" }`,
},
portal: {
connectorId: 'ticket',
operationId: 'created',
triggerLabel: 'Založeno ručně z portálu',
subjects: [
'Interní kontrola nastavení zálohování',
'Revize oprávnění v CRM',
'Aktualizace hlasového scénáře',
],
contact: 'Interní požadavek',
reply: 'portal@automia.cz',
payload: (contact, _reply, subject) => `{ "createdBy": "${contact}", "subject": "${subject}" }`,
},
};
/** Firmy, ktere CRM zna. Simulace z nich bere zakaznika, kdyz ma byt dohledany. */
const knownCompanies = [
{ id: 'crm_1042', company: 'Firma s.r.o.' },
{ id: 'crm_2210', company: 'Nordis a.s.' },
{ id: 'crm_3390', company: 'LogiTrans' },
];
/**
* Resitel podle kanalu. Zamerne primitivni pravidlo - jde o to ukazat,
* ze ticket nekonci na hromade, ale u konkretniho cloveka.
*/
const routingRule: Record<TicketChannel, { id: string; name: string; why: string }> = {
voice: { id: 'ppl_novakova', name: 'Eva Nováková', why: 'voice -> voiceboti' },
email: { id: 'ppl_kriz', name: 'Martin Kříž', why: 'email -> integrace' },
whatsapp: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'whatsapp -> servicedesk' },
form: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'form -> servicedesk' },
portal: { id: 'ppl_uhlir', name: 'Jiří Uhlíř', why: 'portal -> vedoucí týmu' },
};
/**
* Log prichodu pozadavku. Presne tohle chce clovek videt, kdyz se pta
* "proc ten ticket vypada takhle a co na to rekly jednotlive sluzby".
*/
function buildIntakeTrace(input: {
channel: TicketChannel;
subject: string;
contact: string;
reply: string;
priority: TicketPriority;
known: (typeof knownCompanies)[number] | null;
}): TraceInput[] {
const preset = channelPresets[input.channel];
const routing = routingRule[input.channel];
const trace: TraceInput[] = [
{
kind: 'trigger',
connectorId: preset.connectorId,
operationId: preset.operationId,
label: preset.triggerLabel,
status: 'ok',
response: preset.payload(input.contact, input.reply, input.subject),
durationMs: 60,
},
{
kind: 'action',
connectorId: 'ai-text',
operationId: 'classify',
label: 'Zařazení do kategorie',
status: 'ok',
response: `{ "category": "pozadavek", "priority": "${input.priority}", "confidence": 0.83 }`,
durationMs: 880,
},
{
kind: 'action',
connectorId: 'raynet',
operationId: 'upsert-contact',
label: 'Dohledání firmy v CRM',
status: input.known ? 'ok' : 'error',
response: input.known
? `{ "companyId": "${input.known.id}", "name": "${input.known.company}" }`
: `{ "matches": 0, "searchedBy": "reply", "value": "${input.reply}" }`,
durationMs: 590,
},
];
if (input.known) {
trace.push({
kind: 'condition',
label: 'knownCustomer je splněno',
status: 'ok',
response: 'true, pokračuje větev ANO',
children: [
{
kind: 'action',
connectorId: 'ticket',
operationId: 'create',
label: 'Založení ticketu',
status: 'ok',
response: `{ "customerId": "${input.known.id}", "priority": "${input.priority}" }`,
durationMs: 70,
},
{
kind: 'action',
connectorId: 'ticket',
operationId: 'assign',
label: 'Přiřazení řešitele podle kanálu',
status: 'ok',
response: `{ "assignee": "${routing.name}", "rule": "${routing.why}" }`,
durationMs: 35,
},
],
});
return trace;
}
trace.push({
kind: 'condition',
label: 'knownCustomer není splněno',
status: 'skipped',
response: 'false, pokračuje větev NE',
children: [
{
kind: 'action',
connectorId: 'ticket',
operationId: 'create',
label: 'Založení ticketu bez napojení na firmu',
status: 'ok',
response: `{ "customerId": null, "priority": "${input.priority}" }`,
durationMs: 68,
},
{
kind: 'action',
connectorId: 'raynet',
operationId: 'create-lead',
label: 'Založení obchodního případu k dohledání',
status: 'ok',
response: `{ "leadId": "lead_${Math.floor(Math.random() * 9_000) + 1_000}", "stage": "k overeni" }`,
durationMs: 910,
},
{
kind: 'action',
connectorId: 'ticket',
operationId: 'assign',
label: 'Přiřazení řešitele',
status: 'skipped',
response: 'Přeskočeno, bez známé firmy nelze určit garanta a ticket zůstal ve frontě',
},
],
});
return trace;
}
const defaultIncidents = [
{ title: 'Výpadek spojení s fakturačním API', service: 'Integrace / iDoklad' },
{ title: 'Zpoždění doručování webhooků', service: 'Webhook Router' },
@@ -80,10 +304,27 @@ simulateRouter.post('/', (req, res) => {
switch (input.action) {
case 'ticket.created': {
const channel = input.channel ?? 'whatsapp';
const preset = channelPresets[channel];
const priority = input.priority ?? 'normal';
const subject = input.subject ?? pick(preset.subjects);
const contact = input.contact ?? preset.contact;
// Vychozi chovani je "zakaznika zname" - nezname je zajimavejsi, ale vzacnejsi.
const known = (input.knownCustomer ?? true) ? pick(knownCompanies) : null;
const ticket = createTicket({
subject: input.subject ?? pick(defaultSubjects),
requester: input.requester ?? 'Firma s.r.o.',
priority: input.priority ?? 'normal',
subject,
channel,
priority,
customer: {
id: known?.id ?? null,
company: known?.company ?? 'Neznámá firma',
contact,
reply: preset.reply,
},
// Bez znameho zakaznika neni podle ceho resitele urcit, ticket zustane ve fronte.
assigneeId: known ? routingRule[channel].id : null,
trace: buildIntakeTrace({ channel, subject, contact, reply: preset.reply, priority, known }),
});
return res.status(201).json({ ok: true, ticket });
}