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:
JiriUhlir
2026-08-13 15:13:09 +02:00
co-authored by Claude Opus 5
parent 6c90372991
commit 29de584df8
54 changed files with 3479 additions and 323 deletions
+74 -3
View File
@@ -42,7 +42,38 @@ export type WidgetSource =
| { kind: 'ticketCount'; filter: WidgetTicketFilter; groupBy?: WidgetGroupBy }
| { kind: 'ticketList'; filter: WidgetTicketFilter; limit: number }
| { kind: 'ticketSeries'; filter: WidgetTicketFilter; bucket: 'day' | 'week' }
| { kind: 'workload'; groupIds?: string[] };
| { kind: 'workload'; groupIds?: string[] }
/**
* Vykon resitelu: kolik kdo odbavil, jak dlouho mu to trva a kolik se mu
* toho vratilo. `period` plati jen na vyresene - fronta neni vec obdobi.
*/
| { kind: 'agentStats'; period?: WidgetPeriod }
/**
* Data z napojene sluzby. Co umi zjistit konektor, jde vytahnout do dlazdice.
*
* Vola se **tentyz skript**, ktery pouziva krok automatizace a akce na
* ticketu. Widget si nic vlastniho nevymysli, jen vybere z vysledku:
* - `path` je cesta ve vystupu skriptu, napr. `total` nebo `items`,
* - cislo se ukaze jako cislo, seznam jako radky.
*
* Vysledek se **cachuje** (`ttlSec`), aby otevreni prehledu neznamenalo
* deset volani cizi sluzby. Cizi API ma limity a plati se za ne.
*/
| {
kind: 'connector';
serviceId: string;
operationId: string;
/** null = vychozi konektor firmy pro tuhle sluzbu. */
connectorId: string | null;
inputs: Record<string, string>;
/** Cesta ve vystupu skriptu. Prazdne = cely vystup. */
path?: string;
/** U seznamu: co v radku ukazat jako popisek a co jako hodnotu. */
labelPath?: string;
valuePath?: string;
/** Jak dlouho se vysledek drzi. Vychozi 300 s, nejmene 30. */
ttlSec?: number;
};
export type WidgetSize = 'third' | 'half' | 'full';
@@ -67,6 +98,32 @@ export function seedCustomWidgets(): CustomWidget[] {
const base = { tenantId: 'tnt_automia', ownerId: null, createdAt: timestamp, updatedAt: timestamp };
return [
{
...base,
id: 'cw_idoklad_invoices',
name: 'Faktury v iDokladu',
description: 'Ukázka widgetu nad konektorem. Bez napojení hlásí, co chybí.',
render: 'stat',
source: {
kind: 'connector',
serviceId: 'idoklad',
operationId: 'find-issued-invoice',
connectorId: null,
inputs: {},
path: 'total',
ttlSec: 300,
},
size: 'third',
},
{
...base,
id: 'cw_agents_30d',
name: 'Kdo kolik odbavil',
description: 'Vyřešené za 30 dní, doba řešení, vrácené tickety a fronta.',
render: 'table',
source: { kind: 'agentStats', period: '30d' },
size: 'full',
},
{
...base,
id: 'cw_orders_open',
@@ -141,12 +198,26 @@ export function validateWidget(widget: CustomWidget): string[] {
}
// Cislo ze seznamu nebo casove rady nejde vykreslit jako jedna hodnota.
if (widget.render === 'stat' && widget.source.kind !== 'ticketCount') {
problems.push('Jedno číslo umí jen zdroj Počet ticketů.');
if (widget.render === 'stat' && !['ticketCount', 'connector'].includes(widget.source.kind)) {
problems.push('Jedno číslo umí jen zdroj Počet ticketů nebo Konektor.');
}
if (widget.render === 'chart' && widget.source.kind !== 'ticketSeries') {
problems.push('Graf umí jen zdroj Časová řada.');
}
if (widget.render === 'table' && widget.source.kind === 'ticketList') {
problems.push('Tabulka potřebuje seskupení nebo výkon řešitelů, ne seznam ticketů.');
}
if (widget.source.kind === 'connector') {
const { serviceId, operationId, ttlSec } = widget.source;
if (!serviceId || !operationId) {
problems.push('U zdroje Konektor vyberte službu i operaci.');
}
// Bez rozumneho minima by kazde otevreni prehledu volalo cizi sluzbu znovu.
if (ttlSec !== undefined && ttlSec < 30) {
problems.push('Data z konektoru se drží nejméně 30 sekund.');
}
}
return problems;
}
+13
View File
@@ -24,10 +24,13 @@ import type { User } from '../types.js';
export type ModuleKey =
| 'overview'
| 'automations'
| 'actions'
| 'widgets'
| 'services'
| 'connectors'
| 'scripts'
| 'tickets'
| 'people'
| 'incidents'
| 'settings';
@@ -49,7 +52,17 @@ export interface ModuleDefinition {
export const moduleCatalog: ModuleDefinition[] = [
{ key: 'overview', label: 'Přehled', permissions: [], required: true },
{ key: 'tickets', label: 'Tickety', permissions: ['ticket.view'] },
{ key: 'people', label: 'Lidé', permissions: ['ticket.view'], required: true },
{ key: 'automations', label: 'Automatizace', permissions: ['automation.edit'] },
/*
* Akce a widgety jsou **stejna uroven jako automatizace**, ne polozka
* v nastaveni. Je to definice toho, co aplikace umi, ne nastaveni uctu.
*
* `required` je tu schvalne: firmy zalozene driv maji v ulozisti seznam
* modulu bez techhle klicu a bez toho by se jim zalozky nikdy neobjevily.
*/
{ key: 'actions', label: 'Akce', permissions: ['action.manage'], required: true },
{ key: 'widgets', label: 'Widgety', permissions: ['widget.manage'], required: true },
{ key: 'services', label: 'Služby', permissions: ['connector.manage'] },
{ key: 'connectors', label: 'Konektory', permissions: ['connector.manage'] },
{ key: 'incidents', label: 'Incidenty', permissions: ['ticket.view'] },
+29 -3
View File
@@ -13,6 +13,7 @@
* Vzor je popsany v `store/cached.ts`.
*/
import { randomBytes } from 'node:crypto';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withCache } from './store/cached.js';
@@ -21,6 +22,14 @@ export interface Tenant extends TenantEntity {
/** Kratka poznamka pro nas, klient ji nevidi. */
note: string;
enabled: boolean;
/**
* Token pro prijem udalosti na `/webhook/ticket/:token`.
*
* Je na firme, ne na automatizaci: prijem ticketu ma fungovat i bez toho,
* aby se kvuli nemu stavel strom. Token urcuje, ktere firme udalost patri,
* a tim i to, v jakem rozsahu je externi ID unikatni.
*/
intakeToken: string;
}
/**
@@ -31,16 +40,33 @@ export interface Tenant extends TenantEntity {
export const tenantStore = defineStore<Tenant>('tenant');
const cache = withCache(tenantStore);
/** Neodhadnutelny token do adresy. Stejny tvar jako u webhooku automatizace. */
export function generateIntakeToken(): string {
return randomBytes(24).toString('base64url');
}
export function seedTenants(): Tenant[] {
const timestamp = nowIso();
const base = { tenantId: null, note: '', enabled: true, createdAt: timestamp, updatedAt: timestamp };
return [
{ ...base, id: 'tnt_automia', name: 'Automia' },
{ ...base, id: 'tnt_nordis', name: 'Nordis a.s.' },
{ ...base, id: 'tnt_logitrans', name: 'LogiTrans' },
{ ...base, id: 'tnt_automia', name: 'Automia', intakeToken: generateIntakeToken() },
{ ...base, id: 'tnt_nordis', name: 'Nordis a.s.', intakeToken: generateIntakeToken() },
{ ...base, id: 'tnt_logitrans', name: 'LogiTrans', intakeToken: generateIntakeToken() },
];
}
/**
* Firma podle tokenu prijmu.
*
* Prochazi se linearne, firem jsou desitky. Az jich budou tisice, patri sem
* index - ne driv, protoze udrzovat dve mapy kvuli desitkam zaznamu je zbytecna
* prilezitost k chybe.
*/
export function findByIntakeToken(token: string): Tenant | undefined {
if (token.length < 16) return undefined;
return cache.all().find((tenant) => tenant.intakeToken === token && tenant.enabled);
}
export async function refreshTenants(): Promise<void> {
await cache.refresh();
}
+371 -5
View File
@@ -76,10 +76,41 @@ export interface TicketTraceEntry {
at: string;
}
/**
* Jedna prichozi udalost k ticketu.
*
* Rozdil proti radku logu: log je nase stopa toho, co se delo uvnitr, udalost
* je **fakt zvenku**. Odesilatel posle "objednavka 3 vznikla" a pozdeji
* "objednavka 3 vyfakturovana" - obojí patri k jednomu ticketu a obojí musi
* zustat v puvodni podobe, vcetne cele prijate zpravy.
*/
export interface TicketEvent {
id: string;
/** Typ od odesilatele, napr. `order.created`, `email.sent`. */
type: string;
/** Kdo to poslal: `webhook`, `portal`, ID automatizace. */
source: string;
/** Popisek do casove osy. */
label: string;
/** Cela prijata data. Nic se nezahazuje, at jde dohledat, co presne prislo. */
payload: Record<string, unknown>;
at: string;
}
export interface Ticket {
id: string;
/** Firma, ktere ticket patri. Hranice viditelnosti, viz data/access.ts. */
tenantId: string;
/**
* ID u odesilatele, napr. cislo objednavky.
*
* **Unikatni v ramci firmy**, ne globalne: dve firmy mohou mit objednavku
* cislo 3 a nesmi si o sebe zavadit. Dalsi zprava se stejnym externim ID se
* **navesi na existujici ticket** misto zalozeni druheho, viz `intakeEvent`.
*/
externalId: string | null;
/** Odkud externi ID pochazi, napr. `eshop`. Jen informace, do klice nevstupuje. */
externalSource: string | null;
subject: string;
/**
* Cely text pozadavku - telo e-mailu, zprava z WhatsApp, prepis hovoru.
@@ -114,12 +145,30 @@ export interface Ticket {
tags: string[];
/** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */
automationId: string | null;
/**
* Kdy se ticketu prvne nekdo venoval: prirazeni, komentar, zmena stavu.
* Bez toho nejde rict, jak dlouho zakaznik cekal na reakci.
*/
firstResponseAt: string | null;
/** Kdy byl vyresen. Znovuotevreni to vynuluje. */
resolvedAt: string | null;
/** Kdo ho vyresil. Bez toho nejde rict, kdo kolik odbavil. */
resolvedById: string | null;
/**
* Kolikrat se vratil z vyreseno zpatky do prace.
*
* Nejlepsi jednoduchy ukazatel toho, ze prace nebyla hotova. Pocet vyresenych
* sam o sobe odmenuje toho, kdo tickety zaviral predcasne.
*/
reopenCount: number;
createdAt: string;
updatedAt: string;
}
export interface TicketDetail extends Ticket {
trace: TicketTraceEntry[];
/** Prichozi udalosti, nejstarsi nahore. */
events: TicketEvent[];
}
/**
@@ -130,12 +179,32 @@ export interface TicketDetail extends Ticket {
* `toTicket`, takze navenek je `Ticket` uplny.
*/
interface StoredTicket
extends Omit<Ticket, 'assignee' | 'typeId' | 'fields' | 'tags' | 'assigneeGroupId'> {
extends Omit<
Ticket,
| 'assignee'
| 'typeId'
| 'fields'
| 'tags'
| 'assigneeGroupId'
| 'externalId'
| 'externalSource'
| 'firstResponseAt'
| 'resolvedAt'
| 'resolvedById'
| 'reopenCount'
> {
assigneeId: string | null;
assigneeGroupId?: string | null;
typeId?: string | null;
fields?: Record<string, string | number | boolean | null>;
tags?: string[];
externalId?: string | null;
externalSource?: string | null;
firstResponseAt?: string | null;
resolvedAt?: string | null;
resolvedById?: string | null;
reopenCount?: number;
events?: TicketEvent[];
}
export const channelLabels: Record<TicketChannel, string> = {
@@ -167,11 +236,18 @@ interface PersistedTicket extends StoredTicket {
trace: TicketTraceEntry[];
}
/** Udalosti drzime u ticketu stejne jako log - jina zivotnost, stejny zaznam. */
const events = new Map<string, TicketEvent[]>();
const mirror = withMirror(defineStore<PersistedTicket>('ticket'));
/** Ulozi ticket vcetne logu. Necekana se, chyba se loguje. */
function persist(ticket: StoredTicket): void {
mirror.save({ ...ticket, trace: traces.get(ticket.id) ?? [] });
mirror.save({
...ticket,
trace: traces.get(ticket.id) ?? [],
events: events.get(ticket.id) ?? [],
});
}
/**
@@ -198,10 +274,12 @@ export async function initTickets(): Promise<void> {
tickets.length = 0;
traces.clear();
events.clear();
for (const row of rows) {
const { trace, ...stored } = row;
const { trace, events: rowEvents, ...stored } = row;
tickets.push(stored);
traces.set(row.id, trace ?? []);
events.set(row.id, rowEvents ?? []);
}
// Citac musi pokracovat za nejvyssim ulozenym cislem, jinak by nove tickety
@@ -697,7 +775,7 @@ seed(
// ------------------------------------------------------------------ prevody
function toTicket(stored: StoredTicket): Ticket {
const { assigneeId, ...rest } = stored;
const { assigneeId, events: _events, ...rest } = stored;
// Chybejici nova pole dostanou vychozi hodnotu, aby navenek byl Ticket uplny.
const base = {
...rest,
@@ -705,6 +783,12 @@ function toTicket(stored: StoredTicket): Ticket {
typeId: stored.typeId ?? null,
fields: stored.fields ?? {},
tags: stored.tags ?? [],
externalId: stored.externalId ?? null,
externalSource: stored.externalSource ?? null,
firstResponseAt: stored.firstResponseAt ?? null,
resolvedAt: stored.resolvedAt ?? null,
resolvedById: stored.resolvedById ?? null,
reopenCount: stored.reopenCount ?? 0,
};
if (!assigneeId) return { ...base, assignee: null };
@@ -760,7 +844,11 @@ export function getTicket(id: string, tenantIds: string[]): TicketDetail | undef
console.warn(`[tickets] pokus o cteni ticketu ${id} mimo povolene firmy`);
return undefined;
}
return { ...toTicket(stored), trace: traces.get(id) ?? [] };
return {
...toTicket(stored),
trace: traces.get(id) ?? [],
events: events.get(id) ?? [],
};
}
/** Prvni nevyrizeny ticket - pouziva simulace, kdyz uzivatel neurci ktery. */
@@ -825,10 +913,259 @@ export function getWorkload(everyone: Person[], tenantIds: string[]): Workload {
};
}
/**
* Prvni reakce se zapisuje jednou a uz se neprepisuje.
*
* Je to cas, kdy zakaznik prestal cekat. Kdyby se prepisoval pri kazde zmene,
* merilo by to posledni dotek, coz je uplne jina velicina.
*/
function markResponded(ticket: StoredTicket): void {
if (!ticket.firstResponseAt) ticket.firstResponseAt = new Date().toISOString();
}
/** Ticket firmy podle externiho ID. Klic je dvojice firma a ID, ne ID samotne. */
export function findByExternalId(tenantId: string, externalId: string): Ticket | undefined {
const stored = tickets.find(
(ticket) => ticket.tenantId === tenantId && ticket.externalId === externalId,
);
return stored ? toTicket(stored) : undefined;
}
export interface IntakeInput {
tenantId: string;
/** Bez nej se navazani nema o co oprit a zalozi se novy ticket. */
externalId: string | null;
externalSource?: string | null;
/** Typ udalosti od odesilatele, napr. `order.created`. */
type: string;
/** Popisek do casove osy. Bez nej se pouzije typ udalosti. */
label?: string;
/** Cela prijata data. */
payload?: Record<string, unknown>;
/** Pouzije se jen kdyz ticket teprve vznika. */
create?: Partial<Omit<CreateTicketInput, 'tenantId' | 'externalId' | 'externalSource'>>;
/** Vlastni pole, ktera se doplni i na existujici ticket. */
fields?: Record<string, string | number | boolean | null>;
/** Tagy, ktere se **pridaji**. Existujici se nemazou. */
addTags?: string[];
}
export interface IntakeResult {
ticket: Ticket;
/** true = ticket teprve ted vznikl, false = udalost se navesila na existujici. */
created: boolean;
event: TicketEvent;
}
/**
* Prijem udalosti zvenku.
*
* Tohle je vstup do ticketovaciho systemu: **jakakoliv udalost se muze stat
* ticketem**. Kdyz uz ticket se stejnym externim ID ve **stejne firme** je,
* udalost se na nej navesi misto zalozeni druheho. Diky tomu muze odesilatel
* poslat "objednavka 3 vznikla" a za hodinu "objednavka 3 vyfakturovana"
* a obojí skonci na jednom miste.
*
* Bez externiho ID se vzdy zaklada novy ticket - nemame podle ceho navazovat
* a hadat podle predmetu by slucovalo veci, ktere spolu nesouvisi.
*/
export function intakeEvent(input: IntakeInput): IntakeResult {
const timestamp = new Date().toISOString();
const event: TicketEvent = {
id: nextEventId(),
type: input.type,
source: input.externalSource ?? 'webhook',
label: input.label ?? input.type,
payload: input.payload ?? {},
at: timestamp,
};
const existing = input.externalId
? tickets.find(
(ticket) => ticket.tenantId === input.tenantId && ticket.externalId === input.externalId,
)
: undefined;
if (existing) {
appendEvent(existing, event);
if (input.fields) existing.fields = { ...(existing.fields ?? {}), ...input.fields };
if (input.addTags && input.addTags.length > 0) {
existing.tags = [...new Set([...(existing.tags ?? []), ...input.addTags])];
}
touch(existing);
appendTrace(existing.id, [
{ kind: 'trigger', status: 'info', label: `Přijata událost: ${event.label}`, response: describePayload(event.payload) },
]);
publish('ticket.updated', `Ticket ${existing.id}: ${event.label}`, {
ticketId: existing.id,
externalId: existing.externalId,
});
return { ticket: toTicket(existing), created: false, event };
}
const created = createTicket({
tenantId: input.tenantId,
externalId: input.externalId,
externalSource: input.externalSource ?? null,
subject: input.create?.subject ?? event.label,
body: input.create?.body ?? '',
sourceRef: input.create?.sourceRef ?? null,
channel: input.create?.channel ?? 'form',
customer: input.create?.customer ?? { id: null, company: '', contact: '', reply: '' },
priority: input.create?.priority ?? 'normal',
assigneeId: input.create?.assigneeId ?? null,
assigneeGroupId: input.create?.assigneeGroupId ?? null,
typeId: input.create?.typeId ?? null,
fields: { ...(input.create?.fields ?? {}), ...(input.fields ?? {}) },
tags: [...new Set([...(input.create?.tags ?? []), ...(input.addTags ?? [])])],
automationId: input.create?.automationId ?? null,
trace: [
{
kind: 'trigger',
status: 'info',
label: `Ticket vznikl z události: ${event.label}`,
response: describePayload(event.payload),
},
],
});
const stored = tickets.find((ticket) => ticket.id === created.id);
if (stored) {
appendEvent(stored, event);
persist(stored);
}
return { ticket: toTicket(stored ?? created as unknown as StoredTicket), created: true, event };
}
/** Kolik udalosti se u jednoho ticketu drzi. Starsi se odmazavaji. */
const MAX_EVENTS = 200;
function appendEvent(ticket: StoredTicket, event: TicketEvent): void {
const list = events.get(ticket.id) ?? [];
list.push(event);
// Nekonecne rostouci ticket by pri kazdem zapisu prepisoval vic a vic dat.
if (list.length > MAX_EVENTS) list.splice(0, list.length - MAX_EVENTS);
events.set(ticket.id, list);
}
let eventCounter = 0;
function nextEventId(): string {
eventCounter += 1;
return `tev_${eventCounter.toString(36)}`;
}
/** Kratky popis dat do logu. Cela data zustavaji v udalosti. */
function describePayload(payload: Record<string, unknown>): string | null {
const keys = Object.keys(payload);
if (keys.length === 0) return null;
const text = JSON.stringify(payload);
return text.length > 2_000 ? `${text.slice(0, 2_000)}...` : text;
}
// ---------------------------------------------------------------- statistiky
export interface AgentStatsRow {
personId: string;
name: string;
resolved: number;
open: number;
critical: number;
medianResolveMs: number | null;
medianResponseMs: number | null;
reopened: number;
oldestOpenAt: string | null;
overloaded: boolean;
}
/** Median, ne prumer: jeden ticket zapomenuty pres dovolenou jinak prebije vsechno. */
function median(values: number[]): number | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1
? sorted[middle]
: Math.round((sorted[middle - 1] + sorted[middle]) / 2);
}
/**
* Vykon resitelu za obdobi.
*
* `since` je hranice pro **vyresene** tickety, tedy "kolik toho odbavil za
* poslednich 30 dni". Nevyrizene se pocitaji vzdy vsechny - fronta neni
* vec obdobi, lezi tam bez ohledu na to, na co se zrovna divame.
*/
export function getAgentStats(
everyone: Person[],
tenantIds: string[],
since: number | null,
): AgentStatsRow[] {
const visible = tickets.filter((ticket) => tenantIds.includes(ticket.tenantId));
const rows = everyone.map((person) => {
const open = visible.filter((t) => t.assigneeId === person.id && t.status !== 'resolved');
/**
* Cas z nepovinneho pole. `undefined` znamena starsi ticket zalozeny driv,
* nez se to zacalo evidovat - takovy se do statistiky nepocita.
*/
function at(value: string | null | undefined): number | null {
if (!value) return null;
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : null;
}
const resolved = visible.filter((t) => {
if (t.resolvedById !== person.id) return false;
const time = at(t.resolvedAt);
if (time === null) return false;
return since === null || time >= since;
});
const resolveTimes = resolved
.map((t) => (at(t.resolvedAt) ?? 0) - (at(t.createdAt) ?? 0))
.filter((value) => value >= 0);
const responseTimes = visible
.filter((t) => t.assigneeId === person.id && at(t.firstResponseAt) !== null)
.map((t) => (at(t.firstResponseAt) ?? 0) - (at(t.createdAt) ?? 0))
.filter((value) => value >= 0);
const oldest = open.reduce<string | null>(
(acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc),
null,
);
return {
personId: person.id,
name: person.name,
resolved: resolved.length,
open: open.length,
critical: open.filter((t) => t.priority === 'critical').length,
medianResolveMs: median(resolveTimes),
medianResponseMs: median(responseTimes),
reopened: resolved.reduce((sum, t) => sum + (t.reopenCount ?? 0), 0),
oldestOpenAt: oldest,
overloaded: open.length > person.capacity,
};
});
// Nejvic odbavenych nahoru. Prehled ma odpovedet na "kdo toho udelal nejvic".
rows.sort((a, b) => b.resolved - a.resolved || a.name.localeCompare(b.name, 'cs'));
return rows;
}
// --------------------------------------------------------------------- zmeny
export interface CreateTicketInput {
tenantId: string;
/** ID u odesilatele. Unikatni za firmu, viz `Ticket.externalId`. */
externalId?: string | null;
externalSource?: string | null;
subject: string;
body?: string;
sourceRef?: string | null;
@@ -859,6 +1196,12 @@ export function createTicket(input: CreateTicketInput): Ticket {
const stored: StoredTicket = {
id: `TK-${ticketCounter}`,
tenantId: input.tenantId,
externalId: input.externalId ?? null,
externalSource: input.externalSource ?? null,
firstResponseAt: null,
resolvedAt: null,
resolvedById: null,
reopenCount: 0,
subject: input.subject,
body: input.body ?? '',
sourceRef: input.sourceRef ?? null,
@@ -867,12 +1210,19 @@ export function createTicket(input: CreateTicketInput): Ticket {
status: 'new',
priority: input.priority,
assigneeId,
// Tyhle ctyri se driv zahazovaly: vstup je nabizel, ale zaznam je nemel.
// Ticket zalozeny s typem tak zustaval bez typu a bez vlastnich poli.
assigneeGroupId: input.assigneeGroupId ?? null,
typeId: input.typeId ?? null,
fields: input.fields ?? {},
tags: input.tags ?? [],
automationId: input.automationId ?? null,
createdAt: now,
updatedAt: now,
};
tickets.unshift(stored);
traces.set(stored.id, flattenTrace(input.trace ?? [], null, []));
events.set(stored.id, []);
persist(stored);
publish('ticket.created', `Nový ticket ${stored.id}: ${stored.subject}`, {
@@ -908,6 +1258,20 @@ export function updateTicketStatus(
const previous = ticket.status;
ticket.status = status;
if (status === 'resolved') {
ticket.resolvedAt = new Date().toISOString();
// Vyresil ten, kdo ho mel u sebe. Kdyz nikdo, zustane to nekomu nepripsane -
// radeji nez pripsat vyreseni cloveku, ktery s tim nic nemel.
ticket.resolvedById = ticket.assigneeId;
} else if (previous === 'resolved') {
// Navrat z vyreseno je nejlepsi ukazatel toho, ze hotovo nebylo.
ticket.reopenCount = (ticket.reopenCount ?? 0) + 1;
ticket.resolvedAt = null;
ticket.resolvedById = null;
}
markResponded(ticket);
touch(ticket);
appendTrace(id, [
@@ -956,6 +1320,7 @@ export function assignTicket(
}
ticket.assigneeId = person?.id ?? null;
if (person) markResponded(ticket);
touch(ticket);
appendTrace(id, [
@@ -1055,6 +1420,7 @@ export function addComment(
return undefined;
}
markResponded(ticket);
touch(ticket);
appendTrace(id, [{ kind: 'note', label: `${author}: ${text}`, status: 'info' }]);
+22 -1
View File
@@ -13,7 +13,9 @@ export type WidgetKind =
| 'chart'
| 'ticketList'
| 'incidentList'
| 'workload';
| 'workload'
/** Vykon resitelu. Data chodi z `/widget-data`, stejne jako u vlastnich. */
| 'agents';
export interface WidgetDefinition {
id: string;
@@ -118,8 +120,27 @@ export const widgets: WidgetDefinition[] = [
sizes: ['half', 'full'],
defaultSize: 'half',
},
{
id: 'panel.agents',
name: 'Výkon řešitelů',
description: 'Kdo kolik odbavil za 30 dní, jak dlouho mu to trvá a co se mu vrací.',
kind: 'agents',
sizes: ['half', 'full'],
defaultSize: 'full',
},
];
/**
* Vestavene widgety, jejichz data se pocitaji stejne jako u vlastnich, tedy
* pres `/widget-data`. Klic je ID widgetu, hodnota jeho zdroj dat.
*
* Diky tomu nemusi mit "Vykon resitelu" vlastni endpoint ani vlastni cestu
* v klientovi - je to tentyz zdroj, jen ho nesklada uzivatel.
*/
export const builtinSources: Record<string, { kind: 'agentStats'; period: '30d' }> = {
'panel.agents': { kind: 'agentStats', period: '30d' },
};
export function findWidget(id: string): WidgetDefinition | undefined {
return widgets.find((widget) => widget.id === id);
}
+130
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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!, {