Ticketovaci system: udalosti, externi ID, statistiky a widgety nad konektory
Jakakoliv udalost se muze stat ticketem. Prijem je verejny endpoint na firmu (`POST /webhook/ticket/:token`), takze zalozit ticket jde i bez stavby stromu. Externi ID je unikatni V RAMCI FIRMY: dalsi zprava se stejnym ID se navesi na existujici ticket misto zalozeni druheho, a stejne ID u jine firmy je jiny ticket. Cislo a retezec jsou tentyz klic. Udalosti se drzi cele vcetne prijatych dat a jdou rozbalit v detailu - je to neco jineho nez log. Ticket nove nese firstResponseAt, resolvedAt, resolvedById a reopenCount. Bez nich neslo rict, kdo kolik odbavil ani jak dlouho zakaznik cekal. `getAgentStats` z toho pocita vykon resitelu vcetne medianovych casu a vracenych ticketu. Pocet vyresenych sam o sobe odmenuje toho, kdo tickety zaviral predcasne, proto je vraceni videt vedle nej. Widgety: klient konecne vola /widget-data. Endpoint existoval, ale nikdo ho nepouzival, takze vlastni widget hlasil "nepodarilo se zobrazit". Pribyl zdroj `connector` - co umi zjistit napojena sluzba, jde vytahnout do dlazdice pres tentyz skript, ktery pouziva krok automatizace. Vysledek se cachuje. Akce a widgety uz nejsou v nastaveni, maji vlastni zalozku vedle automatizaci. Telo akce se sklada stromem, ne JSONem v textarei - je to tentyz editor, jen misto karty spoustece je "spousti clovek tlacitkem na ticketu". Nova zalozka Lide se seznamem a detailem osoby. Seznam ticketu i lidi ma dva pohledy, tabulku a dlazdice. Opraveno: createTicket bral typeId, fields, tags i assigneeGroupId, ale nikdy je neukladal. Ticket zalozeny s typem zustaval bez typu a bez vlastnich poli. Dlouhe pomlcky, sipky, vypustky a bullety pryc z celeho projektu. Overeno 21 kontrolami proti bezicimu serveru v rezimu souboru. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6c90372991
commit
29de584df8
@@ -44,9 +44,14 @@ import { listCustomWidgets, sizesFor } from '../data/customWidgets.js';
|
||||
import { listIncidents } from '../data/incidentStore.js';
|
||||
import { getSummary } from '../data/mock.js';
|
||||
import { findPersonByEmail, listGroups, listPeople } from '../data/people.js';
|
||||
import { hasPermission } from '../data/permissions.js';
|
||||
import { recordAudit } from '../data/audit.js';
|
||||
import { findTenant, generateIntakeToken, refreshTenants, tenantStore } from '../data/tenants.js';
|
||||
import { listTicketTypes } from '../data/ticketTypes.js';
|
||||
import {
|
||||
addComment,
|
||||
assignTicket,
|
||||
getAgentStats,
|
||||
getTicket,
|
||||
getWorkload,
|
||||
listTickets,
|
||||
@@ -215,6 +220,131 @@ dashboardRouter.delete('/layout', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Detail resitele: kdo to je, jak mu to jde a co ma u sebe.
|
||||
*
|
||||
* Statistika i tickety chodi jednim requestem. Stranka o jednom cloveku by
|
||||
* jinak delala tri dotazy na tri veci, ktere se pocitaji z tehoz seznamu.
|
||||
*/
|
||||
dashboardRouter.get('/people/:id', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
const person = listPeople(scope.tenantIds).find((item) => item.id === req.params.id);
|
||||
if (!person) {
|
||||
// Cizi resitel se chova jako neexistujici, ne jako odepreni prava.
|
||||
return res.status(404).json({ error: 'not_found', message: 'Řešitel neexistuje.' });
|
||||
}
|
||||
|
||||
const tickets = listTickets({ tenantIds: scope.tenantIds, assignee: person.id });
|
||||
const resolvedRecently = listTickets({ tenantIds: scope.tenantIds }).filter(
|
||||
(ticket) => ticket.resolvedById === person.id,
|
||||
);
|
||||
|
||||
// Obdobi drzime stejne jako u widgetu vykonu, aby cisla sedela na obou mistech.
|
||||
const since = Date.now() - 30 * 86_400_000;
|
||||
const stats = getAgentStats([person], scope.tenantIds, since)[0] ?? null;
|
||||
|
||||
return res.json({
|
||||
person,
|
||||
stats,
|
||||
groups: listGroups(scope.tenantIds)
|
||||
.filter((group) => group.personIds.includes(person.id))
|
||||
.map((group) => ({ id: group.id, name: group.name })),
|
||||
/** Co ma prave ted u sebe. */
|
||||
open: tickets.filter((ticket) => ticket.status !== 'resolved'),
|
||||
/** Co za posledni dobu vyresil, nejnovejsi nahore. */
|
||||
resolved: resolvedRecently
|
||||
.filter((ticket) => ticket.status === 'resolved')
|
||||
.sort((a, b) => (b.resolvedAt ?? '').localeCompare(a.resolvedAt ?? ''))
|
||||
.slice(0, 20),
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------- prijem udalosti zvenku
|
||||
|
||||
/**
|
||||
* Adresa pro prijem udalosti do ticketu.
|
||||
*
|
||||
* Token je pristupovy udaj, proto ho vidi jen ten, kdo spravuje napojeni.
|
||||
* Kdo umi zalozit konektor, umi zaridit i prijem - je to tatáz prace.
|
||||
*/
|
||||
dashboardRouter.get('/intake', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
if (!hasPermission(req.user!, 'connector.manage')) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: 'Adresu pro příjem vidí ten, kdo spravuje napojení.',
|
||||
});
|
||||
}
|
||||
|
||||
const tenantId = scope.tenantIds[0];
|
||||
const tenant = tenantId ? findTenant(tenantId) : undefined;
|
||||
if (!tenant) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Firma neexistuje.' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
url: `${publicBaseUrl()}/webhook/ticket/${tenant.intakeToken}`,
|
||||
ticketTypes: listTicketTypes([tenant.id]).map((type) => ({
|
||||
id: type.id,
|
||||
name: type.name,
|
||||
fields: type.fields.map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/** Nova adresa. Stara okamzite prestane fungovat. */
|
||||
dashboardRouter.post('/intake/regenerate', (req, res) => {
|
||||
const scope = scopeOrDeny(req, res);
|
||||
if (!scope) return;
|
||||
|
||||
if (!hasPermission(req.user!, 'connector.manage')) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: 'Adresu pro příjem mění ten, kdo spravuje napojení.',
|
||||
});
|
||||
}
|
||||
|
||||
const tenantId = scope.tenantIds[0];
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'no_tenant', message: 'Vyberte firmu.' });
|
||||
}
|
||||
|
||||
return void tenantStore
|
||||
.update(tenantId, { intakeToken: generateIntakeToken() }, { tenantIds: [], includeGlobal: true })
|
||||
.then(async (updated) => {
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Firma neexistuje.' });
|
||||
}
|
||||
await refreshTenants();
|
||||
recordAudit({
|
||||
userId: req.user!.id,
|
||||
userEmail: req.user!.email,
|
||||
tenantId,
|
||||
action: 'intake.regenerate',
|
||||
target: tenantId,
|
||||
});
|
||||
console.info(`[intake] ${tenantId}: adresa pregenerovana, stara neplati`);
|
||||
return res.json({ url: `${publicBaseUrl()}/webhook/ticket/${updated.intakeToken}` });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('[intake] regenerace selhala:', err);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'internal_error', message: 'Adresu se nepodařilo změnit.' });
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------- tickety
|
||||
|
||||
/**
|
||||
|
||||
+87
-3
@@ -36,8 +36,18 @@ import {
|
||||
featuresOf,
|
||||
type TenantFeatures,
|
||||
} from '../data/tenantFeatures.js';
|
||||
import { listTenants, tenantStore, type Tenant } from '../data/tenants.js';
|
||||
import { listTicketTypes, ticketTypeStore, type TicketType } from '../data/ticketTypes.js';
|
||||
import {
|
||||
generateIntakeToken,
|
||||
listTenants,
|
||||
tenantStore,
|
||||
type Tenant,
|
||||
} from '../data/tenants.js';
|
||||
import {
|
||||
listTicketTypes,
|
||||
ticketScopeFields,
|
||||
ticketTypeStore,
|
||||
type TicketType,
|
||||
} from '../data/ticketTypes.js';
|
||||
import {
|
||||
actionStore,
|
||||
listActions,
|
||||
@@ -97,7 +107,15 @@ settingsRouter.use(
|
||||
writePermission: 'tenant.manage',
|
||||
platformOnly: true,
|
||||
// Firma nepatri jine firme, proto tenantId null.
|
||||
build: (input) => ({ name: input.name, note: input.note ?? '', enabled: true, tenantId: null }),
|
||||
build: (input) => ({
|
||||
name: input.name,
|
||||
note: input.note ?? '',
|
||||
enabled: true,
|
||||
tenantId: null,
|
||||
// Token dostane firma hned pri zalozeni, aby prijem udalosti fungoval
|
||||
// bez dalsiho kroku. Menit ho zvenku nejde, viz updateSchema vyse.
|
||||
intakeToken: generateIntakeToken(),
|
||||
}),
|
||||
validate: (tenant, all) =>
|
||||
all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase())
|
||||
? [`Firma ${tenant.name} už existuje.`]
|
||||
@@ -589,6 +607,52 @@ settingsRouter.get('/actions-overview', (req, res) => {
|
||||
return res.json({ items: listActions([tenantId]) });
|
||||
});
|
||||
|
||||
/**
|
||||
* Na co se da ve stromu akce odkazovat.
|
||||
*
|
||||
* Akci nespousti sluzba, ale clovek na ticketu, takze parametry nejsou od
|
||||
* spoustece - jsou to udaje ticketu plus vlastni pole jeho typu. Pocita to
|
||||
* server, aby si klient nedelal druhy seznam, ktery se casem rozejde.
|
||||
*/
|
||||
settingsRouter.get('/actions/:id/scope', (req, res) => {
|
||||
const tenantId = currentTenant(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const action = listActions([tenantId]).find((item) => item.id === req.params.id);
|
||||
if (!action) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Akce neexistuje.' });
|
||||
}
|
||||
|
||||
/*
|
||||
* Kdyz je akce navazana na vic typu, nabizeji se pole vsech. Krok je pak
|
||||
* muze pouzit, i kdyz u konkretniho ticketu nekterá chybi - to uz je vec
|
||||
* behu, ne definice.
|
||||
*/
|
||||
const types = listTicketTypes([tenantId]).filter((type) =>
|
||||
action.ticketTypeIds.includes(type.id),
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const fields = (types.length > 0 ? types : [undefined])
|
||||
.flatMap((type) => ticketScopeFields(type))
|
||||
.filter((field) => {
|
||||
if (seen.has(field.name)) return false;
|
||||
seen.add(field.name);
|
||||
return true;
|
||||
});
|
||||
|
||||
return res.json({
|
||||
fields,
|
||||
// Doptavaci pole akce jsou v sablonach taky k dispozici.
|
||||
formFields: action.form.map((field) => ({
|
||||
id: `form.${field.id}`,
|
||||
name: field.id,
|
||||
type: 'string' as const,
|
||||
required: field.required,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- vlastni widgety
|
||||
|
||||
const sourceSchema = z.discriminatedUnion('kind', [
|
||||
@@ -608,6 +672,26 @@ const sourceSchema = z.discriminatedUnion('kind', [
|
||||
bucket: z.enum(['day', 'week']).default('day'),
|
||||
}),
|
||||
z.object({ kind: z.literal('workload'), groupIds: z.array(z.string()).optional() }),
|
||||
z.object({
|
||||
kind: z.literal('agentStats'),
|
||||
period: z.enum(['today', '7d', '30d', 'month', 'all']).default('30d'),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('connector'),
|
||||
serviceId: z.string().trim().min(1),
|
||||
operationId: z.string().trim().min(1),
|
||||
/** null = vychozi konektor firmy pro tuhle sluzbu. */
|
||||
connectorId: z.string().trim().min(1).nullable().default(null),
|
||||
inputs: z.record(z.string()).default({}),
|
||||
path: z.string().trim().max(200).optional(),
|
||||
labelPath: z.string().trim().max(200).optional(),
|
||||
valuePath: z.string().trim().max(200).optional(),
|
||||
/**
|
||||
* Jak dlouho se vysledek drzi. Minimum je 30 s: bez nej by kazde otevreni
|
||||
* prehledu volalo cizi sluzbu znovu a limity jsou nase i klientovy.
|
||||
*/
|
||||
ttlSec: z.number().int().min(30).max(86_400).default(300),
|
||||
}),
|
||||
]);
|
||||
|
||||
const widgetCreate = z.object({
|
||||
|
||||
+147
-1
@@ -1,10 +1,156 @@
|
||||
import { Router } from 'express';
|
||||
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 { intakeEvent } from '../data/ticketStore.js';
|
||||
import { findTicketType, listTicketTypes } from '../data/ticketTypes.js';
|
||||
import { publish } from '../events/bus.js';
|
||||
|
||||
export const webhookRouter = Router();
|
||||
|
||||
/**
|
||||
* Prijem udalosti do ticketu. VEREJNY endpoint, autorizuje token firmy.
|
||||
*
|
||||
* Tohle je vstup do ticketovaciho systemu: **jakakoliv udalost se muze stat
|
||||
* ticketem**. Kdyz uz ticket se stejnym `externalId` v te firme existuje,
|
||||
* udalost se na nej **navesi** misto zalozeni druheho.
|
||||
*
|
||||
* Externi ID je unikatni **v ramci firmy**. Token urcuje firmu, takze dve firmy
|
||||
* mohou obe poslat objednavku cislo 3 a nedojde ke smichani.
|
||||
*
|
||||
* Je to zamerne zvlast od `/webhook/:token`, ktery patri automatizaci: zalozit
|
||||
* ticket ma jit i bez toho, aby se kvuli tomu stavel strom.
|
||||
*/
|
||||
const intakeSchema = z.object({
|
||||
/** ID u odesilatele. Bez nej se vzdy zaklada novy ticket. */
|
||||
externalId: z.union([z.string().trim().min(1).max(120), z.number()]).nullish(),
|
||||
/** Odkud to je, napr. `eshop`. Do klice nevstupuje, je to informace. */
|
||||
source: z.string().trim().min(1).max(60).optional(),
|
||||
/** Typ udalosti, napr. `order.created`. */
|
||||
event: z.string().trim().min(1).max(80).default('event'),
|
||||
/** Popisek do casove osy a predmet noveho ticketu. */
|
||||
label: z.string().trim().min(1).max(200).optional(),
|
||||
subject: z.string().trim().min(1).max(200).optional(),
|
||||
body: z.string().max(20_000).optional(),
|
||||
priority: z.enum(['low', 'normal', 'high', 'critical']).optional(),
|
||||
channel: z.enum(['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal']).optional(),
|
||||
typeId: z.string().trim().min(1).optional(),
|
||||
tags: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
|
||||
/** Vlastni pole typu ticketu. Doplni se i na uz existujici ticket. */
|
||||
fields: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
|
||||
customer: z
|
||||
.object({
|
||||
company: z.string().max(160).optional(),
|
||||
contact: z.string().max(160).optional(),
|
||||
reply: z.string().max(200).optional(),
|
||||
})
|
||||
.optional(),
|
||||
/** Cokoliv dalsiho. Ulozi se cele k udalosti. */
|
||||
payload: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
webhookRouter.post('/ticket/:token', (req, res) => {
|
||||
const { token } = req.params;
|
||||
const tenant = findByIntakeToken(token);
|
||||
|
||||
if (!tenant) {
|
||||
console.warn(`[intake] volani na neznamy token (${token.slice(0, 6)}...)`);
|
||||
return res.status(404).json({ error: 'not_found', message: 'Příjem neexistuje.' });
|
||||
}
|
||||
|
||||
const parsed = intakeSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
const first = parsed.error.issues[0];
|
||||
console.warn(`[intake] ${tenant.id}: neplatna data - ${first?.message}`);
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: first ? `${first.path.join('.')}: ${first.message}` : 'Neplatná data.',
|
||||
issues: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`),
|
||||
});
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
// Cislo i retezec jsou pro nas tentyz klic. Odesilatel posle 3, my drzime "3".
|
||||
const externalId =
|
||||
input.externalId === undefined || input.externalId === null ? null : String(input.externalId);
|
||||
|
||||
// Neznamy typ radeji zahodime, nez abychom ticketu dali cizi pole.
|
||||
let typeId = input.typeId ?? null;
|
||||
if (typeId) {
|
||||
const type = findTicketType(typeId);
|
||||
if (!type || type.tenantId !== tenant.id) {
|
||||
console.warn(`[intake] ${tenant.id}: neznamy typ ticketu ${typeId}, ignoruji`);
|
||||
typeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
const result = intakeEvent({
|
||||
tenantId: tenant.id,
|
||||
externalId,
|
||||
externalSource: input.source ?? null,
|
||||
type: input.event,
|
||||
label: input.label ?? input.subject ?? input.event,
|
||||
payload: { ...(input.payload ?? {}), ...(input.fields ?? {}) },
|
||||
fields: input.fields,
|
||||
addTags: input.tags,
|
||||
create: {
|
||||
subject: input.subject ?? input.label ?? input.event,
|
||||
body: input.body ?? '',
|
||||
channel: input.channel ?? 'form',
|
||||
priority: input.priority ?? 'normal',
|
||||
typeId,
|
||||
customer: {
|
||||
id: null,
|
||||
company: input.customer?.company ?? '',
|
||||
contact: input.customer?.contact ?? '',
|
||||
reply: input.customer?.reply ?? '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.info(
|
||||
`[intake] ${tenant.id}: ${result.created ? 'zalozen' : 'doplnen'} ${result.ticket.id}` +
|
||||
`${externalId ? ` (externí ID ${externalId})` : ''} udalosti ${input.event}`,
|
||||
);
|
||||
|
||||
return res.status(result.created ? 201 : 200).json({
|
||||
ok: true,
|
||||
created: result.created,
|
||||
ticketId: result.ticket.id,
|
||||
externalId: result.ticket.externalId,
|
||||
eventId: result.event.id,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Napoveda k prijmu. Sama o sobe nic nemeni a nic tajneho nevraci, jen rekne,
|
||||
* jak se ma volat a jake typy ticketu firma ma.
|
||||
*/
|
||||
webhookRouter.get('/ticket/:token', (req, res) => {
|
||||
const tenant = findByIntakeToken(req.params.token);
|
||||
if (!tenant) return res.status(404).json({ error: 'not_found', message: 'Příjem neexistuje.' });
|
||||
|
||||
return res.json({
|
||||
tenant: tenant.name,
|
||||
howTo:
|
||||
'POST na tuhle adresu. Se stejným externalId se událost navěsí na existující ticket, ' +
|
||||
'jinak vznikne nový. externalId je unikátní v rámci firmy.',
|
||||
example: {
|
||||
externalId: 3,
|
||||
source: 'eshop',
|
||||
event: 'order.created',
|
||||
label: 'Nová objednávka 3',
|
||||
fields: { orderNumber: '3' },
|
||||
},
|
||||
ticketTypes: listTicketTypes([tenant.id]).map((type) => ({
|
||||
id: type.id,
|
||||
name: type.name,
|
||||
fields: type.fields.map((field) => ({ key: field.key, type: field.type, required: field.required })),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* VEREJNY endpoint - zamerne BEZ prihlaseni. Autorizaci resi neodhadnutelny
|
||||
* token v adrese (32 znaku, base64url), presne tak, jak to dela vetsina
|
||||
@@ -17,7 +163,7 @@ webhookRouter.post('/:token', (req, res) => {
|
||||
const automation = findByWebhookToken(token);
|
||||
|
||||
if (!automation || !automation.flow.trigger) {
|
||||
console.warn(`[webhook] volani na neznamy token (${token.slice(0, 6)}…)`);
|
||||
console.warn(`[webhook] volani na neznamy token (${token.slice(0, 6)}...)`);
|
||||
return res.status(404).json({ error: 'not_found', message: 'Webhook neexistuje.' });
|
||||
}
|
||||
|
||||
|
||||
+209
-22
@@ -19,11 +19,24 @@ import {
|
||||
findCustomWidget,
|
||||
type CustomWidget,
|
||||
type WidgetGroupBy,
|
||||
type WidgetSource,
|
||||
type WidgetTicketFilter,
|
||||
} from '../data/customWidgets.js';
|
||||
import { defaultConnectorFor, getConnector } from '../data/connectorStore.js';
|
||||
import { findGroup, findPerson, listGroups, listPeople } from '../data/people.js';
|
||||
import { channelLabels, getWorkload, listTickets, type Ticket } from '../data/ticketStore.js';
|
||||
import { getPath } from '../scripts/mapping.js';
|
||||
import { runScript } from '../scripts/runner.js';
|
||||
import { scriptIdFor } from '../scripts/lookup.js';
|
||||
import {
|
||||
channelLabels,
|
||||
getAgentStats,
|
||||
getWorkload,
|
||||
listTickets,
|
||||
type AgentStatsRow,
|
||||
type Ticket,
|
||||
} from '../data/ticketStore.js';
|
||||
import { findTicketType, listTicketTypes } from '../data/ticketTypes.js';
|
||||
import { builtinSources } from '../data/widgets.js';
|
||||
|
||||
export const widgetDataRouter = Router();
|
||||
|
||||
@@ -48,7 +61,17 @@ type WidgetValue =
|
||||
| { 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: 'workload'; rows: Array<{ name: string; open: number; overCapacity: boolean }> }
|
||||
| { kind: 'agents'; rows: AgentStatsRow[] }
|
||||
/** Vysledek z konektoru. `stale` rika, ze je to z cache, ne cerstve zavolane. */
|
||||
| {
|
||||
kind: 'external';
|
||||
value: number | null;
|
||||
text: string | null;
|
||||
rows: Array<{ key: string; label: string; value: number }>;
|
||||
at: string;
|
||||
stale: boolean;
|
||||
};
|
||||
|
||||
interface WidgetResult {
|
||||
id: string;
|
||||
@@ -154,8 +177,139 @@ function groupKeys(ticket: Ticket, groupBy: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function computeWidget(widget: CustomWidget, scope: ResolvedScope): WidgetValue {
|
||||
const source = widget.source;
|
||||
/**
|
||||
* Cache vysledku z konektoru.
|
||||
*
|
||||
* Bez ni by kazde otevreni prehledu znamenalo volani ciziho API za kazdou
|
||||
* dlazdici. Cizi sluzby maji limity a nekdy se za volani plati, takze je to
|
||||
* ochrana nas i zakaznika. Klic je widget a firma, hodnota se drzi `ttlSec`.
|
||||
*/
|
||||
const externalCache = new Map<string, { at: number; value: WidgetValue }>();
|
||||
|
||||
/** Kolik zaznamu se v cache drzi, aby nerostla bez konce. */
|
||||
const CACHE_LIMIT = 500;
|
||||
|
||||
function cacheKey(widgetId: string, tenantIds: string[]): string {
|
||||
return `${widgetId}|${[...tenantIds].sort().join(',')}`;
|
||||
}
|
||||
|
||||
function rememberExternal(key: string, value: WidgetValue): void {
|
||||
if (externalCache.size >= CACHE_LIMIT) {
|
||||
// Nejstarsi ven. Mapa si drzi poradi vkladani, takze staci prvni klic.
|
||||
const oldest = externalCache.keys().next().value;
|
||||
if (oldest !== undefined) externalCache.delete(oldest);
|
||||
}
|
||||
externalCache.set(key, { at: Date.now(), value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Data z konektoru.
|
||||
*
|
||||
* Vola se tentyz skript, ktery pouziva krok automatizace i akce na ticketu -
|
||||
* widget nema vlastni cestu k cizi sluzbe, jinak by se chovala jinak nez zbytek.
|
||||
*/
|
||||
async function computeConnector(
|
||||
widgetId: string,
|
||||
source: Extract<WidgetSource, { kind: 'connector' }>,
|
||||
scope: ResolvedScope,
|
||||
): Promise<WidgetValue> {
|
||||
const key = cacheKey(widgetId, scope.tenantIds);
|
||||
const ttl = Math.max(source.ttlSec ?? 300, 30) * 1_000;
|
||||
const cached = externalCache.get(key);
|
||||
if (cached && Date.now() - cached.at < ttl) {
|
||||
return cached.value.kind === 'external' ? { ...cached.value, stale: true } : cached.value;
|
||||
}
|
||||
|
||||
const scriptId = scriptIdFor(source.serviceId, source.operationId);
|
||||
if (!scriptId) {
|
||||
throw new Error(`Operace ${source.serviceId}/${source.operationId} nemá výkonnou část.`);
|
||||
}
|
||||
|
||||
const tenantId = scope.tenantIds[0];
|
||||
if (!tenantId) throw new Error('Widget nad konektorem potřebuje vybranou firmu.');
|
||||
|
||||
const connector = source.connectorId
|
||||
? await getConnector(source.connectorId, scope.tenantIds)
|
||||
: await defaultConnectorFor(tenantId, source.serviceId);
|
||||
|
||||
const result = await runScript(scriptId, source.inputs, {
|
||||
connector: connector ?? null,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
// Cela chyba, nezkracena. Widget ji ukaze na sve pozici.
|
||||
throw new Error(result.error?.message ?? 'Konektor nevrátil data.');
|
||||
}
|
||||
|
||||
const picked = source.path ? getPath(result.outputs, source.path) : result.outputs;
|
||||
const value = toExternal(picked, source);
|
||||
rememberExternal(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Prevede cokoliv, co skript vratil, na neco, co jde nakreslit. */
|
||||
function toExternal(
|
||||
picked: unknown,
|
||||
source: Extract<WidgetSource, { kind: 'connector' }>,
|
||||
): WidgetValue {
|
||||
const at = new Date().toISOString();
|
||||
|
||||
if (typeof picked === 'number') {
|
||||
return { kind: 'external', value: picked, text: null, rows: [], at, stale: false };
|
||||
}
|
||||
|
||||
if (Array.isArray(picked)) {
|
||||
const rows = picked.slice(0, 50).map((item, index) => {
|
||||
const label = source.labelPath ? getPath(item, source.labelPath) : undefined;
|
||||
const value = source.valuePath ? getPath(item, source.valuePath) : undefined;
|
||||
return {
|
||||
key: String(index),
|
||||
label: label === undefined || label === null ? `#${index + 1}` : String(label),
|
||||
value: typeof value === 'number' ? value : Number(value ?? 0) || 0,
|
||||
};
|
||||
});
|
||||
// Delka pole je casto to jedine cislo, ktere dava smysl.
|
||||
return { kind: 'external', value: picked.length, text: null, rows, at, stale: false };
|
||||
}
|
||||
|
||||
if (picked === null || picked === undefined) {
|
||||
return { kind: 'external', value: null, text: null, rows: [], at, stale: false };
|
||||
}
|
||||
|
||||
if (typeof picked === 'object') {
|
||||
const rows = Object.entries(picked as Record<string, unknown>)
|
||||
.filter(([, value]) => typeof value === 'number')
|
||||
.slice(0, 50)
|
||||
.map(([key, value]) => ({ key, label: key, value: value as number }));
|
||||
return {
|
||||
kind: 'external',
|
||||
value: null,
|
||||
text: rows.length > 0 ? null : JSON.stringify(picked).slice(0, 200),
|
||||
rows,
|
||||
at,
|
||||
stale: false,
|
||||
};
|
||||
}
|
||||
|
||||
const text = String(picked);
|
||||
const asNumber = Number(text);
|
||||
return {
|
||||
kind: 'external',
|
||||
value: Number.isFinite(asNumber) && text.trim() !== '' ? asNumber : null,
|
||||
text,
|
||||
rows: [],
|
||||
at,
|
||||
stale: false,
|
||||
};
|
||||
}
|
||||
|
||||
function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue {
|
||||
if (source.kind === 'agentStats') {
|
||||
return {
|
||||
kind: 'agents',
|
||||
rows: getAgentStats(listPeople(scope.tenantIds), scope.tenantIds, since(source.period)),
|
||||
};
|
||||
}
|
||||
|
||||
if (source.kind === 'workload') {
|
||||
const workload = getWorkload(listPeople(scope.tenantIds), scope.tenantIds);
|
||||
@@ -169,6 +323,12 @@ function computeWidget(widget: CustomWidget, scope: ResolvedScope): WidgetValue
|
||||
};
|
||||
}
|
||||
|
||||
if (source.kind === 'connector') {
|
||||
// Konektor se pocita jinou cestou, protoze cizi sluzba je asynchronni
|
||||
// a vysledek se cachuje. Sem se to nikdy nedostane, viz volajici.
|
||||
throw new Error('Zdroj Konektor se počítá zvlášť.');
|
||||
}
|
||||
|
||||
const tickets = listTickets({ tenantIds: scope.tenantIds }).filter((ticket) =>
|
||||
matches(ticket, source.filter, scope.personId),
|
||||
);
|
||||
@@ -217,7 +377,12 @@ function computeWidget(widget: CustomWidget, scope: ResolvedScope): WidgetValue
|
||||
return { kind: 'series', points };
|
||||
}
|
||||
|
||||
widgetDataRouter.post('/', (req, res) => {
|
||||
/** Zdroj vlastniho widgetu. Vestavene maji svuj v `builtinSources`. */
|
||||
function computeWidget(widget: CustomWidget, scope: ResolvedScope): WidgetValue {
|
||||
return computeSource(widget.source, scope);
|
||||
}
|
||||
|
||||
widgetDataRouter.post('/', async (req, res) => {
|
||||
const scope = resolveScope(req.user!, {
|
||||
scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
|
||||
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
|
||||
@@ -234,28 +399,50 @@ widgetDataRouter.post('/', (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const results: WidgetResult[] = parsed.data.widgetIds.map((id) => {
|
||||
const widget = findCustomWidget(id);
|
||||
if (!widget || !scope.tenantIds.includes(widget.tenantId)) {
|
||||
return { id, ok: false, error: 'Widget neexistuje.' };
|
||||
}
|
||||
// Osobni widget vidi jen jeho autor.
|
||||
if (widget.ownerId !== null && widget.ownerId !== req.user!.id) {
|
||||
return { id, ok: false, error: 'Widget neexistuje.' };
|
||||
}
|
||||
const results: WidgetResult[] = await Promise.all(
|
||||
parsed.data.widgetIds.map(async (id): Promise<WidgetResult> => {
|
||||
// Vestaveny widget s vypoctem. Nema definici v ulozisti, jen zdroj dat.
|
||||
const builtin = builtinSources[id];
|
||||
if (builtin) {
|
||||
try {
|
||||
return { id, ok: true, value: computeSource(builtin, scope) };
|
||||
} catch (err) {
|
||||
console.error(`[widgets] ${id} selhal:`, err);
|
||||
return { id, ok: false, error: message(err) };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return { id, ok: true, value: computeWidget(widget, scope) };
|
||||
} catch (err) {
|
||||
// Jeden rozbity zdroj nesmi zhasnout cely prehled.
|
||||
console.error(`[widgets] ${id} selhal:`, err);
|
||||
return { id, ok: false, error: err instanceof Error ? err.message : 'Data se nepodařilo spočítat.' };
|
||||
}
|
||||
});
|
||||
const widget = findCustomWidget(id);
|
||||
if (!widget || !scope.tenantIds.includes(widget.tenantId)) {
|
||||
return { id, ok: false, error: 'Widget neexistuje.' };
|
||||
}
|
||||
// Osobni widget vidi jen jeho autor.
|
||||
if (widget.ownerId !== null && widget.ownerId !== req.user!.id) {
|
||||
return { id, ok: false, error: 'Widget neexistuje.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const value =
|
||||
widget.source.kind === 'connector'
|
||||
? await computeConnector(widget.id, widget.source, scope)
|
||||
: computeWidget(widget, scope);
|
||||
return { id, ok: true, value };
|
||||
} catch (err) {
|
||||
// Jeden rozbity zdroj nesmi zhasnout cely prehled.
|
||||
console.error(`[widgets] ${id} selhal:`, err);
|
||||
return { id, ok: false, error: message(err) };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return res.json({ items: results });
|
||||
});
|
||||
|
||||
/** Cele hlaseni, nezkracene. U konektoru je to jedina stopa, proc to nejde. */
|
||||
function message(err: unknown): string {
|
||||
return err instanceof Error ? err.message : 'Data se nepodařilo spočítat.';
|
||||
}
|
||||
|
||||
/** Co lze do filtru vybrat. Klient si nesklada vlastni seznam. */
|
||||
widgetDataRouter.get('/options', (req, res) => {
|
||||
const scope = resolveScope(req.user!, {
|
||||
|
||||
Reference in New Issue
Block a user