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