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
+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!, {