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
+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 });
}