Runtime vykonava strom, prokliky z widgetu, oprava ukladani rozlozeni
Runtime: `src/runtime/executor.ts` jde krok po kroku, u podminky se vetvi, do poli dosadi parametry, akci pusti pres runScript a vystupy pripise do kontextu pro dalsi krok. Cely prubeh jde do logu ticketu vcetne toho, co sluzba vratila. Pouzivaji ho obe cesty: akce na ticketu i webhook. Opraveno: rozlozeni dashboardu s vlastnim widgetem se NEDALO ULOZIT. `validateLayout` znala jen vestaveny katalog, takze kazdy pokus skoncil hlaskou "widget v katalogu neexistuje" - presne to, co hlasil uzivatel. Katalog je ted jedna funkce a pouziva ji nabidka i kontrola. Zaroven je za konkretni firmu, driv slo polozit dlazdici jedne firmy na dashboard druhe. Prokliky: z widgetu lidi na cloveka, ze seskupeni na vyfiltrovany seznam ticketu. Odkazy sklada server, protoze on jediny zna filtr widgetu. Seznam ticketu cte filtr z adresy a umi filtrovat na typ, tag a skupinu. Tabulky: spolecna `TicketTable` pro seznam i detail osoby. Na mobilu se neposouva do strany, uzka obrazovka dostane karty. Detail osoby ma velkou tabulku se zalozkami "ma u sebe" a "vyresil" a prepinacem pohledu. Odebrano: simulace vcetne tlacitka, dialogu i endpointu. Trojice pohledu nad tickety - vyber firmy je select, "moje" je prepinac, driv to delalo totez dvakrat. Pridan zmereny rozbor kapacity pro 200 firem (19-kapacita-200-firem.md): soucasny stav to nezvladne, protoze data jsou v pameti a vypis je linearni. Zmereno na 5 000 ticketech, vcetne toho, co s tim a kolik serveru to chce. Overeno 7 kontrolami proti bezicimu serveru. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
29de584df8
commit
5d186dcd2e
+21
-19
@@ -39,8 +39,7 @@ import {
|
||||
validateLayout,
|
||||
} from '../data/dashboardLayouts.js';
|
||||
import { collectScopes } from '../data/flowScope.js';
|
||||
import { widgets } from '../data/widgets.js';
|
||||
import { listCustomWidgets, sizesFor } from '../data/customWidgets.js';
|
||||
import { widgetCatalog } from '../data/widgets.js';
|
||||
import { listIncidents } from '../data/incidentStore.js';
|
||||
import { getSummary } from '../data/mock.js';
|
||||
import { findPersonByEmail, listGroups, listPeople } from '../data/people.js';
|
||||
@@ -125,23 +124,15 @@ dashboardRouter.get('/incidents', (_req, res) => {
|
||||
* Diky tomu se rozlozeni dashboardu nemuselo menit.
|
||||
*/
|
||||
dashboardRouter.get('/widgets', (req, res) => {
|
||||
const access = accessFor(req.user!);
|
||||
const tenantIds = access.tenants.map((tenant) => tenant.id);
|
||||
/*
|
||||
* Katalog je za konkretni firmu, stejne jako rozlozeni. Kdyby vracel widgety
|
||||
* vsech firem uzivatele, sla by polozit dlazdice Automie na dashboard
|
||||
* Nordisu - v nabidce by byla, ale data by k ni nikdy neprisla.
|
||||
*/
|
||||
const tenantId = layoutTenant(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const custom = listCustomWidgets(tenantIds, req.user!.id).map((widget) => ({
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
description: widget.description,
|
||||
kind: widget.render,
|
||||
sizes: sizesFor(widget.render),
|
||||
defaultSize: widget.size,
|
||||
custom: true,
|
||||
render: widget.render,
|
||||
source: widget.source,
|
||||
...(widget.target !== undefined ? { target: widget.target } : {}),
|
||||
}));
|
||||
|
||||
res.json({ items: [...widgets.map((widget) => ({ ...widget, custom: false })), ...custom] });
|
||||
return res.json({ items: widgetCatalog([tenantId], req.user!.id) });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -198,7 +189,10 @@ dashboardRouter.put('/layout', (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const problems = validateLayout(parsed.data.items);
|
||||
const problems = validateLayout(
|
||||
parsed.data.items,
|
||||
widgetCatalog([tenantId], req.user!.id),
|
||||
);
|
||||
if (problems.length > 0) {
|
||||
console.warn(`[layout] ${req.user!.email}: neplatne rozlozeni - ${problems.join(' ')}`);
|
||||
return res.status(400).json({ error: 'validation_error', message: problems[0] });
|
||||
@@ -384,6 +378,14 @@ const ticketChannels: TicketChannel[] = [
|
||||
function ticketFilterFrom(query: Record<string, unknown>, scope: ResolvedScope): TicketFilter {
|
||||
const filter: TicketFilter = { tenantIds: scope.tenantIds };
|
||||
|
||||
// Filtry, na ktere se odkazuje z widgetu. `none` znamena "bez toho".
|
||||
const typeId = query.typeId;
|
||||
if (typeof typeId === 'string' && typeId !== '') filter.typeId = typeId;
|
||||
const tag = query.tag;
|
||||
if (typeof tag === 'string' && tag !== '') filter.tag = tag;
|
||||
const groupId = query.groupId;
|
||||
if (typeof groupId === 'string' && groupId !== '') filter.groupId = groupId;
|
||||
|
||||
// Pohled "moje" je silnejsi nez rucni filtr na resitele.
|
||||
if (scope.scope === 'mine') {
|
||||
filter.assignee = scope.personId ?? '__nikdo__';
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { listAutomations, recordRun } from '../data/automationStore.js';
|
||||
import {
|
||||
createIncident,
|
||||
firstActiveIncident,
|
||||
updateIncidentStatus,
|
||||
} from '../data/incidentStore.js';
|
||||
import {
|
||||
createTicket,
|
||||
firstOpenTicket,
|
||||
updateTicketStatus,
|
||||
type TicketChannel,
|
||||
type TicketPriority,
|
||||
type TraceInput,
|
||||
} from '../data/ticketStore.js';
|
||||
import { accessFor } from '../data/access.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const simulateRouter = Router();
|
||||
|
||||
simulateRouter.use(requireAuth);
|
||||
|
||||
/**
|
||||
* Simulace provoznich udalosti pro nahled zivého dashboardu.
|
||||
*
|
||||
* Zamerne MENI skutecna data v ulozisti, ne jen posila falesnou notifikaci.
|
||||
* Diky tomu se zmena projevi i v seznamech a v souhrnu, ne jen v bublinach.
|
||||
*/
|
||||
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', '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. */
|
||||
knownCustomer: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('ticket.resolved'),
|
||||
ticketId: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('incident.started'),
|
||||
title: z.string().trim().min(3).optional(),
|
||||
service: z.string().trim().min(2).optional(),
|
||||
severity: z.enum(['sev1', 'sev2', 'sev3']).optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('incident.resolved'),
|
||||
incidentId: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('automation.run'),
|
||||
automationId: z.string().optional(),
|
||||
ok: z.boolean().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Ukazkovy prichod pozadavku podle kanalu. Kanal urcuje nejen text,
|
||||
* ale i to, jak vypada zaznam spoustece v logu ticketu.
|
||||
*/
|
||||
const channelPresets: Record<
|
||||
TicketChannel,
|
||||
{
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
triggerLabel: string;
|
||||
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;
|
||||
}
|
||||
> = {
|
||||
whatsapp: {
|
||||
serviceId: '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',
|
||||
sourceRef: 'wamid.HBgLNDIwNzc0OTAyMzMx',
|
||||
payload: (contact, reply, subject) =>
|
||||
`{ "from": "${reply.replace(/\s/g, '')}", "profileName": "${contact}", "text": "${subject}" }`,
|
||||
},
|
||||
facebook: {
|
||||
serviceId: '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: {
|
||||
serviceId: '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: {
|
||||
serviceId: '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',
|
||||
sourceRef: '<sim-4f8a@nordis.cz>',
|
||||
payload: (_contact, reply, subject) =>
|
||||
`{ "from": "${reply}", "subject": "${subject}", "attachments": 1 }`,
|
||||
},
|
||||
voice: {
|
||||
serviceId: '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',
|
||||
sourceRef: 'cl_simulace',
|
||||
payload: (_contact, reply, _subject) =>
|
||||
`{ "callId": "cl_${Math.floor(Math.random() * 90_000) + 10_000}", "from": "${reply.replace(/\s/g, '')}", "durationSec": 74 }`,
|
||||
},
|
||||
form: {
|
||||
serviceId: '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',
|
||||
sourceRef: null,
|
||||
payload: (contact, _reply, subject) =>
|
||||
`{ "company": "LogiTrans", "contact": "${contact}", "topic": "${subject}" }`,
|
||||
},
|
||||
portal: {
|
||||
serviceId: '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',
|
||||
sourceRef: null,
|
||||
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' },
|
||||
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' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 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',
|
||||
serviceId: preset.serviceId,
|
||||
operationId: preset.operationId,
|
||||
label: preset.triggerLabel,
|
||||
status: 'ok',
|
||||
response: preset.payload(input.contact, input.reply, input.subject),
|
||||
durationMs: 60,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: 'ai-text',
|
||||
operationId: 'classify',
|
||||
label: 'Zařazení do kategorie',
|
||||
status: 'ok',
|
||||
response: `{ "category": "pozadavek", "priority": "${input.priority}", "confidence": 0.83 }`,
|
||||
durationMs: 880,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: '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',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
response: `{ "customerId": "${input.known.id}", "priority": "${input.priority}" }`,
|
||||
durationMs: 70,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: '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',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu bez napojení na firmu',
|
||||
status: 'ok',
|
||||
response: `{ "customerId": null, "priority": "${input.priority}" }`,
|
||||
durationMs: 68,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: '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',
|
||||
serviceId: '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' },
|
||||
{ title: 'Hlasová brána odmítá hovory', service: 'Voicebot Gateway' },
|
||||
];
|
||||
|
||||
/** Nahodny prvek - jen pro rozmanitost ukazkovych dat. */
|
||||
function pick<T>(items: T[]): T {
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
simulateRouter.post('/', (req, res) => {
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup simulace.',
|
||||
});
|
||||
}
|
||||
|
||||
// Simulace pisi do dat, takze musi vedet, ci. Bez firmy nema kam.
|
||||
const access = accessFor(req.user!);
|
||||
const tenantId = access.defaultTenantId;
|
||||
if (!tenantId) {
|
||||
return res.status(403).json({
|
||||
error: 'no_tenant',
|
||||
message: 'Účet nepatří do žádné firmy, simulace nemá kam zapsat.',
|
||||
});
|
||||
}
|
||||
const tenantIds = access.tenants.map((tenant) => tenant.id);
|
||||
|
||||
const input = parsed.data;
|
||||
console.info(`[simulace] ${input.action} spustil ${req.user?.email} pro ${tenantId}`);
|
||||
|
||||
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({
|
||||
tenantId,
|
||||
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: {
|
||||
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 });
|
||||
}
|
||||
|
||||
case 'ticket.resolved': {
|
||||
const target = input.ticketId ? { id: input.ticketId } : firstOpenTicket(tenantIds);
|
||||
if (!target) {
|
||||
return res.status(409).json({
|
||||
error: 'nothing_to_resolve',
|
||||
message: 'Není co vyřešit, všechny tickety jsou hotové.',
|
||||
});
|
||||
}
|
||||
const ticket = updateTicketStatus(target.id, 'resolved', tenantIds);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
return res.json({ ok: true, ticket });
|
||||
}
|
||||
|
||||
case 'incident.started': {
|
||||
const preset = pick(defaultIncidents);
|
||||
const incident = createIncident({
|
||||
title: input.title ?? preset.title,
|
||||
service: input.service ?? preset.service,
|
||||
severity: input.severity ?? 'sev2',
|
||||
});
|
||||
return res.status(201).json({ ok: true, incident });
|
||||
}
|
||||
|
||||
case 'incident.resolved': {
|
||||
const target = input.incidentId ? { id: input.incidentId } : firstActiveIncident();
|
||||
if (!target) {
|
||||
return res.status(409).json({
|
||||
error: 'nothing_to_resolve',
|
||||
message: 'Není co vyřešit, žádný incident neběží.',
|
||||
});
|
||||
}
|
||||
const incident = updateIncidentStatus(target.id, 'resolved');
|
||||
if (!incident) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' });
|
||||
}
|
||||
return res.json({ ok: true, incident });
|
||||
}
|
||||
|
||||
case 'automation.run': {
|
||||
const automations = listAutomations(tenantIds).filter((a) => a.enabled);
|
||||
const targetId = input.automationId ?? automations[0]?.id;
|
||||
if (!targetId) {
|
||||
return res.status(409).json({
|
||||
error: 'no_automation',
|
||||
message: 'Není co spustit, žádná automatizace není aktivní.',
|
||||
});
|
||||
}
|
||||
const automation = recordRun(targetId, input.ok ?? true, tenantIds);
|
||||
if (!automation) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
return res.json({ ok: true, automation });
|
||||
}
|
||||
|
||||
default: {
|
||||
// Vetve jsou vycerpane, tohle je jen pojistka pri rozsireni schematu.
|
||||
console.error('[simulace] neosetrena akce:', input);
|
||||
return res.status(400).json({ error: 'unknown_action', message: 'Neznámá akce.' });
|
||||
}
|
||||
}
|
||||
});
|
||||
+38
-10
@@ -37,6 +37,7 @@ import {
|
||||
import { findTicketType, validateTicketFields } from '../data/ticketTypes.js';
|
||||
import { runScript } from '../scripts/runner.js';
|
||||
import { scriptIdFor } from '../scripts/lookup.js';
|
||||
import { runFlow } from '../runtime/executor.js';
|
||||
|
||||
export const ticketActionsRouter = Router();
|
||||
|
||||
@@ -235,9 +236,9 @@ interface ActionResult {
|
||||
/**
|
||||
* Vykonani tela akce.
|
||||
*
|
||||
* Zatim synchronne v requestu. Zamerne: rucni akce nemusi cekat na frontu
|
||||
* a `runScript` je uz hotovy vstupni bod na kroku. Az bude runtime, zavola
|
||||
* se odsud totez z workeru.
|
||||
* Synchronne v requestu. Zamerne: rucni akci clovek odklikl a ceka na vysledek,
|
||||
* fronta by mu ho jen oddalila. Az bude runtime na vic strojich, zavola se
|
||||
* odsud totez z workeru - `runFlow` je stejny vstupni bod.
|
||||
*/
|
||||
async function runAction(
|
||||
action: TicketAction,
|
||||
@@ -250,14 +251,41 @@ async function runAction(
|
||||
const body = action.body;
|
||||
|
||||
if (body.kind === 'tree') {
|
||||
// Strom akce potrebuje runtime, ktery zatim neexistuje. Rekne se to nahlas,
|
||||
// misto aby se tvarilo, ze se neco stalo.
|
||||
if (body.steps.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: 'akce nemá žádné kroky',
|
||||
detail: 'Strom je prázdný. Doplňte kroky v definici akce.',
|
||||
durationMs: Date.now() - startedAt,
|
||||
outputs: {},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Kontext stromu: udaje ticketu a hodnoty z doptavaciho dialogu. Na obojí
|
||||
* jde v krocích odkazovat pres `{{jmeno}}`, stejne jako u jedne operace.
|
||||
*/
|
||||
const context: Record<string, unknown> = { ...facts, ...form };
|
||||
const run = await runFlow(body.steps, context, {
|
||||
tenantId: ticket.tenantId,
|
||||
ticketId: ticket.id,
|
||||
idempotencyKey: `ticket:${ticket.id}:${action.id}`,
|
||||
});
|
||||
|
||||
if (!run.ok) {
|
||||
console.warn(`[actions] ${action.id} na ${ticket.id} od ${actorEmail}: ${run.error}`);
|
||||
}
|
||||
|
||||
const failed = run.steps.find((step) => !step.ok);
|
||||
return {
|
||||
ok: false,
|
||||
summary: 'strom akce zatím nejde vykonat, chybí runtime',
|
||||
detail: 'Akce s vlastním stromem půjde spustit až s runtime automatizací.',
|
||||
durationMs: Date.now() - startedAt,
|
||||
outputs: {},
|
||||
ok: run.ok,
|
||||
summary: run.ok
|
||||
? `proběhlo, ${run.steps.length} ${run.steps.length === 1 ? 'krok' : 'kroků'}`
|
||||
: (run.error ?? 'selhalo'),
|
||||
// Cele hlaseni toho kroku, ktery to zastavil. Zbytek je v logu ticketu.
|
||||
detail: failed?.detail ?? null,
|
||||
durationMs: run.durationMs,
|
||||
outputs: run.context as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+50
-8
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import { findByWebhookToken, recordRun, type TriggerField } from '../data/automationStore.js';
|
||||
import type { FieldType } from '../data/conditions.js';
|
||||
import { findByIntakeToken } from '../data/tenants.js';
|
||||
import { runFlow } from '../runtime/executor.js';
|
||||
import { intakeEvent } from '../data/ticketStore.js';
|
||||
import { findTicketType, listTicketTypes } from '../data/ticketTypes.js';
|
||||
import { publish } from '../events/bus.js';
|
||||
@@ -191,18 +192,59 @@ webhookRouter.post('/:token', (req, res) => {
|
||||
automationId: automation.id,
|
||||
fields: Object.keys(payload),
|
||||
});
|
||||
recordRun(automation.id);
|
||||
console.info(
|
||||
`[webhook] ${automation.id}: prijato (${Object.keys(payload).join(', ') || 'bez dat'})`,
|
||||
);
|
||||
|
||||
// PROTOTYP: strom se nevykonava, jen potvrdime prijem.
|
||||
// Runtime je popsany v docs/08-automatizace-builder.md.
|
||||
return res.status(202).json({
|
||||
accepted: true,
|
||||
automationId: automation.id,
|
||||
message: 'Požadavek přijat. Prototyp strom akcí nevykonává.',
|
||||
});
|
||||
if (automation.flow.steps.length === 0) {
|
||||
recordRun(automation.id);
|
||||
return res.status(202).json({
|
||||
accepted: true,
|
||||
automationId: automation.id,
|
||||
message: 'Přijato. Automatizace nemá žádné kroky.',
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Strom se vykona hned v requestu. Odesilatel tim dostane skutecny vysledek
|
||||
* misto "prijato" - u desitek kroku je to v poradku, u tisicu udalosti za
|
||||
* minutu to patri do fronty, viz documentation/10-runtime-a-kapacita.md.
|
||||
*/
|
||||
return void runFlow(automation.flow.steps, payload, {
|
||||
tenantId: automation.tenantId,
|
||||
ticketId: null,
|
||||
idempotencyKey: `webhook:${automation.id}:${token.slice(0, 8)}`,
|
||||
})
|
||||
.then((run) => {
|
||||
recordRun(automation.id, run.ok);
|
||||
const failed = run.steps.find((step) => !step.ok);
|
||||
|
||||
if (!run.ok) {
|
||||
console.warn(`[webhook] ${automation.id}: beh selhal - ${run.error}`);
|
||||
}
|
||||
|
||||
// Chyba stromu neni chyba prijmu. Data jsme prevzali, jen se nepovedlo
|
||||
// je zpracovat - proto 200 s `ok: false`, ne 500.
|
||||
return res.status(200).json({
|
||||
accepted: true,
|
||||
automationId: automation.id,
|
||||
ok: run.ok,
|
||||
steps: run.steps.length,
|
||||
durationMs: run.durationMs,
|
||||
error: run.error,
|
||||
// Cele hlaseni toho kroku, ktery to zastavil.
|
||||
detail: failed?.detail ?? null,
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// `runFlow` chyby nevyhazuje, tohle je posledni pojistka.
|
||||
console.error(`[webhook] ${automation.id}: neocekavana chyba behu:`, err);
|
||||
recordRun(automation.id, false);
|
||||
return res.status(500).json({
|
||||
error: 'internal_error',
|
||||
message: err instanceof Error ? err.message : 'Běh selhal.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Overi prichozi data proti deklarovanym parametrum spoustece. */
|
||||
|
||||
@@ -55,13 +55,30 @@ const requestSchema = z.object({
|
||||
widgetIds: z.array(z.string().min(1)).max(24),
|
||||
});
|
||||
|
||||
/** Jedna hodnota, nebo seskupeny vysledek. Klient podle `render` vi, co s tim. */
|
||||
/**
|
||||
* Jedna hodnota, nebo seskupeny vysledek. Klient podle `render` vi, co s tim.
|
||||
*
|
||||
* U cisel a radku chodi `href`: kam se proklikne ten, kdo chce videt, co je
|
||||
* za tim. Sklada ho **server**, protoze on jediny zna filtr widgetu - klient
|
||||
* by si musel odvozovat, co ktery zdroj znamena, a jednou by se to rozeslo.
|
||||
*/
|
||||
type WidgetValue =
|
||||
| { kind: 'number'; value: number }
|
||||
| { kind: 'groups'; rows: Array<{ key: string; label: string; value: number }> }
|
||||
| { kind: 'series'; points: Array<{ date: string; value: number }> }
|
||||
| { kind: 'tickets'; items: Array<{ id: string; subject: string; status: string; assignee: string | null }> }
|
||||
| { kind: 'workload'; rows: Array<{ name: string; open: number; overCapacity: boolean }> }
|
||||
| { kind: 'number'; value: number; href?: string }
|
||||
| {
|
||||
kind: 'groups';
|
||||
rows: Array<{ key: string; label: string; value: number; href?: string }>;
|
||||
}
|
||||
| { kind: 'series'; points: Array<{ date: string; value: number }>; href?: string }
|
||||
| {
|
||||
kind: 'tickets';
|
||||
items: Array<{ id: string; subject: string; status: string; assignee: string | null }>;
|
||||
href?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'workload';
|
||||
rows: Array<{ personId: string; name: string; open: number; overCapacity: boolean; href: string }>;
|
||||
href?: string;
|
||||
}
|
||||
| { kind: 'agents'; rows: AgentStatsRow[] }
|
||||
/** Vysledek z konektoru. `stale` rika, ze je to z cache, ne cerstve zavolane. */
|
||||
| {
|
||||
@@ -303,6 +320,48 @@ function toExternal(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresa seznamu ticketu s dosazenym filtrem.
|
||||
*
|
||||
* Klic seskupeni se prevadi na parametr, ktery seznam umi. Prazdny klic
|
||||
* znamena "bez toho", proto `none` - prazdna hodnota v adrese by se cetla
|
||||
* jako "nefiltrovat".
|
||||
*/
|
||||
function ticketsHref(
|
||||
filter: WidgetTicketFilter,
|
||||
scope: ResolvedScope,
|
||||
extra?: { groupBy?: WidgetGroupBy; key?: string },
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
const tenantId = scope.tenantIds.length === 1 ? scope.tenantIds[0] : null;
|
||||
if (tenantId) params.set('tenantId', tenantId);
|
||||
|
||||
if (filter.status?.length === 1) params.set('status', filter.status[0]);
|
||||
if (filter.channel?.length === 1) params.set('channel', filter.channel[0]);
|
||||
if (filter.typeIds?.length === 1) params.set('typeId', filter.typeIds[0]);
|
||||
if (filter.tags?.length === 1) params.set('tag', filter.tags[0]);
|
||||
if (filter.assignee?.length === 1) params.set('assignee', filter.assignee[0]);
|
||||
if (filter.groupIds?.length === 1) params.set('groupId', filter.groupIds[0]);
|
||||
|
||||
if (extra?.groupBy) {
|
||||
const key = extra.key === '' || extra.key === undefined ? 'none' : extra.key;
|
||||
const byGroup: Record<WidgetGroupBy, string> = {
|
||||
assignee: 'assignee',
|
||||
group: 'groupId',
|
||||
status: 'status',
|
||||
type: 'typeId',
|
||||
tag: 'tag',
|
||||
channel: 'channel',
|
||||
};
|
||||
// Prazdna skupina u resitele je fronta, na to ma seznam vlastni hodnotu.
|
||||
if (extra.groupBy === 'assignee' && key === 'none') params.set('assignee', 'unassigned');
|
||||
else params.set(byGroup[extra.groupBy], key);
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
return query ? `/dashboard/tickety?${query}` : '/dashboard/tickety';
|
||||
}
|
||||
|
||||
function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue {
|
||||
if (source.kind === 'agentStats') {
|
||||
return {
|
||||
@@ -316,10 +375,14 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
|
||||
return {
|
||||
kind: 'workload',
|
||||
rows: workload.rows.map((row) => ({
|
||||
personId: row.person.id,
|
||||
name: row.person.name,
|
||||
open: row.open,
|
||||
overCapacity: row.overloaded,
|
||||
// Z vytizeni se chce clovek dostat na toho cloveka, ne na cisla.
|
||||
href: `/dashboard/lide/${row.person.id}`,
|
||||
})),
|
||||
href: '/dashboard/lide',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,7 +397,13 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
|
||||
);
|
||||
|
||||
if (source.kind === 'ticketCount') {
|
||||
if (!source.groupBy) return { kind: 'number', value: tickets.length };
|
||||
if (!source.groupBy) {
|
||||
return {
|
||||
kind: 'number',
|
||||
value: tickets.length,
|
||||
href: ticketsHref(source.filter, scope),
|
||||
};
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const ticket of tickets) {
|
||||
@@ -345,7 +414,12 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
|
||||
return {
|
||||
kind: 'groups',
|
||||
rows: [...counts.entries()]
|
||||
.map(([key, value]) => ({ key, label: groupLabel(key, source.groupBy!), value }))
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: groupLabel(key, source.groupBy!),
|
||||
value,
|
||||
href: ticketsHref(source.filter, scope, { groupBy: source.groupBy, key }),
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value),
|
||||
};
|
||||
}
|
||||
@@ -353,6 +427,7 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
|
||||
if (source.kind === 'ticketList') {
|
||||
return {
|
||||
kind: 'tickets',
|
||||
href: ticketsHref(source.filter, scope),
|
||||
items: tickets
|
||||
.slice(0, source.limit)
|
||||
.map((ticket) => ({
|
||||
@@ -374,7 +449,7 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
|
||||
value: tickets.filter((ticket) => ticket.createdAt.slice(0, 10) === day).length,
|
||||
});
|
||||
}
|
||||
return { kind: 'series', points };
|
||||
return { kind: 'series', points, href: ticketsHref(source.filter, scope) };
|
||||
}
|
||||
|
||||
/** Zdroj vlastniho widgetu. Vestavene maji svuj v `builtinSources`. */
|
||||
|
||||
Reference in New Issue
Block a user