Revize projektu: prava, vykon, runtime, portal a ARES

Prava a bezpecnost: spravce firmy uz nemuze nastavit priznak spravce
platformy ani clenstvi v cizi firme; pozvanky, konektory a automatizace
kontroluji sve pravo; cizi firma v query je 404; zivy stream posila
udalosti jen firmam, kterych se tykaji; akce nad ticketem maji kontrolu
prava za firmu ticketu a strop viditelnosti; tokeny se nelogujou; limit
pokusu na prihlaseni, kontakt a pozvanky; bezpecnostni hlavicky;
zachyceni chyb v async handlerech; timing-safe porovnani tokenu.

Vykon: audit neskenuje celou kolekci pri kazdem zapisu a konecne maze
firemni zaznamy; ticket se uklada jednou misto trikrat; zapisy do
Postgresu jsou serializovane podle ID; prava se pocitaji jednou na
request; widgety nacitaji tickety jednou; strankovani seznamu; worker
je pool misto kol; na webu udalost ze streamu neodmontuje stranku,
dotazy maji spolecny debounce a cache, ciselniky drzi typovany sklad.

Runtime: opakuji se jen chyby oznacene retryable; smycka nenarazi na
strop 50 kroku (novy strop 1000 akci); podminka nad datem funguje;
vystup MCP nastroje neprepisuje spoustec; sandbox skriptu firmy nejde
opustit; MCP session id se drzi mezi volanimi; incident z kroku patri
firme; jedno rozhodnuti o rezimu uloziste; snapshot neprepise soubor
po chybe cteni.

Refaktory: sdilene typy API v src/shared (web nic nekopiruje, osm
rozjetych tvaru sjednoceno); spolecny modul net/guard pro volani ven;
formularova vrstva ui/form; rozdeleni Connectors a FlowCanvas; jeden
helper pro firmu z query, validaci a CRUD udalosti; pomucky ctx.util
pro skripty konektoru; i18n verejneho webu vcetne anglictiny.

Nova funkce: zalozeni firmy z registru ARES v Nastaveni (IC nebo nazev,
dotazeni IC, DIC, sidla a pravni formy, vyber soucasnych statutarnich
zastupcu a prokury, ucty spravce firmy s nahradnim e-mailem
IC-poradi@placeholder.cz).

Dokumentace: zaznam v 99-zmeny.md a aktualizace 15 dalsich dokumentu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-09 10:26:07 +02:00
co-authored by Claude Fable 5.1
parent 0c405ea55a
commit 104ae36783
215 changed files with 13226 additions and 8320 deletions
+247
View File
@@ -0,0 +1,247 @@
/**
* Klient registru ARES (ekonomicke subjekty CR).
*
* Slouzi spravci platformy k zalozeni firmy: podle IC nebo nazvu se dotahne
* nazev, DIC, adresa a pravni forma, a z verejneho rejstriku osoby, ktere
* za firmu jednaji. ARES je verejne API bez klice, takze tu neni zadny
* pristupovy udaj ani konektor.
*
* Tri volani, kazde s vlastnim tvarem odpovedi:
* - GET /ekonomicke-subjekty/{ico} zakladni udaje (registr ROS)
* - POST /ekonomicke-subjekty/vyhledat hledani podle nazvu
* - GET /ekonomicke-subjekty-vr/{ico} verejny rejstrik vcetne organu
*
* Odpovedi se nekesuji: zaklada se jedna firma jednou a ARES ma svuj limit
* pocitany na IP, ktery tenhle objem nedosahne.
*/
import { config } from '../config.js';
import { describeFetchError, readJsonLimited, tooLargeMessage } from '../net/guard.js';
/** Odpoved ARES ma desitky kB i u male firmy (historie zapisu), proto vyssi strop. */
const BODY_LIMIT = 4_000_000;
const TIMEOUT_MS = 15_000;
export class AresError extends Error {
constructor(
message: string,
/** HTTP stav od ARES, 0 = chyba spojeni. */
readonly status: number,
) {
super(message);
this.name = 'AresError';
}
}
export interface AresCompany {
ico: string;
name: string;
/** null = neni platce DPH nebo ARES DIC neuvadi. */
dic: string | null;
/** Textova adresa sidla tak, jak ji sklada ARES. */
address: string;
/** Kod pravni formy podle ciselniku ARES, napr. 112. */
legalFormCode: string;
/** Nazev pravni formy, kdyz kod zname; jinak samotny kod. */
legalForm: string;
}
export interface AresPerson {
/** Jmeno v beznem tvaru ("Jan Novak"), ARES ho vraci velkymi pismeny. */
name: string;
firstName: string;
lastName: string;
/** Organ a funkce, napr. "jednatel" nebo "clen predstavenstva". */
roles: string[];
}
/**
* Nejcastejsi pravni formy. ARES ma vlastni ciselnik na dalsim endpointu,
* ale kvuli popisku v jednom formulari ho nema smysl stahovat; neznamy kod
* se ukaze jako cislo a nic se tim nerozbije.
*/
const LEGAL_FORMS: Record<string, string> = {
'100': 'Podnikající fyzická osoba',
'101': 'Fyzická osoba podnikající dle živnostenského zákona',
'105': 'Fyzická osoba podnikající dle jiných zákonů',
'111': 'Veřejná obchodní společnost',
'112': 'Společnost s ručením omezeným',
'113': 'Komanditní společnost',
'121': 'Akciová společnost',
'141': 'Obecně prospěšná společnost',
'145': 'Společenství vlastníků jednotek',
'161': 'Ústav',
'205': 'Družstvo',
'301': 'Státní podnik',
'331': 'Příspěvková organizace',
'421': 'Odštěpný závod zahraniční právnické osoby',
'424': 'Zahraniční fyzická osoba',
'601': 'Vysoká škola',
'641': 'Školská právnická osoba',
'661': 'Veřejná výzkumná instituce',
'701': 'Spolek',
'706': 'Pobočný spolek',
'801': 'Obec',
'804': 'Kraj',
'941': 'Evropská společnost',
};
/** IC ma 8 cislic; kratsi se doplnuje nulami zleva, jak to dela i ARES. */
export function normalizeIco(value: string): string | null {
const digits = value.replace(/\s+/g, '');
if (!/^\d{1,8}$/.test(digits)) return null;
return digits.padStart(8, '0');
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : '';
}
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function list(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.map(record).filter((v): v is Record<string, unknown> => v !== null) : [];
}
async function call(path: string, init?: RequestInit): Promise<unknown> {
const url = `${config.aresBaseUrl}${path}`;
let response: Response;
try {
response = await fetch(url, {
...init,
headers: { accept: 'application/json', ...(init?.headers ?? {}) },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch (err) {
const info = describeFetchError(err, 'ARES');
console.error(`[ares] ${info.detail}`);
throw new AresError(info.message, 0);
}
const body = await readJsonLimited(response, BODY_LIMIT);
if (body.tooLarge) throw new AresError(tooLargeMessage(BODY_LIMIT), response.status);
if (response.status === 404) return null;
if (!response.ok) {
// ARES vraci v tele `popis`, ktery rika vic nez samotny kod.
const reason = text(record(body.json)?.popis) || text(record(body.json)?.subKod);
throw new AresError(
`ARES vrátil HTTP ${response.status}${reason ? `: ${reason}` : ''}.`,
response.status,
);
}
if (body.json === undefined) throw new AresError('ARES vrátil odpověď, která není JSON.', response.status);
return body.json;
}
function toCompany(subject: Record<string, unknown>): AresCompany | null {
const ico = normalizeIco(text(subject.ico));
const name = text(subject.obchodniJmeno);
if (ico === null || name === '') return null;
const legalFormCode = text(subject.pravniForma);
const seat = record(subject.sidlo);
return {
ico,
name,
dic: text(subject.dic) || null,
address: text(seat?.textovaAdresa),
legalFormCode,
legalForm: LEGAL_FORMS[legalFormCode] ?? legalFormCode,
};
}
/** Firma podle IC. `null` = ARES ji nezna. */
export async function lookupCompany(ico: string): Promise<AresCompany | null> {
const normalized = normalizeIco(ico);
if (normalized === null) throw new AresError('IČ musí být 1 až 8 číslic.', 400);
const json = await call(`/ekonomicke-subjekty/${normalized}`);
const subject = record(json);
return subject ? toCompany(subject) : null;
}
/** Firmy podle casti nazvu, nejvys `limit`. */
export async function searchCompanies(name: string, limit = 10): Promise<AresCompany[]> {
const query = name.trim();
if (query.length < 2) throw new AresError('Název pro hledání je moc krátký.', 400);
const json = await call('/ekonomicke-subjekty/vyhledat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ obchodniJmeno: query, start: 0, pocet: Math.min(Math.max(limit, 1), 50) }),
});
return list(record(json)?.ekonomickeSubjekty)
.map(toCompany)
.filter((c): c is AresCompany => c !== null);
}
/** "NOVAK" -> "Novak", "DE LA CRUZ-NOVA" -> "De La Cruz-Nova". */
function properCase(value: string): string {
return value
.toLocaleLowerCase('cs-CZ')
.replace(/(^|[\s\-'])(\p{L})/gu, (_, sep: string, ch: string) => sep + ch.toLocaleUpperCase('cs-CZ'));
}
/**
* Osoby, ktere za firmu dnes jednaji: clenove statutarnich organu a prokura.
*
* Verejny rejstrik vraci celou historii vcetne vymazanych clenu, proto se
* bere jen zaznam bez `datumVymazu`. Tataz osoba muze sedet ve dvou organech,
* slucuje se podle jmena a data narozeni.
*/
export async function listCompanyPersons(ico: string): Promise<AresPerson[]> {
const normalized = normalizeIco(ico);
if (normalized === null) throw new AresError('IČ musí být 1 až 8 číslic.', 400);
const json = await call(`/ekonomicke-subjekty-vr/${normalized}`);
const records = list(record(json)?.zaznamy);
// Firma muze mit vic zaznamu (napr. po presunu k jinemu soudu), primarni je ten platny.
const primary = records.find((r) => r.primarniZaznam === true) ?? records[0];
if (!primary) return [];
const found = new Map<string, AresPerson>();
for (const organ of list(primary.statutarniOrgany)) {
if (organ.datumVymazu) continue;
const organName = text(organ.nazevOrganu);
for (const member of list(organ.clenoveOrganu)) {
if (member.datumVymazu) continue;
const person = record(member.fyzickaOsoba);
if (!person) continue;
const firstName = properCase(text(person.jmeno));
const lastName = properCase(text(person.prijmeni));
if (firstName === '' && lastName === '') continue;
const membership = record(member.clenstvi);
const role = text(record(membership?.funkce)?.nazev) || organName || 'člen orgánu';
const key = `${firstName}|${lastName}|${text(person.datumNarozeni)}`;
const existing = found.get(key);
if (existing) {
if (!existing.roles.includes(role)) existing.roles.push(role);
continue;
}
found.set(key, {
name: [text(person.titulPred), firstName, lastName].filter(Boolean).join(' '),
firstName,
lastName,
roles: [role],
});
}
}
return [...found.values()];
}
/**
* Nahradni e-mail pro osobu z ARES: registr e-maily nevede, ale ucet bez
* e-mailu neexistuje. Tvar IC-poradi je dohodnuty a portal ho pozna, aby
* mohl upozornit, ze se ma nahradit skutecnym.
*/
export const PLACEHOLDER_DOMAIN = 'placeholder.cz';
export function placeholderEmail(ico: string, index: number): string {
return `${ico}-${index}@${PLACEHOLDER_DOMAIN}`;
}
export function isPlaceholderEmail(email: string): boolean {
return email.toLowerCase().endsWith(`@${PLACEHOLDER_DOMAIN}`);
}
+7
View File
@@ -188,6 +188,13 @@ export const config = {
* Jen pro lokalni vyvoj, v nasazeni musi zustat vypnute.
*/
allowPrivateTargets: process.env.ALLOW_PRIVATE_TARGETS === 'true',
/**
* Registr ARES pro zalozeni firmy podle IC nebo nazvu. Verejne API bez
* klice; promenna je tu jen pro testovaci prostredi nebo zrcadlo.
*/
aresBaseUrl: (process.env.ARES_BASE_URL ?? 'https://ares.gov.cz/ekonomicke-subjekty-v-be/rest')
.trim()
.replace(/\/+$/, ''),
/**
* Zpracovava tenhle proces frontu behu?
*
+22 -40
View File
@@ -16,12 +16,15 @@
*/
import { findPersonByEmail, groupMembers, groupsOfPerson, listGroups } from './people.js';
import { hasPermission, permissionsOf } from './permissions.js';
import { hasPermission, permissionsOf, rolesFor } from './permissions.js';
import { listActiveTenants, findTenant, type Tenant } from './tenants.js';
import { navFor, type NavItem } from './tenantFeatures.js';
import type { User } from '../types.js';
import { navFor } from './tenantFeatures.js';
import { rolesIn, type User } from '../types.js';
export type TicketScope = 'all' | 'tenant' | 'mine';
import type { Access, TicketScope } from '../shared/access.js';
/** Tvar prav je sdileny s webem, viz src/shared/access.ts. */
export type { Access, TicketScope };
/**
* Strop viditelnosti: co uzivatel **vubec smi** videt.
@@ -85,39 +88,6 @@ export function seesWholeTenant(user: User, tenantId: string | null): boolean {
return visibilityFor(user, tenantId).kind === 'all';
}
export interface Access {
/** Pohledy, ktere smi uzivatel pouzit. Klient podle toho kresli prepinac. */
scopes: TicketScope[];
/** Firmy, mezi kterymi muze prepinat. */
tenants: Array<{ id: string; name: string }>;
/** Vychozi firma, kdyz zadnou nezvoli. null = zadna, nema kam. */
defaultTenantId: string | null;
/** Smi prehazovat tickety mezi lidmi, ne jen brat na sebe. */
canAssignOthers: boolean;
/** ID resitele odpovidajiciho uzivateli, nebo null. */
personId: string | null;
/** Efektivni prava. Klient podle nich kresli tlacitka. */
permissions: string[];
/** Zalozky, ktere ma videt. Prunik toho, co firma ma, a toho, na co ma pravo. */
nav: NavItem[];
/** true = vidi napric firmami a smi platformni nastaveni. */
platformAdmin: boolean;
/**
* Vidi i cizi tickety, ne jen svoje a frontu?
*
* Podle toho se nabizi zalozka "Vsechny tickety". Kdo vidi jen svoje,
* nema co prepinat - zalozka by mu ukazala totez co ta prvni.
*/
seesOthers: boolean;
/**
* Sekce, na ktere clovek vidi. Filtr v zalozce "Vsechny tickety".
*
* Kdo vidi celou firmu, dostane vsechny. Vedouci jen ty, ktere vede -
* nabidnout mu sekci, ze ktere stejne nic neuvidi, je jen matouci.
*/
visibleGroups: Array<{ id: string; name: string }>;
}
/**
* Co uzivatel smi.
*
@@ -154,6 +124,14 @@ export function accessFor(user: User, tenantId?: string | null): Access {
? groups
: groups.filter((group) => visibility.groupIds.includes(group.id));
// Odkaz v clenstvi smi byt ID role i klic systemove role, viz `permissionsOf`.
const roles = activeTenant ? rolesFor(activeTenant) : [];
const roleNames = activeTenant
? rolesIn(user, activeTenant)
.map((ref) => roles.find((role) => role.id === ref || role.key === ref)?.name)
.filter((name): name is string => name !== undefined)
: [];
return {
scopes,
tenants: tenants.map((tenant) => ({ id: tenant.id, name: tenant.name })),
@@ -164,6 +142,7 @@ export function accessFor(user: User, tenantId?: string | null): Access {
// Prava se pocitaji za **vybranou firmu**. Kdo je spravce v jedne firme
// a resitel v druhe, uvidi po prepnuti jen to, co smi tam.
permissions: [...permissionsOf(user, activeTenant)].sort(),
roleNames,
nav: navFor(user, activeTenant),
platformAdmin: user.platformAdmin,
seesOthers: visibility.kind === 'all' || visibility.groupIds.length > 0,
@@ -178,8 +157,9 @@ export function accessFor(user: User, tenantId?: string | null): Access {
* Vraci `null`, kdyz uzivatel do zadane firmy nepatri nebo zadnou nema -
* volajici pak nema pravo na nic, coz je spravna odpoved.
*/
export function actingTenant(user: User, requested?: string): string | null {
const access = accessFor(user, requested);
export function actingTenant(user: User, requested?: string, precomputed?: Access): string | null {
// Prava uz za request spocital middleware, druhy vypocet by byl stejny.
const access = precomputed ?? accessFor(user, requested);
const tenantId = requested ?? access.defaultTenantId;
if (!tenantId) return null;
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
@@ -231,8 +211,10 @@ export interface ScopeDenied {
export function resolveScope(
user: User,
requested: { scope?: string; tenantId?: string },
precomputed?: Access,
): ResolvedScope | ScopeDenied {
const access = accessFor(user, requested.tenantId);
// Prava uz za request spocital middleware, druhy vypocet by byl stejny.
const access = precomputed ?? accessFor(user, requested.tenantId);
const scope = (requested.scope ?? (access.scopes.includes('tenant') ? 'tenant' : 'mine')) as
| TicketScope
+24 -5
View File
@@ -68,28 +68,47 @@ export function recordAudit(input: AuditInput): void {
void auditStore
.create(entry)
.then(() => trim())
.then(() => trimIfDue())
.catch((err: unknown) => {
console.error('[audit] zaznam se nepodarilo ulozit:', err);
});
}
/**
* Uklid je drahy - cte cely audit. Proto ne po kazdem zapisu, ale az po
* `TRIM_EVERY` zapisech, nebo nejdriv po `TRIM_INTERVAL_MS` od minuleho.
* Par zaznamu nad limit mezitim nikomu nevadi.
*/
const TRIM_EVERY = 50;
const TRIM_INTERVAL_MS = 60_000;
let writesSinceTrim = 0;
let lastTrimAt = 0;
let trimming = false;
function trimIfDue(): Promise<void> {
writesSinceTrim += 1;
const due = writesSinceTrim >= TRIM_EVERY || Date.now() - lastTrimAt >= TRIM_INTERVAL_MS;
if (!due) return Promise.resolve();
return trim();
}
/** Odmaze nejstarsi zaznamy nad limit. Bezi nejvyse jednou naraz. */
async function trim(): Promise<void> {
if (trimming) return;
trimming = true;
writesSinceTrim = 0;
lastTrimAt = Date.now();
try {
const all = await auditStore.listAll();
if (all.length <= MAX_ENTRIES) return;
const sorted = all.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
const excess = sorted.slice(0, all.length - MAX_ENTRIES);
for (const entry of excess) {
await auditStore.remove(entry.id, { tenantIds: [], includeGlobal: true });
}
console.info(`[audit] odmazano ${excess.length} nejstarsich zaznamu`);
// Bez filtru na firmu: maze se to, co se prave nacetlo pres listAll.
// Driv se mazalo s prazdnym seznamem firem a firemni zaznamy zustavaly.
const removed = await auditStore.removeMany(excess.map((entry) => entry.id));
console.info(`[audit] odmazano ${removed} z ${excess.length} nejstarsich zaznamu`);
} catch (err) {
console.error('[audit] uklid selhal:', err);
} finally {
+94 -274
View File
@@ -1,154 +1,54 @@
/**
* Uloziste automatizaci vcetne jejich stromu akci (flow).
*
* POZOR: data jsou v pameti procesu - restart API je vrati na vychozi sadu.
* To je vedome zjednoduseni prototypu, nahrada za databazi je popsana
* v docs/04-backend-api.md, sekce "Kam dal".
* Automatizace se drzi v pameti kvuli synchronnimu cteni a po kazde zmene se
* cela zapise do uloziste (`withMirror`). Kam - databaze, soubor, nebo nikam -
* rozhoduje `data/store/index.ts`; viz documentation/14-databaze.md.
*/
import { randomBytes } from 'node:crypto';
import { config } from '../config.js';
import { publish } from '../events/bus.js';
import { defineStore } from './store/index.js';
import { timingSafeEqualString } from '../lib/secure.js';
import { defineStore, highestNumber, minutesAgo, writableOrWarn } from './store/index.js';
import { withMirror } from './store/mirror.js';
import {
isUnary,
type ConditionMatch,
type ConditionOperator,
type ConditionRule,
type FieldType,
} from './conditions.js';
import { actionInputsFor, findService } from './services.js';
import { collectScopes, duplicateNames, scopeFor } from './flowScope.js';
import { referencedFields, rootOf } from './templates.js';
import { describeModel, rootsOf, type ModelNode } from './model.js';
import { describeModel, rootsOf } from './model.js';
export type AutomationKind = 'workflow' | 'voicebot' | 'integrace' | 'report';
/** Jeden vstupni parametr, ktery spoustec preda dal do stromu. */
export interface TriggerField {
id: string;
/** Klic v prichozich datech - napr. "orderTotal". Musi byt unikatni. */
name: string;
type: FieldType;
required: boolean;
/**
* Kde ta hodnota v tele je, kdyz to neni primo `name`.
*
* Odesilatele posilaji ruzne tvary: jeden `{"a":"aaa"}`, druhy cely model
* s vnorenymi objekty a poli. Bez cesty by slo napojit jen ploche telo
* a slozitejsi odesilatel by se musel prizpusobovat nam, coz nejde.
*
* Priklady: `customer.id`, `errors.0.message`, `data.items`.
*/
path?: string;
}
export interface FlowTrigger {
serviceId: string;
operationId: string;
/** Deklarovane vstupni parametry. Podminky se odkazuji na jejich `id`. */
fields: TriggerField[];
/**
* Ukazka toho, co odesilatel opravdu posila.
*
* Vlepi se cele telo z realneho volani. Z nej se odvodi model, tedy seznam
* cest i s typy, ktery se v builderu ukaze jako strom a klikanim se z nej
* vklada odkaz do sablony.
*
* Proc ukazka a ne rucne psany seznam poli: model objednavky ze Shoptetu ma
* desitky cest a seznam polozek. Vypsat ho po jednom nikdo nebude a pole
* do plocheho seznamu nepatri vubec.
*/
sample?: unknown;
/**
* Jak casto se ma sluzba obvolavat, kdyz nam sama nezavola (v sekundach).
* Plati jen u spoustecu, ktere se musi ptat. Minimum je 10 s.
*/
intervalSec?: number;
/**
* Neodhadnutelny token v adrese webhooku. Generuje VZDY server,
* klient ho nesmi urcovat ani menit.
*/
webhookToken?: string;
}
import type {
Automation,
AutomationDetail,
AutomationFlow,
AutomationKind,
FlowStep,
FlowTrigger,
TriggerField,
WebhookCall,
WebhookCallField,
} from '../shared/automations.js';
/**
* Krok stromu. `action` je jeden ukon nad sluzbou, `condition` rozdeluje
* beh na dve vetve podle hodnoty vstupniho parametru - proto je to strom.
* Tvar automatizace a stromu je sdileny s webem, viz src/shared/automations.ts.
* Tady zustava uloziste a prace se stromem.
*/
export type FlowStep =
| {
id: string;
kind: 'action';
/** Ktera sluzba a jeji operace. Sluzba je to, co umime. */
serviceId: string;
operationId: string;
/**
* Ktery konektor firmy se pouzije, tedy pod jakymi udaji se to zavola.
*
* `null` znamena vychozi konektor firmy pro tuhle sluzbu. Diky tomu je
* vzorovy strom prenositelny mezi firmami - kazde se dosadi jeji vlastni
* napojeni. U obecnych sluzeb (webhook, pauza, transformace) je vzdy null,
* ty konektor nepotrebuji.
*/
connectorId?: string | null;
/**
* Nastaveni akce: klic je `OperationField.id` z katalogu, hodnota je
* sablona s odkazy na parametry spoustece (`{{subject}}`).
* Chybi u akci, ktere zatim nemaji `inputs`.
*/
inputs?: Record<string, string>;
}
| {
id: string;
kind: 'condition';
/**
* Otazky, ktere se ptaji naraz. Cte se **vzdycky pres `rulesOf`**.
*
* Driv byla podminka prave jedna otazka a slozitejsi vetveni se skladalo
* z vnorenych podminek: "vysledek dorazil" a v nem "vysledek je X" byly
* dve urovne stromu misto jedne vety.
*/
rules?: ConditionRule[];
/** `all` = a zaroven, `any` = nebo. Chybi u jedne otazky, tam nema co spojovat. */
match?: ConditionMatch;
/**
* Stara podoba: jedna otazka primo na kroku.
*
* Zaznamy ulozene driv ji maji misto `rules`. Prevadi ji `rulesOf`,
* zapisuje se uz vzdycky `rules`.
*/
fieldId?: string;
operator?: ConditionOperator;
value?: string;
yes: FlowStep[];
no: FlowStep[];
}
| {
id: string;
/**
* Podstrom, ktery se vykona **nad kazdou polozkou seznamu**.
*
* Bez tohohle nejde rict "kazdou polozku objednavky zaloz v CRM". Jedno
* volani se seznamem uvnitr se da poskladat transformaci, ale kdyz ma
* kazda polozka vyvolat vlastni akci a vratit vlastni ID, je potreba
* smycka.
*
* Uvnitr je k dispozici `item` (cela polozka) a `index`. Po skonceni
* `krok.results`, coz je seznam vysledku za kazdou polozku - prave to se
* pak posila dal jako radky objednavky.
*/
kind: 'foreach';
/** Cesta k seznamu v datech, napr. `data.order.items`. */
path: string;
steps: FlowStep[];
};
export interface AutomationFlow {
trigger: FlowTrigger | null;
steps: FlowStep[];
}
export type {
Automation,
AutomationDetail,
AutomationFlow,
AutomationKind,
FlowStep,
FlowTrigger,
TriggerField,
WebhookCall,
WebhookCallField,
};
/**
* Otazky podminky, at uz je krok v jakekoliv podobe.
@@ -173,85 +73,6 @@ export function generateWebhookToken(): string {
return randomBytes(24).toString('base64url');
}
/** Polozka v seznamu automatizaci - bez celeho stromu. */
export interface Automation {
id: string;
/** Firma, ktere automatizace patri. Hranice viditelnosti. */
tenantId: string;
name: string;
kind: AutomationKind;
enabled: boolean;
/**
* Behy za **dnesni den**. Po pulnoci zacina od nuly.
*
* Driv to byl citac od zalozeni automatizace, jen se jmenoval "dnes".
* U automatizace, ktera bezi tri mesice, to rikalo cislo, ktere s dneskem
* nemelo nic spolecneho.
*/
runsToday: number;
/** Behy za vcerejsi den, at je s cim srovnat. */
runsYesterday: number;
/** Behy od zalozeni. Puvodni citac se neztratil, jen se spravne jmenuje. */
runsTotal: number;
/** Uspesnost dnesnich behu. Kdyz dnes zadny nebyl, posledniho dne, kdy byly. */
successRate: number;
avgDurationMs: number;
lastRunAt: string;
/** Pocet vsech kroku vcetne vnorenych vetvi. */
stepCount: number;
/** false = automatizace jeste nema spoustec, je to koncept. */
configured: boolean;
/**
* Co chybi k tomu, aby se dala zapnout. Prazdne = je hotova.
* Zobrazuje se uzivateli, nesmi zmizet tise.
*/
issues: string[];
}
/** Jak dopadl jeden deklarovany parametr v jednom volani. */
export interface WebhookCallField {
name: string;
/** Cesta v tele. Shodna se jmenem, kdyz se nezadala vlastni. */
path: string;
required: boolean;
/** Deklarovany typ z kontraktu. */
type: string;
/** Co na te ceste opravdu bylo: text, cislo, objekt, seznam, nepreslo. */
got: string;
/** Kratky nahled prijate hodnoty. */
preview: string;
/** false = tenhle parametr je duvod, proc se volani odmitlo. */
ok: boolean;
}
/**
* Jedno volani webhooku, jak dopadlo.
*
* Odmitnute volani do ted skoncilo jako radek v logu kontejneru, kam se nikdo
* nedostane. Odesilatel dostal 400 a vedel o tom, ale ten, kdo kontrakt napsal,
* se nedozvedel nic - automatizace svitila zelene a jen do ni nic nechodilo.
*
* Drzi se **cele prijate telo i rozpad po parametrech**. Duvod odmitnuti sam
* nestaci: "parametr data ma mit typ text" nerekne, co tedy prislo, a bez toho
* zbyva hadat. Tickety si cela prijata tela u udalosti drzi uz davno, takze to
* neni novy druh dat, jen se konecne daji videt i u volani, ktera neprosla.
*/
export interface WebhookCall {
at: string;
/** true = prevzato do fronty, false = odmitnuto pro neplatna data. */
ok: boolean;
/** Duvody odmitnuti. Prazdne u prevzatych volani. */
problems: string[];
/** Beh, ktery z volani vznikl. null u odmitnutych a u duplicit. */
runId: string | null;
/** Kazdy deklarovany parametr a jak dopadl. */
fields: WebhookCallField[];
/** Cele prijate telo jako JSON. Delsi nez `MAX_BODY` se usekne. */
body: string;
/** true = telo bylo delsi a je useknute. */
truncated: boolean;
}
/** Kolik znaku tela se u volani drzi. Vic uz je v pameti zbytecne. */
const MAX_BODY = 8_000;
@@ -286,27 +107,6 @@ export function recentWebhookCalls(automationId: string): WebhookCall[] {
return calls.get(automationId) ?? [];
}
export interface AutomationDetail extends Automation {
flow: AutomationFlow;
/**
* Poslednich `MAX_CALLS` volani webhooku, nejnovejsi prvni.
*
* Drzi se v pameti, restart je zahodi. Je to diagnostika posledni hodiny,
* ne historie - na tu jsou behy.
*/
recentCalls: WebhookCall[];
/**
* Model prichozich dat odvozeny z ukazky u spoustece.
*
* Pocita se pri cteni, neuklada se - je to pohled na `trigger.sample`.
* Kdyby se ukladal, sel by rozejit s ukazkou a nikdo by nepoznal, ktery
* z nich plati.
*/
model: ModelNode[];
createdAt: string;
updatedAt: string;
}
interface StoredAutomation {
id: string;
tenantId: string;
@@ -327,6 +127,16 @@ interface StoredAutomation {
avgDurationMs: number;
lastRunAt: string;
flow: AutomationFlow;
/**
* Nedodelky a pocet kroku **spocitane pri ulozeni**, ne pri kazdem cteni.
*
* Seznam automatizaci se cte pri kazdem nacteni prehledu i pri kazde zmene
* ticketu (spoustece), a prochazet kvuli tomu pokazde cely strom vsech
* automatizaci bylo znat. Nepovinne, protoze starsi zaznamy je nemaji -
* dopocitaji se pri startu.
*/
issues?: string[];
stepCount?: number;
createdAt: string;
updatedAt: string;
}
@@ -378,10 +188,6 @@ function statsOf(stored: StoredAutomation): {
};
}
function minutesAgo(minutes: number): string {
return new Date(Date.now() - minutes * 60_000).toISOString();
}
/** Rekurzivne secte kroky vcetne obou vetvi podminek. */
export function countSteps(steps: FlowStep[]): number {
return steps.reduce((sum, step) => {
@@ -597,6 +403,15 @@ function save(automation: StoredAutomation): void {
mirror.save(automation);
}
/** Doplni to, co se ze stromu dopocitava a uklada s nim (nedodelky, pocet kroku). */
function withDerived(automation: StoredAutomation): StoredAutomation {
return {
...automation,
stepCount: countSteps(automation.flow.steps),
issues: collectFlowIssues(automation.flow),
};
}
/**
* Nacte automatizace z uloziste. Vola se pri startu, viz data/bootstrap.ts.
*
@@ -607,14 +422,16 @@ export async function initAutomations(): Promise<void> {
const rows = await mirror.load(() => [...store.values()]);
store.clear();
for (const row of rows) store.set(row.id, row);
/*
* Nedodelky se pri startu prepocitaji u vsech, ne jen u zaznamu bez nich:
* zavisi na katalogu sluzeb a ten se mezi nasazenimi meni (nove povinne
* pole kroku). Jednou pri startu je to levne, pri kazdem cteni ne.
*/
for (const row of rows) store.set(row.id, withDerived(row));
// Citac musi pokracovat za nejvyssim ulozenym cislem, jinak by nova
// automatizace prepsala starou.
for (const id of store.keys()) {
const number = Number(/^AUT-(\d+)$/.exec(id)?.[1] ?? 0);
if (number > idCounter) idCounter = number;
}
idCounter = Math.max(idCounter, highestNumber(store.keys(), 'AUT'));
}
function nextId(): string {
@@ -628,15 +445,18 @@ function seed(
},
) {
const id = nextId();
store.set(id, {
// Ukazkova data patri Automii, kdyz neni receno jinak.
tenantId: 'tnt_automia',
...automation,
store.set(
id,
kind: deriveKind(automation.flow),
createdAt: minutesAgo(60 * 24 * 90),
updatedAt: minutesAgo(60 * 12),
});
withDerived({
// Ukazkova data patri Automii, kdyz neni receno jinak.
tenantId: 'tnt_automia',
...automation,
id,
kind: deriveKind(automation.flow),
createdAt: minutesAgo(60 * 24 * 90),
updatedAt: minutesAgo(60 * 12),
}),
);
}
/**
@@ -1102,15 +922,17 @@ function seedDemoAutomations(): void {
/**
* Automatizace, ktere na instanci opravdu bezi.
*
* Dokud data neprezivaji redeploy, je tohle jedine misto, kde nastaveni
* prezije nasazeni. Az bude Postgres, patri to zpatky do dat a odsud pryc.
* Je to **vychozi sada pro prazdne uloziste**, ne zdroj pravdy: pouzije se
* jen pri prvnim startu (nebo po ztrate dat, napr. redeploy bez databaze
* a bez svazku). Kdyz uloziste uz neco ma, tenhle kod se nepouzije a strom
* na instanci muze byt jiny. Zdrojem pravdy je uloziste, viz
* documentation/14-databaze.md.
*
* Token webhooku se bere z `WEBHOOK_TOKEN_TEST`, aby se adresa po nasazeni
* Token webhooku se bere z `WEBHOOK_TOKEN_TEST`, aby se adresa po ztrate dat
* nemenila a odesilatel ji nemusel prepisovat.
*
* **Opsano z bezici instance, ne vymysleno.** Kdyz se strom na instanci zmeni,
* patri ta zmena sem, jinak ji dalsi nasazeni zahodi. Naposledy srovnano
* 2026-09-02.
* Opsano z bezici instance 2026-09-02. Kdyz se strom na instanci zmeni a ma
* prezit i ztratu dat, patri ta zmena sem.
*/
function seedRealAutomations(): void {
seed({
@@ -1254,9 +1076,11 @@ function toSummary(stored: StoredAutomation): Automation {
* je "dnes" nula, dokud opravdu neco nebezi.
*/
...statsOf(stored),
stepCount: countSteps(stored.flow.steps),
// Spocitane pri ulozeni nebo pri startu, viz StoredAutomation. Zaloha
// pro pripad, ze by zaznam prisel jinudy nez pres `withDerived`.
stepCount: stored.stepCount ?? countSteps(stored.flow.steps),
configured: stored.flow.trigger !== null,
issues: collectFlowIssues(stored.flow),
issues: stored.issues ?? collectFlowIssues(stored.flow),
};
}
@@ -1295,7 +1119,7 @@ export function getAutomation(id: string, tenantIds: string[]): AutomationDetail
export function createAutomation(name: string, tenantId: string): AutomationDetail {
const id = nextId();
const now = new Date().toISOString();
const stored: StoredAutomation = {
const stored = withDerived({
id,
tenantId,
name,
@@ -1308,22 +1132,16 @@ export function createAutomation(name: string, tenantId: string): AutomationDeta
flow: { trigger: null, steps: [] },
createdAt: now,
updatedAt: now,
};
});
save(stored);
console.info(`[automations] vytvorena automatizace ${id} "${name}"`);
publish('automation.created', `Vytvořena automatizace ${id}: ${name}`, { automationId: id });
publish('automation.created', `Vytvořena automatizace ${id}: ${name}`, { automationId: id }, tenantId);
return toDetail(stored);
}
/** Automatizace z povolenych firem. Cizi se chova jako neexistujici. */
function findWritable(id: string, tenantIds: string[]): StoredAutomation | undefined {
const stored = store.get(id);
if (!stored) return undefined;
if (!tenantIds.includes(stored.tenantId)) {
console.warn(`[automations] pokus o zapis do ${id} mimo povolene firmy`);
return undefined;
}
return stored;
return writableOrWarn(store.get(id), id, tenantIds, 'automations');
}
export function updateAutomation(
@@ -1338,18 +1156,18 @@ export function updateAutomation(
}
const flow = withWebhookToken(patch.flow ?? stored.flow, stored.flow, id);
const updated: StoredAutomation = {
const updated = withDerived({
...stored,
name: patch.name ?? stored.name,
enabled: patch.enabled ?? stored.enabled,
flow,
kind: deriveKind(flow),
updatedAt: new Date().toISOString(),
};
});
// Nedokoncenou automatizaci nepustime do provozu - "aktivni" by nic nedelala
// nebo by delala neco jineho, nez uzivatel ceka.
const issues = collectFlowIssues(updated.flow);
const issues = updated.issues ?? [];
if (updated.enabled && issues.length > 0) {
console.warn(`[automations] ${id}: zapnuti odmitnuto - ${issues.join(' ')}`);
updated.enabled = false;
@@ -1357,12 +1175,12 @@ export function updateAutomation(
save(updated);
console.info(
`[automations] ulozena automatizace ${id} (kroku: ${countSteps(updated.flow.steps)}, aktivni: ${updated.enabled}, nedodelku: ${issues.length})`,
`[automations] ulozena automatizace ${id} (kroku: ${updated.stepCount}, aktivni: ${updated.enabled}, nedodelku: ${issues.length})`,
);
publish('automation.updated', `Automatizace ${id} uložena: ${updated.name}`, {
automationId: id,
enabled: updated.enabled,
});
}, updated.tenantId);
return toDetail(updated);
}
@@ -1414,14 +1232,15 @@ export function regenerateWebhookToken(
return undefined;
}
const updated: StoredAutomation = {
// Token je soucast nedodelku ("webhook nema adresu"), proto se dopocitava znovu.
const updated = withDerived({
...stored,
flow: {
...stored.flow,
trigger: { ...stored.flow.trigger, webhookToken: generateWebhookToken() },
},
updatedAt: new Date().toISOString(),
};
});
save(updated);
console.info(`[automations] ${id}: token webhooku pregenerovan, stara adresa neplati`);
return toDetail(updated);
@@ -1430,7 +1249,7 @@ export function regenerateWebhookToken(
/** Najde automatizaci podle tokenu v adrese webhooku. */
export function findByWebhookToken(token: string): AutomationDetail | undefined {
for (const stored of store.values()) {
if (stored.flow.trigger?.webhookToken === token) return toDetail(stored);
if (stored.flow.trigger?.webhookToken && timingSafeEqualString(stored.flow.trigger.webhookToken, token)) return toDetail(stored);
}
return undefined;
}
@@ -1482,6 +1301,7 @@ export function recordRun(id: string, ok = true, tenantIds?: string[]): Automati
? `Automatizace ${id} proběhla: ${updated.name}`
: `Automatizace ${id} skončila chybou: ${updated.name}`,
{ automationId: id, ok },
updated.tenantId,
);
return toDetail(updated);
}
@@ -1497,7 +1317,7 @@ export function deleteAutomation(id: string, tenantIds: string[]): boolean {
console.info(`[automations] smazana automatizace ${id}`);
publish('automation.deleted', `Automatizace ${id} smazána: ${name ?? ''}`, {
automationId: id,
});
}, stored?.tenantId ?? null);
}
return existed;
}
+34 -33
View File
@@ -9,23 +9,16 @@
* co se na ne pta.
*/
import { refreshActions, actionPermissions, actionStore, seedActions } from './ticketActions.js';
import { refreshCustomWidgets, customWidgetStore, seedCustomWidgets } from './customWidgets.js';
import { actionPermissions, actionStore, seedActions } from './ticketActions.js';
import { customWidgetStore, seedCustomWidgets } from './customWidgets.js';
import { auditStore } from './audit.js';
import { notificationStore, refreshNotifications } from './notifications.js';
import { inviteStore, refreshInvites } from './invites.js';
import { tenantScriptStore, refreshTenantScripts } from './tenantScripts.js';
import { notificationStore } from './notifications.js';
import { inviteStore } from './invites.js';
import { tenantScriptStore } from './tenantScripts.js';
import { onTicket } from './ticketHooks.js';
import { onTicketEvent } from '../runtime/triggers.js';
import { initQueue } from '../runtime/queue.js';
import {
groupStore,
personStore,
refreshGroups,
refreshPeople,
seedGroups,
seedPeople,
} from './people.js';
import { groupStore, personStore, seedGroups, seedPeople } from './people.js';
import {
refreshRoles,
roleStore,
@@ -34,11 +27,12 @@ import {
systemRoles,
invalidatePermissions,
} from './permissions.js';
import { refreshAllCaches, refreshCache } from './store/cached.js';
import { initStores, type EntityStore, type TenantEntity } from './store/index.js';
import { featuresStore, refreshFeatures, seedFeatures } from './tenantFeatures.js';
import { refreshTenants, seedTenants, tenantStore } from './tenants.js';
import { refreshTicketTypes, seedTicketTypes, ticketTypeStore } from './ticketTypes.js';
import { refreshUsers, seedUsers, userStore } from './users.js';
import { featuresStore, seedFeatures } from './tenantFeatures.js';
import { seedTenants, tenantStore } from './tenants.js';
import { seedTicketTypes, ticketTypeStore } from './ticketTypes.js';
import { seedUsers, userStore } from './users.js';
import { refreshMcpTools } from './mcpTools.js';
import { initTickets } from './ticketStore.js';
import { initAutomations } from './automationStore.js';
@@ -84,25 +78,32 @@ const runtimeData: Array<{ name: string; init: () => Promise<void> }> = [
];
/**
* Obnovi kopie v pameti u entit, ktere se ctou pri kazdem requestu.
* Vola se pri startu a po kazdem zapisu, ktery je muze zmenit.
* Obnovi kopie v pameti u vsech entit, ktere se ctou pri kazdem requestu.
* Vola se pri startu. Po zapisu staci `refreshEntity` jen pro to, co se zmenilo.
*/
export async function refreshCaches(): Promise<void> {
await Promise.all([
refreshTenants(),
refreshUsers(),
refreshRoles(),
refreshPeople(),
refreshGroups(),
refreshFeatures(),
refreshTicketTypes(),
refreshActions(),
refreshCustomWidgets(),
refreshNotifications(),
refreshInvites(),
refreshTenantScripts(),
]);
// Role maji vlastni kopii mimo `withCache`, proto zvlast.
await Promise.all([refreshAllCaches(), refreshRoles()]);
afterRefresh();
}
/**
* Obnovi kopii jednoho druhu entity (`store.kind`, napr. `person`).
*
* Zapis jedne firmy nema znamenat cteni vsech dvanacti tabulek. Neznamy druh
* vraci false a volajici si ma zvolit plnou obnovu.
*/
export async function refreshEntity(kind: string): Promise<boolean> {
if (kind === roleStore.kind) {
await refreshRoles();
} else if (!(await refreshCache(kind))) {
return false;
}
afterRefresh();
return true;
}
function afterRefresh(): void {
// Prava k akcim vznikaji z definic akci, takze se registruji az po jejich nacteni.
setDynamicPermissions(actionPermissions());
invalidatePermissions();
+10 -45
View File
@@ -1,30 +1,19 @@
/**
* Typy vstupnich parametru a operatory podminek.
*
* POZOR: stejne tabulky ma i frontend v apps/web/src/lib/flow.ts.
* Pri zmene je nutne upravit obe strany (viz docs/08-automatizace-builder.md).
* POZOR: stejnou tabulku `operatorsByType` ma i web ve `web/src/lib/flow.ts`.
* Pri zmene je nutne upravit obe strany (viz documentation/05-dashboard-a-builder.md).
*/
/**
* `object` a `list` jsou celé struktury, ne jednotlive hodnoty. Vznikly kvuli
* transformacim: krok muze predat dal cely objekt objednavky, ne jen jeho pole.
* Do sablony se nedosazuji, predavaji se jen jako celek dalsimu kroku.
*/
export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object' | 'list';
import type {
ConditionMatch,
ConditionOperator,
ConditionRule,
FieldType,
} from '../shared/conditions.js';
export type ConditionOperator =
| 'eq'
| 'neq'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'startsWith'
| 'isEmpty'
| 'isNotEmpty'
| 'isTrue'
| 'isFalse';
/** Typy jsou sdilene s webem, viz src/shared/conditions.ts. Tady zustavaji tabulky. */
export type { ConditionMatch, ConditionOperator, ConditionRule, FieldType };
export const fieldTypes: FieldType[] = [
'string',
@@ -70,30 +59,6 @@ export const operatorsByType: Record<FieldType, ConditionOperator[]> = {
list: ['contains', 'isEmpty', 'isNotEmpty'],
};
/**
* Jedna otazka v podmince.
*
* Driv byla podminka **prave jedna otazka** a slozitejsi vetveni se muselo
* skladat z vnorenych podminek. U tri hodnot, ktere maji dopadnout stejne, to
* znamenalo tri urovne stromu, ve kterych se nikdo nevyzna.
*/
export interface ConditionRule {
/** ID parametru spoustece, nebo `krok.vystup`. */
fieldId: string;
operator: ConditionOperator;
/** Chybi u operatoru, ktere hodnotu nepotrebuji (isEmpty, isTrue apod.). */
value?: string;
}
/**
* Jak se otazky spoji.
*
* `all` = a zaroven, `any` = nebo. Zamerne **jedna uroven**, ne strom se
* zavorkami: dve treti podminek jsou "vsechny tohle" nebo "cokoliv z tohohle"
* a zavorky by v rozhrani znamenaly editor vyrazu, ktery uz nikdo neuklika.
*/
export type ConditionMatch = 'all' | 'any';
/** Operatory, ktere nepotrebuji hodnotu k porovnani. */
export const unaryOperators: ConditionOperator[] = [
'isEmpty',
+15 -26
View File
@@ -1,5 +1,5 @@
/**
* Konektory: vyber uloziste a jedine misto, kde se rozhoduje o rezimu.
* Konektory: vyber uloziste podle rezimu, o kterem rozhodl `store/index.ts`.
*
* Tri rezimy, jedno rozhrani:
*
@@ -9,9 +9,9 @@
* | `file` | neni databaze, ale je datova slozka | restart, ne redeploy |
* | `memory` | ani jedno, nebo se nepodarilo zapsat | nic |
*
* Rozhodnuti je **jen tady**. Nikde jinde se nezjistuje, ktery rezim jede -
* kdyby se to rozlezlo po kodu, jedno misto by se zapomnelo a chovalo by se
* pak jinak nez zbytek.
* Rozhodnuti je **jedno pro vsechna uloziste** (`initStores`). Driv se tady
* rozhodovalo podruhe a s jinou podminkou, takze tickety mohly jit do databaze
* a konektory do souboru. Tady se uz jen sestavuje stav pro portal a log.
*
* Databaze potrebuje klic ze `SECRETS_KEY`. Klic vedle dat by u ni nemel smysl:
* kdo ma zalohu tabulky, ma i klic ze stejneho stroje.
@@ -20,7 +20,7 @@
import path from 'node:path';
import { config } from '../config.js';
import { isDatabaseEnabled } from '../db/pool.js';
import { canSealSecrets, initSecrets, secretsKeySource } from '../db/secretBox.js';
import { canSealSecrets, secretsKeySource } from '../db/secretBox.js';
import { createLocalConnectors } from './connectors/local.js';
import { postgresConnectors } from './connectors/postgres.js';
import type {
@@ -31,6 +31,7 @@ import type {
UpdateConnectorInput,
} from './connectors/types.js';
import { fileSnapshot, memorySnapshot } from './snapshot.js';
import { initStores } from './store/index.js';
import type { McpToolset } from '../mcp/client.js';
export type {
@@ -43,21 +44,10 @@ export type {
} from './connectors/types.js';
export { CHECK_HISTORY, toPublicConnector, validateConnectorValues } from './connectors/types.js';
export type StorageMode = 'postgres' | 'file' | 'memory';
import type { StorageMode, StorageStatus } from '../shared/connectors.js';
export interface StorageStatus {
mode: StorageMode;
/** Kratke vysvetleni pro portal. Vzdy vyplnene, i u databaze. */
reason: string;
/** true = restart procesu data smaze. */
ephemeral: boolean;
/** true = redeploy containeru data smaze, i kdyz restart ne. */
lostOnRedeploy: boolean;
/** Odkud je klic k sifrovani tajnych hodnot. */
secrets: 'env' | 'file' | 'none';
/** Kde data lezi. null u pameti. */
location: string | null;
}
/** Stav uloziste je sdileny s webem, viz src/shared/connectors.ts. */
export type { StorageMode, StorageStatus };
let repository: ConnectorRepository = createLocalConnectors({
store: memorySnapshot(),
@@ -85,13 +75,12 @@ export interface InitOptions {
/** Vybere uloziste. Vola se jednou pri startu, po migracich. */
export async function initConnectorStore(options: InitOptions = {}): Promise<StorageStatus> {
const databaseReady = options.databaseReady ?? true;
const useDatabase = isDatabaseEnabled() && databaseReady;
// Klic vedle dat je pripustny jen mimo databazi. U ni ma prijit ze secretu.
initSecrets({ allowKeyFile: !useDatabase, dir: config.dataDir });
// Rezim je spolecny pro vsechna uloziste. `initStores` zaroven nastavi klic
// k sifrovani, proto se na nej pta az po nem.
const mode = initStores({ databaseReady });
const secrets = secretsKeySource();
if (useDatabase && canSealSecrets()) {
if (mode === 'postgres') {
repository = postgresConnectors;
flushRepository = null;
status = {
@@ -111,13 +100,13 @@ export async function initConnectorStore(options: InitOptions = {}): Promise<Sto
const reasons: string[] = [];
if (isDatabaseEnabled() && !databaseReady) {
reasons.push('Databáze je nastavená, ale schéma není použitelné, zkontrolujte log migrací.');
} else if (useDatabase && !canSealSecrets()) {
} else if (isDatabaseEnabled() && !canSealSecrets()) {
reasons.push('Databáze je nastavená, ale chybí SECRETS_KEY. Údaje by se ukládaly čitelně.');
} else if (!isDatabaseEnabled()) {
reasons.push('Databáze není nastavená (DATABASE_URL).');
}
const useFile = config.dataDir !== '';
const useFile = mode === 'file';
const file = path.join(config.dataDir, 'connectors.json');
const local = createLocalConnectors({
+67 -50
View File
@@ -21,6 +21,7 @@ import type { SnapshotStore } from '../snapshot.js';
import type { McpToolset } from '../../mcp/client.js';
import {
CHECK_HISTORY,
mergeValues,
nowIso,
type Connector,
type ConnectorRepository,
@@ -28,6 +29,44 @@ import {
type UpdateConnectorInput,
} from './types.js';
/**
* Novy konektor bez overeni a bez nastroju. Jedno misto pro zalozeni
* z formulare i pro ukazkovy zaznam - dva literaly by se rozesly v tom,
* jake vychozi hodnoty ma nove pole.
*/
function newConnector(input: {
tenantId: string;
serviceId: string;
name: string;
baseUrl: string | null;
values: Record<string, string>;
isDefault: boolean;
}): Connector {
const timestamp = nowIso();
return {
id: `con_${randomUUID().slice(0, 8)}`,
tenantId: input.tenantId,
serviceId: input.serviceId,
name: input.name,
baseUrl: input.baseUrl,
values: { ...input.values },
enabled: true,
status: 'untested',
lastCheckAt: null,
lastError: null,
checks: [],
mcp: null,
isDefault: input.isDefault,
createdAt: timestamp,
updatedAt: timestamp,
};
}
/** Stejne poradi jako `ORDER BY name` v Postgresu: vychozi prvni, pak podle nazvu. */
function byDefaultThenName(a: Connector, b: Connector): number {
return Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name, 'cs');
}
/**
* Tvar zaznamu v souboru. Proti `Connector` se lisi jen tim, ze `secrets`
* je zasifrovana obalka misto ctecich hodnot.
@@ -79,7 +118,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
}
return {
kind: store.kind === 'file' ? 'memory' : 'memory',
kind: store.kind,
async init() {
const stored = await store.load();
@@ -117,11 +156,12 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
},
async defaultFor(tenantId, serviceId) {
const forService = rows.filter(
(row) => row.tenantId === tenantId && row.serviceId === serviceId && row.enabled,
);
const chosen = forService.find((row) => row.isDefault) ?? forService[0];
return chosen ? copy(chosen) : undefined;
// Vychozi vyhrava, jinak prvni podle nazvu. Vypnute konektory se neberou.
// Stejne pravidlo jako v Postgresu, aby se rezimy nelisily.
const forService = rows
.filter((row) => row.tenantId === tenantId && row.serviceId === serviceId && row.enabled)
.sort(byDefaultThenName);
return forService[0] ? copy(forService[0]) : undefined;
},
async countsByService(tenantIds) {
@@ -133,29 +173,19 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
},
async create(input: CreateConnectorInput) {
const timestamp = nowIso();
const existing = rows.filter(
(row) => row.tenantId === input.tenantId && row.serviceId === input.serviceId,
);
const connector: Connector = {
id: `con_${randomUUID().slice(0, 8)}`,
const connector = newConnector({
tenantId: input.tenantId,
serviceId: input.serviceId,
name: input.name,
baseUrl: input.baseUrl ?? null,
values: { ...(input.values ?? {}) },
enabled: true,
status: 'untested',
lastCheckAt: null,
lastError: null,
checks: [],
mcp: null,
values: input.values ?? {},
// Prvni konektor na sluzbu je vychozi, jinak by krok bez vyberu nemel co vzit.
isDefault: input.isDefault ?? existing.length === 0,
createdAt: timestamp,
updatedAt: timestamp,
};
});
if (connector.isDefault) clearDefaults(input.tenantId, input.serviceId);
rows.push(connector);
@@ -172,10 +202,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
if (patch.enabled !== undefined) row.enabled = patch.enabled;
if (patch.values) {
for (const [key, value] of Object.entries(patch.values)) {
if (value === '') delete row.values[key];
else row.values[key] = value;
}
row.values = mergeValues(row.values, patch.values);
// Zmena udaju znamena, ze predchozi overeni uz nic nerika. Nastroje
// se ale nemazou: jina adresa muze vratit jiny seznam, jenze dokud ho
// nekdo nenacte, jsou ty stare porad to jedine, co v ulozenych
@@ -201,12 +228,13 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
rows.splice(rows.indexOf(row), 1);
// Kdyz zmizel vychozi, prevezme to prvni zbyly - jinak by kroky bez vyberu
// prestaly fungovat, aniz by se cokoliv jineho zmenilo.
// Kdyz zmizel vychozi, prevezme to prvni zbyly podle nazvu - jinak by kroky
// bez vyberu prestaly fungovat, aniz by se cokoliv jineho zmenilo.
// Podle nazvu, ne podle poradi vlozeni: tak to dela i Postgres.
if (row.isDefault) {
const next = rows.find(
(item) => item.tenantId === row.tenantId && item.serviceId === row.serviceId,
);
const next = rows
.filter((item) => item.tenantId === row.tenantId && item.serviceId === row.serviceId)
.sort(byDefaultThenName)[0];
if (next) next.isDefault = true;
}
@@ -231,10 +259,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
const row = rows.find((item) => item.id === id);
if (!row || !tenantIds.includes(row.tenantId)) return undefined;
for (const [key, value] of Object.entries(values)) {
if (value === '') delete row.values[key];
else row.values[key] = value;
}
row.values = mergeValues(row.values, values);
row.updatedAt = nowIso();
persist();
return copy(row);
@@ -262,24 +287,16 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
if (rows.length > 0) return;
if (!findService('idoklad')) return;
const timestamp = nowIso();
rows.push({
id: `con_${randomUUID().slice(0, 8)}`,
tenantId: 'tnt_automia',
serviceId: 'idoklad',
name: 'iDoklad Automia',
baseUrl: null,
values: {},
enabled: true,
status: 'untested',
lastCheckAt: null,
lastError: null,
checks: [],
mcp: null,
isDefault: true,
createdAt: timestamp,
updatedAt: timestamp,
});
rows.push(
newConnector({
tenantId: 'tnt_automia',
serviceId: 'idoklad',
name: 'iDoklad Automia',
baseUrl: null,
values: {},
isDefault: true,
}),
);
persist();
}
}
+3 -12
View File
@@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto';
import type { McpToolset } from '../../mcp/client.js';
import { query, queryOne, transaction } from '../../db/pool.js';
import { openAll, sealAll } from '../../db/secretBox.js';
import { CHECK_HISTORY } from './types.js';
import { CHECK_HISTORY, mergeValues } from './types.js';
import type {
Connector,
ConnectorCheck,
@@ -169,12 +169,7 @@ export const postgresConnectors: ConnectorRepository = {
let secrets = row.secrets;
let statusReset = false;
if (patch.values) {
const merged = openAll(row.secrets);
for (const [key, value] of Object.entries(patch.values)) {
if (value === '') delete merged[key];
else merged[key] = value;
}
secrets = sealAll(merged);
secrets = sealAll(mergeValues(openAll(row.secrets), patch.values));
statusReset = true;
}
@@ -279,11 +274,7 @@ export const postgresConnectors: ConnectorRepository = {
const row = current.rows[0];
if (!row) return undefined;
const merged = openAll(row.secrets);
for (const [key, value] of Object.entries(values)) {
if (value === '') delete merged[key];
else merged[key] = value;
}
const merged = mergeValues(openAll(row.secrets), values);
const updated = await client.query<ConnectorRow>(
`UPDATE connectors SET secrets = $2::jsonb, updated_at = now()
+32 -89
View File
@@ -5,44 +5,25 @@
* X-ClientId a X-ClientSecret". Konektor drzi hodnoty. Kazda firma ma svoje.
*
* Tenhle soubor je jen model a to, co plati v obou ulozistich. Samotna
* uloziste jsou vedle: `memory.ts` a `postgres.ts`.
* uloziste jsou vedle: `local.ts` (soubor nebo pamet) a `postgres.ts`.
*/
import { findService, type Service, type ServiceCredentialField } from '../services.js';
import { fieldsFromSchema, outputsFromTool } from '../../mcp/schema.js';
import type { McpToolset } from '../../mcp/client.js';
import type {
Connector as PublicConnector,
ConnectorCheck,
ConnectorStatus,
Toolset as PublicToolset,
} from '../../shared/connectors.js';
/**
* Jeden zaznam o overeni konektoru.
*
* Drzi se **cele telo odpovedi**, ne jen kod. Prave tam sluzba pise, co ji
* vadilo, a bez toho se neda rozlisit spatny udaj od zakazane IP adresy.
* Text je zredigovany uz pri vzniku, takze pristupovy udaj v nem nikdy neni.
* Verejny tvar konektoru je sdileny s webem, viz src/shared/connectors.ts.
* Tady zustava ulozeny tvar s hodnotami a prevod na verejny.
*/
export interface ConnectorCheck {
/** ISO cas overeni. */
at: string;
ok: boolean;
/** Co se overovalo: pristupove udaje, nebo jen dostupnost sluzby. */
checked: string;
/** HTTP kod od sluzby. null, kdyz se k volani vubec nedoslo. */
status: number | null;
message: string;
/** Cele telo odpovedi sluzby. */
detail: string | null;
/** Cela adresa vcetne serveru, bez query. null, kdyz se k volani nedoslo. */
request: { method: string; path: string; url: string } | null;
/**
* Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal - aplikace, nebo
* proxy pred ni. U kodu bez tela je to jedina stopa, ktera zbyde.
*/
responseHeaders: Record<string, string> | null;
/**
* Odchozi IP adresa portalu ve chvili volani. Vyplnuje se jen u odmitnuteho
* pristupu - jinde nema co rict a nestoji za volani ven.
*/
egressIp: string | null;
}
export type { ConnectorCheck, ConnectorStatus, PublicConnector, PublicToolset };
/** Kolik zaznamu o overeni se u konektoru drzi. */
export const CHECK_HISTORY = 5;
@@ -59,7 +40,7 @@ export interface Connector {
values: Record<string, string>;
enabled: boolean;
/** untested = jeste se neoverovalo, ok = posledni overeni proslo. */
status: 'untested' | 'ok' | 'error';
status: ConnectorStatus;
lastCheckAt: string | null;
lastError: string | null;
/** Poslednich `CHECK_HISTORY` overeni, nejnovejsi prvni. */
@@ -80,61 +61,6 @@ export interface Connector {
updatedAt: string;
}
/**
* Verze pro klienta. **Tajne hodnoty tady nejsou** a nikdy nesmi byt -
* secrets se nevraci z beznych endpointu (AGENTS.md).
*/
export interface PublicConnector {
id: string;
tenantId: string;
serviceId: string;
name: string;
baseUrl: string | null;
enabled: boolean;
status: Connector['status'];
lastCheckAt: string | null;
lastError: string | null;
/**
* Kolik zaznamu o overeni je k dispozici. Samotna historie se sem nedava,
* v seznamu konektoru by to byly desitky kilobajtu tel odpovedi navic.
* Cte se zvlast pres `/connectors/:id/checks`.
*/
checkCount: number;
isDefault: boolean;
createdAt: string;
updatedAt: string;
/** ID poli, ktera jsou vyplnena. Hodnoty se nevraci. */
filled: string[];
/** ID povinnych poli, ktera jeste chybi. */
missing: string[];
/** Necitliva nastaveni. Tajna pole tu nejsou vubec. */
config: Record<string, string>;
/** true = vsechna povinna pole jsou vyplnena, jde volat. */
ready: boolean;
/**
* Nastroje MCP serveru pro portal. Schemata tu **nejsou** - klient je
* nepotrebuje, uz prevedena na pole kroku chodi v katalogu sluzeb. Tady jde
* jen o to ukazat u konektoru, co se nacetlo.
*/
mcp: PublicToolset | null;
}
/** Nastroje jednoho serveru tak, jak je vidi portal. */
export interface PublicToolset {
at: string;
server: string;
protocolVersion: string;
tools: Array<{
name: string;
label: string;
description: string;
/** Nazvy parametru, ktere nastroj prijima. Povinne jsou prvni. */
inputs: string[];
/** Nazvy hodnot, ktere vraci. Vzdy aspon text a priznak chyby. */
outputs: string[];
}>;
}
export interface CreateConnectorInput {
tenantId: string;
serviceId: string;
@@ -163,7 +89,7 @@ export interface ConnectorIssue {
/** Rozhrani uloziste. Dve implementace, vyber je na jednom miste. */
export interface ConnectorRepository {
readonly kind: 'memory' | 'postgres';
readonly kind: 'memory' | 'file' | 'postgres';
list(tenantIds: string[], options?: { serviceId?: string }): Promise<Connector[]>;
get(id: string, tenantIds: string[]): Promise<Connector | undefined>;
defaultFor(tenantId: string, serviceId: string): Promise<Connector | undefined>;
@@ -205,8 +131,25 @@ export interface ConnectorRepository {
// ------------------------------------------------------------------- pomocne
export function nowIso(): string {
return new Date().toISOString();
export { nowIso } from '../store/types.js';
/**
* Slouceni hodnot konektoru s patchem.
*
* Prazdny retezec hodnotu **smaze**, chybejici klic ji nechava. Diky tomu jde
* ulozit formular, ktery tajne hodnoty neposila. Jedno misto pro obe uloziste
* i pro spravovane hodnoty - ctyri kopie tehoz pravidla by se casem rozesly.
*/
export function mergeValues(
current: Record<string, string>,
patch: Record<string, string>,
): Record<string, string> {
const merged = { ...current };
for (const [key, value] of Object.entries(patch)) {
if (value === '') delete merged[key];
else merged[key] = value;
}
return merged;
}
/** Povinna pole, ktera nejsou vyplnena. */
+16 -78
View File
@@ -17,79 +17,17 @@
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withCache } from './store/cached.js';
import type { TicketChannel, TicketStatus } from './ticketStore.js';
import type {
WidgetGroupBy,
WidgetPeriod,
WidgetRender,
WidgetSize,
WidgetSource,
WidgetTicketFilter,
} from '../shared/widgets.js';
export type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge';
/** Podle ceho se da seskupovat. Bez toho by byl widget na kazdeho cloveka. */
export type WidgetGroupBy =
| 'assignee'
| 'group'
/** Stav ticketu. Volny retezec, u ticketu z cizich aplikaci jejich vlastni. */
| 'status'
| 'type'
| 'tag'
| 'channel';
export type WidgetPeriod = 'today' | '7d' | '30d' | 'month' | 'all';
/** Filtr nad tickety. Tentyz, ktery umi `GET /api/dashboard/tickets`. */
export interface WidgetTicketFilter {
status?: TicketStatus[];
channel?: TicketChannel[];
typeIds?: string[];
tags?: string[];
/** `me` = prihlaseny, `unassigned` = fronta, jinak ID resitelu. */
assignee?: string[];
groupIds?: string[];
period?: WidgetPeriod;
/**
* Jen vyrizene, nebo jen nevyrizene. Nevyplnene = oboji.
*
* Ptat se na to pres stav nejde: stav je volny retezec a vyjmenovat
* vsechny podoby slova "hotovo" se neda. Priznak `closed` je od toho.
*/
closed?: boolean;
}
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[] }
/**
* 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';
/** Tvar zdroje dat je sdileny s webem, viz src/shared/widgets.ts. Tady je uloziste. */
export type { WidgetGroupBy, WidgetPeriod, WidgetRender, WidgetSize, WidgetSource, WidgetTicketFilter };
export interface CustomWidget extends TenantEntity {
tenantId: string;
@@ -159,7 +97,9 @@ export function seedCustomWidgets(): CustomWidget[] {
render: 'stat',
source: {
kind: 'ticketCount',
filter: { typeIds: ['tt_order'], status: ['new', 'open', 'waiting'] },
// Pres priznak vyrizeni, ne pres text stavu: stav je volny retezec
// a vyjmenovat vsechny podoby "nevyrizeno" nejde.
filter: { typeIds: ['tt_order'], closed: false },
},
size: 'third',
},
@@ -171,7 +111,7 @@ export function seedCustomWidgets(): CustomWidget[] {
render: 'table',
source: {
kind: 'ticketCount',
filter: { status: ['new', 'open', 'waiting'] },
filter: { closed: false },
groupBy: 'assignee',
},
size: 'half',
@@ -186,10 +126,8 @@ export async function refreshCustomWidgets(): Promise<void> {
/** Widgety firmy: firemni plus osobni prihlaseneho. */
export function listCustomWidgets(tenantIds: string[], userId: string): CustomWidget[] {
return cache
.all()
.filter((widget) => tenantIds.includes(widget.tenantId))
.filter((widget) => widget.ownerId === null || widget.ownerId === userId)
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
.listByTenant(tenantIds, 'name')
.filter((widget) => widget.ownerId === null || widget.ownerId === userId);
}
export function findCustomWidget(id: string): CustomWidget | undefined {
+19 -13
View File
@@ -8,16 +8,13 @@
*/
import type { CatalogWidget } from './widgets.js';
import type { WidgetSize } from './widgets.js';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withMirror } from './store/mirror.js';
export interface LayoutItem {
/** Instance widgetu. Tentyz widget muze byt na dashboardu vickrat. */
id: string;
widgetId: string;
size: WidgetSize;
}
import type { LayoutItem } from '../shared/widgets.js';
/** Polozka rozlozeni je sdilena s webem, viz src/shared/widgets.ts. */
export type { LayoutItem };
/** Kolik widgetu jeste dava smysl. Nad tim je to seznam, ne prehled. */
export const MAX_WIDGETS = 12;
@@ -41,7 +38,8 @@ const defaultLayout: LayoutItem[] = [
* V katalogu zustava, kdo ho chce, prida si ho.
*/
const layouts = new Map<string, LayoutItem[]>();
/** Kopie v pameti. Drzi i cas vzniku, aby ho dalsi ulozeni neprepsalo. */
const layouts = new Map<string, { items: LayoutItem[]; createdAt: string }>();
/**
* Zaznam v ulozisti. ID je dvojice uzivatel a firma, takze zapis nepotrebuje
@@ -62,11 +60,16 @@ function key(userId: string, tenantId: string): string {
export async function initLayouts(): Promise<void> {
const rows = await mirror.load();
layouts.clear();
for (const row of rows) layouts.set(key(row.userId, row.tenantId ?? ''), row.items);
for (const row of rows) {
layouts.set(key(row.userId, row.tenantId ?? ''), {
items: row.items,
createdAt: row.createdAt,
});
}
}
export function getLayout(userId: string, tenantId: string): LayoutItem[] {
return layouts.get(key(userId, tenantId)) ?? defaultLayout.map((item) => ({ ...item }));
return layouts.get(key(userId, tenantId))?.items ?? defaultLayout.map((item) => ({ ...item }));
}
/** true = uzivatel si rozlozeni uz upravil, nekouka na vychozi. */
@@ -76,10 +79,13 @@ export function hasCustomLayout(userId: string, tenantId: string): boolean {
export function saveLayout(userId: string, tenantId: string, items: LayoutItem[]): LayoutItem[] {
const id = key(userId, tenantId);
layouts.set(id, items);
const timestamp = nowIso();
mirror.save({ id, tenantId, userId, items, createdAt: timestamp, updatedAt: timestamp });
// Cas vzniku se pri dalsim ulozeni nemeni. Postgres ho stejne drzi puvodni,
// soubor prepisoval cely zaznam - ted se chovaji stejne.
const createdAt = layouts.get(id)?.createdAt ?? timestamp;
layouts.set(id, { items, createdAt });
mirror.save({ id, tenantId, userId, items, createdAt, updatedAt: timestamp });
console.info(`[layout] ${userId}@${tenantId}: ulozeno ${items.length} widgetu`);
return items;
}
+3 -43
View File
@@ -14,50 +14,10 @@
import { config } from '../config.js';
export interface EgressIp {
/** null = nepodarilo se zjistit, nebo je funkce vypnuta. */
ip: string | null;
/** Odkud to je. Pro uzivatele, at vi, komu ta odpoved patri. */
source: string;
checkedAt: string;
/** Vyplnene jen kdyz `ip` je null. */
error?: string;
/**
* Jak nas vidi **nase vlastni reverse proxy**, kdyz volani vyjde z tohoto
* containeru a otoci se pres verejnou domenu zpatky. Tohle je ta adresa,
* kterou porovnava seznam povolenych IP u sluzeb za toutez proxy - a byva
* jina nez ta verejna, protoze volani na vlastni stroj se hairpinuje
* a proxy vidi adresu docker bridge.
*
* null = nezmereno, chybi `PUBLIC_ORIGIN`, nebo se to nepodarilo.
*/
viaProxy: ProxyView | null;
}
import type { EgressIp, ProxyView } from '../shared/connectors.js';
export interface ProxyView {
/**
* **Tohle je to cislo.** Adresa po `trust proxy`, tedy z X-Forwarded-For -
* proxy tam zapsala, s jakou adresou k ni nase volani doslo.
*/
ip: string | null;
/** Surova hlavicka, at je videt i to, co proxy nepripsala. */
forwardedFor: string | null;
/**
* Adresa druheho konce spojeni. Pozor, to je **sama proxy**, ne volajici -
* k nam uz to jde od ni. Je to tu na to, aby bylo poznat, ze se volani
* opravdu tocilo pres ni a ne primo.
*/
remoteAddress: string | null;
/**
* Rozsah, ktery tuhle adresu pokryje cely. Docker prideluje z bloku,
* takze povolit jednu adresu vydrzi do prvniho prekresleni site - patri
* tam cely rozsah. null = adresa je verejna, zadny blok se nenabizi.
*/
suggestedRange: string | null;
/** Kam se volalo. Pro uzivatele, at vi, co se vlastne merilo. */
url: string;
error?: string;
}
/** Tvar odpovedi je sdileny s webem, viz src/shared/connectors.ts. */
export type { EgressIp, ProxyView };
/**
* Rozsah, ktery danou adresu pokryje cely.
+31 -63
View File
@@ -9,48 +9,13 @@
import { config } from '../config.js';
import { publish } from '../events/bus.js';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { defineStore, highestNumber, minutesAgo, nowIso, type TenantEntity } from './store/index.js';
import { withMirror } from './store/mirror.js';
export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3';
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
import type { Incident, IncidentSeverity, IncidentStatus } from '../shared/incidents.js';
export interface Incident {
id: string;
/**
* Firma, ktere se to tyka. `null` = nas vlastni, platformni incident
* (vypadek sluzby, ktera je spolecna vsem).
*/
tenantId: string | null;
/** Srozumitelne pro klienta. Zadne stack trace, zadne ID kroku. */
title: string;
service: string;
severity: IncidentSeverity;
status: IncidentStatus;
/**
* Co s tim ma klient delat, nebo co to pro nej znamena.
*
* "Objednávka 5001 se nepřenesla do účetnictví, zkusíme to znovu" je
* pouzitelne. "TypeError: undefined" neni.
*/
impact: string;
/**
* Vsechno, co potrebuje admin: cele hlaseni, ktery beh, ktery krok, co
* prislo na vstupu. Zamerne oddelene od `title` - klient tohle videt nema
* a admin bez toho nema z ceho vychazet.
*/
detail: string | null;
/** Odkud incident vznikl, napr. `run_ab12` nebo `rucne`. */
source: string | null;
/** Ticket, u ktereho to prasklo. */
ticketId: string | null;
startedAt: string;
resolvedAt: string | null;
}
function minutesAgo(minutes: number): string {
return new Date(Date.now() - minutes * 60_000).toISOString();
}
/** Tvar incidentu je sdileny s webem, viz src/shared/incidents.ts. */
export type { Incident, IncidentSeverity, IncidentStatus };
/**
* Ukazkove incidenty. Se `SEED_DEMO=1` se pouziji, jinak se zacina naprazdno -
@@ -134,33 +99,36 @@ export async function initIncidents(): Promise<void> {
incidents.push(incident);
}
for (const incident of incidents) {
const number = Number(/^INC-(\d+)$/.exec(incident.id)?.[1] ?? 0);
if (number > counter) counter = number;
}
counter = Math.max(counter, highestNumber(incidents.map((incident) => incident.id), 'INC'));
}
/** Vidi firmy z `tenantIds` tenhle incident? Platformni vidi kazdy. */
function visibleTo(incident: Incident, tenantIds: string[]): boolean {
return incident.tenantId === null || tenantIds.includes(incident.tenantId);
}
/**
* Incidenty firmy plus platformni.
*
* Bez omezeni na firmy vraci prazdno, ne vse. Zapomenuty filtr nesmi znamenat
* "ukaz cizi incidenty" - je to stejne pravidlo jako u ticketu.
* Filtr na firmy je **povinny argument**, ne volitelny. Zapomenuty filtr tak
* neznamena "ukaz cizi incidenty", ale nezkompiluje se - stejne pravidlo
* jako u ticketu. Prazdny seznam vraci jen platformni.
*/
export function listIncidents(tenantIds?: string[]): Incident[] {
const visible = tenantIds
? incidents.filter((item) => item.tenantId === null || tenantIds.includes(item.tenantId))
: incidents;
return [...visible].sort((a, b) => {
if (a.status === 'resolved' && b.status !== 'resolved') return 1;
if (b.status === 'resolved' && a.status !== 'resolved') return -1;
return b.startedAt.localeCompare(a.startedAt);
});
export function listIncidents(tenantIds: string[]): Incident[] {
return incidents
.filter((item) => visibleTo(item, tenantIds))
.sort((a, b) => {
if (a.status === 'resolved' && b.status !== 'resolved') return 1;
if (b.status === 'resolved' && a.status !== 'resolved') return -1;
return b.startedAt.localeCompare(a.startedAt);
});
}
/** Uz je stejny problem otevreny? Aby deset stejnych chyb nedelalo deset incidentu. */
export function findOpenIncident(source: string): Incident | undefined {
return incidents.find((item) => item.source === source && item.status !== 'resolved');
export function findOpenIncident(source: string, tenantIds: string[]): Incident | undefined {
return incidents.find(
(item) => item.source === source && item.status !== 'resolved' && visibleTo(item, tenantIds),
);
}
export function createIncident(input: {
@@ -194,7 +162,7 @@ export function createIncident(input: {
publish('incident.started', `Nový incident ${incident.id}: ${incident.title}`, {
incidentId: incident.id,
severity: incident.severity,
});
}, incident.tenantId);
return incident;
}
@@ -212,17 +180,17 @@ export function updateIncidentStatus(id: string, status: IncidentStatus): Incide
if (status === 'resolved') {
publish('incident.resolved', `Incident ${incident.id} vyřešen: ${incident.title}`, {
incidentId: incident.id,
});
}, incident.tenantId);
} else {
publish('incident.updated', `Incident ${incident.id}: ${status}`, {
incidentId: incident.id,
status,
});
}, incident.tenantId);
}
return incident;
}
/** Prvni bezici incident. */
export function firstActiveIncident(): Incident | undefined {
return incidents.find((i) => i.status !== 'resolved');
/** Prvni bezici incident viditelny pro dane firmy. */
export function firstActiveIncident(tenantIds: string[]): Incident | undefined {
return incidents.find((i) => i.status !== 'resolved' && visibleTo(i, tenantIds));
}
+4 -5
View File
@@ -14,6 +14,7 @@
*/
import { randomBytes, randomUUID } from 'node:crypto';
import { timingSafeEqualString } from '../lib/secure.js';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withCache } from './store/cached.js';
@@ -87,7 +88,7 @@ export function newInvite(input: CreateInviteInput): Invite {
/** Pozvanka podle kodu. Nezkoumá platnost, na to je `inviteProblem`. */
export function findByCode(code: string): Invite | undefined {
if (code.length < 16) return undefined;
return cache.find((invite) => invite.code === code);
return cache.find((invite) => timingSafeEqualString(invite.code, code));
}
/**
@@ -105,10 +106,8 @@ export function inviteProblem(invite: Invite): string | null {
}
export function listInvites(tenantIds: string[]): Invite[] {
return cache
.all()
.filter((invite) => tenantIds.includes(invite.tenantId))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
// Nejnovejsi nahore.
return cache.listByTenant(tenantIds, (a, b) => b.createdAt.localeCompare(a.createdAt));
}
export async function markUsed(invite: Invite, userId: string): Promise<void> {
+3 -2
View File
@@ -9,6 +9,7 @@
import { listAutomations } from './automationStore.js';
import { listIncidents } from './incidentStore.js';
import { listTickets } from './ticketStore.js';
import type { DashboardSummary } from '../shared/dashboard.js';
/** Casova rada pro graf na dashboardu - poslednich 14 dni. */
export function getRunsSeries(days = 14) {
@@ -24,10 +25,10 @@ export function getRunsSeries(days = 14) {
return series;
}
export function getSummary(tenantIds: string[]) {
export function getSummary(tenantIds: string[]): DashboardSummary {
// Ukazkova cisla nad vlastnimi daty, strop viditelnosti se sem nevztahuje.
const tickets = listTickets({ tenantIds, visibility: { kind: 'all' } });
const incidents = listIncidents();
const incidents = listIncidents(tenantIds);
const automations = listAutomations(tenantIds);
const series = getRunsSeries();
+4 -9
View File
@@ -18,15 +18,10 @@
import type { FieldType } from './conditions.js';
export interface ModelNode {
/** Cesta k hodnote, napr. `data.order.items[].name`. */
path: string;
type: FieldType;
/** Ukazkova hodnota, zkracena. Podle ni clovek pozna, co to je. */
sample: string | null;
/** Kolik polozek melo pole v ukazce. Jen u typu `list`. */
count?: number;
}
import type { ModelNode } from '../shared/automations.js';
/** Tvar uzlu modelu je sdileny s webem, viz src/shared/automations.ts. */
export type { ModelNode };
/** Kolik urovni zanoreni se prochazi. Hlubsi model uz nikdo necte. */
const MAX_DEPTH = 8;
+21 -17
View File
@@ -86,30 +86,32 @@ export function notify(input: NotifyInput): void {
void notificationStore
.create(notification)
.then(async () => {
// Uklid nad kopii pred obnovou: nove upozorneni v ni jeste neni, takze
// se pocita s +1. Obnova je pak jedna, ne dve.
await trim(user.id, 1);
await cache.refresh();
// Portal ma cislo u zalozky prekreslit hned, ne az pri obnoveni stranky.
publish('notification.created', input.title, {
userId: user.id,
ticketId: notification.ticketId,
});
await trim(user.id);
}, notification.tenantId);
})
.catch((err: unknown) => {
console.error('[upozorneni] zapis selhal:', err);
});
}
async function trim(userId: string): Promise<void> {
/** Odmaze nejstarsi nad limit. `extra` = kolik jich pribylo mimo kopii. */
async function trim(userId: string, extra: number): Promise<void> {
const mine = cache
.all()
.filter((item) => item.userId === userId)
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
const excess = mine.slice(0, Math.max(0, mine.length - MAX_PER_USER));
for (const item of excess) {
await notificationStore.remove(item.id, { tenantIds: [item.tenantId], includeGlobal: true });
}
if (excess.length > 0) await cache.refresh();
const excess = mine.slice(0, Math.max(0, mine.length + extra - MAX_PER_USER));
if (excess.length === 0) return;
// Maze se, co se nacetlo z vlastni kopie, filtr na firmu tu nema co hlidat.
await notificationStore.removeMany(excess.map((item) => item.id));
}
/** Upozorneni uzivatele, nejnovejsi nahore. */
@@ -133,13 +135,15 @@ export async function markRead(userId: string, ids?: string[]): Promise<number>
.filter((item) => item.userId === userId && item.readAt === null)
.filter((item) => !ids || ids.includes(item.id));
for (const item of mine) {
await notificationStore.update(
item.id,
{ readAt: timestamp },
{ tenantIds: [item.tenantId], includeGlobal: true },
);
}
if (mine.length > 0) await cache.refresh();
return mine.length;
if (mine.length === 0) return 0;
// Jeden zapis pro vsechna, ne N za sebou. Firmy se vezmou z toho, co se oznacuje.
const tenantIds = [...new Set(mine.map((item) => item.tenantId))];
const changed = await notificationStore.updateMany(
mine.map((item) => item.id),
{ readAt: timestamp },
{ tenantIds, includeGlobal: true },
);
await cache.refresh();
return changed;
}
+6 -35
View File
@@ -12,30 +12,10 @@
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { withCache } from './store/cached.js';
import type { Person } from '../shared/people.js';
export interface Person extends TenantEntity {
/** Firma, jejiz je clenem tymu. Hranice viditelnosti, proto nikdy null. */
tenantId: string;
name: string;
email: string;
/** Cim se v tymu zabyva. Jen popisek, nic nerozhoduje. */
role: string;
/** Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez. */
capacity: number;
/** Vypnuty resitel se nenabizi k prirazeni, ale stare tickety nespadnou. */
enabled: boolean;
/**
* ID, pod kterymi cloveka znaji cizi aplikace.
*
* Voicebot posle `voicebotId`, telefonni ustredna klapku, chat svoje ID -
* a my z toho musime poznat, komu ticket patri. Bez toho by se to muselo
* mapovat v kazde automatizaci zvlast a pri zmene cloveka opravovat na
* peti mistech.
*
* Jeden clovek jich muze mit vic, protoze aplikaci je vic.
*/
externalIds: string[];
}
/** Tvar resitele je sdileny s webem, viz src/shared/people.ts. */
export type { Person };
export const personStore = defineStore<Person>('person');
const cache = withCache(personStore);
@@ -63,18 +43,12 @@ export async function refreshPeople(): Promise<void> {
/** Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse". */
export function listPeople(tenantIds: string[]): Person[] {
return cache
.all()
.filter((person) => tenantIds.includes(person.tenantId) && person.enabled)
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
return cache.listByTenant(tenantIds, 'name').filter((person) => person.enabled);
}
/** Vcetne vypnutych. Pro spravu tymu. */
export function listAllPeople(tenantIds: string[]): Person[] {
return cache
.all()
.filter((person) => tenantIds.includes(person.tenantId))
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
return cache.listByTenant(tenantIds, 'name');
}
/**
@@ -201,10 +175,7 @@ export async function refreshGroups(): Promise<void> {
}
export function listGroups(tenantIds: string[]): PersonGroup[] {
return groupCache
.all()
.filter((group) => tenantIds.includes(group.tenantId))
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
return groupCache.listByTenant(tenantIds, 'name');
}
export function findGroup(id: string): PersonGroup | undefined {
+4 -21
View File
@@ -12,20 +12,13 @@
* Popis modelu je v documentation/16-prava-a-role.md.
*/
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { defineStore, nowIso } from './store/index.js';
import type { User } from '../types.js';
export type PermissionKey = string;
import type { PermissionDefinition, PermissionKey, Role } from '../shared/permissions.js';
export interface PermissionDefinition {
key: PermissionKey;
label: string;
/** Do ktere skupiny patri v nastaveni role. */
group: string;
/** true = pravo je nase, klientsky admin ho neprideluje. */
platform?: boolean;
hint?: string;
}
/** Tvar prava a role je sdileny s webem, viz src/shared/permissions.ts. */
export type { PermissionDefinition, PermissionKey, Role };
/**
* Katalog prav.
@@ -128,16 +121,6 @@ const catalogKeys = new Set(permissionCatalog.map((item) => item.key));
// ------------------------------------------------------------------------ role
export interface Role extends TenantEntity {
/** Stabilni klic, napr. `admin`. U vlastnich roli je stejny jako ID. */
key: string;
name: string;
description: string;
permissions: PermissionKey[];
/** true = systemova role, nejde smazat ani prejmenovat. */
system: boolean;
}
export const roleStore = defineStore<Role>('role');
/** Vsechna prava mimo platformni. Pro systemovou roli spravce firmy. */
+59 -19
View File
@@ -7,32 +7,72 @@
*
* Volani se **slucuje**. Deset zapisu za sebou znamena jednu obnovu, jinak by
* hromadna uprava znamenala desetkrat precteni vsech roli a lidi.
*
* Obnovuje se **jen to, co se zmenilo**: zapis do lidi nema cist znovu firmy,
* role, typy ticketu a dalsich devet tabulek. Bez udani entity se obnovi vse.
*/
import { refreshCaches } from './bootstrap.js';
import { refreshCaches, refreshEntity } from './bootstrap.js';
/**
* Ktera entita se meni pod kterou cestou routeru nastaveni (`/api/settings/...`).
* Prvni segment cesty -> `store.kind`. Neznama cesta znamena plnou obnovu,
* radeji precist vse nez nechat zastaralou kopii.
*/
const routeKinds: Record<string, string> = {
tenants: 'tenant',
users: 'user',
roles: 'role',
people: 'person',
groups: 'personGroup',
features: 'tenantFeatures',
'ticket-types': 'ticketType',
actions: 'ticketAction',
widgets: 'customWidget',
};
let pending: Promise<void> | null = null;
let again = false;
const queuedKinds = new Set<string>();
let queuedFull = false;
export async function bootstrapDataRefresh(): Promise<void> {
if (pending) {
// Uz se obnovuje. Poznacime, ze po dokonceni ma prijit jeste jedna -
// jinak by se zmena z posledniho zapisu neprojevila.
again = true;
return pending;
}
/**
* Obnovi kopie v pameti po zapisu.
*
* `route` je cesta v routeru nastaveni, napr. `/people/ppl_1`. Podle ni se
* pozna, co se zmenilo. Bez ni se obnovi vsechno.
*/
export function bootstrapDataRefresh(route?: string): Promise<void> {
const kind = route ? routeKinds[route.split('/').filter(Boolean)[0] ?? ''] : undefined;
if (kind) queuedKinds.add(kind);
else queuedFull = true;
pending = refreshCaches()
.catch((err: unknown) => {
console.error('[data] obnova kopii selhala:', err);
})
.finally(() => {
// Uz se obnovuje: pozadavek je ve fronte a smycka ho vezme po dokonceni -
// jinak by se zmena z posledniho zapisu neprojevila.
if (!pending) {
pending = drain().finally(() => {
pending = null;
if (again) {
again = false;
void bootstrapDataRefresh();
}
});
}
return pending;
}
async function drain(): Promise<void> {
while (queuedFull || queuedKinds.size > 0) {
const full = queuedFull;
const kinds = [...queuedKinds];
queuedFull = false;
queuedKinds.clear();
try {
if (full) {
await refreshCaches();
continue;
}
for (const kind of kinds) {
if (!(await refreshEntity(kind))) await refreshCaches();
}
} catch (err) {
console.error('[data] obnova kopii selhala:', err);
}
}
}
+58 -259
View File
@@ -18,249 +18,37 @@
* Jak pridat sluzbu: documentation/12-sluzby-a-konektory.md
*/
import type { FieldType } from './conditions.js';
import type { User } from '../types.js';
import { isMcpService, MCP_EASYWEB_SERVICE_ID, MCP_SERVICE_ID } from '../mcp/dialect.js';
import { tenantHasService } from './tenantFeatures.js';
export type ServiceCategory =
/** Obecne veci, ktere ma kazdy. Nepotrebuji konektor. */
| 'obecne'
| 'crm'
| 'ekonomika'
| 'logistika'
| 'komunikace'
| 'analytika'
| 'ai'
/**
* Prevody dat mezi kroky. Vlastni kategorie, ne polozka v Obecnem:
* je jich hodne a clovek je hleda jako skupinu, ne mezi webhookem a pauzou.
*/
| 'transformace';
import type {
OperationField,
ProvidedField,
Service,
ServiceCategory,
ServiceCategoryEntry,
ServiceCredentialField,
ServiceOperation,
ServiceStatus,
ServiceVisibility,
} from '../shared/services.js';
/**
* available = sluzbu umime, planned = je na roadmape.
*
* "Napojeno" tady zamerne NENI. Jestli je sluzba napojena, zavisi na tom,
* jestli si firma vytvorila konektor - to je vlastnost firmy, ne sluzby.
* Pocita se v `serviceViewsFor`.
* Tvar sluzby je sdileny s webem, viz src/shared/services.ts. Tady je katalog
* samotny a to, co se z nej pocita.
*/
export type ServiceStatus = 'available' | 'planned';
/**
* Kdo sluzbu vidi.
*
* `everyone` vsichni prihlaseni
* `restricted` jen uvedene firmy a jmenovite uvedeni lide
* `admin` jen spravce platformy
*
* Spravce platformy vidi vzdy vsechno. Obecne sluzby (`general: true`) taky
* vidi vzdy vsichni, viditelnost se u nich neresi.
*/
export interface ServiceVisibility {
mode: 'everyone' | 'restricted' | 'admin';
/** Jen u `restricted`: firmy, ktere sluzbu vidi. */
tenantIds: string[];
/** Jen u `restricted`: konkretni lide bez ohledu na firmu. */
userIds: string[];
}
/**
* Jeden udaj, ktery je potreba vyplnit pri zakladani konektoru.
*
* `target: 'header'` znamena, ze se hodnota posila jako hlavicka requestu
* pojmenovana v `name`. Tim je popsane, co ktera sluzba vyzaduje, a konektor
* uz jen doplni hodnoty.
*
* `secret: true` se **nikdy nevraci z API** a redaguje se v logu.
*/
export interface ServiceCredentialField {
id: string;
label: string;
target: 'header' | 'config';
/** Jmeno hlavicky u `header`, jmeno klice v `ctx.config` u `config`. */
name: string;
required: boolean;
secret: boolean;
hint?: string;
/**
* Text, ktery se pred hodnotu doplni az pri sestaveni hlavicky.
*
* Kvuli `Authorization: Bearer <klic>`: uzivatel do formulare vlepi klic
* tak, jak ho dostal od sluzby, a slovo `Bearer` dopise runtime. Kdyby si ho
* mel psat sam, byl by to prvni zdroj chyb, ktery nikdo nevidi - hodnota se
* z API nevraci, takze preklep v ni neni videt ani zpetne.
*
* Redakce v logu se dela na obojí, na hodnotu i na celou hlavicku.
*/
prefix?: string;
/**
* true = hodnotu **spravuje portal**, ne uzivatel.
*
* Ve formulari se nezobrazuje a nikdo ji nevyplnuje. Zapisuje ji az napojeni
* samo, kdyz si za behu neco vyrobi - klic zarizeni u MCP EasyWebu. Bez
* tohohle priznaku by uzivatel videl pole, do ktereho nema co psat, a mohl
* by ho prepsat.
*/
managed?: boolean;
}
/**
* Parametr, ktery spoustec sam preda do stromu. Tvarem odpovida `TriggerField`
* v automationStore, ale definuje se tady - katalog je zdroj pravdy o tom,
* co ktera sluzba posila.
*
* ID musi byt stabilni, odkazuji se na nej podminky v ulozenych stromech.
* Prejmenovani ID rozbije existujici automatizace.
*/
export interface ProvidedField {
id: string;
name: string;
type: FieldType;
required: boolean;
}
/**
* Nastavitelne pole akce. Na rozdil od `fields` to neni napoveda, ale skutecna
* konfigurace - hodnota se uklada do kroku stromu (`FlowStep.inputs`).
*
* Hodnota je sablona. `{{nazev}}` se nahradi parametrem spoustece,
* viz `src/data/templates.ts`.
*/
export interface OperationField {
id: string;
label: string;
/**
* Jak se pole vykresli v builderu.
*
* `text` jeden radek
* `longtext` vice radku
* `html` vice radku, obsah je HTML - dosazene hodnoty se escapuji
* `choice` vyber z `options`
* `lookup` ciselnik **a zaroven** volny text: bud se vybere ze seznamu,
* nebo se hodnota dosadi z dat sablonou. Pro pole, kde by jinak
* clovek musel opisovat ID, ale kde ho krok muze dostat i z toho,
* co prislo.
* `json` JSON, kontroluje se uz pri ulozeni stromu
* `mapping` pravidla transformace, klikatelny editor nad JSONem
* `object` odkaz na parametr typu objekt nebo seznam z predchoziho kroku
*
* Hodnota je vzdy retezec, i u `json` a `mapping`. Diky tomu se nemenil
* `FlowStep.inputs` a strukturu si rozparsuje az ten, kdo ji pouziva.
*/
kind: 'text' | 'longtext' | 'html' | 'choice' | 'lookup' | 'json' | 'mapping' | 'object';
required: boolean;
options?: Array<{ value: string; label: string }>;
/**
* Odkud se doplni nabidka za behu. Katalog vznika pri importu modulu,
* ale resitele a skupiny se nacitaji z uloziste az pozdeji.
*/
optionsFrom?: 'people' | 'groups' | 'scripts' | 'ticketTypes';
hint?: string;
}
export interface ServiceOperation {
id: string;
name: string;
description: string;
/**
* Popis toho, co bude potreba nastavit - jen napoveda, nikam se neuklada.
* Cilovy stav je prevest vsechny akce na `inputs`, zatim je to udelane
* u ticketu a kanalu, ktere do nej ustuji.
*/
fields?: string[];
/** Skutecne nastavitelna pole akce. Kdyz chybi, akci nejde konfigurovat. */
inputs?: OperationField[];
/**
* Jen u akci: co krok vrati dalsim krokum. Diky tomu jde po kroku
* "dohledej firmu v CRM" postavit podminku na to, jestli se nasla.
*
* `required: true` znamena, ze sluzba hodnotu vraci vzdy.
* ID musi zustat stabilni, odkazuji se na nej podminky v ulozenych stromech.
*/
outputFields?: ProvidedField[];
/**
* Jen u triggeru: true = vstupni parametry si definuje uzivatel
* (webhook, formular). false/chybi = data urcuje sluzba.
*/
customPayload?: boolean;
/**
* Jen u triggeru: parametry, ktere sluzba predava sama. Uzivatel je nemeni,
* server je pri ukladani stromu dosadi misto toho, co poslal klient.
* Diky nim jde stavet podminky nad daty, ktera si nikdo nevymyslel.
*/
providedFields?: ProvidedField[];
/**
* `script` = operaci obsluhuje skript ze slozky skriptu, tedy se opravdu
* vykona. Kdyz chybi, je to zatim jen zapis v katalogu.
* Doplnuje `src/scripts/registry.ts`, rucne se to nepise.
*/
implementation?: 'script';
/** Ktery skript operaci obsluhuje. Vyplnene spolu s `implementation`. */
scriptId?: string;
}
export interface Service {
id: string;
name: string;
category: ServiceCategory;
description: string;
/** Klic ikony - frontend si ho mapuje na komponentu (lib/serviceIcons.ts). */
icon: string;
status: ServiceStatus;
/**
* true = sluzba funguje bez konektoru. Obecne veci, ktere ma kazdy:
* webhook, planovac, tickety, transformace dat, HTTP pozadavek, pauza, log.
*/
general: boolean;
/**
* ID aplikace v services.csbot.cz/apps. Zaklad adresy se sklada z nej
* a ze `SERVICES_BASE_URL`, verejna domena se nikdy nehardcoduje (AGENTS.md).
* null = sluzba se nevola pres jednu pevnou adresu.
*/
appId: string | null;
/**
* Cim se sluzba vola.
*
* `http` (vychozi) je zbytek katalogu: skript rekne cestu a runtime doplni
* adresu a hlavicky. `smtp` je e-mail - neni to HTTP, takze operaci nevykona
* skript, ale vnitrni krok, a overeni konektoru se misto cteciho volani
* prihlasi na posmovni server. `mcp` je JSON-RPC nad HTTP, kde se pred
* kazdym volanim navazuje sezeni a operace nejsou v katalogu - rekne je
* az server.
*
* Je to priznak sluzby, ne konektoru: jak se sluzba vola, je jeji vlastnost.
*/
transport?: 'http' | 'smtp' | 'mcp';
/**
* Absolutni adresa sluzby, ktera **nebezi u nas**. Typicky OpenAI.
*
* Je to jina vec nez `appId`: tam se adresa sklada ze `SERVICES_BASE_URL`,
* protoze aplikace je nase a muze se cela prestehovat. Tohle je cizi domena,
* ktera se ridi cizim rozhodnutim, takze patri ke sluzbe.
*
* Prepsat ji jde promennou `<SLUZBA>_BASE_URL` (napr. `OPENAI_BASE_URL`)
* nebo adresou u konektoru - kvuli branam typu Azure OpenAI a kvuli testu
* proti napodobenine.
*/
baseUrl?: string;
visibility: ServiceVisibility;
/**
* Co je potreba vyplnit pri zakladani konektoru.
* Hodnoty patri konektoru, ne prostredi - kazda firma ma svoje.
* Prazdne pole u obecnych sluzeb a u tech, ktere jeste nemame popsane.
*/
credentials: ServiceCredentialField[];
/**
* Levne cteci volani, kterym se overi, ze konektor funguje.
* Musi to byt neco, co **nic nemeni** a co vyzaduje autorizaci - jinak by test
* prosel i se spatnymi udaji a rekl uzivateli nepravdu.
* Kdyz chybi, overi se jen dostupnost sluzby na `/health`.
*/
verifyPath?: string;
triggers: ServiceOperation[];
actions: ServiceOperation[];
}
export type {
OperationField,
ProvidedField,
Service,
ServiceCategory,
ServiceCategoryEntry,
ServiceCredentialField,
ServiceOperation,
ServiceStatus,
ServiceVisibility,
};
/**
* Nabidka resitelu do vyberu u akci.
@@ -281,14 +69,7 @@ const priorityOptions = [
{ value: 'critical', label: 'Kritická' },
];
const statusOptions = [
{ value: 'new', label: 'Nový' },
{ value: 'open', label: 'V řešení' },
{ value: 'waiting', label: 'Čeká na klienta' },
{ value: 'resolved', label: 'Vyřešeno' },
];
export const serviceCategories: Array<{ id: ServiceCategory; label: string }> = [
export const serviceCategories: ServiceCategoryEntry[] = [
{ id: 'obecne', label: 'Obecné' },
{ id: 'crm', label: 'CRM' },
{ id: 'ekonomika', label: 'Ekonomika a banky' },
@@ -905,21 +686,10 @@ export const services: Service[] = [
},
],
},
{
id: 'set-status',
name: 'Změnit stav',
description: 'Posune ticket na nový, v řešení, čeká na klienta nebo vyřešeno.',
inputs: [
{ id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true },
{
id: 'status',
label: 'Nový stav',
kind: 'choice',
required: true,
options: statusOptions,
},
],
},
/*
* Druhy `set-status` s pevnym ciselnikem stavu tady byl a katalog ho
* ukazoval vedle prvniho. Stav je volny retezec, plati jen ten vyse.
*/
{
id: 'link-customer',
name: 'Napojit na zákazníka',
@@ -2783,6 +2553,35 @@ export function findService(serviceId: string): Service | undefined {
return services.find((service) => service.id === serviceId);
}
/**
* Kontrola katalogu pri startu: ID operace musi byt v ramci sluzby jedine.
*
* `findOperation` bere prvni shodu, takze druha operace se stejnym ID by se
* v katalogu ukazala, ale nikdy by se nespustila - a nikdo by nepoznal proc.
* Neshazuje start (AGENTS.md), ale rekne to nahlas.
*/
function checkOperationIds(): void {
for (const service of services) {
for (const [type, pool] of [
['spoustec', service.triggers],
['akce', service.actions],
] as const) {
const seen = new Set<string>();
for (const operation of pool) {
if (seen.has(operation.id)) {
console.error(
`[sluzby] sluzba ${service.id} ma dvakrat ${type} "${operation.id}", ` +
'pouzije se jen prvni definice',
);
}
seen.add(operation.id);
}
}
}
}
checkOperationIds();
// -------------------------------------------------------- kdo co vidi
/**
+29 -2
View File
@@ -59,6 +59,12 @@ export function fileSnapshot<T>(file: string): SnapshotStore<T> {
let pending: T[] | null = null;
let timer: NodeJS.Timeout | null = null;
let writing: Promise<void> = Promise.resolve();
/**
* true = soubor existuje, ale nesel precist ani odlozit stranou. Zapisovat
* se pak nesmi: prvni ulozeni by ho prepsalo prazdnymi daty a puvodni
* obsah by byl pryc. Jede se v pameti a rika se to v logu.
*/
let locked = false;
async function writeNow(items: T[]): Promise<void> {
const payload: SnapshotFile<T> = {
@@ -91,8 +97,15 @@ export function fileSnapshot<T>(file: string): SnapshotStore<T> {
let raw: string;
try {
raw = await fs.readFile(file, 'utf8');
} catch {
} catch (err) {
// Chybejici soubor je bezny stav, ne chyba. Prvni spusteni.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
// Cokoliv jineho (prava, disk) je chyba a soubor se nesmi prepsat.
locked = true;
console.error(
`[snapshot] ${file} nelze precist (${err instanceof Error ? err.message : err}), ` +
'zapisy jsou vypnute, aby se soubor neprepsal prazdnymi daty',
);
return [];
}
@@ -114,12 +127,26 @@ export function fileSnapshot<T>(file: string): SnapshotStore<T> {
`[snapshot] ${file} nelze precist (${err instanceof Error ? err.message : err}), ` +
`puvodni soubor je v ${broken}`,
);
await fs.rename(file, broken).catch(() => undefined);
try {
await fs.rename(file, broken);
} catch (renameErr) {
// Bez zalohy se zapisovat nesmi, jinak by prvni ulozeni rozbity
// soubor prepsalo a nebylo by z ceho data zachranit.
locked = true;
console.error(
`[snapshot] ${file} se nepodarilo odlozit do ${broken} ` +
`(${renameErr instanceof Error ? renameErr.message : renameErr}), zapisy jsou vypnute`,
);
}
return [];
}
},
save(items) {
if (locked) {
console.warn(`[snapshot] ${file}: zapis odmitnut, soubor se pri startu nepodarilo precist`);
return;
}
// Kopie, aby pozdejsi zmeny v pameti neovlivnily to, co se prave zapisuje.
pending = items.map((item) => ({ ...item }));
if (timer) return;
+48 -2
View File
@@ -18,6 +18,9 @@
import type { EntityStore, TenantEntity } from './types.js';
/** Podle ceho se seznam radi: nazev pole (ceske razeni) nebo vlastni porovnani. */
export type SortBy<T> = (keyof T & string) | ((a: T, b: T) => number);
export interface CachedStore<T extends TenantEntity> {
readonly store: EntityStore<T>;
/** Nacte kopii. Vola se pri startu a po kazdem zapisu. */
@@ -26,16 +29,32 @@ export interface CachedStore<T extends TenantEntity> {
all(): T[];
find(predicate: (entity: T) => boolean): T | undefined;
byId(id: string): T | undefined;
/**
* Zaznamy vybranych firem, serazene. Platformni (`tenantId: null`) tu nejsou.
*
* Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse" -
* stejne pravidlo jako u ticketu.
*/
listByTenant(tenantIds: string[], sortBy?: SortBy<T>): T[];
}
/**
* Vsechny kopie podle druhu entity. Diky tomu je obnova po zapisu jeden
* radek (`refreshCache(kind)`) a ne seznam dvanacti funkci, ktery se pri
* pridani entity zapomene doplnit.
*/
const caches = new Map<string, CachedStore<TenantEntity>>();
export function withCache<T extends TenantEntity>(store: EntityStore<T>): CachedStore<T> {
let rows: T[] = [];
let index = new Map<string, T>();
return {
const cache: CachedStore<T> = {
store,
async refresh() {
rows = await store.listAll();
index = new Map(rows.map((row) => [row.id, row]));
},
all() {
@@ -47,7 +66,34 @@ export function withCache<T extends TenantEntity>(store: EntityStore<T>): Cached
},
byId(id) {
return rows.find((row) => row.id === id);
return index.get(id);
},
listByTenant(tenantIds, sortBy) {
const selected = rows.filter(
(row) => row.tenantId !== null && tenantIds.includes(row.tenantId),
);
if (!sortBy) return selected;
if (typeof sortBy === 'function') return selected.sort(sortBy);
return selected.sort((a, b) =>
String(a[sortBy] ?? '').localeCompare(String(b[sortBy] ?? ''), 'cs'),
);
},
};
caches.set(store.kind, cache as CachedStore<TenantEntity>);
return cache;
}
/** Obnovi kopii jednoho druhu entity. Neznamy druh je chyba volajiciho, ne ticho. */
export async function refreshCache(kind: string): Promise<boolean> {
const cache = caches.get(kind);
if (!cache) return false;
await cache.refresh();
return true;
}
/** Obnovi vsechny kopie naraz. Vola se pri startu. */
export async function refreshAllCaches(): Promise<void> {
await Promise.all([...caches.values()].map((cache) => cache.refresh()));
}
+28 -8
View File
@@ -18,28 +18,46 @@
import path from 'node:path';
import { config } from '../../config.js';
import { isDatabaseEnabled } from '../../db/pool.js';
import { canSealSecrets, initSecrets } from '../../db/secretBox.js';
import { createLocalStore } from './local.js';
import { createPostgresStore } from './postgres.js';
import type { EntityStore, TenantEntity } from './types.js';
export type { Entity, EntityStore, ListOptions, TenantEntity } from './types.js';
export { isVisible, nowIso } from './types.js';
export { highestNumber, isVisible, minutesAgo, nowIso, writableOrWarn } from './types.js';
export type StoresMode = 'postgres' | 'file' | 'memory';
/** Rezim se urcuje jednou pri startu, aby se stores nechovaly kazdy jinak. */
let useDatabase = false;
let ready = false;
const registry = new Map<string, EntityStore<TenantEntity>>();
/** Nastavi rezim. Vola se pri startu po migracich, pred `init` jednotlivych stores. */
export function initStores(options: { databaseReady: boolean }): 'postgres' | 'file' | 'memory' {
useDatabase = isDatabaseEnabled() && options.databaseReady;
ready = true;
/**
* Nastavi rezim. Vola se pri startu po migracich, pred `init` jednotlivych stores.
*
* **Jedine rozhodnuti pro vsechna uloziste**, konektory vcetne. Databaze se
* pouzije jen kdyz je `DATABASE_URL`, migrace prosly a je cim sifrovat
* (`SECRETS_KEY`): konektory nesou pristupove udaje a ty do tabulky citelne
* nesmi. Driv se to rozhodovalo na dvou mistech a mohlo se stat, ze tickety
* sly do databaze a konektory do souboru.
*
* Opakovane volani rezim nemeni - konektory i ostatni entity se ptaji
* kazde zvlast a odpoved musi byt stejna.
*/
export function initStores(options: { databaseReady: boolean }): StoresMode {
if (ready) return storesMode();
if (useDatabase) return 'postgres';
return config.dataDir === '' ? 'memory' : 'file';
const databaseUsable = isDatabaseEnabled() && options.databaseReady;
// Klic vedle dat je pripustny jen mimo databazi. U ni ma prijit ze secretu.
initSecrets({ allowKeyFile: !databaseUsable, dir: config.dataDir });
useDatabase = databaseUsable && canSealSecrets();
ready = true;
return storesMode();
}
export function storesMode(): 'postgres' | 'file' | 'memory' {
export function storesMode(): StoresMode {
if (useDatabase) return 'postgres';
return config.dataDir === '' ? 'memory' : 'file';
}
@@ -78,7 +96,9 @@ export function defineStore<T extends TenantEntity>(kind: string): EntityStore<T
create: (entity) => resolve().create(entity),
put: (entity) => resolve().put(entity),
update: (id, patch, options) => resolve().update(id, patch, options),
updateMany: (ids, patch, options) => resolve().updateMany(ids, patch, options),
remove: (id, options) => resolve().remove(id, options),
removeMany: (ids) => resolve().removeMany(ids),
flush: () => resolve().flush(),
};
+28
View File
@@ -72,6 +72,11 @@ export function createLocalStore<T extends TenantEntity>(
},
async create(entity) {
// Stejne chovani jako primarni klic v Postgresu: druhy zaznam s tymz ID
// je chyba, ne tichy duplikat, ktery by `get` uz nikdy nenasel.
if (rows.some((item) => item.id === entity.id)) {
throw new Error(`[${options.kind}] zaznam ${entity.id} uz existuje`);
}
rows.push(entity);
persist();
return copy(entity);
@@ -95,6 +100,20 @@ export function createLocalStore<T extends TenantEntity>(
return copy(rows[index]);
},
async updateMany(ids, patch, listOptions) {
const { id: _id, createdAt: _createdAt, ...rest } = patch as Partial<TenantEntity>;
const wanted = new Set(ids);
const timestamp = nowIso();
let changed = 0;
rows = rows.map((row) => {
if (!wanted.has(row.id) || !isVisible(row, listOptions)) return row;
changed += 1;
return { ...row, ...(rest as Partial<T>), updatedAt: timestamp };
});
if (changed > 0) persist();
return changed;
},
async remove(id, listOptions) {
const index = rows.findIndex((item) => item.id === id);
if (index === -1 || !isVisible(rows[index], listOptions)) return false;
@@ -103,6 +122,15 @@ export function createLocalStore<T extends TenantEntity>(
return true;
},
async removeMany(ids) {
const wanted = new Set(ids);
const before = rows.length;
rows = rows.filter((row) => !wanted.has(row.id));
const removed = before - rows.length;
if (removed > 0) persist();
return removed;
},
async flush() {
await snapshot.flush();
},
+31 -11
View File
@@ -42,6 +42,28 @@ export function withMirror<T extends TenantEntity>(store: EntityStore<T>): Mirro
console.error(`[${store.kind}] ${action} se nepodarilo ulozit:`, err);
}
/**
* Rozepsane zapisy po zaznamech.
*
* Zapisy **tehoz zaznamu jdou za sebou**, ne soubezne. Dva `put` stejneho
* ticketu vystrelene hned po sobe by v databazi mohly dobehnout v opacnem
* poradi a vyhral by starsi stav. Ruzne zaznamy se navzajem nebrzdi.
*/
const chains = new Map<string, Promise<void>>();
function enqueue(id: string, action: string, work: () => Promise<unknown>): void {
const previous = chains.get(id) ?? Promise.resolve();
const next = previous
.then(work)
.then(() => undefined)
.catch((err: unknown) => report(action, err));
chains.set(id, next);
void next.finally(() => {
// Retez se uklidi, jen kdyz mezitim nepribyl dalsi zapis.
if (chains.get(id) === next) chains.delete(id);
});
}
return {
store,
@@ -51,7 +73,7 @@ export function withMirror<T extends TenantEntity>(store: EntityStore<T>): Mirro
},
save(entity) {
void store.put(entity).catch((err: unknown) => report(entity.id, err));
enqueue(entity.id, entity.id, () => store.put(entity));
},
drop(id) {
@@ -60,16 +82,14 @@ export function withMirror<T extends TenantEntity>(store: EntityStore<T>): Mirro
* znamena "nic" (viz isVisible), takze bez dohledani by se nesmazalo nic.
* Pravo si volajici overil driv, tady uz jde jen o zapis.
*/
void store
.getRaw(id)
.then((entity) => {
if (!entity) return false;
return store.remove(id, {
tenantIds: entity.tenantId === null ? [] : [entity.tenantId],
includeGlobal: true,
});
})
.catch((err: unknown) => report(`smazani ${id}`, err));
enqueue(id, `smazani ${id}`, async () => {
const entity = await store.getRaw(id);
if (!entity) return false;
return store.remove(id, {
tenantIds: entity.tenantId === null ? [] : [entity.tenantId],
includeGlobal: true,
});
});
},
};
}
+26
View File
@@ -142,6 +142,23 @@ export function createPostgresStore<T extends TenantEntity>(kind: string): Entit
return row ? toEntity<T>(row) : undefined;
},
async updateMany(ids, patch, options) {
if (ids.length === 0) return 0;
const { id: _id, createdAt: _createdAt, ...rest } = patch as Partial<TenantEntity>;
const { sql, params } = scope(options);
// Patch se slouci primo v SQL, at je to jeden dotaz a ne cteni a zapis
// za kazdy zaznam zvlast. Cas zmeny se pri cteni bere ze sloupce (toEntity).
const rows = await query<{ id: string }>(
`UPDATE records
SET data = data || $${params.length + 2}::jsonb,
updated_at = now()
WHERE ${sql} AND id = ANY($${params.length + 1})
RETURNING id`,
[...params, ids, JSON.stringify(rest)],
);
return rows.length;
},
async remove(id, options) {
const current = await this.get(id, options);
if (!current) return false;
@@ -149,6 +166,15 @@ export function createPostgresStore<T extends TenantEntity>(kind: string): Entit
return true;
},
async removeMany(ids) {
if (ids.length === 0) return 0;
const rows = await query<{ id: string }>(
'DELETE FROM records WHERE kind = $1 AND id = ANY($2) RETURNING id',
[kind, ids],
);
return rows.length;
},
async flush() {
// Databaze nic neodklada.
},
+56 -15
View File
@@ -17,22 +17,10 @@
* neda, viz documentation/14-databaze.md.
*/
/** Kazdy zaznam ma ID a vi, kdy vznikl. */
export interface Entity {
id: string;
createdAt: string;
updatedAt: string;
}
import type { Entity, TenantEntity } from '../../shared/entities.js';
/**
* Zaznam patrici firme.
*
* `tenantId: null` znamena platformni zaznam, tedy nas, ne zakaznikuv.
* Typicky systemova role nebo sluzba dostupna vsem.
*/
export interface TenantEntity extends Entity {
tenantId: string | null;
}
/** Zakladni tvar zaznamu je sdileny s webem, viz src/shared/entities.ts. */
export type { Entity, TenantEntity };
export interface ListOptions {
/**
@@ -64,7 +52,21 @@ export interface EntityStore<T extends TenantEntity> {
*/
put(entity: T): Promise<void>;
update(id: string, patch: Partial<T>, options: ListOptions): Promise<T | undefined>;
/**
* Stejny patch na vic zaznamu naraz. Vraci, kolik se jich zmenilo.
*
* Jeden zapis misto N: oznaceni dvaceti upozorneni jako prectenych by jinak
* znamenalo dvacet dotazu za sebou.
*/
updateMany(ids: string[], patch: Partial<T>, options: ListOptions): Promise<number>;
remove(id: string, options: ListOptions): Promise<boolean>;
/**
* Smaze zaznamy podle ID **bez kontroly firmy**. Vraci, kolik se smazalo.
*
* Jen pro uklid (audit, upozorneni), kde volajici maze to, co sam nacetl
* pres `listAll`. Pro zapis za uzivatele patri `remove` s filtrem.
*/
removeMany(ids: string[]): Promise<number>;
/** Dokonci rozepsany zapis. Vola se pri ukonceni procesu. */
flush(): Promise<void>;
}
@@ -73,6 +75,45 @@ export function nowIso(): string {
return new Date().toISOString();
}
/** Cas pred N minutami. Pouziva jen ukazkova data. */
export function minutesAgo(minutes: number): string {
return new Date(Date.now() - minutes * 60_000).toISOString();
}
/**
* Nejvyssi cislo v ID tvaru `PREFIX-123`.
*
* Citace ticketu, automatizaci a incidentu musi po startu pokracovat za tim,
* co uz je v ulozisti, jinak by nove zaznamy prepisovaly stare.
*/
export function highestNumber(ids: Iterable<string>, prefix: string): number {
const pattern = new RegExp(`^${prefix}-(\\d+)$`);
let highest = 0;
for (const id of ids) {
const number = Number(pattern.exec(id)?.[1] ?? 0);
if (number > highest) highest = number;
}
return highest;
}
/**
* Zaznam k zapisu jen z povolenych firem. Cizi se chova jako neexistujici
* a zaloguje se - pokus o zapis mimo firmu je vec, o ktere chceme vedet.
*/
export function writableOrWarn<T extends { tenantId: string }>(
entity: T | undefined,
id: string,
tenantIds: string[],
label: string,
): T | undefined {
if (!entity) return undefined;
if (!tenantIds.includes(entity.tenantId)) {
console.warn(`[${label}] pokus o zapis do ${id} mimo povolene firmy`);
return undefined;
}
return entity;
}
/** Vidi volajici tenhle zaznam? */
export function isVisible(entity: TenantEntity, options: ListOptions): boolean {
if (entity.tenantId === null) return options.includeGlobal === true;
+4 -19
View File
@@ -20,20 +20,10 @@ import { withCache } from './store/cached.js';
import { hasAnyPermission } from './permissions.js';
import type { User } from '../types.js';
/** Klic zalozky. Frontend si ho mapuje na cestu a komponentu. */
export type ModuleKey =
| 'overview'
| 'automations'
| 'actions'
| 'widgets'
| 'services'
| 'connectors'
| 'scripts'
| 'tickets'
| 'helpdesk'
| 'people'
| 'incidents'
| 'settings';
import type { ModuleKey, NavItem } from '../shared/access.js';
/** Klic zalozky a polozka navigace jsou sdilene s webem, viz src/shared/access.ts. */
export type { ModuleKey, NavItem };
export interface ModuleDefinition {
key: ModuleKey;
@@ -161,11 +151,6 @@ export function limitsOf(tenantId: string | null): TenantLimits {
return featuresOf(tenantId).limits;
}
export interface NavItem {
key: ModuleKey;
label: string;
}
/**
* Zalozky, ktere ma uzivatel videt.
*
+15 -10
View File
@@ -52,15 +52,12 @@ export async function refreshTenantScripts(): Promise<void> {
export const MAX_CODE_LENGTH = 20_000;
export function listTenantScripts(tenantIds: string[]): TenantScript[] {
return cache
.all()
.filter((script) => tenantIds.includes(script.tenantId))
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
return cache.listByTenant(tenantIds, 'name');
}
/** Skript firmy. Cizi skript se chova jako neexistujici, ne jako zakazany. */
export function findTenantScript(id: string, tenantIds: string[]): TenantScript | undefined {
const script = cache.all().find((item) => item.id === id);
const script = cache.byId(id);
if (!script || !tenantIds.includes(script.tenantId)) return undefined;
return script;
}
@@ -93,14 +90,22 @@ export function newTenantScript(input: {
* uz probehl. Je to informace do prehledu, ne soucast vysledku.
*/
export function noteRun(id: string, tenantId: string, ok: boolean): void {
const script = cache.all().find((item) => item.id === id);
const script = cache.byId(id);
if (!script) return;
script.lastRunAt = nowIso();
script.lastRunOk = ok;
const lastRunAt = nowIso();
void tenantScriptStore
.update(id, { lastRunAt: script.lastRunAt, lastRunOk: ok }, { tenantIds: [tenantId], includeGlobal: false })
.update(id, { lastRunAt, lastRunOk: ok }, { tenantIds: [tenantId], includeGlobal: false })
.then((updated) => {
// Kopie se meni az po zapisu. Driv se prepsala hned a kdyz zapis tise
// neprosel (skript jine firmy), portal ukazoval beh, ktery v ulozisti nebyl.
if (!updated) {
console.warn(`[skripty] skript ${id} nepatri firme ${tenantId}, beh se nepoznamenal`);
return;
}
script.lastRunAt = updated.lastRunAt;
script.lastRunOk = updated.lastRunOk;
})
.catch((err: unknown) => {
console.warn(`[skripty] beh skriptu ${id} se nepodarilo poznamenat:`, err);
});
+6 -27
View File
@@ -14,34 +14,13 @@
*/
import { randomBytes } from 'node:crypto';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { timingSafeEqualString } from '../lib/secure.js';
import { defineStore, nowIso } from './store/index.js';
import { withCache } from './store/cached.js';
import type { Tenant } from '../shared/tenants.js';
export interface Tenant extends TenantEntity {
name: string;
/** 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;
/**
* Firma, ktera teto firme resi pozadavky z helpdesku.
*
* `null` = helpdesk nema komu poslat, takze se pozadavek nezalozi a rekne se
* to nahlas. Nastavuje to spravce platformy, ne firma sama: kdo koho obsluhuje
* je obchodni vztah, ne volba klienta.
*
* Pozadavek pak vznikne jako ticket **teto dodavatelske firmy** a zadavatel
* zustane v `helpdeskSourceId`.
*/
helpdeskProviderId: string | null;
}
/** Tvar firmy je sdileny s webem, viz src/shared/tenants.ts. */
export type { Tenant };
/**
* Firma je platformni zaznam: `tenantId` je vzdy null, protoze firmu nevlastni
@@ -95,7 +74,7 @@ export function seedTenants(): Tenant[] {
*/
export function findByIntakeToken(token: string): Tenant | undefined {
if (token.length < 16) return undefined;
return cache.all().find((tenant) => tenant.intakeToken === token && tenant.enabled);
return cache.all().find((tenant) => tenant.enabled && timingSafeEqualString(tenant.intakeToken, token));
}
export async function refreshTenants(): Promise<void> {
+4 -4
View File
@@ -120,10 +120,10 @@ export async function refreshActions(): Promise<void> {
}
export function listActions(tenantIds: string[]): TicketAction[] {
return cache
.all()
.filter((action) => tenantIds.includes(action.tenantId))
.sort((a, b) => a.order - b.order || a.label.localeCompare(b.label, 'cs'));
return cache.listByTenant(
tenantIds,
(a, b) => a.order - b.order || a.label.localeCompare(b.label, 'cs'),
);
}
export function findAction(id: string): TicketAction | undefined {
+283 -427
View File
@@ -23,213 +23,49 @@ import type { Visibility } from './access.js';
import { onTicketChanged } from './ticketHooks.js';
import { notify } from './notifications.js';
import { findPerson, type Person } from './people.js';
import { defineStore } from './store/index.js';
import { defineStore, highestNumber, minutesAgo, writableOrWarn } from './store/index.js';
import { withMirror } from './store/mirror.js';
import type {
AgentStatsRow,
Ticket,
TicketAssignee,
TicketChannel,
TicketCustomer,
TicketDetail,
TicketEvent,
TicketPriority,
TicketStatus,
TicketTraceEntry,
TraceKind,
TraceStatus,
Workload,
WorkloadRow,
} from '../shared/tickets.js';
/**
* Stav ticketu. **Volny retezec**, ne ciselnik.
*
* Tickety chodi z cizich aplikaci, ktere maji svoje stavy: voicebot posila
* `ringing` a `completed`, e-shop `pripraveno k expedici`. Nutit je do nasi
* ctverice znamenalo, ze uzivatel videl "Novy" u ticketu, ktery byl podle
* jeho systemu davno hotovy.
*
* Vychozi ctverice zustava jako **doporuceni**, ne omezeni - kdo si nic
* nenastavi, dostane rozumne stavy a nemusi vymyslet vlastni.
* Tvar ticketu je sdileny s webem, viz src/shared/tickets.ts. Tady zustava
* uloziste, ukazkova data a to, co se z ticketu odvozuje.
*/
export type TicketStatus = string;
export type {
AgentStatsRow,
Ticket,
TicketAssignee,
TicketChannel,
TicketCustomer,
TicketDetail,
TicketEvent,
TicketPriority,
TicketStatus,
TicketTraceEntry,
TraceKind,
TraceStatus,
Workload,
WorkloadRow,
};
/** Vychozi stavy, kdyz si typ ticketu nenadefinuje vlastni. Jen nabidka. */
export const defaultStatuses = ['Nový', 'V řešení', 'Čeká na klienta', 'Vyřešeno'] as const;
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
/** Odkud pozadavek prisel. Podle toho se pozna, co ma smysl hledat v logu. */
export type TicketChannel =
| 'whatsapp'
| 'facebook'
| 'instagram'
| 'email'
| 'voice'
| 'form'
| 'portal';
export interface TicketCustomer {
/**
* ID firmy v CRM. null = zakaznika se nepodarilo dohledat.
* Prave na tohle se pta podminka "mame zakaznika?" ve stromu automatizace.
*/
id: string | null;
company: string;
/** Konkretni clovek, ktery pozadavek poslal. */
contact: string;
/** Adresa nebo cislo, odkud to prislo a kam se odpovida. */
reply: string;
}
/** Odkaz na resitele v odpovedi API. Uvnitr se drzi jen jeho ID. */
export interface TicketAssignee {
id: string;
name: string;
}
export type TraceStatus = 'ok' | 'error' | 'skipped' | 'info';
export type TraceKind = 'trigger' | 'action' | 'condition' | 'note';
/**
* Jeden radek v logu ticketu. Strom se sklada pres `parentId` -
* vetev podminky visi na zaznamu te podminky.
*/
export interface TicketTraceEntry {
id: string;
/** null = zaznam v hlavni sekvenci. */
parentId: string | null;
kind: TraceKind;
/** Ktera sluzba to byla. null u poznamek a podminek. */
serviceId: string | null;
operationId: string | null;
label: string;
status: TraceStatus;
/** Co sluzba vratila. Tohle je duvod, proc log existuje. */
response: string | null;
durationMs: number | null;
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;
/**
* Kolikrat prislo totež. Vetsinou 1.
*
* Odesilatele umi poslat stejnou zpravu i osmdesatkrat za minutu. Zalozit
* osmdesat radku znamena, ze v historii nikdo nic nenajde - a pritom se
* nestalo nic noveho. Proto se shodna udalost **pocita** u te predchozi.
* Zahodit ji nejde: bez pocitadla by nikdo nezjistil, ze odesilatel tluce.
*/
repeats: number;
/** Kdy prisla naposledy. Same jako `at`, dokud se neopakuje. */
lastAt: string;
}
export interface Ticket {
id: string;
/** Firma, ktere ticket patri. Hranice viditelnosti, viz data/access.ts. */
tenantId: string;
/**
* Firma, ze ktere pozadavek prisel pres helpdesk. `null` u beznych ticketu.
*
* Vlastnikem ticketu (`tenantId`) je ta firma, ktera pozadavek **resi** -
* u helpdesku se dosadi sama podle toho, koho ma zadavatel nastaveneho jako
* dodavatele. Zadavatel neni vlastnik: kdyby byl, videl by ho resitel jen
* jako cizi ticket a nemel by ho ve sve fronte.
*
* Podle tohohle pole vidi zadavatel svoje pozadavky, a **jen je**. Do bezneho
* seznamu ticketu se nepromita, ten zustava vlastnikovi.
*/
helpdeskSourceId: string | null;
/**
* 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.
* Prazdny retezec znamena, ze krok "Zalozit ticket" obsah nenaplnil.
*/
body: string;
/** Odkaz na zdrojovou zpravu u poskytovatele. null u rucne zalozenych. */
sourceRef: string | null;
channel: TicketChannel;
customer: TicketCustomer;
status: TicketStatus;
priority: TicketPriority;
assignee: TicketAssignee | null;
/**
* Skupina, ktera ma ticket u sebe. Prehazovat praci na jmeno nestaci -
* clovek chce rict "tohle je pro ucetni" bez toho, aby resil, kdo z nich
* ma dovolenou. Viz data/people.ts, sekce skupiny.
*/
assigneeGroupId: string | null;
/**
* Typ ticketu. Za nim stoji vlastni pole, proto se na nej vazou akce.
* null = ticket bez typu, jako driv.
*/
typeId: string | null;
/** Hodnoty vlastnich poli typu. Klic je `TicketTypeField.key`. */
fields: Record<string, string | number | boolean | null>;
/**
* Je vyrizeny?
*
* **Nastavuje se vyslovne**, neodvozuje se ze stavu. Stav je volny retezec
* a hadat z nej, jestli `completed` nebo `hotovo` znamena konec, by znamenalo
* uhodnout spatne pokazde, kdyz si nekdo pojmenuje stavy po svem.
*
* Fronta, vytizeni i statistiky se ptaji na tenhle priznak.
*/
closed: boolean;
/**
* Volne oznaceni. Na rozdil od typu jich muze byt vic a nestoji za nimi
* zadna pole - proto se hodi na filtry a widgety, ne na akce, ktere
* potrebuji data.
*/
tags: string[];
/** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */
automationId: string | null;
/**
* Ucet, ktery ticket zalozil rucne. null u ticketu z automatizace.
*
* Je to **ucet, ne resitel**: v helpdesku podle nej zadavatel pozna svoje
* pozadavky, a ten je nikdy nema prirazene - resi je nekdo u dodavatele.
*/
createdById: 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[];
}
/**
* Tvar v ulozisti.
*
@@ -282,16 +118,30 @@ export const channelLabels: Record<TicketChannel, string> = {
portal: 'Portál',
};
function minutesAgo(minutes: number): string {
return new Date(Date.now() - minutes * 60_000).toISOString();
}
// ------------------------------------------------------------------- uloziste
const tickets: StoredTicket[] = [];
/**
* Indexy nad polem. Ticket se hleda podle ID pri kazdem zapisu do logu
* a podle externiho ID pri kazde prichozi udalosti - linearni hledani
* v tisicich ticketu by bylo znat. Pole zustava kvuli poradi v seznamu.
*/
const ticketsById = new Map<string, StoredTicket>();
const ticketsByExternal = new Map<string, StoredTicket>();
/** Log ticketu drzime zvlast - je to jina zivotnost i jiny objem dat. */
const traces = new Map<string, TicketTraceEntry[]>();
/** Klic externiho ID: unikatni je v ramci firmy, ne globalne. */
function externalKey(tenantId: string, externalId: string): string {
return `${tenantId}:${externalId}`;
}
/** Zaradi ticket do indexu. Vola se vsude, kde ticket pribyva do pole. */
function index(ticket: StoredTicket): void {
ticketsById.set(ticket.id, ticket);
if (ticket.externalId) ticketsByExternal.set(externalKey(ticket.tenantId, ticket.externalId), ticket);
}
/**
* Tvar v ulozisti. Log je soucasti zaznamu zamerne: v pameti se drzi zvlast
* kvuli objemu, ale ukladat ho jako druhou entitu by znamenalo dva zapisy
@@ -306,13 +156,33 @@ const events = new Map<string, TicketEvent[]>();
const mirror = withMirror(defineStore<PersistedTicket>('ticket'));
/**
* Tickety, ktere cekaji na zapis. Zapisuje se **jednou za tik** smycky:
* jedna operace (zmena stavu, radek do logu, udalost na sbernici) volala
* `persist` dvakrat az trikrat a pokazde sla do uloziste cela kopie ticketu
* vcetne logu a az dvou set udalosti. Ted se zmeny za tik slouci a zapise se
* stav, ktery plati na jeho konci. Poradi zapisu tehoz ticketu hlida `withMirror`.
*/
const pendingPersist = new Set<string>();
/** Ulozi ticket vcetne logu. Necekana se, chyba se loguje. */
function persist(ticket: StoredTicket): void {
mirror.save({
...ticket,
trace: traces.get(ticket.id) ?? [],
events: events.get(ticket.id) ?? [],
});
if (pendingPersist.size === 0) queueMicrotask(flushPersist);
pendingPersist.add(ticket.id);
}
function flushPersist(): void {
const ids = [...pendingPersist];
pendingPersist.clear();
for (const id of ids) {
const ticket = ticketsById.get(id);
if (!ticket) continue;
mirror.save({
...ticket,
trace: traces.get(id) ?? [],
events: events.get(id) ?? [],
});
}
}
/**
@@ -341,11 +211,14 @@ export async function initTickets(): Promise<void> {
);
tickets.length = 0;
ticketsById.clear();
ticketsByExternal.clear();
traces.clear();
events.clear();
for (const row of rows) {
const { trace, events: rowEvents, ...stored } = row;
tickets.push(stored);
index(stored);
traces.set(row.id, trace ?? []);
/*
* Pocitadlo opakovani pribylo pozdeji. Ulozene udalosti ho nemaji, takze
@@ -363,10 +236,7 @@ export async function initTickets(): Promise<void> {
// Citac musi pokracovat za nejvyssim ulozenym cislem, jinak by nove tickety
// prepisovaly stare.
for (const ticket of tickets) {
const number = Number(/^TK-(\d+)$/.exec(ticket.id)?.[1] ?? 0);
if (number > ticketCounter) ticketCounter = number;
}
ticketCounter = Math.max(ticketCounter, highestNumber(ticketsById.keys(), 'TK'));
// Log muze byt dlouhy, do citace radku se to nepocita.
traceCounter = [...traces.values()].reduce((sum, list) => sum + list.length, traceCounter);
@@ -440,7 +310,7 @@ export function appendTrace(ticketId: string, inputs: TraceInput[]): number {
flattenTrace(inputs, currentRun()?.traceParent.id ?? null, existing);
// Log je soucast ulozeneho ticketu, takze zapis do logu je zmena ticketu.
const ticket = tickets.find((item) => item.id === ticketId);
const ticket = ticketsById.get(ticketId);
if (ticket) persist(ticket);
return existing.length - before;
@@ -450,15 +320,17 @@ export function appendTrace(ticketId: string, inputs: TraceInput[]): number {
function seed(ticket: StoredTicket, trace: TraceInput[]) {
tickets.push(ticket);
index(ticket);
traces.set(ticket.id, flattenTrace(trace, null, []));
}
/**
* Ukazkove tickety.
*
* Nasypou se **jen se `SEED_DEMO=1`**. Na instanci, kde uz chodi skutecny
* provoz, jsou to cizi zaznamy mezi opravdovymi a po kazdem redeployi
* se vraceji.
* Nasypou se **jen se `SEED_DEMO=1`**, vsechny vcetne tech u klientskych firem.
* Na instanci, kde uz chodi skutecny provoz, jsou to cizi zaznamy mezi
* opravdovymi a po kazdem redeployi se vraceji. Stavy jsou z `defaultStatuses`,
* tedy ty, se kterymi vznikaji i skutecne tickety.
*/
function seedDemoTickets(): void {
seed(
@@ -477,7 +349,7 @@ function seedDemoTickets(): void {
contact: 'Petra Klientová',
reply: '+420 601 118 224',
},
status: 'open',
status: 'V řešení',
priority: 'high',
assigneeId: 'ppl_novakova',
automationId: 'AUT-02',
@@ -591,7 +463,7 @@ function seedDemoTickets(): void {
contact: 'Tomáš Beran',
reply: 'tomas.beran@nordis.cz',
},
status: 'waiting',
status: 'Čeká na klienta',
priority: 'normal',
assigneeId: 'ppl_kriz',
automationId: 'AUT-03',
@@ -672,7 +544,7 @@ function seedDemoTickets(): void {
contact: 'Bistro Kolektiv',
reply: '+420 774 902 331',
},
status: 'new',
status: 'Nový',
priority: 'normal',
assigneeId: null,
automationId: 'AUT-01',
@@ -751,122 +623,123 @@ function seedDemoTickets(): void {
},
],
);
seed(
{
id: 'TK-4817',
tenantId: 'tnt_logitrans',
subject: 'Rozšíření hlasového scénáře o objednávku svozu',
body:
'Chtěli bychom, aby si zákazník mohl objednat svoz rovnou po telefonu, ' +
'bez přepojení na dispečink. Rozpočet do 40 000. Prosím o odhad.',
sourceRef: null,
channel: 'form',
customer: {
id: 'crm_3390',
company: 'LogiTrans',
contact: 'Jana Sedláčková',
reply: 'jana.sedlackova@logitrans.cz',
},
status: 'V řešení',
priority: 'low',
assigneeId: 'ppl_kadlec',
automationId: null,
createdAt: minutesAgo(2_600),
updatedAt: minutesAgo(420),
},
[
{
kind: 'trigger',
serviceId: 'form',
operationId: 'submitted',
label: 'Odeslán formulář Požadavek na úpravu',
status: 'ok',
response: '{ "company": "LogiTrans", "topic": "voicebot", "budget": "do 40 000" }',
durationMs: 45,
agoMinutes: 2_600,
},
{
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
label: 'Založení ticketu',
status: 'ok',
response: '{ "ticketId": "TK-4817", "priority": "low" }',
durationMs: 71,
agoMinutes: 2_600,
},
{
kind: 'note',
label: 'Ticket ručně přiřazen na Ondřeje Kadlece.',
status: 'info',
agoMinutes: 2_580,
},
{
kind: 'note',
label: 'Ondřej Kadlec: Odhad odeslán, čeká se na objednávku.',
status: 'info',
agoMinutes: 420,
},
],
);
seed(
{
id: 'TK-4812',
tenantId: 'tnt_nordis',
subject: 'Duplicitní zápis kontaktů z webového formuláře',
body:
'Každé odeslání formuláře zakládá nový kontakt, i když stejný e-mail už v CRM je. ' +
'Máme tam desítky duplicit.',
sourceRef: null,
channel: 'portal',
customer: {
id: 'crm_1042',
company: 'Firma s.r.o.',
contact: 'Petra Klientová',
reply: 'petra.klientova@firma.cz',
},
status: 'Vyřešeno',
closed: true,
priority: 'critical',
assigneeId: 'ppl_bartos',
automationId: null,
createdAt: minutesAgo(5_100),
updatedAt: minutesAgo(1_500),
},
[
{
kind: 'trigger',
serviceId: 'form',
operationId: 'submitted',
label: 'Nahlášeno z portálu',
status: 'ok',
response: '{ "reportedBy": "petra.klientova@firma.cz" }',
durationMs: 38,
agoMinutes: 5_100,
},
{
kind: 'action',
serviceId: 'transform',
operationId: 'deduplicate',
label: 'Kontrola duplicit v CRM',
status: 'ok',
response: '{ "scanned": 1284, "duplicates": 37, "key": "email" }',
durationMs: 6_700,
agoMinutes: 4_900,
},
{
kind: 'note',
label: 'Lukáš Bartoš: Do formuláře doplněna kontrola podle e-mailu, duplicity sloučeny.',
status: 'info',
agoMinutes: 1_500,
},
],
);
}
if (config.seedDemo) seedDemoTickets();
seed(
{
id: 'TK-4817',
tenantId: 'tnt_logitrans',
subject: 'Rozšíření hlasového scénáře o objednávku svozu',
body:
'Chtěli bychom, aby si zákazník mohl objednat svoz rovnou po telefonu, ' +
'bez přepojení na dispečink. Rozpočet do 40 000. Prosím o odhad.',
sourceRef: null,
channel: 'form',
customer: {
id: 'crm_3390',
company: 'LogiTrans',
contact: 'Jana Sedláčková',
reply: 'jana.sedlackova@logitrans.cz',
},
status: 'open',
priority: 'low',
assigneeId: 'ppl_kadlec',
automationId: null,
createdAt: minutesAgo(2_600),
updatedAt: minutesAgo(420),
},
[
{
kind: 'trigger',
serviceId: 'form',
operationId: 'submitted',
label: 'Odeslán formulář Požadavek na úpravu',
status: 'ok',
response: '{ "company": "LogiTrans", "topic": "voicebot", "budget": "do 40 000" }',
durationMs: 45,
agoMinutes: 2_600,
},
{
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
label: 'Založení ticketu',
status: 'ok',
response: '{ "ticketId": "TK-4817", "priority": "low" }',
durationMs: 71,
agoMinutes: 2_600,
},
{
kind: 'note',
label: 'Ticket ručně přiřazen na Ondřeje Kadlece.',
status: 'info',
agoMinutes: 2_580,
},
{
kind: 'note',
label: 'Ondřej Kadlec: Odhad odeslán, čeká se na objednávku.',
status: 'info',
agoMinutes: 420,
},
],
);
seed(
{
id: 'TK-4812',
tenantId: 'tnt_nordis',
subject: 'Duplicitní zápis kontaktů z webového formuláře',
body:
'Každé odeslání formuláře zakládá nový kontakt, i když stejný e-mail už v CRM je. ' +
'Máme tam desítky duplicit.',
sourceRef: null,
channel: 'portal',
customer: {
id: 'crm_1042',
company: 'Firma s.r.o.',
contact: 'Petra Klientová',
reply: 'petra.klientova@firma.cz',
},
status: 'resolved',
priority: 'critical',
assigneeId: 'ppl_bartos',
automationId: null,
createdAt: minutesAgo(5_100),
updatedAt: minutesAgo(1_500),
},
[
{
kind: 'trigger',
serviceId: 'form',
operationId: 'submitted',
label: 'Nahlášeno z portálu',
status: 'ok',
response: '{ "reportedBy": "petra.klientova@firma.cz" }',
durationMs: 38,
agoMinutes: 5_100,
},
{
kind: 'action',
serviceId: 'transform',
operationId: 'deduplicate',
label: 'Kontrola duplicit v CRM',
status: 'ok',
response: '{ "scanned": 1284, "duplicates": 37, "key": "email" }',
durationMs: 6_700,
agoMinutes: 4_900,
},
{
kind: 'note',
label: 'Lukáš Bartoš: Do formuláře doplněna kontrola podle e-mailu, duplicity sloučeny.',
status: 'info',
agoMinutes: 1_500,
},
],
);
// ------------------------------------------------------------------ prevody
function toTicket(stored: StoredTicket): Ticket {
@@ -1024,7 +897,7 @@ export function getTicket(
helpdeskSourceIds: string[] = [],
visibility: Visibility = { kind: 'all' },
): TicketDetail | undefined {
const stored = tickets.find((t) => t.id === id);
const stored = ticketsById.get(id);
if (!stored) return undefined;
const owns = tenantIds.includes(stored.tenantId);
@@ -1064,7 +937,7 @@ export function getTicket(
* za firmu ticketu, ne za prave prepnutou.
*/
export function ticketWithinVisibility(id: string, visibility: Visibility): boolean {
const stored = tickets.find((t) => t.id === id);
const stored = ticketsById.get(id);
return stored ? withinVisibility(stored, visibility) : false;
}
@@ -1076,29 +949,6 @@ export function firstOpenTicket(tenantIds: string[]): Ticket | undefined {
return stored ? toTicket(stored) : undefined;
}
/**
* Kdo co ma u sebe. Prehled pres cely tym, ne jen pres prihlaseneho cloveka -
* bez nej nejde poznat, ze jeden clovek utahuje polovinu servicedesku.
*/
export interface WorkloadRow {
person: Person;
/** Nevyresene tickety. Tohle je to cislo, na ktere se lidi divaji. */
open: number;
/** Vcetne vyresenych, aby bylo videt, kolik uz ma za sebou. */
total: number;
critical: number;
/** Nejstarsi nevyreseny ticket. null = nic nevisi. */
oldestOpenAt: string | null;
/** true = ma vic nevyrizenych, nez je jeho kapacita. */
overloaded: boolean;
}
export interface Workload {
rows: WorkloadRow[];
unassigned: number;
openTotal: number;
}
export function getWorkload(
everyone: Person[],
tenantIds: string[],
@@ -1108,20 +958,19 @@ export function getWorkload(
// a vytizeni tymu tak obchazelo kazde omezeni viditelnosti.
const visible = listTickets({ tenantIds, visibility });
// Jeden pruchod pres tickety, ne jeden filtr za kazdeho cloveka.
const byAssignee = groupByAssignee(visible);
const rows = everyone.map((person) => {
const mine = visible.filter((t) => t.assignee?.id === person.id);
const mine = byAssignee.get(person.id) ?? [];
const open = mine.filter((t) => !t.closed);
const oldest = open.reduce<string | null>(
(acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc),
null,
);
return {
person,
open: open.length,
total: mine.length,
critical: open.filter((t) => t.priority === 'critical').length,
oldestOpenAt: oldest,
oldestOpenAt: oldestCreatedAt(open),
overloaded: open.length > person.capacity,
};
});
@@ -1136,6 +985,26 @@ export function getWorkload(
};
}
/** Tickety podle resitele. Nezarazene tu nejsou, ty maji vlastni pocitadlo. */
function groupByAssignee(list: Ticket[]): Map<string, Ticket[]> {
const groups = new Map<string, Ticket[]>();
for (const ticket of list) {
if (!ticket.assignee) continue;
const mine = groups.get(ticket.assignee.id);
if (mine) mine.push(ticket);
else groups.set(ticket.assignee.id, [ticket]);
}
return groups;
}
/** Nejstarsi cas vzniku. null = prazdny seznam. */
function oldestCreatedAt(list: Ticket[]): string | null {
return list.reduce<string | null>(
(acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc),
null,
);
}
/**
* Prvni reakce se zapisuje jednou a uz se neprepisuje.
*
@@ -1148,16 +1017,14 @@ function markResponded(ticket: StoredTicket): void {
/** Ticket podle naseho ID, jen z povolenych firem. */
export function findTicket(id: string, tenantIds: string[]): Ticket | undefined {
const stored = tickets.find((ticket) => ticket.id === id);
const stored = ticketsById.get(id);
if (!stored || !tenantIds.includes(stored.tenantId)) return undefined;
return toTicket(stored);
}
/** 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,
);
const stored = ticketsByExternal.get(externalKey(tenantId, externalId));
return stored ? toTicket(stored) : undefined;
}
@@ -1260,9 +1127,7 @@ export function intakeEvent(input: IntakeInput): IntakeResult {
};
const existing = input.externalId
? tickets.find(
(ticket) => ticket.tenantId === input.tenantId && ticket.externalId === input.externalId,
)
? ticketsByExternal.get(externalKey(input.tenantId, input.externalId))
: undefined;
if (existing) {
@@ -1309,7 +1174,8 @@ export function intakeEvent(input: IntakeInput): IntakeResult {
publish('ticket.updated', `Ticket ${existing.id}: ${event.label}`, {
ticketId: existing.id,
externalId: existing.externalId,
});
ticket: toTicket(existing),
}, existing.tenantId);
return { ticket: toTicket(existing), created: false, event, repeated: false };
}
@@ -1357,7 +1223,7 @@ export function intakeEvent(input: IntakeInput): IntakeResult {
],
});
const stored = tickets.find((ticket) => ticket.id === created.id);
const stored = ticketsById.get(created.id);
if (stored) {
appendEvent(stored, event);
persist(stored);
@@ -1501,19 +1367,6 @@ function describePayload(payload: Record<string, unknown>): string | null {
// ---------------------------------------------------------------- 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;
@@ -1539,39 +1392,43 @@ export function getAgentStats(
): AgentStatsRow[] {
const visible = listTickets({ tenantIds, visibility });
/**
* 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;
}
// Jeden pruchod: tickety podle resitele a vyresene podle toho, kdo je vyresil.
const byAssignee = groupByAssignee(visible);
const byResolver = new Map<string, Ticket[]>();
for (const ticket of visible) {
if (!ticket.resolvedById) continue;
const time = at(ticket.resolvedAt);
if (time === null || (since !== null && time < since)) continue;
const mine = byResolver.get(ticket.resolvedById);
if (mine) mine.push(ticket);
else byResolver.set(ticket.resolvedById, [ticket]);
}
const rows = everyone.map((person) => {
const open = visible.filter((t) => t.assignee?.id === person.id && !t.closed);
/**
* 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 mine = byAssignee.get(person.id) ?? [];
const open = mine.filter((t) => !t.closed);
const resolved = byResolver.get(person.id) ?? [];
const resolveTimes = resolved
.map((t) => (at(t.resolvedAt) ?? 0) - (at(t.createdAt) ?? 0))
.filter((value) => value >= 0);
const responseTimes = visible
.filter((t) => t.assignee?.id === person.id && at(t.firstResponseAt) !== null)
const responseTimes = mine
.filter((t) => 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,
);
const oldest = oldestCreatedAt(open);
return {
personId: person.id,
@@ -1671,6 +1528,7 @@ export function createTicket(input: CreateTicketInput): Ticket {
updatedAt: now,
};
tickets.unshift(stored);
index(stored);
traces.set(stored.id, flattenTrace(input.trace ?? [], null, []));
events.set(stored.id, []);
persist(stored);
@@ -1682,19 +1540,13 @@ export function createTicket(input: CreateTicketInput): Ticket {
channel: stored.channel,
priority: stored.priority,
knownCustomer: stored.customer.id !== null,
});
}, stored.tenantId);
return toTicket(stored);
}
/** Ticket z povolenych firem. Cizi se chova jako neexistujici. */
function findWritable(id: string, tenantIds: string[]): StoredTicket | undefined {
const ticket = tickets.find((t) => t.id === id);
if (!ticket) return undefined;
if (!tenantIds.includes(ticket.tenantId)) {
console.warn(`[tickets] pokus o zapis do ticketu ${id} mimo povolene firmy`);
return undefined;
}
return ticket;
return writableOrWarn(ticketsById.get(id), id, tenantIds, 'tickets');
}
/**
@@ -1755,12 +1607,14 @@ export function updateTicketStatus(
if (ticket.closed) {
publish('ticket.resolved', `Ticket ${ticket.id} vyřešen: ${ticket.subject}`, {
ticketId: ticket.id,
});
ticket: toTicket(ticket),
}, ticket.tenantId);
} else {
publish('ticket.updated', `Ticket ${ticket.id} má nový stav`, {
ticketId: ticket.id,
status,
});
ticket: toTicket(ticket),
}, ticket.tenantId);
}
return toTicket(ticket);
}
@@ -1820,7 +1674,8 @@ export function assignTicket(
person
? `Ticket ${ticket.id} přiřazen: ${person.name}`
: `Ticket ${ticket.id} vrácen do fronty`,
{ ticketId: ticket.id, assigneeId: ticket.assigneeId },
{ ticketId: ticket.id, assigneeId: ticket.assigneeId, ticket: toTicket(ticket) },
ticket.tenantId,
);
return toTicket(ticket);
}
@@ -1848,7 +1703,7 @@ export function setTicketType(
appendTrace(id, [
{ kind: 'note', status: 'info', label: `Typ ticketu nastaven na ${typeId ?? 'bez typu'}` },
]);
publish('ticket.updated', `Ticket ${ticket.id} má nový typ`, { ticketId: ticket.id });
publish('ticket.updated', `Ticket ${ticket.id} má nový typ`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
return toTicket(ticket);
}
@@ -1859,7 +1714,7 @@ export function setTicketTags(id: string, tags: string[], tenantIds: string[]):
ticket.tags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
touch(ticket);
publish('ticket.updated', `Ticket ${ticket.id} má upravené tagy`, { ticketId: ticket.id });
publish('ticket.updated', `Ticket ${ticket.id} má upravené tagy`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
return toTicket(ticket);
}
@@ -1890,7 +1745,7 @@ export function assignTicketGroup(
label: groupId ? `Přehozeno na skupinu ${groupId}` : 'Odebráno ze skupiny',
},
]);
publish('ticket.assigned', `Ticket ${ticket.id} přehozen na skupinu`, { ticketId: ticket.id });
publish('ticket.assigned', `Ticket ${ticket.id} přehozen na skupinu`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
return toTicket(ticket);
}
@@ -1924,13 +1779,14 @@ export function claimTicket(
publish('ticket.assigned', `${person.name} si vzal ticket ${ticket.id}`, {
ticketId: ticket.id,
assigneeId: person.id,
});
ticket: toTicket(ticket),
}, ticket.tenantId);
return toTicket(ticket);
}
/** Ma ticket uz nekoho? Pro rozhodnuti, jestli jde prevzit. */
export function ticketAssignee(id: string, tenantIds: string[]): string | null | undefined {
const ticket = tickets.find((item) => item.id === id);
const ticket = ticketsById.get(id);
if (!ticket || !tenantIds.includes(ticket.tenantId)) return undefined;
return ticket.assigneeId;
}
@@ -1950,7 +1806,7 @@ export function addComment(
tenantIds: string[],
helpdeskSourceIds: string[] = [],
): Ticket | undefined {
const found = tickets.find((item) => item.id === id);
const found = ticketsById.get(id);
const asked =
found?.helpdeskSourceId !== null &&
found?.helpdeskSourceId !== undefined &&
@@ -1966,6 +1822,6 @@ export function addComment(
touch(ticket);
appendTrace(id, [{ kind: 'note', label: `${author}: ${text}`, status: 'info' }]);
publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id });
publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
return toTicket(ticket);
}
+5 -33
View File
@@ -14,39 +14,14 @@
* "Typ nebo tag".
*/
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { defineStore, nowIso } from './store/index.js';
import { withCache } from './store/cached.js';
import type { FieldType } from './conditions.js';
export interface TicketTypeField {
/** Stabilni, odkazuji se na nej podminky. Prejmenovani je nerozbije. */
id: string;
/** Pouziva se v sablonach jako `{{orderNumber}}`. */
key: string;
label: string;
type: FieldType;
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
}
import type { TicketType, TicketTypeField } from '../shared/tickets.js';
export interface TicketType extends TenantEntity {
tenantId: string;
/** Kratky klic, napr. `order`. Pouziva se ve filtrech a podminkach. */
key: string;
name: string;
/** Klic ikony, mapuje se na klientovi. */
icon: string;
/**
* Vlastni stavy ticketu. Prazdne = vychozi doporucena ctverice.
*
* Stav je volny retezec, tohle je jen nabidka v rozhrani - ticket muze mit
* i stav, ktery poslala cizi aplikace a v seznamu neni.
*/
statuses: string[];
fields: TicketTypeField[];
}
/** Tvar typu ticketu je sdileny s webem, viz src/shared/tickets.ts. */
export type { TicketType, TicketTypeField };
export const ticketTypeStore = defineStore<TicketType>('ticketType');
const cache = withCache(ticketTypeStore);
@@ -96,10 +71,7 @@ export async function refreshTicketTypes(): Promise<void> {
}
export function listTicketTypes(tenantIds: string[]): TicketType[] {
return cache
.all()
.filter((type) => tenantIds.includes(type.tenantId))
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
return cache.listByTenant(tenantIds, 'name');
}
export function findTicketType(id: string): TicketType | undefined {
+11 -2
View File
@@ -115,8 +115,17 @@ export function findUserById(id: string): User | undefined {
return found && found.enabled ? toUser(found) : undefined;
}
export function hashPassword(plain: string): string {
return bcrypt.hashSync(plain, 10);
/**
* Asynchronne zamerne. `hashSync` blokuje event loop na desitky milisekund
* a po tu dobu nikdo jiny nedostane odpoved - u hesla se to opakuje pri
* kazdem prihlaseni.
*/
export function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, 10);
}
export function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}
/** Uzivatele, kteri patri do dane firmy. */
+10 -36
View File
@@ -6,35 +6,18 @@
* i novou komponentu na webu, proto jich je zamerne malo a jsou obecne.
*/
import { listCustomWidgets, sizesFor, type WidgetRender, type WidgetSource } from './customWidgets.js';
import { listCustomWidgets, sizesFor, type WidgetSource } from './customWidgets.js';
export type WidgetSize = 'third' | 'half' | 'full';
import type {
CatalogWidget,
WidgetDefinition,
WidgetKind,
WidgetMetric,
WidgetSize,
} from '../shared/widgets.js';
export type WidgetKind =
| 'stat'
| 'chart'
| 'ticketList'
| 'incidentList'
| 'workload'
/** Vykon resitelu. Data chodi z `/widget-data`, stejne jako u vlastnich. */
| 'agents'
/** Vlastni widget: kresleni si urcuje jeho `render`. */
| WidgetRender;
export interface WidgetDefinition {
id: string;
name: string;
description: string;
kind: WidgetKind;
/** Ktere sirky ma smysl nabizet. Graf v tretine sloupce necteme. */
sizes: WidgetSize[];
defaultSize: WidgetSize;
/**
* Jen u `stat`: ktere cislo ze souhrnu ukazat.
* Klic odpovida poli v odpovedi /api/dashboard/summary.
*/
metric?: 'openTickets' | 'activeIncidents' | 'activeAutomations' | 'runsToday' | 'savedHoursMonth' | 'uptime';
}
/** Tvar widgetu je sdileny s webem, viz src/shared/widgets.ts. Tady je katalog. */
export type { CatalogWidget, WidgetDefinition, WidgetKind, WidgetMetric, WidgetSize };
export const widgets: WidgetDefinition[] = [
{
@@ -189,15 +172,6 @@ export function findWidget(id: string): WidgetDefinition | undefined {
return widgets.find((widget) => widget.id === id);
}
/** Polozka katalogu tak, jak ji vidi klient. Vlastni widget nese i zdroj dat. */
export interface CatalogWidget extends WidgetDefinition {
/** true = widget si firma nadefinovala sama. */
custom: boolean;
render?: WidgetRender;
source?: WidgetSource;
target?: number;
}
/**
* Cely katalog: pevne widgety plus vlastni te firmy.
*
+26 -28
View File
@@ -9,31 +9,15 @@ import { randomUUID } from 'node:crypto';
* nahradit Redis pub/sub nebo jina sdilena fronta (viz dokumentace).
*/
export type DashboardEventType =
| 'ticket.created'
| 'ticket.updated'
| 'ticket.assigned'
| 'ticket.resolved'
| 'incident.started'
| 'incident.updated'
| 'incident.resolved'
| 'automation.created'
| 'automation.updated'
| 'automation.deleted'
| 'automation.run'
| 'webhook.received'
/** Nekomu prislo upozorneni. Portal podle toho prekresli cislo u zalozky. */
| 'notification.created';
import type {
DashboardEvent,
DashboardEventType,
EntityEventKind,
EntityEventType,
} from '../shared/events.js';
export interface DashboardEvent {
id: string;
type: DashboardEventType;
at: string;
/** Kratka veta pro uzivatele, zobrazuje se v notifikaci. */
message: string;
/** Doplnkova data, napr. id ticketu. */
payload?: Record<string, unknown>;
}
/** Typy udalosti jsou sdilene s webem, viz src/shared/events.ts. */
export type { DashboardEvent, DashboardEventType, EntityEventKind, EntityEventType };
const CHANNEL = 'dashboard';
const MAX_RECENT = 50;
@@ -44,10 +28,15 @@ emitter.setMaxListeners(200);
const recent: DashboardEvent[] = [];
/**
* `tenantId` je povinny a posledni zamerne: kdyby byl nepovinny, prvni
* zapomenute volani by udalost poslalo vsem. `null` jen u platformnich udalosti.
*/
export function publish(
type: DashboardEventType,
message: string,
payload?: Record<string, unknown>,
payload: Record<string, unknown> | undefined,
tenantId: string | null,
): DashboardEvent {
const event: DashboardEvent = {
id: randomUUID(),
@@ -55,6 +44,7 @@ export function publish(
at: new Date().toISOString(),
message,
payload,
tenantId,
};
recent.unshift(event);
@@ -73,9 +63,17 @@ export function subscribe(listener: (event: DashboardEvent) => void): () => void
};
}
/** Poslednich par udalosti - klient je dostane hned po pripojeni. */
export function recentEvents(limit = 10): DashboardEvent[] {
return recent.slice(0, limit);
/**
* Poslednich par udalosti - klient je dostane hned po pripojeni.
*
* `visible` filtruje pred orezem, jinak by clovek z male firmy dostal prazdno
* vzdycky, kdyz posledni udalosti patrily jinym.
*/
export function recentEvents(
limit = 10,
visible: (event: DashboardEvent) => boolean = () => true,
): DashboardEvent[] {
return recent.filter(visible).slice(0, limit);
}
export function listenerCount(): number {
+58 -11
View File
@@ -1,10 +1,5 @@
import cors from 'cors';
import express, {
Router,
type NextFunction,
type Request,
type Response,
} from 'express';
import express, { type NextFunction, type Request, type Response } from 'express';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -20,6 +15,7 @@ import { bootstrapData } from './data/bootstrap.js';
import { flushConnectorStore, initConnectorStore, storageStatus } from './data/connectorStore.js';
import { flushStores } from './data/store/index.js';
import { adminRouter } from './routes/admin.js';
import { safeRouter } from './middleware/asyncHandler.js';
import { runMigrations } from './db/migrate.js';
import { closeDatabase, databaseHealth, isDatabaseEnabled } from './db/pool.js';
import { ensureLoaded, scriptsDir } from './scripts/registry.js';
@@ -31,8 +27,13 @@ const here = path.dirname(fileURLToPath(import.meta.url));
const webRoot = path.join(here, 'public');
const app = express();
// Aplikace bezi za reverse proxy, jinak by req.ip a protokol byly containeru.
app.set('trust proxy', true);
/*
* Aplikace bezi za reverse proxy (Caddy), jinak by req.ip a protokol byly
* containeru. Duveruje se **jednomu** skoku, ne vsem: pri `true` by si kazdy
* volajici mohl do X-Forwarded-For vepsat cizi adresu a obejit tak limit
* poctu pokusu, ktery je na adresu navazany.
*/
app.set('trust proxy', 1);
app.use(
cors({
@@ -47,9 +48,30 @@ app.use(
);
app.use(express.json({ limit: '256kb' }));
/*
* Bezpecnostni hlavicky. Rucne a stridme: zadne CSP, ktere by rozbilo SPA
* nebo Swagger UI. Ramovani jen ze stejne domeny, zadne hadani typu obsahu,
* referer bez cesty pri odchodu jinam a vypnute senzory, ktere portal nepouziva.
*/
app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
});
/**
* Token v adrese je pristupovy udaj. Do logu jde jen jeho zacatek, aby slo
* volani dohledat, ale ne zopakovat.
*/
function maskSecretsInUrl(url: string): string {
return url.replace(/(\/webhook\/(?:ticket\/)?|\/invites\/)([^/?#]{6})[^/?#]*/g, '$1$2...');
}
app.use((req, _res, next) => {
// Loguje se jen metoda a cesta, nikdy hlavicky ani telo - obsahuji secrets.
console.info(`[req] ${req.method} ${req.originalUrl}`);
console.info(`[req] ${req.method} ${maskSecretsInUrl(req.originalUrl)}`);
next();
});
@@ -67,8 +89,11 @@ function isOriginAllowed(origin: string): boolean {
/**
* strict: true je nutne. Bez nej by se cesta /docs shodovala i s /docs/
* a presmerovani nize by se zacyklilo.
*
* `safeRouter`: async handler, ktery spadne, dojde do error handleru nize
* misto toho, aby request visel a chyba skoncila jako unhandledRejection.
*/
const api = Router({ strict: true });
const api = safeRouter({ strict: true });
/**
* Liveness. Zamerne **nezavisi na databazi**: kratky vypadek DB by jinak vedl
@@ -151,8 +176,12 @@ app.use(api);
if (config.rootPath) app.use(config.rootPath, api);
// Neexistujici API cesta musi vratit JSON, ne HTML aplikace.
// Prefix se bere z ROOT_PATH, ne z tvaru `/apps/<id>` napsaneho natvrdo.
const apiPathPattern = new RegExp(
`^(${config.rootPath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')})?/(api|webhook)/`,
);
app.use((req, res, next) => {
if (/^(\/apps\/[^/]+)?\/(api|webhook)\//.test(req.path)) {
if (apiPathPattern.test(req.path)) {
console.warn(`[404] ${req.method} ${req.originalUrl}`);
return res.status(404).json({ error: 'not_found', message: 'Endpoint neexistuje.' });
}
@@ -315,6 +344,24 @@ for (const signal of ['SIGTERM', 'SIGINT'] as const) {
});
}
/*
* Posledni zachytna sit. Bez ni Node odmitnuty promise jen vypise a jede dal
* (u starsich verzi), nebo proces shodi bez slova o tom, kde to bylo.
*
* Odmitnuty promise: zalogovat i se stackem a bezet dal - byva to jedna
* rozbita uloha na pozadi, ne stav celeho procesu. Nechycena vyjimka: proces
* uz nemusi byt konzistentni, proto se po zapisu do logu ukonci a AppFactory
* ho nastartuje znovu.
*/
process.on('unhandledRejection', (reason: unknown) => {
console.error('[fatal] neosetreny odmitnuty promise:', reason instanceof Error ? reason.stack : reason);
});
process.on('uncaughtException', (err: Error) => {
console.error('[fatal] nechycena vyjimka, proces se ukonci:', err.stack ?? err);
process.exit(1);
});
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`[start] Port ${config.port} je obsazeny.`);
+16
View File
@@ -0,0 +1,16 @@
/**
* Porovnani tajnych retezcu v konstantnim case.
*
* `a === b` skonci u prvniho jineho znaku, takze se z doby odpovedi da po
* kouskach uhadnout token. U webhooku a pozvanek je token jedina ochrana,
* proto se porovnava takhle.
*/
import { createHash, timingSafeEqual } from 'node:crypto';
export function timingSafeEqualString(a: string, b: string): boolean {
// Hash srovna delky, jinak by uz rozdilna delka prozradila, ze se netrefil.
const left = createHash('sha256').update(a).digest();
const right = createHash('sha256').update(b).digest();
return timingSafeEqual(left, right);
}
+3 -8
View File
@@ -17,7 +17,7 @@
import nodemailer from 'nodemailer';
import { config } from '../config.js';
import type { ResolvedTarget } from '../scripts/connections.js';
import { isPrivateHost } from '../scripts/http.js';
import { hostProblem } from '../net/guard.js';
import { createRedactor, truncate } from '../scripts/util.js';
export interface SmtpSettings {
@@ -81,13 +81,8 @@ export function smtpSettings(target: ResolvedTarget): { settings: SmtpSettings }
if (host === '') return { error: 'Konektor nemá vyplněný SMTP server.' };
// Stejne pravidlo jako u HTTP: adresu vyplnuje firma, takze tohle je jedina
// zabrana proti navazani spojeni dovnitr nasi site.
if (!config.allowPrivateTargets && isPrivateHost(host)) {
return {
error:
`Server ${host} míří do vnitřní sítě. ` +
'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.',
};
}
const problem = hostProblem(host);
if (problem !== null) return { error: problem };
if (user === '') return { error: 'Konektor nemá vyplněného uživatele.' };
if (password === '') return { error: 'Konektor nemá vyplněné heslo.' };
if (from === '') return { error: 'Konektor nemá vyplněnou adresu odesílatele.' };
+64 -71
View File
@@ -22,10 +22,15 @@
*/
import { config } from '../config.js';
import { describeFetchError, readJsonLimited } from '../net/guard.js';
import type { ResolvedTarget } from '../scripts/connections.js';
import { truncate } from '../scripts/util.js';
import { jwtExpiry, pickText, truncate } from '../scripts/util.js';
import { dialectFor, normalizeServerUrl } from './dialect.js';
import { ensureAccess, forgetSession, type EasyWebCredentials } from './easyweb/session.js';
import { AuthFailure } from './errors.js';
// Trida bydli v `errors.ts` kvuli kruhu s EasyWebem, ale patri k prihlaseni.
export { AuthFailure } from './errors.js';
/**
* O kolik driv nez vyprsi se token vymeni.
@@ -75,16 +80,14 @@ interface Session {
const cache = new Map<string, Session>();
/** Chyba prihlaseni. Nese vetu pro uzivatele, ne stack. */
export class AuthFailure extends Error {
constructor(
message: string,
readonly detail: string | null = null,
) {
super(message);
this.name = 'AuthFailure';
}
}
/**
* Rozdelane prihlaseni podle konektoru.
*
* Dva soubezne behy nad tim samym konektorem nesmi spustit dve prihlaseni:
* je to zbytecne a autorizacni servery to pocitaji do limitu. Druhy volajici
* pocka na prvni, stejne jako u EasyWebu.
*/
const inflight = new Map<string, Promise<Session>>();
interface Credentials {
serviceId: string;
@@ -157,17 +160,6 @@ function bearer(token: string, method: AuthMethod): Authorization {
// ------------------------------------------------------- cteni odpovedi
/** Hodnota z JSONu bez ohledu na velikost pismen. Servery se v tom lisi. */
function stringField(source: Record<string, unknown> | null, ...names: string[]): string | null {
if (!source) return null;
const wanted = names.map((name) => name.toLowerCase());
for (const [key, value] of Object.entries(source)) {
if (!wanted.includes(key.toLowerCase())) continue;
if (typeof value === 'string' && value.trim() !== '') return value.trim();
}
return null;
}
/**
* Kdy token vyprsi.
*
@@ -180,48 +172,31 @@ function expiryFrom(body: Record<string, unknown>, token: string): number {
const seconds = body.expires_in ?? body.expiresIn ?? body.ExpiresIn;
if (typeof seconds === 'number' && seconds > 0) return Date.now() + seconds * 1000;
const stamp = stringField(body, 'expiresAt', 'expiration', 'expires', 'expiresUtc', 'expirationUtc');
const stamp = pickText(body, 'expiresAt', 'expiration', 'expires', 'expiresUtc', 'expirationUtc');
if (stamp) {
const parsed = Date.parse(stamp);
if (!Number.isNaN(parsed)) return parsed;
}
const claim = expiryFromJwt(token);
const claim = jwtExpiry(token);
if (claim !== null) return claim;
return Date.now() + DEFAULT_LIFETIME_MS;
}
/** `exp` z prostredni casti JWT. null, kdyz to JWT neni. */
function expiryFromJwt(token: string): number | null {
const parts = token.split('.');
if (parts.length < 2) return null;
try {
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const json = Buffer.from(padded, 'base64').toString('utf8');
const payload = JSON.parse(json) as { exp?: unknown };
return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
} catch {
return null;
}
}
/** Telo odpovedi jako objekt. Vyhazuje, kdyz to JSON neni. */
async function readJson(response: Response, where: string): Promise<Record<string, unknown>> {
const raw = await response.text();
if (raw.length > config.scriptMaxResponseBytes) {
const body = await readJsonLimited(response, config.scriptMaxResponseBytes);
if (body.tooLarge) {
throw new AuthFailure(`Odpověď z ${where} je nad povoleným limitem.`);
}
try {
const parsed: unknown = JSON.parse(raw);
if (parsed === null || typeof parsed !== 'object') throw new Error('neni objekt');
return parsed as Record<string, unknown>;
} catch {
if (body.json === undefined || body.json === null || typeof body.json !== 'object') {
throw new AuthFailure(
`Odpověď z ${where} není platný JSON. Míří adresa opravdu na přihlášení?`,
truncate(raw, config.errorDetailBytes),
truncate(body.text, config.errorDetailBytes),
);
}
return body.json as Record<string, unknown>;
}
// ---------------------------------------------------------------- OAuth
@@ -234,10 +209,11 @@ async function readMetadata(
try {
const response = await fetch(url, { method: 'GET', signal, headers: { Accept: 'application/json' } });
if (!response.ok) return null;
const raw = await response.text();
if (raw.length > config.scriptMaxResponseBytes) return null;
const parsed: unknown = JSON.parse(raw);
return parsed !== null && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
const body = await readJsonLimited(response, config.scriptMaxResponseBytes);
const parsed = body.json;
return parsed !== undefined && parsed !== null && typeof parsed === 'object'
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
@@ -322,7 +298,7 @@ async function discoverTokenUrl(serverUrl: string, signal: AbortSignal): Promise
for (const suffix of ['oauth-authorization-server', 'openid-configuration']) {
for (const url of wellKnown(issuerUrl, suffix)) {
const endpoint = stringField(await readMetadata(url, signal), 'token_endpoint');
const endpoint = pickText(await readMetadata(url, signal), 'token_endpoint');
if (endpoint) return endpoint;
}
}
@@ -347,9 +323,9 @@ async function tokenRequest(
body: new URLSearchParams(body).toString(),
});
} catch (err) {
throw new AuthFailure(
`Nepodařilo se spojit s ${tokenUrl}: ${err instanceof Error ? err.message : String(err)}`,
);
const info = describeFetchError(err, tokenUrl);
// Nenavazane spojeni pomine, odmitnute udaje ne.
throw new AuthFailure(info.message, info.detail, null, !info.timedOut);
}
if (!response.ok) {
@@ -384,7 +360,7 @@ async function oauthLogin(
signal,
);
const accessToken = body ? stringField(body, 'access_token', 'accessToken') : null;
const accessToken = pickText(body, 'access_token', 'accessToken');
if (!body || !accessToken) {
throw new AuthFailure(
`Server ${tokenUrl} přihlášení aplikace nepřijal. Ověřte ID a tajemství, ` +
@@ -395,7 +371,7 @@ async function oauthLogin(
return {
accessToken,
expiresAt: expiryFrom(body, accessToken),
refreshToken: stringField(body, 'refresh_token', 'refreshToken'),
refreshToken: pickText(body, 'refresh_token', 'refreshToken'),
method: 'OAuth jako aplikace',
renewUrl: tokenUrl,
fingerprint: fingerprintOf(credentials),
@@ -422,14 +398,14 @@ async function oauthRenew(
signal,
);
const accessToken = body ? stringField(body, 'access_token', 'accessToken') : null;
const accessToken = pickText(body, 'access_token', 'accessToken');
if (!body || !accessToken) return null;
return {
...session,
accessToken,
expiresAt: expiryFrom(body, accessToken),
refreshToken: stringField(body, 'refresh_token', 'refreshToken') ?? session.refreshToken,
refreshToken: pickText(body, 'refresh_token', 'refreshToken') ?? session.refreshToken,
fingerprint: fingerprintOf(credentials),
};
}
@@ -488,17 +464,36 @@ export async function authorize(
const fingerprint = fingerprintOf(credentials);
if (force) cache.delete(key);
const cached = cache.get(key);
const usable = cached && cached.fingerprint === fingerprint ? cached : null;
if (cached && cached.fingerprint === fingerprint) {
if (Date.now() < cached.expiresAt - EXPIRY_MARGIN_MS) {
return bearer(cached.accessToken, cached.method);
}
// Vyprsel. Obnova je levnejsi nez cele prihlaseni znovu.
const renewed = await oauthRenew(cached, credentials, signal);
if (renewed) {
cache.set(key, renewed);
return bearer(renewed.accessToken, renewed.method);
}
if (usable && Date.now() < usable.expiresAt - EXPIRY_MARGIN_MS) {
return bearer(usable.accessToken, usable.method);
}
// Jedno prihlaseni naraz na konektor. Kdo prijde druhy, pocka na prvni.
let pending = inflight.get(key);
if (!pending) {
pending = obtainSession(usable, credentials, signal).finally(() => {
if (inflight.get(key) === pending) inflight.delete(key);
});
inflight.set(key, pending);
}
const session = await pending;
cache.set(key, session);
return bearer(session.accessToken, session.method);
}
/** Obnovi prosly token, nebo se prihlasi znovu. Vzdy vraci platne sezeni. */
async function obtainSession(
expired: Session | null,
credentials: Credentials,
signal: AbortSignal,
): Promise<Session> {
if (expired) {
// Obnova je levnejsi nez cele prihlaseni znovu.
const renewed = await oauthRenew(expired, credentials, signal);
if (renewed) return renewed;
}
const tokenUrl = credentials.tokenUrl || (await discoverTokenUrl(credentials.serverUrl, signal));
@@ -509,9 +504,7 @@ export async function authorize(
);
}
const session = await oauthLogin(tokenUrl, credentials, signal);
cache.set(key, session);
return bearer(session.accessToken, session.method);
return oauthLogin(tokenUrl, credentials, signal);
}
/**
+116 -67
View File
@@ -22,9 +22,15 @@
*/
import { config } from '../config.js';
import {
assertAllowedUrl,
describeFetchError,
readBodyLimited,
readJsonLimited,
tooLargeMessage,
} from '../net/guard.js';
import { targetSecrets, type ResolvedTarget } from '../scripts/connections.js';
import { isPrivateHost } from '../scripts/http.js';
import { createRedactor, truncate } from '../scripts/util.js';
import { createRedactor, pick, truncate } from '../scripts/util.js';
import { AuthFailure, authorize, type AuthMethod } from './auth.js';
import { dialectFor, normalizeServerUrl, type McpDialect } from './dialect.js';
@@ -51,6 +57,15 @@ const SESSION_LOST_CODE = -32008;
/** Kod, kterym server rika, ze nastroj se musi spustit jako uloha. */
const TASK_REQUIRED_CODE = -32601;
/**
* HTTP kody, po kterych ma smysl zkusit to znovu.
*
* Server volani odmitl driv, nez nastroj spustil (pretizeni, limit, proxy
* bez backendu). Bez 500: to uz mohl nastroj bezet a MCP nema idempotencni
* klic, ktery by druhe provedeni zastavil.
*/
const retryableStatuses = new Set([408, 425, 429, 502, 503, 504]);
/** Schema podle JSON Schema. Tvar se prochazi az v `schema.ts`. */
export type JsonSchema = Record<string, unknown>;
@@ -106,6 +121,11 @@ export interface McpOutcome<T> {
status: number | null;
request: { method: string; path: string; url: string } | null;
value: T | null;
/**
* true = k nastroji se nedoslo (spojeni, odmitnuti pred spustenim) a za
* chvili to muze projit. Timeout beziciho nastroje ne, viz `McpFailure`.
*/
retryable: boolean;
}
/** Chyba uvnitr tohoto souboru. Ven se nedostane, prevede se na `McpOutcome`. */
@@ -118,6 +138,17 @@ class McpFailure extends Error {
readonly sessionLost = false,
/** Kod chyby JSON-RPC, kdyz ji server poslal. */
readonly code: number | null = null,
/**
* true = opakovani ma smysl: spojeni se nenavazalo, nebo server odmitl
* driv, nez cokoliv spustil. Timeout uz odeslaneho volani ne - nastroj
* mohl bezet a druhy pokus by ho provedl podruhe.
*/
readonly retryable = false,
/**
* true = server nezna sezeni, ktere jsme meli za platne (400/404 po
* preskocenem handshaku). Staci handshake zopakovat, token plati.
*/
readonly handshakeLost = false,
) {
super(message);
this.name = 'McpFailure';
@@ -141,15 +172,7 @@ function serverUrl(target: ResolvedTarget): URL {
} catch {
throw new McpFailure(`Adresa ${raw} není platná URL.`);
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new McpFailure(`Adresa ${url.protocol} není povolená, jen http a https.`);
}
if (!config.allowPrivateTargets && isPrivateHost(url.hostname)) {
throw new McpFailure(
`Adresa ${url.hostname} míří do vnitřní sítě. ` +
'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.',
);
}
assertAllowedUrl(url, (message) => new McpFailure(message));
return url;
}
@@ -160,6 +183,8 @@ interface Session {
authHeader: string;
/** ID sezeni z hlavicky odpovedi. Server ho mit nemusi. */
sessionId: string | null;
/** true = handshake se preskocil, protoze uz probehl driv s timto tokenem. */
reused: boolean;
protocolVersion: string;
redact: (value: string) => string;
/** Jak se portal prihlasil. Jde to do hlasky u konektoru. */
@@ -172,13 +197,19 @@ interface Session {
}
/**
* Pro ktery token uz probehl handshake.
* Pro ktery token uz probehl handshake a jake sezeni pri nem server zalozil.
*
* Klic je konektor, hodnota je hlavicka `Authorization`. Jina hlavicka znamena
* jiny token, tedy jine sezeni, a handshake se musi zopakovat. Servery, ktere
* si sezeni drzi u tokenu, jinak odpovi, ze relace neni inicializovana.
* Klic je konektor. Jina hlavicka `Authorization` znamena jiny token, tedy
* jine sezeni, a handshake se musi zopakovat. Servery, ktere si sezeni drzi
* u tokenu, jinak odpovi, ze relace neni inicializovana.
*
* `sessionId` se drzi u handshaku, ne u jednoho volani: server ho vydá pri
* `initialize` a pak ho vyzaduje u kazdeho dalsiho volani. Bez toho by druhe
* `tools/call` slo bez hlavicky a spec-compliant server odpovi 400 nebo 404.
* Plati i pro server bez prihlaseni - i ten ma sezeni a handshake stoji dve
* volani navic pri kazdem kroku.
*/
const initialized = new Map<string, string>();
const initialized = new Map<string, { authHeader: string; sessionId: string | null }>();
let nextId = 1;
@@ -303,12 +334,10 @@ async function rpc(
body: JSON.stringify(body),
});
} catch (err) {
const name = err instanceof Error ? err.name : '';
if (name === 'AbortError' || name === 'TimeoutError') {
throw new McpFailure(`Server ${session.url.host} neodpověděl v limitu.`);
}
const reason = err instanceof Error ? err.message : String(err);
throw new McpFailure(`Nepodařilo se spojit se serverem ${session.url.host}: ${reason}`);
const info = describeFetchError(err, session.url.host);
// Nenavazane spojeni se smi opakovat. Timeout ne: volani uz odeslo
// a nastroj mohl bezet, druhy pokus by ho provedl podruhe.
throw new McpFailure(info.message, null, null, false, null, !info.timedOut);
}
// Sezeni zaklada server pri prvnim volani a pak ho vyzaduje u dalsich.
@@ -320,8 +349,9 @@ async function rpc(
const isStream = response.headers.get('content-type')?.includes('event-stream') ?? false;
if (!response.ok) {
// Telo chyby byva male, u nej se po kouscich cist nemusi.
const raw = await response.text().catch(() => '');
const raw = await readBodyLimited(response, config.scriptMaxResponseBytes)
.then((body) => body.text)
.catch(() => '');
const detail = session.redact(truncate(raw, config.errorDetailBytes));
throw new McpFailure(
`${method} vrátilo HTTP ${response.status}.` +
@@ -329,6 +359,10 @@ async function rpc(
response.status,
detail === '' ? null : detail,
response.status === 401,
null,
retryableStatuses.has(response.status),
// Preskoceny handshake a server sezeni nezna: 400/404 podle specifikace.
session.reused && (response.status === 400 || response.status === 404),
);
}
@@ -344,23 +378,20 @@ async function rpc(
// Stream skoncil bez odpovedi. Server to obcas udela, neni to chyba.
if (!envelope) return null;
} else {
const raw = await response.text();
if (raw.length > config.scriptMaxResponseBytes) {
throw new McpFailure(
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
response.status,
);
const body = await readJsonLimited(response, config.scriptMaxResponseBytes);
if (body.tooLarge) {
throw new McpFailure(tooLargeMessage(config.scriptMaxResponseBytes), response.status);
}
if (raw.trim() === '') return null;
try {
envelope = JSON.parse(raw) as Record<string, unknown>;
} catch {
// Prazdne telo neni chyba, server to obcas udela.
if (body.json === null) return null;
if (body.json === undefined || typeof body.json !== 'object') {
throw new McpFailure(
`${method} nevrátilo platný JSON. Míří adresa opravdu na MCP server?`,
response.status,
session.redact(truncate(raw, config.errorDetailBytes)),
session.redact(truncate(body.text, config.errorDetailBytes)),
);
}
envelope = body.json as Record<string, unknown>;
}
const error = envelope.error as { code?: number; message?: string } | undefined;
@@ -394,6 +425,7 @@ async function buildSession(
headers: { ...target.headers, ...auth.headers },
authHeader: auth.headers.Authorization ?? '',
sessionId: null,
reused: false,
protocolVersion: dialect.protocolVersion,
redact: createRedactor([...targetSecrets(target), ...auth.secrets]),
authMethod: auth.method,
@@ -403,6 +435,10 @@ async function buildSession(
};
}
function handshakeKey(session: Session): string {
return session.connectorId ?? session.url.toString();
}
/**
* Handshake, jednou na token.
*
@@ -413,8 +449,13 @@ async function buildSession(
* probehl driv a jmeno tedy znovu neprislo.
*/
async function ensureInitialized(session: Session, signal: AbortSignal): Promise<string> {
const key = session.connectorId ?? session.url.toString();
if (session.authHeader !== '' && initialized.get(key) === session.authHeader) return '';
const known = initialized.get(handshakeKey(session));
if (known && known.authHeader === session.authHeader) {
// Sezeni z handshaku plati dal, server ho chce u kazdeho volani.
session.sessionId = known.sessionId;
session.reused = true;
return '';
}
const result = (await rpc(
session,
@@ -429,16 +470,19 @@ async function ensureInitialized(session: Session, signal: AbortSignal): Promise
}
await rpc(session, 'notifications/initialized', undefined, signal, false);
if (session.authHeader !== '') initialized.set(key, session.authHeader);
initialized.set(handshakeKey(session), {
authHeader: session.authHeader,
sessionId: session.sessionId,
});
const name = result?.serverInfo?.name ?? 'neznámý server';
const version = result?.serverInfo?.version;
return version ? `${name} ${version}` : name;
}
/** Zapomene handshake. Po odmitnutem tokenu se musi udelat znovu. */
/** Zapomene handshake. Po odmitnutem tokenu nebo ztracenem sezeni se musi udelat znovu. */
function forgetHandshake(session: Session): void {
initialized.delete(session.connectorId ?? session.url.toString());
initialized.delete(handshakeKey(session));
}
// ------------------------------------------------------------ rozbaleni
@@ -510,8 +554,10 @@ function toTool(value: unknown): McpTool | null {
* Obal, ktery z vyjimky udela vysledek. Ven z tohoto souboru nic nevyhazuje.
*
* Resi jednu vec navic: **kdyz server odmitne token nebo sezeni, ktere jsme
* povazovali za platne, prihlasi se znovu a zkusi to jeste jednou**. Stava se
* to, kdyz nekdo na druhe strane odebral opravneni driv, nez token vyprsel.
* povazovali za platne, zopakuje handshake a zkusi to jeste jednou**. Odmitnuty
* token (401, -32008) se pri tom vymeni za novy - stava se to, kdyz nekdo na
* druhe strane odebral opravneni driv, nez token vyprsel. Ztracene sezeni
* (400/404 po preskocenem handshaku) staci navazat znovu s tymz tokenem.
* Podruhe uz se to neopakuje - to uz nejsou udaje, ktere by sedely.
*/
async function attempt<T>(
@@ -539,10 +585,13 @@ async function attempt<T>(
timer = setTimeout(() => controller.abort(), ms);
};
const signal = controller.signal;
// Posledni sestavene sezeni. Jeho redakce zna i token, ne jen udaje konektoru.
let session: Session | null = null;
try {
let force = false;
for (let round = 0; ; round += 1) {
const session = await buildSession(target, signal, round > 0, extend);
session = await buildSession(target, signal, force, extend);
try {
const value = await run(session, signal);
return {
@@ -552,11 +601,15 @@ async function attempt<T>(
status: 200,
request,
value,
retryable: false,
};
} catch (err) {
const lost = err instanceof McpFailure && err.sessionLost;
if (lost && round === 0 && session.authMethod !== 'bez přihlášení') {
const failure = err instanceof McpFailure ? err : null;
const stale = failure !== null && (failure.sessionLost || failure.handshakeLost);
if (stale && round === 0) {
forgetHandshake(session);
// Odmitnuty token se vymeni. Bez prihlaseni neni co menit, staci handshake.
force = failure.sessionLost && session.authMethod !== 'bez přihlášení';
continue;
}
throw err;
@@ -565,16 +618,19 @@ async function attempt<T>(
} catch (err) {
const failure = err instanceof McpFailure ? err : null;
const auth = err instanceof AuthFailure ? err : null;
const redact = createRedactor(targetSecrets(target));
const redact = session?.redact ?? createRedactor(targetSecrets(target));
const reported = failure ?? auth;
const message = reported ? reported.message : err instanceof Error ? err.message : String(err);
const detail = reported?.detail ?? null;
return {
ok: false,
message: redact(message),
detail: failure?.detail ?? auth?.detail ?? null,
status: failure?.status ?? null,
// Vzdy pres redakci: i chyba prihlaseni nese telo odpovedi serveru.
detail: detail === null ? null : redact(detail),
status: failure?.status ?? auth?.status ?? null,
request,
value: null,
retryable: reported?.retryable ?? false,
};
} finally {
clearTimeout(timer);
@@ -663,27 +719,20 @@ const TASK_WAIT_MS = 600_000;
/** Jak casto se server ptame na stav ulohy, kdyz svuj interval nerekl. */
const TASK_POLL_MS = 2_000;
/** Hodnota z odpovedi .NETu. Klice pise velkymi i malymi pismeny. */
function pick(source: unknown, ...names: string[]): unknown {
if (source === null || typeof source !== 'object') return undefined;
const wanted = names.map((name) => name.toLowerCase());
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
if (wanted.includes(key.toLowerCase())) return value;
}
return undefined;
}
/** Cekani, ktere skonci driv, kdyz se volani zrusi. */
function delay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener(
'abort',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
const onAbort = (): void => {
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
// Posluchac se musi odebrat i kdyz cekani dobehlo samo. Uloha se
// dotazuje stokrat a kazde kolo by jinak nechalo jeden posluchac viset.
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal.addEventListener('abort', onAbort, { once: true });
});
}
+25 -41
View File
@@ -25,7 +25,9 @@
*/
import { config } from '../../config.js';
import { truncate } from '../../scripts/util.js';
import { describeFetchError, readBodyLimited } from '../../net/guard.js';
import { jwtExpiry, pickText, truncate } from '../../scripts/util.js';
import { AuthFailure } from '../errors.js';
import {
KEY_REGISTRATION_CONTEXT,
POP_CONTEXT,
@@ -48,14 +50,20 @@ const RENEW_MARGIN_MS = 90_000;
/** Jak dlouho token plati, kdyz to z nej nejde vycíst. Odhad smerem dolu. */
const FALLBACK_LIFETIME_MS = 300_000;
/** Chyba prihlaseni. Vetu cte uzivatel, proto zadny stack. */
export class EasyWebAuthError extends Error {
/**
* Chyba prihlaseni. Vetu cte uzivatel, proto zadny stack.
*
* Dedi z `AuthFailure`, aby ji klient MCP poznal jako chybu prihlaseni
* a vzal si z ni `detail` i `status` - driv se oboji cestou ztratilo.
*/
export class EasyWebAuthError extends AuthFailure {
constructor(
message: string,
readonly status: number | null = null,
readonly detail: string | null = null,
status: number | null = null,
detail: string | null = null,
retryable = false,
) {
super(message);
super(message, detail, status, retryable);
this.name = 'EasyWebAuthError';
}
}
@@ -101,16 +109,6 @@ function entryFor(connectorId: string): Entry {
// ------------------------------------------------------------------ volani
/** Odpoved serveru jako objekt. Klice cte necitlive, .NET je pise velkymi. */
function fieldOf(body: Record<string, unknown>, ...names: string[]): string | null {
const wanted = names.map((name) => name.toLowerCase());
for (const [key, value] of Object.entries(body)) {
if (!wanted.includes(key.toLowerCase())) continue;
if (typeof value === 'string' && value.trim() !== '') return value.trim();
}
return null;
}
/**
* Kdy vyprsi pristupovy token.
*
@@ -119,18 +117,8 @@ function fieldOf(body: Record<string, unknown>, ...names: string[]): string | nu
* navic u kazdeho kroku.
*/
function accessExpiry(token: string): number {
const parts = token.split('.');
if (parts.length >= 2) {
try {
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as {
exp?: unknown;
};
if (typeof payload.exp === 'number') return payload.exp * 1000;
} catch {
// Neni to JWT. Plati odhad nize.
}
}
return Date.now() + FALLBACK_LIFETIME_MS;
// Neni to JWT: plati odhad smerem dolu.
return jwtExpiry(token) ?? Date.now() + FALLBACK_LIFETIME_MS;
}
function endpoint(serverUrl: string, path: string): string {
@@ -159,16 +147,12 @@ async function post(
body: JSON.stringify(body),
});
} catch (err) {
const name = err instanceof Error ? err.name : '';
if (name === 'AbortError' || name === 'TimeoutError') {
throw new EasyWebAuthError(`Server ${url} neodpověděl v limitu.`);
}
throw new EasyWebAuthError(
`Nepodařilo se spojit s ${url}: ${err instanceof Error ? err.message : String(err)}`,
);
const info = describeFetchError(err, url);
// Nenavazane spojeni pomine, odmitnute udaje ne.
throw new EasyWebAuthError(info.message, null, info.detail, !info.timedOut);
}
const raw = await response.text();
const raw = (await readBodyLimited(response, config.scriptMaxResponseBytes)).text;
const detail = truncate(raw, config.errorDetailBytes);
let parsed: Record<string, unknown> | null = null;
@@ -190,7 +174,7 @@ function tokensFrom(
previous: Tokens | null,
where: string,
): Tokens {
const accessToken = body ? fieldOf(body, 'AccessToken') : null;
const accessToken = body ? pickText(body, 'AccessToken') : null;
if (!accessToken) {
throw new EasyWebAuthError(`Odpověď z ${where} neobsahuje přístupový token.`);
}
@@ -203,10 +187,10 @@ function tokensFrom(
return {
accessToken,
accessExpiresAt: accessExpiry(accessToken),
refreshToken: (body ? fieldOf(body, 'RefreshToken') : null) ?? previous?.refreshToken ?? '',
refreshNonce: (body ? fieldOf(body, 'RefreshTokenNonce') : null) ?? previous?.refreshNonce ?? '',
deviceToken: (body ? fieldOf(body, 'DeviceToken') : null) ?? previous?.deviceToken ?? '',
deviceNonce: (body ? fieldOf(body, 'DeviceTokenNonce') : null) ?? previous?.deviceNonce ?? '',
refreshToken: (body ? pickText(body, 'RefreshToken') : null) ?? previous?.refreshToken ?? '',
refreshNonce: (body ? pickText(body, 'RefreshTokenNonce') : null) ?? previous?.refreshNonce ?? '',
deviceToken: (body ? pickText(body, 'DeviceToken') : null) ?? previous?.deviceToken ?? '',
deviceNonce: (body ? pickText(body, 'DeviceTokenNonce') : null) ?? previous?.deviceNonce ?? '',
};
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Chyba prihlaseni k MCP serveru.
*
* Ve vlastnim souboru, protoze ji dedi i chyba EasyWebu v `easyweb/session.ts`
* a ten soubor importuje `auth.ts`. Kdyby trida bydlela v `auth.ts`, vznikl by
* kruh, ve kterem se trida pouzije driv, nez existuje.
*
* Nese vetu pro uzivatele, ne stack. `detail` je cela odpoved serveru
* a redakci tajemstvi dostane az v klientovi, kde jsou tajemstvi znama.
*/
export class AuthFailure extends Error {
constructor(
message: string,
readonly detail: string | null = null,
/** HTTP kod, kdyz se k odpovedi serveru vubec doslo. */
readonly status: number | null = null,
/** true = spojeni se nenavazalo, za chvili to muze projit. Odmitnute udaje ne. */
readonly retryable = false,
) {
super(message);
this.name = 'AuthFailure';
}
}
+2 -4
View File
@@ -21,6 +21,7 @@
import type { ProvidedField, OperationField } from '../data/services.js';
import type { FieldType } from '../data/conditions.js';
import type { JsonSchema, McpTool } from './client.js';
import { parseBool } from '../scripts/util.js';
/**
* Vystupy, ktere ma **kazdy** nastroj bez ohledu na schema.
@@ -247,10 +248,7 @@ export interface ArgumentsResult {
/** Zaskrtnuto, nebo ne? Ve strome se vsechno predava jako text. */
function toBoolean(value: string): boolean | null {
const text = value.trim().toLowerCase();
if (['true', '1', 'ano', 'yes'].includes(text)) return true;
if (['false', '0', 'ne', 'no'].includes(text)) return false;
return null;
return parseBool(value);
}
/**
+45
View File
@@ -0,0 +1,45 @@
/**
* Async handlery v Expressu 4.
*
* Express 4 odmitnuty promise z handleru nechyti: request zustane viset bez
* odpovedi a chyba skonci jako unhandledRejection. Tenhle soubor to resi na
* jednom miste, aby se na `.catch(next)` nemuselo myslet u kazde routy.
*/
import { Router, type NextFunction, type Request, type RequestHandler, type Response, type RouterOptions } from 'express';
type AnyHandler = (req: Request, res: Response, next: NextFunction) => unknown;
/** Obali handler tak, aby odmitnuty promise dosel do centralniho error handleru. */
export function wrap(fn: AnyHandler): RequestHandler {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'all'] as const;
/** Error handler ma ctyri parametry, ten se obalovat nesmi - zmenil by se mu tvar. */
function wrapArgument(value: unknown): unknown {
if (typeof value === 'function' && value.length < 4) return wrap(value as AnyHandler);
if (Array.isArray(value)) return value.map(wrapArgument);
return value;
}
/**
* Router, ktery kazdy handler obali sam.
*
* Misto `router.get(path, wrap(async ...))` u kazde routy staci jednou
* `safeRouter()` misto `Router()`. Cesta a middleware se predavaji beze zmeny.
*/
export function safeRouter(options?: RouterOptions): Router {
const router = Router(options);
const target = router as unknown as Record<string, (...args: unknown[]) => unknown>;
for (const method of METHODS) {
const original = target[method];
target[method] = (...args: unknown[]) => original.apply(router, args.map(wrapArgument));
}
return router;
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Omezeni poctu pokusu z jedne adresy.
*
* Bez toho jde prihlaseni, prijeti pozvanky nebo kontaktni formular zkouset
* donekonecna. Zamerne v pameti procesu a bez knihovny: klic je adresa a routa,
* okno je klouzave a mapa ma strop, aby ji nikdo nenafoukl.
*/
import type { RequestHandler } from 'express';
export interface RateLimitOptions {
/** Nazev do klice a do logu, napr. `login`. */
name: string;
/** Delka okna v milisekundach. */
windowMs: number;
/** Kolik pokusu se v okne pusti. */
max: number;
}
/** Kolik klicu se drzi nejvys. Pri prekroceni se nejdriv uklidi stare. */
const MAX_KEYS = 10_000;
export function rateLimit(options: RateLimitOptions): RequestHandler {
const hits = new Map<string, number[]>();
let lastCleanup = Date.now();
function cleanup(now: number): void {
for (const [key, stamps] of hits) {
const fresh = stamps.filter((time) => now - time < options.windowMs);
if (fresh.length === 0) hits.delete(key);
else hits.set(key, fresh);
}
lastCleanup = now;
}
return (req, res, next) => {
const now = Date.now();
// Uklid jednou za okno, ne pri kazdem requestu - mapa je mala, ale ne nulova.
if (now - lastCleanup > options.windowMs || hits.size > MAX_KEYS) cleanup(now);
const key = `${options.name}:${req.ip ?? 'unknown'}`;
const stamps = (hits.get(key) ?? []).filter((time) => now - time < options.windowMs);
if (stamps.length >= options.max) {
const retryAfterSec = Math.ceil((stamps[0] + options.windowMs - now) / 1000);
console.warn(`[limit] ${options.name}: ${req.ip} prekrocil ${options.max} pokusu`);
res.setHeader('Retry-After', String(Math.max(retryAfterSec, 1)));
return res.status(429).json({
error: 'too_many_requests',
message: 'Příliš mnoho pokusů. Zkuste to prosím za chvíli.',
});
}
stamps.push(now);
hits.set(key, stamps);
return next();
};
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Firma a prava za jeden request.
*
* `accessFor` se driv pocitalo nekolikrat za request (pohled, firma, prava,
* resitel...). Tady se spocita jednou do `req.access` a vsechno ostatni
* z toho cte. Zaroven je tu jedno misto pro "firma z query plus kontrola
* clenstvi", ktere bylo rozepsane v peti souborech a v sesti dalsich chybelo.
*/
import type { NextFunction, Request, Response } from 'express';
import {
accessFor,
isDenied,
resolveScope,
type Access,
type ResolvedScope,
} from '../data/access.js';
/** Firma z query, kdyz ji klient poslal. */
export function requestedTenantId(req: Request): string | undefined {
return typeof req.query.tenantId === 'string' && req.query.tenantId !== ''
? req.query.tenantId
: undefined;
}
/**
* Prava za request. Kdyz middleware nebezel, spocita se a ulozi ted -
* routa tak funguje i mimo dashboard, jen bez usetreneho vypoctu.
*/
export function accessOf(req: Request): Access {
if (!req.access) req.access = accessFor(req.user!, requestedTenantId(req));
return req.access;
}
/** Spocita prava jednou za request. Musi bezet az po `requireAuth`. */
export function attachAccess(req: Request, res: Response, next: NextFunction) {
if (!req.user) {
console.error('[access] attachAccess pouzito bez requireAuth');
return res.status(401).json({ error: 'unauthorized' });
}
accessOf(req);
return next();
}
/**
* Firma, za kterou request jedna, bez odpovedi.
*
* Vraci `null`, kdyz uzivatel do zadane firmy nepatri nebo zadnou nema.
* Pro routy, ktere si chteji rict, co se stalo, samy.
*/
export function requestTenant(req: Request): string | null {
const access = accessOf(req);
const tenantId = requestedTenantId(req) ?? access.defaultTenantId;
if (!tenantId) return null;
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
console.warn(`[access] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
return null;
}
return tenantId;
}
/**
* Firma z query s kontrolou clenstvi. Pri odepreni rovnou odpovi a vrati null,
* takze volajici jen zkontroluje `if (!tenantId) return;`.
*
* Cizi firma je 404, ne 403: z odpovedi nema jit poznat, ze firma existuje.
*/
export function tenantOrDeny(req: Request, res: Response): string | null {
const access = accessOf(req);
const tenantId = requestedTenantId(req) ?? access.defaultTenantId;
if (!tenantId) {
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
return null;
}
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
console.warn(`[access] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
return null;
}
return tenantId;
}
/**
* Totez, ale bez firmy se nezlobi.
*
* Katalog sluzeb nebo skriptu dava smysl i uctu bez clenstvi, jen bez
* firemnich udaju. Cizi firma je porad 404. Vraci `null`, kdyz uz odpovedel.
*/
export function optionalTenantOrDeny(
req: Request,
res: Response,
): { tenantId: string | null } | null {
const access = accessOf(req);
const tenantId = requestedTenantId(req) ?? access.defaultTenantId;
if (!tenantId) return { tenantId: null };
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
console.warn(`[access] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
return null;
}
return { tenantId };
}
/**
* Prevede query (`scope`, `tenantId`) na povolene firmy a strop viditelnosti.
* Pri odepreni rovnou odpovi a vrati null.
*/
export function scopeOrDeny(req: Request, res: Response): ResolvedScope | null {
const resolved = resolveScope(
req.user!,
{
scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
tenantId: requestedTenantId(req),
},
accessOf(req),
);
if (isDenied(resolved)) {
res.status(resolved.status).json({ error: resolved.error, message: resolved.message });
return null;
}
return resolved;
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Jednotny tvar chyby validace.
*
* Driv se to psalo u kazde routy zvlast a tvar se rozesel: nekde `issues[].path`,
* nekde `issues[].field`, nekde nic. Klient cte `field` (viz web/src/lib/api.ts),
* proto je tady jen ten.
*/
import type { Response } from 'express';
import type { ZodError } from 'zod';
/**
* Odpovi 400 s prvni hlaskou a seznamem vsech problemu.
*
* `message` prebije hlasku ze schematu. Hodi se tam, kde zod vraci anglicky
* text ("String must contain...") a routa ma vlastni, srozumitelnou vetu.
*/
export function validationError(res: Response, error: ZodError, message?: string): Response {
return res.status(400).json({
error: 'validation_error',
message: message ?? error.issues[0]?.message ?? 'Neplatný vstup.',
issues: error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Spolecna pravidla pro volani ven.
*
* Adresu vyplnuje firma (napojeni, MCP server, SMTP), takze tohle je jedina
* zabrana proti navazani spojeni dovnitr nasi site. Pravidlo ma byt jedno:
* driv bylo trikrat (HTTP klient, MCP klient, SMTP) a casem by se rozesla.
*
* Totez plati pro popis chyby spojeni a pro cteni tela s limitem - kazdy
* klient to resil po svem a jeden z nich cetl cele telo do pameti, nez zjistil,
* ze je moc velke.
*/
import { config } from '../config.js';
const privateHostPattern =
/^(localhost|127\.|0\.0\.0\.0$|10\.|192\.168\.|169\.254\.|::1$|\[::1\]$|172\.(1[6-9]|2\d|3[01])\.)/i;
/** Miri tohle jmeno do vnitrni site? */
export function isPrivateHost(hostname: string): boolean {
return privateHostPattern.test(hostname);
}
/** Duvod, proc se na tento server nesmi. `null` = smi. */
export function hostProblem(hostname: string): string | null {
if (!config.allowPrivateTargets && isPrivateHost(hostname)) {
return (
`Adresa ${hostname} míří do vnitřní sítě. ` +
'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.'
);
}
return null;
}
/** Duvod, proc se na tuto adresu nesmi: jen http(s) a ne do vnitrni site. */
export function urlProblem(url: URL): string | null {
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return `Adresa ${url.protocol} není povolená, jen http a https.`;
}
return hostProblem(url.hostname);
}
/**
* Overi adresu a pri problemu vyhodi chybu, kterou si volajici sam vyrobi.
* Kazdy klient ma vlastni typ chyby (ScriptError, McpFailure), proto se
* nevyhazuje primo.
*/
export function assertAllowedUrl(url: URL, fail: (message: string) => Error): void {
const problem = urlProblem(url);
if (problem !== null) throw fail(problem);
}
export interface FetchErrorInfo {
/** true = vyprsel limit (AbortError), ne chyba site. */
timedOut: boolean;
/** Kod od Node (`ECONNREFUSED`), prazdny kdyz zadny neni. */
code: string;
/** Hlaska pro uzivatele. */
message: string;
/** Puvodni chyba vcetne priciny, do `detail`. */
detail: string;
}
/**
* Popis chyby, kterou vyhodil `fetch`.
*
* `where` je server nebo adresa do hlasky. `cause` nese u fetch to podstatne,
* proto se pripojuje - bez nej je v hlasce jen "fetch failed".
*/
export function describeFetchError(err: unknown, where: string): FetchErrorInfo {
const code =
err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).code) : '';
const name = err instanceof Error ? err.name : '';
const reason = err instanceof Error ? err.message : String(err);
const cause = err instanceof Error && err.cause instanceof Error ? `\nPříčina: ${err.cause.message}` : '';
const timedOut = name === 'AbortError' || name === 'TimeoutError';
return {
timedOut,
code,
message: timedOut
? `Server ${where} neodpověděl v limitu.`
: `Nepodařilo se spojit s ${where}: ${reason}`,
detail: `${name}: ${reason}${cause}`,
};
}
/** Hlaska pro telo nad limitem. Jedna, aby byla vsude stejna. */
export function tooLargeMessage(limit: number): string {
return `Odpověď je větší než povolený limit ${limit} bajtů.`;
}
export interface LimitedBody {
text: string;
/** true = telo presahlo limit. Cteni se zastavilo, `text` je neuplny. */
tooLarge: boolean;
}
/**
* Precte telo odpovedi, ale nejvys `limit` bajtu.
*
* Cte se po kouscich a pri prekroceni se spojeni zavre. Precist cele telo
* a az pak merit by znamenalo, ze limit chrani jen log, ne pamet procesu.
*/
export async function readBodyLimited(response: Response, limit: number): Promise<LimitedBody> {
const declared = Number(response.headers.get('content-length') ?? 0);
if (declared > limit) {
await response.body?.cancel().catch(() => undefined);
return { text: '', tooLarge: true };
}
const body = response.body;
if (!body) return { text: '', tooLarge: false };
const reader = body.getReader();
const decoder = new TextDecoder();
let text = '';
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) {
await reader.cancel().catch(() => undefined);
return { text, tooLarge: true };
}
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
return { text, tooLarge: false };
} finally {
reader.releaseLock();
}
}
export type LimitedJson =
| { tooLarge: true; text: string; json: undefined }
/** `json` je `undefined`, kdyz telo neni platny JSON. Prazdne telo je `null`. */
| { tooLarge: false; text: string; json: unknown };
/** Telo jako JSON s limitem. Nevyhazuje, volajici si vybere vlastni chybu. */
export async function readJsonLimited(response: Response, limit: number): Promise<LimitedJson> {
const body = await readBodyLimited(response, limit);
if (body.tooLarge) return { tooLarge: true, text: body.text, json: undefined };
if (body.text.trim() === '') return { tooLarge: false, text: body.text, json: null };
try {
return { tooLarge: false, text: body.text, json: JSON.parse(body.text) as unknown };
} catch {
return { tooLarge: false, text: body.text, json: undefined };
}
}
+753 -16
View File
File diff suppressed because it is too large Load Diff
+30 -14
View File
@@ -10,17 +10,39 @@
* a smaze neco cizim jmenem.
*/
import { Router } from 'express';
import type { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
import { config } from '../config.js';
import { listAudit, recordAudit } from '../data/audit.js';
import { hasPermission } from '../data/permissions.js';
import { listAllUsers, findUserById } from '../data/users.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { requireAuth, requirePlatformAdmin } from '../middleware/auth.js';
import { attachAccess } from '../middleware/tenant.js';
import { validationError } from '../middleware/validation.js';
import { readScope } from './crud.js';
export const adminRouter = Router();
adminRouter.use(requireAuth);
export const adminRouter = safeRouter();
adminRouter.use(requireAuth, attachAccess);
/**
* Platformni pravo z katalogu (`impersonate`, `audit.view`).
*
* Spravce platformy ma vsechna prava, takze je to dnes totez co
* `requirePlatformAdmin`. Kontroluje se presto: pravo je v katalogu a bez
* teto kontroly by tam bylo jen na okrasu. Az se platformni prava zacnou
* delit mezi vic lidi, tohle je misto, kde se to projevi.
*/
function requirePlatformPermission(permission: string) {
return (req: Request, res: Response, next: NextFunction) => {
if (!hasPermission(req.user!, permission, null)) {
console.warn(`[admin] ${req.user!.email}: chybi pravo ${permission}`);
return res.status(403).json({ error: 'forbidden', message: 'Nedostatečná oprávnění.' });
}
return next();
};
}
/** Kratka platnost je zamer. Impersonace neni rezim, ve kterem se pracuje. */
const IMPERSONATION_MINUTES = 30;
@@ -35,11 +57,9 @@ const startSchema = z.object({
reason: z.string().trim().max(300).optional(),
});
adminRouter.post('/impersonate', requirePlatformAdmin, (req, res) => {
adminRouter.post('/impersonate', requirePlatformAdmin, requirePlatformPermission('impersonate'), (req, res) => {
const parsed = startSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'validation_error', message: 'Vyberte uživatele.' });
}
if (!parsed.success) return validationError(res, parsed.error, 'Vyberte uživatele.');
const target = findUserById(parsed.data.userId);
if (!target) {
@@ -114,7 +134,7 @@ adminRouter.post('/impersonate/stop', (req, res) => {
});
/** Koho lze prepnout. Spravci platformy se nenabizeji. */
adminRouter.get('/impersonate/candidates', requirePlatformAdmin, (_req, res) => {
adminRouter.get('/impersonate/candidates', requirePlatformAdmin, requirePlatformPermission('impersonate'), (_req, res) => {
res.json({
items: listAllUsers()
.filter((user) => !user.platformAdmin && user.enabled)
@@ -124,12 +144,8 @@ adminRouter.get('/impersonate/candidates', requirePlatformAdmin, (_req, res) =>
// ------------------------------------------------------------------- audit
adminRouter.get('/audit', (req, res) => {
// Audit vidi jen kdo ma pravo. Je to zaznam o lidech, ne provozni log.
if (!req.user!.platformAdmin) {
return res.status(403).json({ error: 'forbidden', message: 'Audit vidí správce platformy.' });
}
// Audit vidi jen kdo ma pravo. Je to zaznam o lidech, ne provozni log.
adminRouter.get('/audit', requirePlatformAdmin, requirePlatformPermission('audit.view'), (req, res) => {
const limit = Number(req.query.limit ?? 200);
return void listAudit({
...readScope(req),
+188
View File
@@ -0,0 +1,188 @@
/**
* Zalozeni firmy z registru ARES.
*
* Spravce platformy napise IC nebo nazev, portal dotahne udaje firmy a osoby,
* ktere za ni jednaji, a spravce si vybere, komu zalozit ucet. ARES e-maily
* nevede, takze osoba bez zadaneho e-mailu dostane nahradni ve tvaru
* `IC-poradi@placeholder.cz`, ktery se ma v Nastaveni nahradit skutecnym.
*
* Jen spravce platformy: firmy zaklada jen on (rozhodnuti v 07-firmy-a-prava).
* Router je namontovany pod /settings, takze `requireAuth` uz probehl.
*/
import { randomBytes, randomUUID } from 'node:crypto';
import { z } from 'zod';
import {
AresError,
listCompanyPersons,
lookupCompany,
normalizeIco,
placeholderEmail,
searchCompanies,
} from '../ares/client.js';
import { recordAudit } from '../data/audit.js';
import { nowIso } from '../data/store/index.js';
import { generateIntakeToken, listTenants, tenantStore, type Tenant } from '../data/tenants.js';
import { hashPassword, listAllUsers, userStore, type StoredUser } from '../data/users.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { requirePlatformAdmin } from '../middleware/auth.js';
import { validationError } from '../middleware/validation.js';
export const aresRouter = safeRouter();
aresRouter.use(requirePlatformAdmin);
/** Chyba ARES neni chyba naseho API: 400 je vstup, 404 neznama firma, jinak 502. */
function aresStatus(err: AresError): number {
if (err.status === 400 || err.status === 404) return err.status;
return 502;
}
/** Firmy podle IC (presne) nebo casti nazvu. */
aresRouter.get('/companies', async (req, res) => {
const query = String(req.query.query ?? '').trim();
if (query === '') {
return res.status(400).json({ error: 'validation_error', message: 'Zadejte IČ nebo název.' });
}
try {
const ico = normalizeIco(query);
const companies = ico !== null ? [await lookupCompany(ico)].filter((c) => c !== null) : await searchCompanies(query, 10);
const known = new Map(listTenants().filter((t) => t.ico).map((t) => [t.ico!, t.id]));
return res.json({
// `existingTenantId` rika, ze firma uz v portalu je - druhe zalozeni nema smysl.
companies: companies.map((c) => ({ ...c, existingTenantId: known.get(c.ico) ?? null })),
});
} catch (err) {
if (err instanceof AresError) return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message });
throw err;
}
});
/** Osoby, ktere za firmu dnes jednaji, kazda s navrzenym nahradnim e-mailem. */
aresRouter.get('/companies/:ico/persons', async (req, res) => {
const ico = normalizeIco(req.params.ico);
if (ico === null) return res.status(400).json({ error: 'validation_error', message: 'IČ má 1 až 8 číslic.' });
try {
const persons = await listCompanyPersons(ico);
return res.json({
persons: persons.map((person, index) => ({ ...person, email: placeholderEmail(ico, index + 1) })),
});
} catch (err) {
if (err instanceof AresError) return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message });
throw err;
}
});
const createSchema = z.object({
ico: z.string().trim().regex(/^\d{1,8}$/, 'IČ má 1 až 8 číslic.'),
/** Nazev jde prepsat, ARES vraci i tvary jako "FIRMA, s.r.o." velkymi pismeny. */
name: z.string().trim().min(2).max(80).optional(),
note: z.string().trim().max(500).optional(),
persons: z
.array(
z.object({
name: z.string().trim().min(2, 'Jméno je moc krátké.').max(80),
/** Prazdne = nahradni e-mail podle poradi. */
email: z.string().trim().email('Zadejte platný e-mail.').optional().or(z.literal('')),
}),
)
.max(50)
.default([]),
});
/**
* Zalozi firmu a ucty vybranych osob, vsechny jako spravce firmy.
*
* Udaje firmy se berou znovu z ARES, ne z klienta: co se ulozi k firme, ma
* odpovidat registru, ne tomu, co prislo v tele. Heslo je nahodne a nikam se
* neposila; clovek si ho nastavi pres zmenu hesla v Nastaveni nebo pozvanku.
*/
aresRouter.post('/tenants', async (req, res) => {
const parsed = createSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error);
const ico = normalizeIco(parsed.data.ico)!;
let company;
try {
company = await lookupCompany(ico);
} catch (err) {
if (err instanceof AresError) return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message });
throw err;
}
if (company === null) {
return res.status(404).json({ error: 'not_found', message: `ARES nezná firmu s IČ ${ico}.` });
}
const tenants = listTenants();
const name = parsed.data.name ?? company.name;
if (tenants.some((t) => t.ico === ico)) {
return res.status(409).json({ error: 'conflict', message: `Firma s IČ ${ico} už existuje.` });
}
if (tenants.some((t) => t.name.toLowerCase() === name.toLowerCase())) {
return res.status(409).json({ error: 'conflict', message: `Firma ${name} už existuje.` });
}
// E-maily se kontroluji pred prvnim zapisem: firma bez lidi by jinak vznikla
// a druhy pokus by skoncil na "uz existuje".
const taken = new Set(listAllUsers().map((u) => u.email.toLowerCase()));
const emails = parsed.data.persons.map((person, index) =>
(person.email && person.email !== '' ? person.email : placeholderEmail(ico, index + 1)).toLowerCase(),
);
for (const email of emails) {
if (taken.has(email)) {
return res.status(409).json({ error: 'conflict', message: `E-mail ${email} už někdo má.` });
}
taken.delete(email);
}
if (new Set(emails).size !== emails.length) {
return res.status(400).json({ error: 'validation_error', message: 'Dva lidé mají stejný e-mail.' });
}
const timestamp = nowIso();
const tenant: Tenant = {
id: `tnt_${randomUUID().slice(0, 8)}`,
tenantId: null,
name,
note: parsed.data.note ?? '',
enabled: true,
intakeToken: generateIntakeToken(),
helpdeskProviderId: null,
ico: company.ico,
dic: company.dic,
address: company.address || null,
legalForm: company.legalForm || null,
createdAt: timestamp,
updatedAt: timestamp,
};
const created = await tenantStore.create(tenant);
const users: StoredUser[] = [];
for (const [index, person] of parsed.data.persons.entries()) {
const user: StoredUser = {
id: `usr_${randomUUID().slice(0, 8)}`,
tenantId: null,
email: emails[index],
name: person.name,
passwordHash: await hashPassword(randomBytes(18).toString('base64url')),
platformAdmin: false,
memberships: [{ tenantId: created.id, roleIds: ['role_admin'] }],
enabled: true,
createdAt: timestamp,
updatedAt: timestamp,
};
users.push(await userStore.create(user));
}
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId: created.id,
action: 'tenant.create.ares',
target: created.id,
detail: { ico, users: users.map((u) => u.email) },
});
return res.status(201).json({
tenant: created,
users: users.map(({ passwordHash: _hash, ...rest }) => rest),
});
});
+14 -12
View File
@@ -1,27 +1,29 @@
import bcrypt from 'bcryptjs';
import { Router } from 'express';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
import { config } from '../config.js';
import { findUserByEmail } from '../data/users.js';
import { findUserByEmail, verifyPassword } from '../data/users.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { requireAuth } from '../middleware/auth.js';
import { rateLimit } from '../middleware/rateLimit.js';
import { validationError } from '../middleware/validation.js';
import { toPublicUser, type JwtPayload } from '../types.js';
export const authRouter = Router();
export const authRouter = safeRouter();
const loginSchema = z.object({
email: z.string().email('Zadejte platný e-mail.'),
password: z.string().min(1, 'Zadejte heslo.'),
});
authRouter.post('/login', async (req, res) => {
/**
* Dvacet pokusu za ctvrt hodiny z jedne adresy. Dost na preklepy cele
* kancelare za jednou NAT adresou, malo na hadani hesla.
*/
const loginLimiter = rateLimit({ name: 'login', windowMs: 15 * 60_000, max: 20 });
authRouter.post('/login', loginLimiter, async (req, res) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
const { email, password } = parsed.data;
const user = findUserByEmail(email);
@@ -35,7 +37,7 @@ authRouter.post('/login', async (req, res) => {
return invalid();
}
const passwordOk = await bcrypt.compare(password, user.passwordHash);
const passwordOk = await verifyPassword(password, user.passwordHash);
if (!passwordOk) {
console.info(`[auth] neuspesne prihlaseni - spatne heslo: ${user.email}`);
return invalid();
+57 -39
View File
@@ -15,9 +15,9 @@
* udaje do prohlizece.
*/
import { Router, type Request, type Response } from 'express';
import { type Request, type Response } from 'express';
import { z } from 'zod';
import { accessFor } from '../data/access.js';
import { hasPermission } from '../data/permissions.js';
import {
CHECK_HISTORY,
connectorCountsByService,
@@ -42,6 +42,7 @@ import {
} from '../data/services.js';
import { config } from '../config.js';
import { egressIp } from '../data/egressIp.js';
import { publish } from '../events/bus.js';
import { smtpSettings, smtpTargetUrl, verifySmtp } from '../mail/smtp.js';
import { listTools } from '../mcp/client.js';
import { forgetMcpTools, rememberMcpTools } from '../data/mcpTools.js';
@@ -50,25 +51,25 @@ import { resolveTarget, serviceBaseUrl, targetSecrets } from '../scripts/connect
import { createHttp } from '../scripts/http.js';
import { ScriptError } from '../scripts/types.js';
import { createRedactor, describe, truncate } from '../scripts/util.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { optionalTenantOrDeny, tenantOrDeny } from '../middleware/tenant.js';
import { validationError } from '../middleware/validation.js';
export const connectorsRouter = Router();
export const connectorsRouter = safeRouter();
/**
* Firma, do ktere se zapisuje. Konektor je vzdy jedne firmy, i kdyz uzivatel
* kouka na pohled "vse" - jinak by nebylo kam ho zaloz1t.
* Firma, do ktere se zapisuje, plus pravo konektory spravovat.
*
* Konektor je vzdy jedne firmy, i kdyz uzivatel kouka na pohled "vse". Cteni
* staci clenstvi, zapis a overeni (to vola cizi sluzbu pod udaji firmy) chce
* `connector.manage` - driv stacilo clenstvi i na to.
*/
function tenantOrDeny(req: Request, res: Response): string | null {
const access = accessFor(req.user!);
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const tenantId = requested ?? access.defaultTenantId;
if (!tenantId) {
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
return null;
}
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
console.warn(`[connectors] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
function managedTenantOrDeny(req: Request, res: Response): string | null {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return null;
if (!hasPermission(req.user!, 'connector.manage', tenantId)) {
console.warn(`[connectors] ${req.user!.email}: chybi pravo connector.manage ve firme ${tenantId}`);
res.status(403).json({ error: 'forbidden', message: 'Konektory spravuje ten, kdo na to má právo.' });
return null;
}
return tenantId;
@@ -100,9 +101,10 @@ function serviceOrDeny(
* ale te firmy, proto se to pocita tady a ne v katalogu.
*/
connectorsRouter.get('/services', async (req, res) => {
const access = accessFor(req.user!);
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const tenantId = requested ?? access.defaultTenantId;
// Cizi firma je 404. Bez firmy jen katalog bez poctu konektoru.
const resolved = optionalTenantOrDeny(req, res);
if (!resolved) return;
const { tenantId } = resolved;
const visible = visibleServices(req.user!, tenantId);
// Firma se predava kvuli MCP: nastroje jsou vlastnost jejiho napojeni.
@@ -183,16 +185,11 @@ const createSchema = z.object({
});
connectorsRouter.post('/', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
const tenantId = managedTenantOrDeny(req, res);
if (!tenantId) return;
const parsed = createSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
const service = serviceOrDeny(req, res, parsed.data.serviceId, tenantId);
if (!service) return;
@@ -217,6 +214,13 @@ connectorsRouter.post('/', async (req, res) => {
values: parsed.data.values,
});
// Portal si konektory drzi v pameti, bez udalosti by novy videl az po obnoveni.
publish(
'connector.created',
`Nový konektor ${connector.name}`,
{ id: connector.id, connector: toPublicConnector(connector) },
connector.tenantId,
);
return res.status(201).json(toPublicConnector(connector));
});
@@ -230,7 +234,7 @@ const updateSchema = z.object({
});
connectorsRouter.patch('/:id', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
const tenantId = managedTenantOrDeny(req, res);
if (!tenantId) return;
const existing = await getConnector(req.params.id, [tenantId]);
@@ -242,12 +246,7 @@ connectorsRouter.patch('/:id', async (req, res) => {
if (!service) return;
const parsed = updateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
if (parsed.data.values) {
const issues = validateConnectorValues(service, parsed.data.values);
@@ -263,11 +262,17 @@ connectorsRouter.patch('/:id', async (req, res) => {
// Nazev konektoru je v nazvu kazdeho jeho nastroje ve vyberu kroku. Bez
// tohohle by tam po prejmenovani zustal stary az do restartu.
if (isMcpService(updated.serviceId)) rememberMcpTools(updated);
publish(
'connector.updated',
`Konektor ${updated.name} upraven`,
{ id: updated.id, connector: toPublicConnector(updated) },
updated.tenantId,
);
return res.json(toPublicConnector(updated));
});
connectorsRouter.delete('/:id', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
const tenantId = managedTenantOrDeny(req, res);
if (!tenantId) return;
if (!(await deleteConnector(req.params.id, [tenantId]))) {
@@ -276,6 +281,7 @@ connectorsRouter.delete('/:id', async (req, res) => {
// Smazanym konektorem zmizi i jeho nastroje z katalogu, jinak by v builderu
// zustaly kroky, ktere uz nemaji kam volat.
forgetMcpTools(req.params.id);
publish('connector.deleted', `Konektor ${req.params.id} smazán`, { id: req.params.id }, tenantId);
return res.status(204).end();
});
@@ -321,6 +327,10 @@ async function loadMcpTools(connectorId: string, tenantId: string) {
}
const outcome = await listTools(target);
// Telo chybove odpovedi serveru nesmi nest token. Hlasku uz klient MCP
// rediguje, telo ne vzdy - proto jeste jednou tady, pred zapisem i odpovedi.
const redact = createRedactor(targetSecrets(target));
const detail = outcome.detail ? redact(outcome.detail) : outcome.detail;
await setConnectorStatus(
connector.id,
@@ -332,7 +342,7 @@ async function loadMcpTools(connectorId: string, tenantId: string) {
checked,
status: outcome.status,
message: outcome.message,
detail: outcome.detail,
detail,
request: outcome.request,
responseHeaders: null,
// Server si zaklada firma, seznamy povolenych IP na nem nemame v ruce.
@@ -348,7 +358,7 @@ async function loadMcpTools(connectorId: string, tenantId: string) {
checked,
message: outcome.message,
...(outcome.status !== null ? { status: outcome.status } : {}),
...(outcome.detail ? { detail: outcome.detail } : {}),
...(detail ? { detail } : {}),
request: outcome.request,
tools: [],
};
@@ -359,6 +369,14 @@ async function loadMcpTools(connectorId: string, tenantId: string) {
const publicView = saved ? toPublicConnector(saved) : null;
console.info(`[mcp] ${connector.id}: ${outcome.message}`);
if (saved) {
publish(
'connector.updated',
`Konektor ${saved.name} má nové nástroje`,
{ id: saved.id, connector: publicView },
saved.tenantId,
);
}
return {
ok: true,
@@ -386,7 +404,7 @@ async function loadMcpTools(connectorId: string, tenantId: string) {
* na spravny stroj. Hadat to podle toho, kde je nasazeny portal, nejde.
*/
connectorsRouter.post('/:id/test', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
const tenantId = managedTenantOrDeny(req, res);
if (!tenantId) return;
const connector = await getConnector(req.params.id, [tenantId]);
@@ -598,7 +616,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
* da se spustit kdykoliv.
*/
connectorsRouter.post('/:id/mcp/tools', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
const tenantId = managedTenantOrDeny(req, res);
if (!tenantId) return;
const connector = await getConnector(req.params.id, [tenantId]);
+7 -8
View File
@@ -1,8 +1,13 @@
import { Router } from 'express';
import { z } from 'zod';
import { rateLimit } from '../middleware/rateLimit.js';
import { validationError } from '../middleware/validation.js';
export const contactRouter = Router();
/** Verejny formular bez prihlaseni. Pet poptavek za hodinu z jedne adresy staci. */
const contactLimiter = rateLimit({ name: 'contact', windowMs: 60 * 60_000, max: 5 });
const contactSchema = z.object({
name: z.string().min(2, 'Zadejte jméno.'),
email: z.string().email('Zadejte platný e-mail.'),
@@ -16,15 +21,9 @@ const contactSchema = z.object({
* PROTOTYP: zpravu jen zalogujeme. Realne odeslani (SMTP / ticket system)
* pribude pozdeji - viz docs/04-backend-api.md.
*/
contactRouter.post('/', (req, res) => {
contactRouter.post('/', contactLimiter, (req, res) => {
const parsed = contactSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
});
}
if (!parsed.success) return validationError(res, parsed.error);
const data = parsed.data;
console.info(
+110 -67
View File
@@ -21,43 +21,14 @@
*/
import { randomUUID } from 'node:crypto';
import { Router, type Request, type Response } from 'express';
import { type Request, type Response, type Router } from 'express';
import type { z } from 'zod';
import { accessFor, actingTenant } from '../data/access.js';
import { hasPermission } from '../data/permissions.js';
import { publish as publishEvent, type EntityEventKind } from '../events/bus.js';
import { nowIso, type EntityStore, type ListOptions, type TenantEntity } from '../data/store/index.js';
/** Firma, ve ktere se prave pracuje. Zapis je vzdy do jedne. */
export function currentTenant(req: Request, res: Response): string | null {
const access = accessFor(req.user!);
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const tenantId = requested ?? access.defaultTenantId;
if (!tenantId) {
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
return null;
}
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
console.warn(`[crud] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
return null;
}
return tenantId;
}
/** Rozsah pro cteni: firmy uzivatele plus volitelne platformni zaznamy. */
/**
* Firma, za kterou request jedna. Podle ni se pocitaji prava.
*
* Na rozdil od `currentTenant` neodpovida - vraci jen hodnotu, protoze
* kontrola prava chce sama rict, co se stalo.
*/
export function requestTenant(req: Request): string | null {
return actingTenant(
req.user!,
typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
);
}
import { safeRouter } from '../middleware/asyncHandler.js';
import { requestTenant, tenantOrDeny } from '../middleware/tenant.js';
import { validationError } from '../middleware/validation.js';
/**
* Ze ktere firmy smi request cist.
@@ -88,20 +59,95 @@ export interface CrudOptions<T extends TenantEntity, C, U> {
updateSchema: z.ZodType<U, z.ZodTypeDef, unknown>;
/** Pravo potrebne k zapisu. Cteni staci prihlaseni. */
writePermission: string;
/** Sestavi novy zaznam z overeneho vstupu. */
build: (input: C, tenantId: string) => Omit<T, keyof TenantEntity> & Partial<TenantEntity>;
/** Sestavi novy zaznam z overeneho vstupu. Smi byt asynchronni (hash hesla). */
build: (
input: C,
tenantId: string,
) => BuiltEntity<T> | Promise<BuiltEntity<T>>;
/** Vlastni kontrola nad ulozenym zaznamem. Vraci popisy problemu. */
validate?: (entity: T, all: T[]) => string[];
/** Uprava pred odeslanim klientovi, napr. schovani citlivych poli. */
toPublic?: (entity: T) => unknown;
/**
* Druh udalosti pro zivy stream (`person.created` apod.). Bez nej se zmena
* neohlasi a portal ji uvidi az po novem nacteni. Payload je verejna podoba
* zaznamu (`toPublic`), takze tudy neutece nic, co neutece ani z GET.
*/
event?: EntityEventKind;
/** true = zaznamy vidi jen spravce platformy. */
platformOnly?: boolean;
/**
* Zaznamy bez firmy (`tenantId: null`), ktere se presto spravuji po firmach.
*
* Uzivatel neni majetkem firmy, muze byt ve vic firmach naraz. Fabrika ho
* proto nesmi brat jako systemovy zaznam (to by ho smel menit jen spravce
* platformy), ale pta se tady: patri do firmy, ve ktere se prave pracuje,
* a smi tenhle vstup projit?
*/
scopeBy?: {
/** Patri zaznam do firmy? Cizi se chova jako neexistujici. */
belongsTo: (entity: T, tenantId: string) => boolean;
/**
* Overi a pripadne upravi vstup za firmu. `input` je `undefined` u mazani.
* Vraci upraveny vstup, nebo hlasku a stav, se kterym se odmitne.
*/
prepare: (
req: Request,
tenantId: string,
input: C | U | undefined,
existing?: T,
) => { ok: true; input: C | U | undefined } | { ok: false; status: number; message: string };
};
}
type BuiltEntity<T extends TenantEntity> = Omit<T, keyof TenantEntity> & Partial<TenantEntity>;
export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T, C, U>): Router {
const router = Router();
const router = safeRouter();
const publish = options.toPublic ?? ((entity: T) => entity);
/** Ohlasi zmenu do streamu. Firma zaznamu, u platformnich (null) ji dostanou vsichni. */
const announce = (verb: 'created' | 'updated' | 'deleted', entity: T) => {
if (!options.event) return;
const label = (entity as { name?: unknown }).name;
const message = `${options.event} ${verb}: ${typeof label === 'string' ? label : entity.id}`;
const payload = verb === 'deleted' ? { id: entity.id } : { id: entity.id, [options.event]: publish(entity) };
publishEvent(`${options.event}.${verb}`, message, payload, entity.tenantId);
};
/**
* Zaznam v dosahu firmy. Platformni admin vidi vse, ostatni jen to, co do
* jejich firmy patri - u obycejnych entit to zaridi uloziste, u zaznamu
* bez firmy (`scopeBy`) se to musi rict tady.
*/
function reachable(req: Request, entity: T): boolean {
if (!options.scopeBy || req.user!.platformAdmin) return true;
const tenantId = requestTenant(req);
return tenantId !== null && options.scopeBy.belongsTo(entity, tenantId);
}
/** Vstup po kontrole za firmu. Pri odmitnuti odpovi a vrati `null`. */
function prepared(
req: Request,
res: Response,
input: C | U | undefined,
existing?: T,
): { input: C | U | undefined } | null {
if (!options.scopeBy) return { input };
const tenantId = requestTenant(req);
if (!tenantId) {
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
return null;
}
const outcome = options.scopeBy.prepare(req, tenantId, input, existing);
if (!outcome.ok) {
console.warn(`[crud] ${req.user!.email}: ${options.store.kind} odmitnuto - ${outcome.message}`);
res.status(outcome.status).json({ error: 'forbidden', message: outcome.message });
return null;
}
return { input: outcome.input };
}
/** Smi uzivatel zapisovat? Jedno misto, aby se to nekontrolovalo jen nekde. */
function canWrite(req: Request, res: Response, entity?: T): boolean {
if (options.platformOnly && !req.user!.platformAdmin) {
@@ -109,7 +155,8 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
return false;
}
// Platformni zaznam smi menit jen spravce platformy, i kdyz pravo jinak ma.
if (entity && entity.tenantId === null && !req.user!.platformAdmin) {
// Zaznamy bez firmy spravovane po firmach (`scopeBy`) systemove nejsou.
if (entity && entity.tenantId === null && !options.scopeBy && !req.user!.platformAdmin) {
res.status(403).json({
error: 'forbidden',
message: 'Tenhle záznam je systémový a mění ho jen správce platformy.',
@@ -134,13 +181,13 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
if (options.platformOnly && !req.user!.platformAdmin) {
return res.status(403).json({ error: 'forbidden', message: 'Jen pro správce platformy.' });
}
const items = await options.store.list(readScope(req));
const items = (await options.store.list(readScope(req))).filter((item) => reachable(req, item));
return res.json({ items: items.map(publish) });
});
router.get('/:id', async (req, res) => {
const entity = await options.store.get(req.params.id, readScope(req));
if (!entity) {
if (!entity || !reachable(req, entity)) {
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
}
return res.json(publish(entity));
@@ -149,26 +196,23 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
router.post('/', async (req, res) => {
if (!canWrite(req, res)) return;
const tenantId = currentTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const parsed = options.createSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
issues: parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}
if (!parsed.success) return validationError(res, parsed.error);
const checked = prepared(req, res, parsed.data);
if (!checked) return;
const timestamp = nowIso();
const built = await options.build(checked.input as C, tenantId);
const entity = {
...options.build(parsed.data, tenantId),
...built,
id: `${options.idPrefix}_${randomUUID().slice(0, 8)}`,
tenantId,
// Kdo si firmu urcil sam (`tenantId: null` u uzivatele a firmy), tomu
// se neprepisuje. Driv se prepsala vzdy a "bez firmy" nikdy nenastalo.
tenantId: built.tenantId === undefined ? tenantId : built.tenantId,
createdAt: timestamp,
updatedAt: timestamp,
} as T;
@@ -181,26 +225,26 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
}
const created = await options.store.create(entity);
announce('created', created);
return res.status(201).json(publish(created));
});
router.patch('/:id', async (req, res) => {
const existing = await options.store.get(req.params.id, readScope(req));
if (!existing) {
if (!existing || !reachable(req, existing)) {
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
}
if (!canWrite(req, res, existing)) return;
const parsed = options.updateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
const checked = prepared(req, res, parsed.data, existing);
if (!checked) return;
const patch = checked.input as Partial<T>;
if (options.validate) {
const merged = { ...existing, ...(parsed.data as Partial<T>) } as T;
const merged = { ...existing, ...patch } as T;
const others = (await options.store.list(readScope(req))).filter(
(item) => item.id !== existing.id,
);
@@ -210,25 +254,24 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
}
}
const updated = await options.store.update(
req.params.id,
parsed.data as Partial<T>,
readScope(req),
);
const updated = await options.store.update(req.params.id, patch, readScope(req));
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
}
announce('updated', updated);
return res.json(publish(updated));
});
router.delete('/:id', async (req, res) => {
const existing = await options.store.get(req.params.id, readScope(req));
if (!existing) {
if (!existing || !reachable(req, existing)) {
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
}
if (!canWrite(req, res, existing)) return;
if (!prepared(req, res, undefined, existing)) return;
const removed = await options.store.remove(req.params.id, readScope(req));
if (removed) announce('deleted', existing);
return removed
? res.status(204).end()
: res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
+162 -319
View File
@@ -1,13 +1,8 @@
import { Router, type Request, type Response } from 'express';
import { createHash } from 'node:crypto';
import { type Request, type Response } from 'express';
import { z } from 'zod';
import { publicBaseUrl } from '../config.js';
import {
accessFor,
isDenied,
resolveScope,
visibilityFor,
type ResolvedScope,
} from '../data/access.js';
import { type ResolvedScope } from '../data/access.js';
import {
createAutomation,
deleteAutomation,
@@ -46,13 +41,7 @@ import { collectScopes } from '../data/flowScope.js';
import { widgetCatalog } from '../data/widgets.js';
import { createIncident, listIncidents } from '../data/incidentStore.js';
import { getSummary } from '../data/mock.js';
import {
findPersonByEmail,
groupsOfPerson,
isMember,
listGroups,
listPeople,
} from '../data/people.js';
import { isMember, listGroups, listPeople } from '../data/people.js';
import { hasPermission } from '../data/permissions.js';
import { listNotifications, markRead, unreadCount } from '../data/notifications.js';
import { inviteRouter } from './invites.js';
@@ -61,56 +50,61 @@ import { recordAudit } from '../data/audit.js';
import { findTenant, generateIntakeToken, refreshTenants, tenantStore } from '../data/tenants.js';
import { listTicketTypes } from '../data/ticketTypes.js';
import {
addComment,
assignTicket,
claimTicket,
createTicket,
ticketAssignee,
getAgentStats,
getTicket,
ticketWithinVisibility,
getWorkload,
listTickets,
updateTicketStatus,
type TicketChannel,
type TicketFilter,
} from '../data/ticketStore.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { requireAuth } from '../middleware/auth.js';
import {
accessOf,
attachAccess,
optionalTenantOrDeny,
scopeOrDeny,
tenantOrDeny,
} from '../middleware/tenant.js';
import { validationError } from '../middleware/validation.js';
import { validateRules } from '../scripts/mapping.js';
import { connectorsRouter } from './connectors.js';
import { helpdeskRouter } from './helpdesk.js';
import { settingsRouter } from './settings.js';
import { ticketActionsRouter } from './ticketActions.js';
import { ticketActionsRouter, visibleTicketOrDeny } from './ticketActions.js';
import { widgetDataRouter } from './widgetData.js';
import { scriptsRouter } from './scripts.js';
import { tenantScriptRouter } from './tenantScripts.js';
import { streamRouter } from './stream.js';
export const dashboardRouter = Router();
export const dashboardRouter = safeRouter();
// Cely dashboard je jen pro prihlasene.
dashboardRouter.use(requireAuth);
// Cely dashboard je jen pro prihlasene. Prava se spocitaji jednou za request.
dashboardRouter.use(requireAuth, attachAccess);
/**
* Prevede query na povolene firmy. Pri odepreni rovnou odpovi a vrati null,
* takze volajici jen zkontroluje `if (!scope) return;`.
* Strankovani ze query. `limit` bez hodnoty = vsechno, jako driv.
*
* Odpoved zustava seznam, jen se k ni prida hlavicka `X-Total-Count`
* s poctem pred orezem - klient, ktery strankovani nezna, nic nepozna.
*/
function scopeOrDeny(req: Request, res: Response): ResolvedScope | null {
const resolved = resolveScope(req.user!, {
scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
});
function pageFrom(query: Record<string, unknown>, maxLimit = 500): { limit: number | null; offset: number } {
const limit = Number(query.limit);
const offset = Number(query.offset);
return {
limit: Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLimit) : null,
offset: Number.isInteger(offset) && offset > 0 ? offset : 0,
};
}
if (isDenied(resolved)) {
res.status(resolved.status).json({ error: resolved.error, message: resolved.message });
return null;
}
return resolved;
function paginate<T>(items: T[], page: { limit: number | null; offset: number }): T[] {
if (page.limit === null && page.offset === 0) return items;
return items.slice(page.offset, page.limit === null ? undefined : page.offset + page.limit);
}
/** Co uzivatel smi, aby klient nemusel hadat, ktere prepinace kreslit. */
dashboardRouter.get('/access', (req, res) => {
res.json(accessFor(req.user!));
res.json(accessOf(req));
});
dashboardRouter.get('/summary', (req, res) => {
@@ -125,8 +119,10 @@ dashboardRouter.get('/summary', (req, res) => {
* Portal to musi umet rict nahlas. Bez toho se clovek divi, kam se podely
* jeho konektory, a hleda chybu v aplikaci - presne to se stalo.
*/
dashboardRouter.get('/storage', (_req, res) => {
res.json(storageStatus());
dashboardRouter.get('/storage', (req, res) => {
// Cesta na disku serveru je nase provozni informace, ne zakaznikova.
const { location, ...status } = storageStatus();
res.json(req.user!.platformAdmin ? { ...status, location } : { ...status, location: null });
});
/**
@@ -182,9 +178,29 @@ const FIELD_LIMIT = 4_000;
*/
const DEDUPE_MS = 600_000;
/** Kolik otisku se drzi. Pri prekroceni se uklidi prosle, pak nejstarsi. */
const RECENT_LIMIT = 1_000;
/** Otisk padu a kdy naposled zalozil incident. */
const recent = new Map<string, number>();
/**
* Uklid mapy otisku. Klic je z hlasky od klienta, takze bez stropu by ji
* kdokoliv prihlaseny mohl nafouknout do nekonecna.
*/
function forgetOldCrashes(now: number): void {
if (recent.size < RECENT_LIMIT) return;
for (const [key, at] of recent) {
if (now - at >= DEDUPE_MS) recent.delete(key);
}
// Same cerstve? Mapa drzi poradi vkladani, nejstarsi je prvni.
while (recent.size >= RECENT_LIMIT) {
const oldest = recent.keys().next().value;
if (oldest === undefined) break;
recent.delete(oldest);
}
}
const crashSchema = z.object({
message: z.string().trim().min(1).max(FIELD_LIMIT),
stack: z.string().max(FIELD_LIMIT).optional(),
@@ -196,17 +212,12 @@ const crashSchema = z.object({
});
dashboardRouter.post('/client-crash', (req, res) => {
const access = accessFor(req.user!);
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const tenantId = requested ?? access.defaultTenantId;
// Incident se zaklada firme, do ktere clovek patri. Cizi firma je 404.
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const parsed = crashSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
const crash = parsed.data;
const where = crash.path ?? 'neznámá stránka';
@@ -214,13 +225,19 @@ dashboardRouter.post('/client-crash', (req, res) => {
/*
* Otisk je hlaska a misto, ne cas. Tatáz chyba na tomtez miste je porad
* jeden problem, i kdyz na ni clovek narazi desetkrat za sebou.
*
* Hash, ne surovy text: klic v mape by jinak nesl az 4 kB od klienta.
* Zacatek hlasky staci, konec byva promenlivy (ID, cisla radku).
*/
const fingerprint = `${crash.message}|${where}`;
const fingerprint = createHash('sha1')
.update(`${tenantId}|${crash.message.slice(0, 500)}|${where.slice(0, 500)}`)
.digest('hex');
const last = recent.get(fingerprint) ?? 0;
const now = Date.now();
if (now - last < DEDUPE_MS) {
return res.status(202).json({ created: false, reason: 'stejný pád už je hlášený' });
}
forgetOldCrashes(now);
recent.set(fingerprint, now);
const detail = [
@@ -264,37 +281,20 @@ dashboardRouter.get('/widgets', (req, res) => {
* vsech firem uzivatele, sla by polozit dlazdice Automie na dashboard
* Nordisu - v nabidce by byla, ale data by k ni nikdy neprisla.
*/
const tenantId = layoutTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({
items: widgetCatalog([tenantId], req.user!.id, accessFor(req.user!, tenantId).personId !== null),
items: widgetCatalog([tenantId], req.user!.id, accessOf(req).personId !== null),
});
});
/**
/*
* Rozlozeni je vzdy za konkretni firmu, i kdyz uzivatel kouka na pohled "vse".
* Jinak by clovek ve dvou firmach nemel kam ulozit dve ruzna nastaveni.
*/
function layoutTenant(req: Request, res: Response): string | null {
const access = accessFor(req.user!);
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const tenantId = requested ?? access.defaultTenantId;
if (!tenantId) {
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
return null;
}
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
console.warn(`[layout] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
return null;
}
return tenantId;
}
dashboardRouter.get('/layout', (req, res) => {
const tenantId = layoutTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({
@@ -315,20 +315,15 @@ const layoutSchema = z.object({
});
dashboardRouter.put('/layout', (req, res) => {
const tenantId = layoutTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const parsed = layoutSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatné rozložení.',
});
}
if (!parsed.success) return validationError(res, parsed.error, 'Neplatné rozložení.');
const problems = validateLayout(
parsed.data.items,
widgetCatalog([tenantId], req.user!.id, accessFor(req.user!, tenantId).personId !== null),
widgetCatalog([tenantId], req.user!.id, accessOf(req).personId !== null),
);
if (problems.length > 0) {
console.warn(`[layout] ${req.user!.email}: neplatne rozlozeni - ${problems.join(' ')}`);
@@ -341,7 +336,7 @@ dashboardRouter.put('/layout', (req, res) => {
/** Vraceni na vychozi. Zamerne DELETE - je to smazani ulozeneho nastaveni. */
dashboardRouter.delete('/layout', (req, res) => {
const tenantId = layoutTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({
@@ -367,17 +362,8 @@ dashboardRouter.get('/people/:id', (req, res) => {
return res.status(404).json({ error: 'not_found', message: 'Řešitel neexistuje.' });
}
const tickets = listTickets({
tenantIds: scope.tenantIds,
visibility: scope.visibility,
assignee: person.id,
});
const resolvedRecently = listTickets({
tenantIds: scope.tenantIds,
visibility: scope.visibility,
}).filter(
(ticket) => ticket.resolvedById === person.id,
);
// Jeden pruchod seznamem: "ma u sebe" i "vyresil" se pozna z tehoz ticketu.
const tickets = listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility });
// Obdobi drzime stejne jako u widgetu vykonu, aby cisla sedela na obou mistech.
const since = Date.now() - 30 * 86_400_000;
@@ -390,10 +376,10 @@ dashboardRouter.get('/people/:id', (req, res) => {
.filter((group) => isMember(group, person.id))
.map((group) => ({ id: group.id, name: group.name })),
/** Co ma prave ted u sebe. */
open: tickets.filter((ticket) => !ticket.closed),
open: tickets.filter((ticket) => !ticket.closed && ticket.assignee?.id === person.id),
/** Co za posledni dobu vyresil, nejnovejsi nahore. */
resolved: resolvedRecently
.filter((ticket) => ticket.closed)
resolved: tickets
.filter((ticket) => ticket.closed && ticket.resolvedById === person.id)
.sort((a, b) => (b.resolvedAt ?? '').localeCompare(a.resolvedAt ?? ''))
.slice(0, 20),
});
@@ -518,7 +504,7 @@ dashboardRouter.post('/notifications/read', (req, res) => {
/** Kolik nevyrizenych ma prihlaseny u sebe. */
function myOpenTickets(req: Request): number {
const access = accessFor(req.user!);
const access = accessOf(req);
if (!access.personId) return 0;
// Vlastni tickety jsou ve stropu vzdycky, ale posila se vyslovne - filtr
@@ -542,9 +528,17 @@ dashboardRouter.get('/runs', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const stats = queueStats(scope.tenantIds);
const page = pageFrom(req.query as Record<string, unknown>);
// Bez `limit` poslednich 50, jako driv. Celkovy pocet je soucet stavu fronty.
const runs = page.limit === null && page.offset === 0
? recentRuns(scope.tenantIds)
: recentRuns(scope.tenantIds, page.offset + (page.limit ?? 50)).slice(page.offset);
res.setHeader('X-Total-Count', String(stats.pending + stats.running + stats.failed + stats.done));
return res.json({
stats: queueStats(scope.tenantIds),
items: recentRuns(scope.tenantIds).map((run) => ({
stats,
items: runs.map((run) => ({
id: run.id,
automationId: run.automationId,
trigger: run.trigger,
@@ -573,11 +567,10 @@ dashboardRouter.get('/people', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const me = findPersonByEmail(req.user!.email);
return res.json({
items: listPeople(scope.tenantIds),
groups: listGroups(scope.tenantIds).map((group) => ({ id: group.id, name: group.name })),
meId: me?.id ?? null,
meId: accessOf(req).personId,
});
});
@@ -645,31 +638,32 @@ dashboardRouter.get('/tickets', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const me = findPersonByEmail(req.user!.email);
const filter = ticketFilterFrom(req.query as Record<string, unknown>, scope);
const items = listTickets(filter);
/*
* Stavy, ktere firma opravdu pouziva. Stav je volny retezec, takze pevny
* seznam v rozhrani by u ticketu z cizich aplikaci nikdy nesedel - filtr
* musi nabidnout to, co v datech je.
*
* Bere se z celeho rozsahu, ne z vyfiltrovaneho vysledku: jinak by po
* kliknuti na jeden stav zmizely ostatni a nesel by prepnout.
* kliknuti na jeden stav zmizely ostatni a nesel by prepnout. Kdyz zadny
* filtr neni, je cely rozsah zaroven vysledek a seznam se cte jen jednou.
*/
const statuses = [
...new Set(
listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }).map(
(ticket) => ticket.status,
),
),
].sort((a, b) => a.localeCompare(b, 'cs'));
const all = listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility });
const filtered =
Object.keys(filter).length === 2 ? all : listTickets(filter);
const statuses = [...new Set(all.map((ticket) => ticket.status))].sort((a, b) =>
a.localeCompare(b, 'cs'),
);
const page = pageFrom(req.query as Record<string, unknown>);
res.setHeader('X-Total-Count', String(filtered.length));
return res.json({
items,
items: paginate(filtered, page),
total: filtered.length,
statuses,
meId: me?.id ?? null,
meId: accessOf(req).personId,
scope: scope.scope,
tenantId: scope.tenantId,
});
@@ -715,16 +709,7 @@ dashboardRouter.post('/tickets', (req, res) => {
}
const parsed = createTicketSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Ticket se nepodařilo založit.',
issues: parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}
if (!parsed.success) return validationError(res, parsed.error);
// Zaklada se vzdy do firmy, ve ktere clovek prave je. Vybirat ji ve formulari
// by znamenalo, ze se ticket omylem zalozi jinam.
@@ -778,79 +763,10 @@ dashboardRouter.post('/tickets', (req, res) => {
return res.status(201).json(ticket);
});
/**
* Prevzeti ticketu.
*
* Clovek si vezme praci sam, misto aby cekal, az mu ji nekdo prideli. Tohle je
* bezny zpusob prace v tymu: ticket lezi ve fronte skupiny a kdo ma cas, si ho
* vezme.
*
* Smi to u ticketu **ze sve skupiny** nebo u takoveho, ktery nema nikoho.
* Vzit nekomu rozdelanou praci nejde - to uz je prehozeni a na to je pravo
* `ticket.assign.others`.
/*
* Prevzeti, prirazeni, stav a komentar jsou vestavene akce na ticketu,
* viz `routes/ticketActions.ts` - kazda ma sve pravo a projde auditem.
*/
dashboardRouter.post('/tickets/:id/claim', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const access = accessFor(req.user!);
if (!access.personId) {
return res.status(400).json({
error: 'not_a_person',
message: 'Nejste vedený jako řešitel, takže si ticket nemůžete převzít.',
});
}
const tenantIds = writableTenants(req);
const current = ticketAssignee(req.params.id, tenantIds);
if (current === undefined) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
const ticket = getTicket(req.params.id, tenantIds);
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
// Prevzit jde jen to, na co clovek vidi.
if (!ticketWithinVisibility(ticket.id, visibilityFor(req.user!, ticket.tenantId))) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
if (current !== null && current !== access.personId) {
// Uz to nekdo resi. Prehodit praci nekomu jinemu je jine rozhodnuti.
// Pravo se pta za firmu ticketu, ne za tu prave prepnutou.
if (!hasPermission(req.user!, 'ticket.assign.others', ticket.tenantId)) {
return res.status(409).json({
error: 'already_assigned',
message: 'Ticket už někdo řeší. Přehodit ho může jen ten, kdo na to má právo.',
});
}
}
const mine = groupsOfPerson(access.personId, tenantIds).map((group) => group.id);
const inMyGroup = ticket.assigneeGroupId !== null && mine.includes(ticket.assigneeGroupId);
if (!inMyGroup && current !== null && current !== access.personId) {
return res.status(403).json({
error: 'forbidden',
message: 'Tenhle ticket není ve vaší skupině.',
});
}
const updated = claimTicket(req.params.id, access.personId, tenantIds);
if (!updated) {
return res.status(400).json({ error: 'failed', message: 'Převzetí se nepodařilo.' });
}
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId: updated.tenantId,
action: 'ticket.claim',
target: updated.id,
});
return res.json(updated);
});
/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */
/**
@@ -886,116 +802,18 @@ dashboardRouter.get('/tickets/workload', (req, res) => {
});
dashboardRouter.get('/tickets/:id', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
// Detail hledame pres vsechny firmy uzivatele, ne jen pres tu prave zvolenou.
// Jinak by odkaz z pohledu "vse" na ticket jine firmy vratil 404.
const reachable = accessFor(req.user!).tenants.map((tenant) => tenant.id);
const ticket = getTicket(req.params.id, reachable);
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
// Strop se pocita za firmu ticketu, ne za prave prepnutou. Bez teto kontroly
// by seznam ticket schoval, ale adresa detailu by ho vydala.
if (!ticketWithinVisibility(ticket.id, visibilityFor(req.user!, ticket.tenantId))) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
// Detail se hleda pres vsechny firmy uzivatele a strop se pocita za firmu
// ticketu - stejne jako u akci, viz `visibleTicketOrDeny`.
const ticket = visibleTicketOrDeny(req, res);
if (!ticket) return;
return res.json(ticket);
});
/** Firmy, na ktere ma uzivatel dosah pri zapisu. */
function writableTenants(req: Request): string[] {
return accessFor(req.user!).tenants.map((tenant) => tenant.id);
return accessOf(req).tenants.map((tenant) => tenant.id);
}
const assignSchema = z.object({
/** null = vratit do fronty. */
assigneeId: z.string().min(1).nullable(),
});
dashboardRouter.post('/tickets/:id/assign', (req, res) => {
const parsed = assignSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: 'Chybí ID řešitele, nebo poslete null pro vrácení do fronty.',
});
}
// Prehazovat praci mezi lidmi smi jen admin. Agent si smi vzit ticket na sebe.
const access = accessFor(req.user!);
if (!access.canAssignOthers && parsed.data.assigneeId !== access.personId) {
return res.status(403).json({
error: 'forbidden',
message: 'Přiřazovat ostatním může jen správce firmy. Ticket si můžete vzít na sebe.',
});
}
const ticket = assignTicket(req.params.id, parsed.data.assigneeId, writableTenants(req));
if (!ticket) {
return res.status(404).json({
error: 'not_found',
message: 'Ticket nebo řešitel neexistuje, nebo je řešitel z jiné firmy.',
});
}
return res.json(ticket);
});
const statusSchema = z.object({
/*
* Stav je volny retezec, ne ciselnik. Tickety chodi z cizich aplikaci, ktere
* maji svoje stavy, a nutit je do nasi ctverice znamenalo, ze uzivatel videl
* nesmysl. Omezena je jen delka, aby se do pole nedal cely dokument.
*/
status: z.string().trim().min(1).max(60),
/**
* Je vyrizeny? Kdyz se neposle, priznak zustava - zmena textu stavu sama
* o sobe neznamena, ze je hotovo.
*/
closed: z.boolean().optional(),
});
dashboardRouter.post('/tickets/:id/status', (req, res) => {
const parsed = statusSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'validation_error', message: 'Neplatný stav ticketu.' });
}
const ticket = updateTicketStatus(
req.params.id,
parsed.data.status,
writableTenants(req),
parsed.data.closed,
);
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
return res.json(ticket);
});
const commentSchema = z.object({
text: z.string().trim().min(2, 'Komentář musí mít alespoň 2 znaky.'),
});
dashboardRouter.post('/tickets/:id/comment', (req, res) => {
const parsed = commentSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný komentář.',
});
}
const author = req.user?.name ?? 'Portál';
const ticket = addComment(req.params.id, author, parsed.data.text, writableTenants(req));
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
return res.json(ticket);
});
// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo.
dashboardRouter.use('/stream', streamRouter);
@@ -1035,9 +853,10 @@ dashboardRouter.use('/helpdesk', helpdeskRouter);
* neni vubec, ne se stavem "nemate pravo".
*/
dashboardRouter.get('/services', async (req, res) => {
const access = accessFor(req.user!);
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const tenantId = requested ?? access.defaultTenantId;
// Cizi firma je 404. Bez firmy se vrati jen katalog bez firemnich nabidek.
const resolved = optionalTenantOrDeny(req, res);
if (!resolved) return;
const { tenantId } = resolved;
const visible = new Set(visibleServices(req.user!, tenantId).map((service) => service.id));
const tenantIds = tenantId ? [tenantId] : [];
@@ -1368,14 +1187,33 @@ dashboardRouter.get('/automations/:id', (req, res) => {
return res.json(automation);
});
/**
* Smi volajici menit automatizace teto firmy? Pri odepreni odpovi.
*
* Driv stacilo clenstvi: kdokoliv z firmy mohl prepsat strom, ktery posila
* data ven. Pravo je `automation.edit` a pta se za firmu automatizace.
*/
function mayEditAutomations(req: Request, res: Response, tenantId: string): boolean {
if (hasPermission(req.user!, 'automation.edit', tenantId)) return true;
console.warn(`[automations] ${req.user!.email}: chybi pravo automation.edit ve firme ${tenantId}`);
res.status(403).json({ error: 'forbidden', message: 'Automatizace upravuje ten, kdo na to má právo.' });
return false;
}
/** Automatizace v dosahu uzivatele, nebo 404. Kdo ji smi menit, se pta zvlast. */
function editableAutomationOrDeny(req: Request, res: Response) {
const automation = getAutomation(req.params.id, writableTenants(req));
if (!automation) {
res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
return null;
}
if (!mayEditAutomations(req, res, automation.tenantId)) return null;
return automation;
}
dashboardRouter.post('/automations', (req, res) => {
const parsed = createSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
// Zakladat se musi do konkretni firmy, pohled "vse" na to nestaci.
const scope = scopeOrDeny(req, res);
@@ -1386,6 +1224,7 @@ dashboardRouter.post('/automations', (req, res) => {
message: 'Vyberte firmu, do které se má automatizace založit.',
});
}
if (!mayEditAutomations(req, res, scope.tenantId)) return;
const automation = createAutomation(parsed.data.name, scope.tenantId);
return res.status(201).json(automation);
@@ -1393,32 +1232,30 @@ dashboardRouter.post('/automations', (req, res) => {
dashboardRouter.put('/automations/:id', async (req, res) => {
const parsed = updateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
});
}
if (!parsed.success) return validationError(res, parsed.error);
const existing = editableAutomationOrDeny(req, res);
if (!existing) return;
const flow = parsed.data.flow ? normalizeTriggerFields(parsed.data.flow) : undefined;
let connectorIssues: string[] = [];
if (flow) {
const { problems, issues } = await validateFlowReferences(flow, writableTenants(req));
// Konektory se hledaji jen ve firme automatizace, cizi je rozbity strom.
const { problems, issues } = await validateFlowReferences(flow, [existing.tenantId]);
if (problems.length > 0) {
console.warn(`[automations] ${req.params.id}: neplatny strom - ${problems.join(' ')}`);
return res.status(400).json({
error: 'validation_error',
message: problems[0],
issues: problems.map((message) => ({ path: 'flow', message })),
issues: problems.map((message) => ({ field: 'flow', message })),
});
}
connectorIssues = issues;
}
const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, writableTenants(req));
const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, [existing.tenantId]);
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
}
@@ -1429,7 +1266,10 @@ dashboardRouter.put('/automations/:id', async (req, res) => {
/** Nova adresa webhooku. Stara okamzite prestane fungovat - zamer, ne chyba. */
dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => {
const updated = regenerateWebhookToken(req.params.id, writableTenants(req));
const existing = editableAutomationOrDeny(req, res);
if (!existing) return;
const updated = regenerateWebhookToken(req.params.id, [existing.tenantId]);
if (!updated) {
return res.status(404).json({
error: 'not_found',
@@ -1440,7 +1280,10 @@ dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => {
});
dashboardRouter.delete('/automations/:id', (req, res) => {
if (!deleteAutomation(req.params.id, writableTenants(req))) {
const existing = editableAutomationOrDeny(req, res);
if (!existing) return;
if (!deleteAutomation(req.params.id, [existing.tenantId])) {
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
}
return res.status(204).end();
+9 -19
View File
@@ -23,16 +23,19 @@
* klienta.
*/
import { Router, type Request, type Response } from 'express';
import { type Request, type Response } from 'express';
import { z } from 'zod';
import { actingTenant, seesWholeTenant } from '../data/access.js';
import { seesWholeTenant } from '../data/access.js';
import { recordAudit } from '../data/audit.js';
import { hasPermission } from '../data/permissions.js';
import { findPersonByEmail } from '../data/people.js';
import { findTenant } from '../data/tenants.js';
import { addComment, createTicket, getTicket, listTickets } from '../data/ticketStore.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { requestTenant } from '../middleware/tenant.js';
import { validationError } from '../middleware/validation.js';
export const helpdeskRouter = Router();
export const helpdeskRouter = safeRouter();
/**
* Firma, za kterou clovek prave jedna, plus kontrola prava.
@@ -47,10 +50,7 @@ function asking(req: Request, res: Response, permission: string): string | null
* bez ni neni ceho se ptat - a kdyz clovek patri do vic firem, rozhoduje
* ta, kterou ma prepnutou.
*/
const tenantId = actingTenant(
req.user!,
typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
);
const tenantId = requestTenant(req);
if (!tenantId) {
res.status(403).json({
@@ -150,12 +150,7 @@ helpdeskRouter.post('/', (req, res) => {
if (!provider) return;
const parsed = createSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Požadavek se nepodařilo poslat.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
const tenant = findTenant(tenantId);
const person = findPersonByEmail(req.user!.email);
@@ -227,12 +222,7 @@ helpdeskRouter.post('/:id/comment', (req, res) => {
if (!tenantId) return;
const parsed = commentSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Komentář se nepodařilo uložit.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
const person = findPersonByEmail(req.user!.email);
const author = person?.name ?? req.user!.email;
+51 -20
View File
@@ -11,7 +11,7 @@
* ochrana a odkaz se snadno dostane, kam nema.
*/
import { Router } from 'express';
import type { Request, Response } from 'express';
import { z } from 'zod';
import { recordAudit } from '../data/audit.js';
import { publicBaseUrl } from '../config.js';
@@ -24,26 +24,36 @@ import {
newInvite,
refreshInvites,
} from '../data/invites.js';
import { rolesFor, type Role } from '../data/permissions.js';
import { hasPermission, rolesFor, type Role } from '../data/permissions.js';
import { personStore, refreshPeople, listPeople } from '../data/people.js';
import { findTenant } from '../data/tenants.js';
import bcrypt from 'bcryptjs';
import {
findUserByEmail,
hashPassword,
listAllUsers,
refreshUsers,
userStore,
verifyPassword,
} from '../data/users.js';
import { nowIso } from '../data/store/index.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { requireAuth } from '../middleware/auth.js';
import { currentTenant } from './crud.js';
import { rateLimit } from '../middleware/rateLimit.js';
import { tenantOrDeny } from '../middleware/tenant.js';
import { validationError } from '../middleware/validation.js';
/** Verejne cesty. Bez prihlaseni, autorizuje kod v adrese. */
export const publicInviteRouter = Router();
export const publicInviteRouter = safeRouter();
/** Cesty za prihlasenim: sprava pozvanek. */
export const inviteRouter = Router();
export const inviteRouter = safeRouter();
/**
* Prijeti pozvanky s existujicim uctem overuje heslo, takze je to druhe misto
* vedle prihlaseni, kde jde heslo zkouset. Prisny limit: pet pokusu za ctvrt
* hodiny z jedne adresy. Kdo pozvanku prijima, potrebuje jeden.
*/
const acceptLimiter = rateLimit({ name: 'invite-accept', windowMs: 15 * 60_000, max: 5 });
// ------------------------------------------------------------------ verejne
@@ -88,7 +98,7 @@ const acceptSchema = z.object({
*
* Heslo se **nikdy neposila** - clovek si ho nastavi sam prave tady.
*/
publicInviteRouter.post('/:code/accept', (req, res) => {
publicInviteRouter.post('/:code/accept', acceptLimiter, (req, res) => {
const invite = findByCode(req.params.code);
if (!invite) {
return res.status(404).json({ error: 'not_found', message: 'Pozvánka neexistuje.' });
@@ -98,12 +108,7 @@ publicInviteRouter.post('/:code/accept', (req, res) => {
if (problem) return res.status(409).json({ error: 'invalid_invite', message: problem });
const parsed = acceptSchema.safeParse(req.body ?? {});
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
const email = parsed.data.email.toLowerCase();
@@ -131,7 +136,7 @@ publicInviteRouter.post('/:code/accept', (req, res) => {
*/
const stored = listAllUsers().find((item) => item.id === existing.id);
const passwordOk =
stored !== undefined && (await bcrypt.compare(parsed.data.password, stored.passwordHash));
stored !== undefined && (await verifyPassword(parsed.data.password, stored.passwordHash));
if (!passwordOk) {
return res.status(401).json({
@@ -163,7 +168,8 @@ publicInviteRouter.post('/:code/accept', (req, res) => {
tenantId: null,
email,
name: parsed.data.name,
passwordHash: hashPassword(parsed.data.password),
passwordHash: await hashPassword(parsed.data.password),
// Pozvanka nikdy nedela spravce platformy, at ji vytvoril kdokoliv.
platformAdmin: false,
enabled: true,
memberships: [{ tenantId: invite.tenantId, roleIds: invite.roleIds }],
@@ -229,8 +235,25 @@ inviteRouter.use(requireAuth);
/** Kdo smi zvat. Zvat lidi do firmy je totez co spravovat uzivatele. */
const PERMISSION = 'user.manage';
/**
* Firma plus pravo zvat do ni. Pri odepreni odpovi a vrati null.
*
* Driv se pravo nekontrolovalo vubec: kdokoliv z firmy mohl vytvorit pozvanku
* s roli spravce a pozvat sam sebe podruhe.
*/
function managedTenant(req: Request, res: Response): string | null {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return null;
if (!hasPermission(req.user!, PERMISSION, tenantId)) {
console.warn(`[pozvanky] ${req.user!.email}: chybi pravo ${PERMISSION} ve firme ${tenantId}`);
res.status(403).json({ error: 'forbidden', message: 'Pozvánky spravuje ten, kdo spravuje uživatele.' });
return null;
}
return tenantId;
}
inviteRouter.get('/', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = managedTenant(req, res);
if (!tenantId) return;
const base = `${publicBaseUrl()}/pozvanka/`;
@@ -257,14 +280,22 @@ const createSchema = z.object({
});
inviteRouter.post('/', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = managedTenant(req, res);
if (!tenantId) return;
const parsed = createSchema.safeParse(req.body ?? {});
if (!parsed.success) {
if (!parsed.success) return validationError(res, parsed.error);
// Jen role teto firmy nebo systemove. Cizi role by se stejne nepouzila,
// ale pozvanka s ni by vypadala, ze nekomu neco dava.
const known = rolesFor(tenantId);
const unknown = parsed.data.roleIds.filter(
(ref) => !known.some((role) => role.id === ref || role.key === ref),
);
if (unknown.length > 0) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
message: `Role ${unknown.join(', ')} v této firmě neexistuje.`,
});
}
@@ -304,7 +335,7 @@ inviteRouter.post('/', (req, res) => {
/** Zruseni pozvanky. Odkaz okamzite prestane platit. */
inviteRouter.delete('/:id', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = managedTenant(req, res);
if (!tenantId) return;
return void inviteStore
+21 -30
View File
@@ -7,12 +7,13 @@
* zakladat tickety (viz documentation/09-navrh-rozsireni.md, bod 9).
*/
import { Router } from 'express';
import { z } from 'zod';
import { accessFor } from '../data/access.js';
import { defaultConnectorFor, getConnector, toPublicConnector } from '../data/connectorStore.js';
import { findService } from '../data/services.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { requirePlatformAdmin } from '../middleware/auth.js';
import { optionalTenantOrDeny, tenantOrDeny } from '../middleware/tenant.js';
import { validationError } from '../middleware/validation.js';
import { operationIdOf, serviceIdOf } from '../scripts/manifest.js';
import {
ensureLoaded,
@@ -26,7 +27,7 @@ import {
} from '../scripts/registry.js';
import { runScript } from '../scripts/runner.js';
export const scriptsRouter = Router();
export const scriptsRouter = safeRouter();
/** Manifest plus to, co si klient nema dopocitavat sam. */
async function scriptSummaries() {
@@ -43,11 +44,12 @@ async function scriptSummaries() {
}
scriptsRouter.get('/', async (req, res) => {
// Cizi firma je 404. Bez firmy jen seznam skriptu bez konektoru.
const resolved = optionalTenantOrDeny(req, res);
if (!resolved) return;
const { tenantId } = resolved;
const [items, problems] = await Promise.all([scriptSummaries(), scriptProblems()]);
const access = accessFor(req.user!);
const tenantId =
(typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ??
access.defaultTenantId;
// Ke kazde sluzbe vychozi konektor firmy, aby portal poznal, jestli je cim volat.
const pairs = await Promise.all(
@@ -63,8 +65,11 @@ scriptsRouter.get('/', async (req, res) => {
problems,
connectors,
tenantId: tenantId ?? null,
/** Kam se soubory ukladaji. Kdo ma na server pristup, upravi je i rucne. */
directory: scriptsDir(),
/**
* Kam se soubory ukladaji. Kdo ma na server pristup, upravi je i rucne.
* Cesta na disku je nase provozni informace, zakaznik ji nevidi.
*/
directory: req.user!.platformAdmin ? scriptsDir() : null,
});
});
@@ -88,10 +93,9 @@ scriptsRouter.get('/:id', async (req, res) => {
// Manifest muze chybet, kdyz je soubor rozbity. Kod se vrati vzdy, aby slo opravit.
const problems = await scriptProblems();
const serviceId = serviceIdOf(id);
const access = accessFor(req.user!);
const tenantId =
(typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ??
access.defaultTenantId;
const resolved = optionalTenantOrDeny(req, res);
if (!resolved) return;
const { tenantId } = resolved;
const connector = tenantId ? await defaultConnectorFor(tenantId, serviceId) : undefined;
return res.json({
@@ -121,12 +125,7 @@ scriptsRouter.put('/:id', requirePlatformAdmin, async (req, res) => {
}
const parsed = saveSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
const result = await saveSource(id, parsed.data.code);
if (!result.ok) {
@@ -162,19 +161,11 @@ scriptsRouter.post('/:id/test', requirePlatformAdmin, async (req, res) => {
const parsed = testSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: 'Vstupy musí být objekt s hodnotami parametrů.',
});
return validationError(res, parsed.error, 'Vstupy musí být objekt s hodnotami parametrů.');
}
const access = accessFor(req.user!);
const tenantId =
(typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ??
access.defaultTenantId;
if (!tenantId) {
return res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
}
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const serviceId = serviceIdOf(id);
const service = findService(serviceId);
+176 -42
View File
@@ -10,7 +10,7 @@
* uzivatel ulozil roli a prava by se zmenila az po restartu.
*/
import { Router } from 'express';
import type { Request } from 'express';
import { z } from 'zod';
import { recordAudit } from '../data/audit.js';
import { bootstrapDataRefresh } from '../data/refresh.js';
@@ -54,18 +54,24 @@ import {
validateAction,
type TicketAction,
} from '../data/ticketActions.js';
import { hashPassword, listAllUsers, userStore, type StoredUser } from '../data/users.js';
import { hashPassword, listAllUsers, userStore, usersOfTenant, type StoredUser } from '../data/users.js';
import { hasPermission } from '../data/permissions.js';
import { publish } from '../events/bus.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { requireAuth } from '../middleware/auth.js';
import { crudRouter, currentTenant, readScope } from './crud.js';
import { requestTenant, tenantOrDeny } from '../middleware/tenant.js';
import { validationError } from '../middleware/validation.js';
import { aresRouter } from './ares.js';
import { crudRouter, readScope } from './crud.js';
export const settingsRouter = Router();
export const settingsRouter = safeRouter();
settingsRouter.use(requireAuth);
/** Zapis do jakekoliv entity muze zmenit prava nebo nabidky, proto obnova. */
settingsRouter.use((req, res, next) => {
if (req.method === 'GET') return next();
res.on('finish', () => {
if (res.statusCode < 400) void bootstrapDataRefresh();
if (res.statusCode < 400) void bootstrapDataRefresh(req.path);
});
return next();
});
@@ -88,16 +94,34 @@ settingsRouter.get('/catalog', (req, res) => {
// -------------------------------------------------------------------- firmy
/** Udaje z ARES jsou nepovinne, rucne zalozena firma je mit nemusi. */
const tenantRegistryFields = {
ico: z.string().trim().regex(/^\d{8}$/, 'IČ má 8 číslic.').nullable().optional(),
dic: z.string().trim().max(20).nullable().optional(),
address: z.string().trim().max(300).nullable().optional(),
legalForm: z.string().trim().max(120).nullable().optional(),
};
const tenantCreate = z.object({
name: z.string().trim().min(2, 'Název firmy je moc krátký.').max(80),
note: z.string().trim().max(500).optional(),
...tenantRegistryFields,
});
/** Dve firmy se stejnym IC jsou jedna firma zalozena dvakrat. */
function duplicateIco(tenant: Tenant, all: Tenant[]): string[] {
if (!tenant.ico) return [];
return all.some((other) => other.ico === tenant.ico)
? [`Firma s IČ ${tenant.ico} už existuje.`]
: [];
}
settingsRouter.use(
'/tenants',
crudRouter<Tenant, z.infer<typeof tenantCreate>, Partial<Tenant>>({
store: tenantStore,
idPrefix: 'tnt',
event: 'tenant',
createSchema: tenantCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(80).optional(),
@@ -105,6 +129,7 @@ settingsRouter.use(
enabled: z.boolean().optional(),
/** Kdo teto firme resi helpdesk. null = nikdo, pozadavek nepujde poslat. */
helpdeskProviderId: z.string().trim().min(1).nullable().optional(),
...tenantRegistryFields,
}),
writePermission: 'tenant.manage',
platformOnly: true,
@@ -119,14 +144,23 @@ settingsRouter.use(
intakeToken: generateIntakeToken(),
// Dodavatele helpdesku doplni spravce az pri nastaveni vztahu.
helpdeskProviderId: null,
ico: input.ico ?? null,
dic: input.dic ?? null,
address: input.address ?? null,
legalForm: input.legalForm ?? null,
}),
validate: (tenant, all) =>
all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase())
validate: (tenant, all) => [
...(all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase())
? [`Firma ${tenant.name} už existuje.`]
: [],
: []),
...duplicateIco(tenant, all),
],
}),
);
// Zalozeni firmy z registru ARES vcetne lidi, kteri za ni jednaji.
settingsRouter.use('/ares', aresRouter);
// ---------------------------------------------------------------- uzivatele
const membershipSchema = z.object({
@@ -142,31 +176,102 @@ const userCreate = z.object({
memberships: z.array(membershipSchema).default([]),
});
const userUpdate = z.object({
name: z.string().trim().min(2).max(80).optional(),
email: z.string().trim().email().optional(),
platformAdmin: z.boolean().optional(),
memberships: z.array(membershipSchema).optional(),
enabled: z.boolean().optional(),
});
type UserInput = z.infer<typeof userCreate> | z.infer<typeof userUpdate>;
/** Heslo se z API nikdy nevraci, ani jako hash. */
function publicUser(user: StoredUser) {
const { passwordHash: _passwordHash, ...rest } = user;
return rest;
}
/** Je uzivatel clenem firmy? Spravce firmy vidi jen sve lidi. */
function memberOf(user: StoredUser, tenantId: string): boolean {
return user.memberships.some((membership) => membership.tenantId === tenantId);
}
/**
* Co smi se zaznamem uzivatele spravce firmy.
*
* Spravce platformy muze vsechno. Spravce firmy (pravo `user.manage`) smi
* zakladat a menit jen lidi sve firmy, a to jen v ni: clenstvi v jinych
* firmach se mu nechavaji, jak jsou, priznak spravce platformy nenastavi
* a na spravce platformy vubec nesahne. Bez toho by si kazdy spravce firmy
* mohl pridat clenstvi kamkoliv a udelat ze sebe spravce platformy.
*/
function prepareUserInput(
req: Request,
tenantId: string,
input: UserInput | undefined,
existing?: StoredUser,
): { ok: true; input: UserInput | undefined } | { ok: false; status: number; message: string } {
if (req.user!.platformAdmin) return { ok: true, input };
if (existing?.platformAdmin) {
return { ok: false, status: 403, message: 'Správce platformy upravuje jen správce platformy.' };
}
// Mazani: clovek z vic firem se nemaze, jen se mu vezme clenstvi tady.
if (input === undefined) {
const elsewhere = (existing?.memberships ?? []).some((m) => m.tenantId !== tenantId);
if (elsewhere) {
return {
ok: false,
status: 403,
message: 'Uživatel patří i do jiné firmy. Odeberte mu členství ve vaší, nemažte ho.',
};
}
return { ok: true, input };
}
if (input.platformAdmin !== undefined) {
return {
ok: false,
status: 403,
message: 'Příznak správce platformy nastavuje jen správce platformy.',
};
}
if (input.memberships !== undefined) {
if (input.memberships.some((membership) => membership.tenantId !== tenantId)) {
return {
ok: false,
status: 403,
message: 'Členství můžete nastavit jen ve firmě, ve které právě jste.',
};
}
if (!existing && input.memberships.length === 0) {
return { ok: false, status: 403, message: 'Nový uživatel musí mít členství ve vaší firmě.' };
}
// Clenstvi jinde zustavaji, ta spravce firmy nevidi a nesmi je smazat.
const others = (existing?.memberships ?? []).filter((m) => m.tenantId !== tenantId);
return { ok: true, input: { ...input, memberships: [...others, ...input.memberships] } };
}
return { ok: true, input };
}
settingsRouter.use(
'/users',
crudRouter<StoredUser, z.infer<typeof userCreate>, Partial<StoredUser>>({
crudRouter<StoredUser, z.infer<typeof userCreate>, z.infer<typeof userUpdate>>({
store: userStore,
idPrefix: 'usr',
event: 'user',
createSchema: userCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(80).optional(),
email: z.string().trim().email().optional(),
password: z.string().min(8).optional(),
platformAdmin: z.boolean().optional(),
memberships: z.array(membershipSchema).optional(),
enabled: z.boolean().optional(),
}),
updateSchema: userUpdate,
writePermission: 'user.manage',
build: (input) => ({
scopeBy: { belongsTo: memberOf, prepare: prepareUserInput },
build: async (input) => ({
email: input.email.toLowerCase(),
name: input.name,
passwordHash: hashPassword(input.password),
passwordHash: await hashPassword(input.password),
platformAdmin: input.platformAdmin ?? false,
memberships: input.memberships,
enabled: true,
@@ -182,6 +287,15 @@ settingsRouter.use(
for (const membership of user.memberships) {
if (!listTenants().some((tenant) => tenant.id === membership.tenantId)) {
problems.push(`Firma ${membership.tenantId} neexistuje.`);
continue;
}
// Role musi byt te firmy nebo systemova. Cizi role by se stejne
// nepouzila (viz permissionsOf), ale ulozit ji je matouci.
const known = rolesFor(membership.tenantId);
for (const ref of membership.roleIds) {
if (!known.some((role) => role.id === ref || role.key === ref)) {
problems.push(`Role ${ref} v této firmě neexistuje.`);
}
}
}
return problems;
@@ -196,22 +310,29 @@ settingsRouter.use(
*/
settingsRouter.patch('/users/:id/password', async (req, res) => {
const parsed = z.object({ password: z.string().min(8) }).safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'validation_error', message: 'Heslo musí mít aspoň 8 znaků.' });
}
if (!parsed.success) return validationError(res, parsed.error, 'Heslo musí mít aspoň 8 znaků.');
const target = await userStore.get(req.params.id, readScope(req));
if (!target) return res.status(404).json({ error: 'not_found', message: 'Uživatel neexistuje.' });
// Svoje heslo si smi zmenit kazdy, cizi jen kdo spravuje uzivatele.
/*
* Svoje heslo si smi zmenit kazdy. Cizi spravce platformy, nebo spravce
* firmy svym lidem - ne ale spravci platformy, ten je mimo jeho dosah.
*/
const own = target.id === req.user!.id;
if (!own && !req.user!.platformAdmin) {
const tenantId = requestTenant(req);
const managesTarget =
tenantId !== null &&
!target.platformAdmin &&
memberOf(target, tenantId) &&
hasPermission(req.user!, 'user.manage', tenantId);
if (!own && !req.user!.platformAdmin && !managesTarget) {
return res.status(403).json({ error: 'forbidden', message: 'Cizí heslo měnit nemůžete.' });
}
await userStore.update(
req.params.id,
{ passwordHash: hashPassword(parsed.data.password) } as Partial<StoredUser>,
{ passwordHash: await hashPassword(parsed.data.password) } as Partial<StoredUser>,
readScope(req),
);
recordAudit({
@@ -224,12 +345,22 @@ settingsRouter.patch('/users/:id/password', async (req, res) => {
return res.status(204).end();
});
/** Uzivatele vcetne vypnutych. Seznam pro spravu, ne pro nabidky. */
/**
* Uzivatele vcetne vypnutych. Seznam pro spravu, ne pro nabidky.
*
* Spravce platformy vidi vsechny, spravce firmy jen lidi sve firmy.
*/
settingsRouter.get('/users-overview', (req, res) => {
if (!req.user!.platformAdmin) {
return res.status(403).json({ error: 'forbidden', message: 'Jen pro správce platformy.' });
if (req.user!.platformAdmin) {
return res.json({ items: listAllUsers().map(publicUser) });
}
return res.json({ items: listAllUsers().map(publicUser) });
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
if (!hasPermission(req.user!, 'user.manage', tenantId)) {
return res.status(403).json({ error: 'forbidden', message: 'Uživatele vidí ten, kdo je spravuje.' });
}
return res.json({ items: usersOfTenant(tenantId).map(publicUser) });
});
// ---------------------------------------------------------------------- role
@@ -245,6 +376,7 @@ settingsRouter.use(
crudRouter<Role, z.infer<typeof roleCreate>, Partial<Role>>({
store: roleStore,
idPrefix: 'role',
event: 'role',
createSchema: roleCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(60).optional(),
@@ -270,7 +402,7 @@ settingsRouter.use(
/** Role dostupne firme vcetne systemovych. Pro nabidku u clenstvi. */
settingsRouter.get('/roles-available', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({ items: rolesFor(tenantId) });
});
@@ -291,6 +423,7 @@ settingsRouter.use(
crudRouter<Person, z.infer<typeof personCreate>, Partial<Person>>({
store: personStore,
idPrefix: 'ppl',
event: 'person',
createSchema: personCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(80).optional(),
@@ -318,7 +451,7 @@ settingsRouter.use(
/** Vcetne vypnutych. Pro spravu tymu. */
settingsRouter.get('/people-overview', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({ items: listAllPeople([tenantId]) });
});
@@ -347,6 +480,7 @@ settingsRouter.use(
crudRouter({
store: groupStore,
idPrefix: 'grp',
event: 'group',
createSchema: groupCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(60).optional(),
@@ -377,7 +511,7 @@ const featuresUpdate = z.object({
/** Nastaveni firmy. GET vraci i vychozi, kdyz firma vlastni jeste nema. */
settingsRouter.get('/features', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({ tenantId, features: featuresOf(tenantId) });
});
@@ -386,16 +520,11 @@ settingsRouter.put('/features', async (req, res) => {
if (!req.user!.platformAdmin) {
return res.status(403).json({ error: 'forbidden', message: 'Záložky nastavuje správce platformy.' });
}
const tenantId = currentTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const parsed = featuresUpdate.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
const known = new Set(moduleCatalog.map((module) => module.key));
const unknown = (parsed.data.modules ?? []).filter((key) => !known.has(key as never));
@@ -441,6 +570,8 @@ settingsRouter.put('/features', async (req, res) => {
detail: { modules },
});
// Zalozky a limity meni navigaci portalu; ten si je drzi s pravy v pameti.
publish('feature.updated', 'Záložky firmy upraveny', { id: tenantId, feature: saved }, tenantId);
return res.json({ tenantId, features: saved });
});
@@ -488,6 +619,7 @@ settingsRouter.use(
crudRouter<TicketType, z.infer<typeof typeCreate>, Partial<TicketType>>({
store: ticketTypeStore,
idPrefix: 'tt',
event: 'ticketType',
createSchema: typeCreate,
// Transformace dogeneruje ID poli. Bez ni by pole prislo bez `id`
// a podminky v akcich by se nemely na co odkazat.
@@ -589,6 +721,7 @@ settingsRouter.use(
crudRouter<TicketAction, z.infer<typeof actionCreate>, Partial<TicketAction>>({
store: actionStore,
idPrefix: 'tka',
event: 'action',
createSchema: actionCreate,
updateSchema: z.object({
label: z.string().trim().min(2).max(60).optional(),
@@ -622,7 +755,7 @@ settingsRouter.use(
/** Akce firmy vcetne vypnutych. Pro spravu. */
settingsRouter.get('/actions-overview', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({ items: listActions([tenantId]) });
});
@@ -635,7 +768,7 @@ settingsRouter.get('/actions-overview', (req, res) => {
* server, aby si klient nedelal druhy seznam, ktery se casem rozejde.
*/
settingsRouter.get('/actions/:id/scope', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const action = listActions([tenantId]).find((item) => item.id === req.params.id);
@@ -732,6 +865,7 @@ settingsRouter.use(
crudRouter<CustomWidget, z.infer<typeof widgetCreate>, Partial<CustomWidget>>({
store: customWidgetStore,
idPrefix: 'cw',
event: 'widget',
createSchema: widgetCreate,
updateSchema: z.object({
name: z.string().trim().min(2).max(60).optional(),
@@ -758,7 +892,7 @@ settingsRouter.use(
/** Widgety firmy plus osobni prihlaseneho. */
settingsRouter.get('/widgets-overview', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({ items: listCustomWidgets([tenantId], req.user!.id) });
});
+19 -2
View File
@@ -1,6 +1,7 @@
import { Router } from 'express';
import { listenerCount, recentEvents, subscribe, type DashboardEvent } from '../events/bus.js';
import { requireAuth } from '../middleware/auth.js';
import { accessOf } from '../middleware/tenant.js';
export const streamRouter = Router();
@@ -26,10 +27,26 @@ streamRouter.get('/', requireAuth, (req, res) => {
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const email = req.user?.email ?? 'neznamy';
const user = req.user!;
const email = user.email;
console.info(`[stream] pripojen ${email} (celkem posluchacu: ${listenerCount() + 1})`);
/*
* Co tenhle clovek smi videt. Firmy se berou jednou pri pripojeni: kdo
* clenstvi ztrati, dostava udalosti do dalsiho pripojeni - token mu stejne
* vyprsi driv, nez by to vadilo.
*/
const allowed = new Set(accessOf(req).tenants.map((tenant) => tenant.id));
const visible = (event: DashboardEvent): boolean => {
// Upozorneni patri jednomu cloveku, ne cele firme.
const forUser = event.payload?.userId;
if (typeof forUser === 'string' && forUser !== user.id) return false;
if (event.tenantId === null || user.platformAdmin) return true;
return allowed.has(event.tenantId);
};
const send = (event: DashboardEvent) => {
if (!visible(event)) return;
res.write(`event: ${event.type}\n`);
res.write(`id: ${event.id}\n`);
res.write(`data: ${JSON.stringify(event)}\n\n`);
@@ -39,7 +56,7 @@ streamRouter.get('/', requireAuth, (req, res) => {
res.write(`event: connected\ndata: ${JSON.stringify({ at: new Date().toISOString() })}\n\n`);
// Kratka historie, aby klient nepresel o to, co se stalo tesne pred pripojenim.
for (const event of recentEvents(5).reverse()) send(event);
for (const event of recentEvents(5, visible).reverse()) send(event);
const unsubscribe = subscribe(send);
+19 -32
View File
@@ -10,7 +10,7 @@
* co aplikace ve firme umi - ne bezna prace s tickety.
*/
import { Router } from 'express';
import type { Request, Response } from 'express';
import { z } from 'zod';
import { recordAudit } from '../data/audit.js';
import {
@@ -23,11 +23,13 @@ import {
SCRIPT_TEMPLATE,
} from '../data/tenantScripts.js';
import { hasPermission } from '../data/permissions.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { requireAuth } from '../middleware/auth.js';
import { tenantOrDeny } from '../middleware/tenant.js';
import { validationError } from '../middleware/validation.js';
import { runSandbox } from '../runtime/sandbox.js';
import { currentTenant, requestTenant } from './crud.js';
export const tenantScriptRouter = Router();
export const tenantScriptRouter = safeRouter();
tenantScriptRouter.use(requireAuth);
@@ -35,9 +37,9 @@ tenantScriptRouter.use(requireAuth);
const PERMISSION = 'action.manage';
/** Vrati false a odpovi, kdyz volajici na zmenu nema pravo. */
function mayWrite(req: Parameters<typeof requireAuth>[0], res: Parameters<typeof requireAuth>[1]): boolean {
function mayWrite(req: Request, res: Response, tenantId: string): boolean {
// Pravo za firmu, kterou ma clovek prepnutou. Skript patri jedne firme.
if (hasPermission(req.user!, PERMISSION, requestTenant(req))) return true;
if (hasPermission(req.user!, PERMISSION, tenantId)) return true;
console.warn(`[skripty] ${req.user!.email}: chybi pravo ${PERMISSION}`);
res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' });
return false;
@@ -54,7 +56,7 @@ const bodySchema = z.object({
});
tenantScriptRouter.get('/', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({
@@ -66,17 +68,12 @@ tenantScriptRouter.get('/', (req, res) => {
});
tenantScriptRouter.post('/', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
if (!mayWrite(req, res)) return;
if (!mayWrite(req, res, tenantId)) return;
const parsed = bodySchema.safeParse(req.body ?? {});
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
const script = newTenantScript({ tenantId, ...parsed.data });
@@ -103,21 +100,16 @@ tenantScriptRouter.post('/', (req, res) => {
});
tenantScriptRouter.put('/:id', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
if (!mayWrite(req, res)) return;
if (!mayWrite(req, res, tenantId)) return;
if (!findTenantScript(req.params.id, [tenantId])) {
return res.status(404).json({ error: 'not_found', message: 'Skript neexistuje.' });
}
const parsed = bodySchema.safeParse(req.body ?? {});
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
return void tenantScriptStore
.update(req.params.id, parsed.data, { tenantIds: [tenantId], includeGlobal: false })
@@ -145,9 +137,9 @@ tenantScriptRouter.put('/:id', (req, res) => {
});
tenantScriptRouter.delete('/:id', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
if (!mayWrite(req, res)) return;
if (!mayWrite(req, res, tenantId)) return;
return void tenantScriptStore
.remove(req.params.id, { tenantIds: [tenantId], includeGlobal: false })
@@ -187,17 +179,12 @@ const testSchema = z.object({
* az z padleho behu o hodinu pozdeji.
*/
tenantScriptRouter.post('/test', (req, res) => {
const tenantId = currentTenant(req, res);
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
if (!mayWrite(req, res)) return;
if (!mayWrite(req, res, tenantId)) return;
const parsed = testSchema.safeParse(req.body ?? {});
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.',
});
}
if (!parsed.success) return validationError(res, parsed.error);
const result = runSandbox(parsed.data.code, parsed.data.input ?? {});
+191 -48
View File
@@ -10,22 +10,31 @@
* zmackl" by znamenala dve casove osy a hledani ve dvou mistech.
*/
import { Router } from 'express';
import type { Request, Response } from 'express';
import { z } from 'zod';
import { isDenied, resolveScope } from '../data/access.js';
import { visibilityFor } from '../data/access.js';
import { recordAudit } from '../data/audit.js';
import { defaultConnectorFor, getConnector } from '../data/connectorStore.js';
import { findGroup } from '../data/people.js';
import { findGroup, groupsOfPerson } from '../data/people.js';
import { hasPermission } from '../data/permissions.js';
import { renderTemplate } from '../data/templates.js';
import {
addComment,
appendTrace,
assignTicket,
assignTicketGroup,
claimTicket,
getTicket,
setTicketTags,
setTicketType,
ticketWithinVisibility,
updateTicketStatus,
type Ticket,
type TicketDetail,
} from '../data/ticketStore.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { accessOf } from '../middleware/tenant.js';
import { validationError } from '../middleware/validation.js';
import {
actionPermission,
actionsForTicket,
@@ -39,7 +48,25 @@ import { runScript } from '../scripts/runner.js';
import { scriptIdFor } from '../scripts/lookup.js';
import { runFlow } from '../runtime/executor.js';
export const ticketActionsRouter = Router();
export const ticketActionsRouter = safeRouter();
/**
* Ticket, na ktery volajici vidi. Cizi nebo neviditelny je 404, ne 403.
*
* Hleda se pres **vsechny firmy uzivatele**, ne jen prepnutou - odkaz
* z pohledu "vse" vede i na ticket jine firmy. Strop viditelnosti se pak
* pocita za firmu ticketu. Stejne pravidlo jako u detailu, proto jedno misto:
* kdyby si to kazda akce resila sama, jednou nekde strop chybi.
*/
export function visibleTicketOrDeny(req: Request, res: Response): TicketDetail | null {
const reachable = accessOf(req).tenants.map((tenant) => tenant.id);
const ticket = getTicket(req.params.id, reachable);
if (!ticket || !ticketWithinVisibility(ticket.id, visibilityFor(req.user!, ticket.tenantId))) {
res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
return null;
}
return ticket;
}
/**
* Hodnoty, proti kterym se vyhodnocuji podminky a sablony.
@@ -103,16 +130,8 @@ function publicAction(action: TicketAction) {
/** Akce, ktere na ticket sedi. Uz vyfiltrovane podle typu, tagu, podminek a prav. */
ticketActionsRouter.get('/:id/actions', 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,
});
if (isDenied(scope)) {
return res.status(scope.status).json({ error: scope.error, message: scope.message });
}
const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility);
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
const ticket = visibleTicketOrDeny(req, res);
if (!ticket) return;
const actions = actionsForTicket(
[ticket.tenantId],
@@ -136,15 +155,8 @@ const runSchema = z.object({
* otevrenou starou stranku, nebo tlacitko vubec nezobrazit a poslat request sam.
*/
ticketActionsRouter.post('/:id/actions/:actionId', async (req, res) => {
const scope = resolveScope(req.user!, {
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
});
if (isDenied(scope)) {
return res.status(scope.status).json({ error: scope.error, message: scope.message });
}
const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility);
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
const ticket = visibleTicketOrDeny(req, res);
if (!ticket) return;
const action = findAction(req.params.actionId);
if (!action || action.tenantId !== ticket.tenantId || !action.enabled) {
@@ -173,9 +185,7 @@ ticketActionsRouter.post('/:id/actions/:actionId', async (req, res) => {
}
const parsed = runSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'validation_error', message: 'Neplatný formulář.' });
}
if (!parsed.success) return validationError(res, parsed.error, 'Neplatný formulář.');
for (const field of action.form) {
if (field.required && (parsed.data.form[field.id] ?? '').trim() === '') {
@@ -340,32 +350,63 @@ async function runAction(
// ------------------------------------------------- vestavene akce na ticketu
/**
* Odmitnuti z `apply`, kdyz duvod neni "ticket neexistuje". Ma vlastni tvar,
* aby se nepletl s ticketem - ten ma taky pole `status`.
*/
interface ActionRefusal {
refused: true;
status: number;
error: string;
message: string;
}
function refuse(status: number, error: string, message: string): ActionRefusal {
return { refused: true, status, error, message };
}
function isRefusal(value: unknown): value is ActionRefusal {
return typeof value === 'object' && value !== null && (value as ActionRefusal).refused === true;
}
/**
* Vestavene akce jsou ve stejnem rezimu jako vlastni: kazda ma svoje pravo
* a projde auditem. Diky tomu jde vestavenou akci nekomu vypnout bez zmeny kodu.
*
* Fabrika, ne tri skoro stejne handlery. Kazdy by jinak resil znovu firmu,
* pravo, 404, audit a udalost.
* Fabrika, ne sedm skoro stejnych handleru. Kazdy by jinak resil znovu firmu,
* strop viditelnosti, pravo, 404, audit a udalost - a prirazeni, stav
* a komentar to driv opravdu resily kazdy jinak a pravo nekontrolovaly vubec.
*/
function builtinAction<T>(options: {
path: string;
permission: string;
/** Pravo, nebo funkce, kdyz zalezi na vstupu (vzit si vs. prehodit). */
permission: string | ((input: T, req: Request) => string);
/** Hlaska pri odepreni. Bez ni obecna. */
deniedMessage?: string;
auditAction: string;
schema: z.ZodType<T, z.ZodTypeDef, unknown>;
apply: (ticket: Ticket, input: T, tenantIds: string[]) => Ticket | undefined;
/** Hlaska pri neplatnem vstupu. Bez ni ta ze schematu. */
invalidMessage?: string;
apply: (
ticket: TicketDetail,
input: T,
tenantIds: string[],
req: Request,
) => Ticket | undefined | ActionRefusal;
}): void {
ticketActionsRouter.post(`/:id/${options.path}`, (req, res) => {
const scope = resolveScope(req.user!, {
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
});
if (isDenied(scope)) {
return res.status(scope.status).json({ error: scope.error, message: scope.message });
}
const ticket = visibleTicketOrDeny(req, res);
if (!ticket) return;
const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility);
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
const parsed = options.schema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error, options.invalidMessage);
if (!hasPermission(req.user!, options.permission, ticket.tenantId)) {
// Pravo za firmu ticketu, ne za prave prepnutou.
const permission =
typeof options.permission === 'string'
? options.permission
: options.permission(parsed.data, req);
if (!hasPermission(req.user!, permission, ticket.tenantId)) {
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
@@ -375,18 +416,16 @@ function builtinAction<T>(options: {
target: ticket.id,
result: 'denied',
});
return res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' });
}
const parsed = options.schema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
return res.status(403).json({
error: 'forbidden',
message: options.deniedMessage ?? 'K této změně nemáte oprávnění.',
});
}
const updated = options.apply(ticket, parsed.data, scope.tenantIds);
const updated = options.apply(ticket, parsed.data, [ticket.tenantId], req);
if (isRefusal(updated)) {
return res.status(updated.status).json({ error: updated.error, message: updated.message });
}
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
}
@@ -447,3 +486,107 @@ builtinAction({
return assignTicketGroup(ticket.id, input.groupId, tenantIds);
},
});
/**
* Prirazeni resitele. `null` = vratit do fronty.
*
* Vzit si ticket na sebe je jine pravo nez prehodit ho nekomu jinemu
* (nebo vratit do fronty). Ktere se pta, rozhoduje vstup.
*/
builtinAction({
path: 'assign',
permission: (input: { assigneeId: string | null }, req) =>
input.assigneeId !== null && input.assigneeId === accessOf(req).personId
? 'ticket.assign.self'
: 'ticket.assign.others',
deniedMessage: 'Přiřazovat ostatním může jen ten, kdo na to má právo. Ticket si můžete vzít na sebe.',
auditAction: 'ticket.assign',
schema: z.object({
/** null = vratit do fronty. */
assigneeId: z.string().min(1).nullable(),
}),
invalidMessage: 'Chybí ID řešitele, nebo pošlete null pro vrácení do fronty.',
apply: (ticket, input, tenantIds) =>
assignTicket(ticket.id, input.assigneeId, tenantIds) ??
refuse(404, 'not_found', 'Ticket nebo řešitel neexistuje, nebo je řešitel z jiné firmy.'),
});
builtinAction({
path: 'status',
permission: 'ticket.status.change',
auditAction: 'ticket.status',
schema: z.object({
/*
* Stav je volny retezec, ne ciselnik. Tickety chodi z cizich aplikaci, ktere
* maji svoje stavy, a nutit je do nasi ctverice znamenalo, ze uzivatel videl
* nesmysl. Omezena je jen delka, aby se do pole nedal cely dokument.
*/
status: z.string().trim().min(1).max(60),
/**
* Je vyrizeny? Kdyz se neposle, priznak zustava - zmena textu stavu sama
* o sobe neznamena, ze je hotovo.
*/
closed: z.boolean().optional(),
}),
invalidMessage: 'Neplatný stav ticketu.',
apply: (ticket, input, tenantIds) =>
updateTicketStatus(ticket.id, input.status, tenantIds, input.closed),
});
builtinAction({
path: 'comment',
permission: 'ticket.comment',
auditAction: 'ticket.comment',
schema: z.object({
text: z.string().trim().min(2, 'Komentář musí mít alespoň 2 znaky.'),
}),
apply: (ticket, input, tenantIds, req) =>
addComment(ticket.id, req.user?.name ?? 'Portál', input.text, tenantIds),
});
/**
* Prevzeti ticketu.
*
* Clovek si vezme praci sam, misto aby cekal, az mu ji nekdo prideli. Tohle je
* bezny zpusob prace v tymu: ticket lezi ve fronte skupiny a kdo ma cas, si ho
* vezme.
*
* Smi to u ticketu **ze sve skupiny** nebo u takoveho, ktery nema nikoho.
* Vzit nekomu rozdelanou praci nejde - to uz je prehozeni a na to je pravo
* `ticket.assign.others`.
*/
builtinAction({
path: 'claim',
permission: 'ticket.assign.self',
auditAction: 'ticket.claim',
// Prevzeti nema telo. Cokoliv prijde, se ignoruje.
schema: z.unknown(),
apply: (ticket, _input, tenantIds, req) => {
const personId = accessOf(req).personId;
if (!personId) {
return refuse(400, 'not_a_person', 'Nejste vedený jako řešitel, takže si ticket nemůžete převzít.');
}
const current = ticket.assignee?.id ?? null;
if (current !== null && current !== personId) {
// Uz to nekdo resi. Prehodit praci nekomu jinemu je jine rozhodnuti.
// Pravo se pta za firmu ticketu, ne za tu prave prepnutou.
if (!hasPermission(req.user!, 'ticket.assign.others', ticket.tenantId)) {
return refuse(
409,
'already_assigned',
'Ticket už někdo řeší. Přehodit ho může jen ten, kdo na to má právo.',
);
}
const mine = groupsOfPerson(personId, tenantIds).map((group) => group.id);
const inMyGroup = ticket.assigneeGroupId !== null && mine.includes(ticket.assigneeGroupId);
if (!inMyGroup) return refuse(403, 'forbidden', 'Tenhle ticket není ve vaší skupině.');
}
return (
claimTicket(ticket.id, personId, tenantIds) ??
refuse(400, 'failed', 'Převzetí se nepodařilo.')
);
},
});
+1 -1
View File
@@ -222,7 +222,7 @@ webhookRouter.post('/:token', (req, res) => {
publish('webhook.received', `Webhook přijal data pro ${automation.id}`, {
automationId: automation.id,
fields: Object.keys(values),
});
}, automation.tenantId);
return void enqueue({
tenantId: automation.tenantId,
+49 -85
View File
@@ -12,9 +12,9 @@
* nejde poslat nic, co by server polozilo.
*/
import { Router } from 'express';
import { z } from 'zod';
import { isDenied, resolveScope, type ResolvedScope } from '../data/access.js';
import type { WidgetResult, WidgetValue } from '../shared/widgets.js';
import { type ResolvedScope } from '../data/access.js';
import {
findCustomWidget,
type CustomWidget,
@@ -29,27 +29,45 @@ import { runScript } from '../scripts/runner.js';
import { scriptIdFor } from '../scripts/lookup.js';
import {
channelLabels,
defaultStatuses,
getAgentStats,
getWorkload,
listTickets,
type AgentStatsRow,
type Ticket,
} from '../data/ticketStore.js';
import { findTicketType, listTicketTypes } from '../data/ticketTypes.js';
import { builtinSources } from '../data/widgets.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { scopeOrDeny } from '../middleware/tenant.js';
import { validationError } from '../middleware/validation.js';
export const widgetDataRouter = Router();
export const widgetDataRouter = safeRouter();
/**
* Tickety v dosahu, nactene **jednou za request**.
*
* Prehled ma az 24 dlazdic a skoro kazda filtruje tentyz seznam. Driv si ho
* kazda cetla znovu, coz bylo 24 pruchodu ulozistem kvuli jednomu prekresleni.
*/
function ticketSourceFor(scope: ResolvedScope): () => Ticket[] {
let loaded: Ticket[] | null = null;
return () => {
if (loaded === null) {
loaded = listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility });
}
return loaded;
};
}
/**
* Popisky stavu. `ticketStore` je neexportuje, a duplikovat je sem je mensi
* zlo nez rozsirovat verejne rozhrani uloziste kvuli jednomu widgetu.
*/
const statusLabels: Record<string, string> = {
new: 'Nový',
open: 'V řešení',
waiting: 'Čeká na klienta',
resolved: 'Vyřešeno',
};
// Stav je volny retezec a tickety vznikaji s hodnotami z `defaultStatuses`,
// takze nabidka ve widgetu je tataz - jiny seznam by nikdy nic nenapocital.
const statusLabels: Record<string, string> = Object.fromEntries(
defaultStatuses.map((status) => [status, status]),
);
const requestSchema = z.object({
widgetIds: z.array(z.string().min(1)).max(24),
@@ -62,55 +80,6 @@ const requestSchema = z.object({
force: z.boolean().default(false),
});
/**
* Jedna hodnota, nebo seskupeny vysledek. Klient podle `render` vi, co s tim.
*
* U cisel a radku chodi `href`: kam se proklikne ten, kdo chce videt, co je
* za tim. Sklada ho **server**, protoze on jediny zna filtr widgetu - klient
* by si musel odvozovat, co ktery zdroj znamena, a jednou by se to rozeslo.
*/
type WidgetValue =
| { kind: 'number'; value: number; href?: string }
| {
kind: 'groups';
rows: Array<{ key: string; label: string; value: number; href?: string }>;
}
| { kind: 'series'; points: Array<{ date: string; value: number }>; href?: string }
| {
kind: 'tickets';
items: Array<{ id: string; subject: string; status: string; assignee: string | null }>;
href?: string;
}
| {
kind: 'workload';
rows: Array<{ personId: string; name: string; open: number; overCapacity: boolean; href: string }>;
href?: string;
}
| { kind: 'agents'; rows: AgentStatsRow[] }
/** Vysledek z konektoru. `stale` rika, ze je to z cache, ne cerstve zavolane. */
| {
kind: 'external';
value: number | null;
text: string | null;
rows: Array<{ key: string; label: string; value: number }>;
at: string;
stale: boolean;
/**
* Kdy se data zase natáhnou. Klient podle toho pozna, jestli ma smysl
* cekat, nebo si vyzadat obnovu rucne.
*/
nextAt: string;
/** Jak dlouho se drzi, v sekundach. Nastavuje se u widgetu. */
ttlSec: number;
};
interface WidgetResult {
id: string;
ok: boolean;
value?: WidgetValue;
error?: string;
}
/** Od kdy se pocita. `all` znamena bez omezeni. */
function since(period: WidgetTicketFilter['period']): number | null {
const now = Date.now();
@@ -391,7 +360,11 @@ function ticketsHref(
return query ? `/dashboard/tickety?${query}` : '/dashboard/tickety';
}
function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue {
function computeSource(
source: WidgetSource,
scope: ResolvedScope,
allTickets: () => Ticket[],
): WidgetValue {
if (source.kind === 'agentStats') {
return {
kind: 'agents',
@@ -426,10 +399,7 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
throw new Error('Zdroj Konektor se počítá zvlášť.');
}
const tickets = listTickets({
tenantIds: scope.tenantIds,
visibility: scope.visibility,
}).filter((ticket) => matches(ticket, source.filter, scope.personId));
const tickets = allTickets().filter((ticket) => matches(ticket, source.filter, scope.personId));
if (source.kind === 'ticketCount') {
if (!source.groupBy) {
@@ -488,34 +458,32 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
}
/** Zdroj vlastniho widgetu. Vestavene maji svuj v `builtinSources`. */
function computeWidget(widget: CustomWidget, scope: ResolvedScope): WidgetValue {
return computeSource(widget.source, scope);
function computeWidget(
widget: CustomWidget,
scope: ResolvedScope,
allTickets: () => Ticket[],
): WidgetValue {
return computeSource(widget.source, scope, allTickets);
}
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,
});
if (isDenied(scope)) {
return res.status(scope.status).json({ error: scope.error, message: scope.message });
}
const scope = scopeOrDeny(req, res);
if (!scope) return;
const parsed = requestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: 'Pošlete seznam ID widgetů, nejvýš 24.',
});
return validationError(res, parsed.error, 'Pošlete seznam ID widgetů, nejvýš 24.');
}
const allTickets = ticketSourceFor(scope);
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) };
return { id, ok: true, value: computeSource(builtin, scope, allTickets) };
} catch (err) {
console.error(`[widgets] ${id} selhal:`, err);
return { id, ok: false, error: message(err) };
@@ -535,7 +503,7 @@ widgetDataRouter.post('/', async (req, res) => {
const value =
widget.source.kind === 'connector'
? await computeConnector(widget.id, widget.source, scope, parsed.data.force)
: computeWidget(widget, scope);
: computeWidget(widget, scope, allTickets);
return { id, ok: true, value };
} catch (err) {
// Jeden rozbity zdroj nesmi zhasnout cely prehled.
@@ -555,12 +523,8 @@ function message(err: unknown): string {
/** Co lze do filtru vybrat. Klient si nesklada vlastni seznam. */
widgetDataRouter.get('/options', (req, res) => {
const scope = resolveScope(req.user!, {
tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
});
if (isDenied(scope)) {
return res.status(scope.status).json({ error: scope.error, message: scope.message });
}
const scope = scopeOrDeny(req, res);
if (!scope) return;
return res.json({
types: listTicketTypes(scope.tenantIds).map((type) => ({ id: type.id, name: type.name })),
+23 -12
View File
@@ -14,7 +14,7 @@ import { ALL_PAGES_INPUT, findMcpTool, MAX_TOOL_PAGES, parseOperationId } from '
import { isMcpService } from '../mcp/dialect.js';
import { callTool, type McpCallResult } from '../mcp/client.js';
import { argumentsFrom, cursorFieldOf, nextCursorFrom, rowsFrom } from '../mcp/schema.js';
import { truncate } from '../scripts/util.js';
import { DETAIL_BYTES, parseBool, truncate } from '../scripts/util.js';
import { createIncident } from '../data/incidentStore.js';
import { sendMail } from '../mail/smtp.js';
import { resolveTarget } from '../scripts/connections.js';
@@ -125,11 +125,8 @@ const channels: TicketChannel[] = [
* ktery meni stav, mimochodem otevrel vyrizeny ticket.
*/
function boolInput(value: string | undefined): boolean | undefined {
const text = value?.trim().toLowerCase();
if (!text) return undefined;
if (['true', '1', 'ano', 'yes'].includes(text)) return true;
if (['false', '0', 'ne', 'no'].includes(text)) return false;
return undefined;
if (!value?.trim()) return undefined;
return parseBool(value) ?? undefined;
}
/**
@@ -604,11 +601,13 @@ const handlers: Record<string, Handler> = {
* Incident je neco jineho nez ticket: ticket je pozadavek jednoho zakaznika,
* incident je "nefunguje to a tyka se to vic lidi". Proto vlastni krok.
*/
'incident/create': (inputs) => {
'incident/create': (inputs, context) => {
const title = inputs.title?.trim();
if (!title) return missing('název');
const incident = createIncident({
// Bez firmy by byl incident globalni a videly by ho vsechny firmy.
tenantId: context.tenantId,
title,
service: inputs.service?.trim() || 'Neurčeno',
severity: (['sev1', 'sev2', 'sev3'] as const).includes(inputs.severity as 'sev1')
@@ -741,9 +740,11 @@ function safeJson(text: string): Record<string, unknown> {
* operace (`tool:<konektor>:<nastroj>`) - jinak by musel existovat kus kodu na
* kazdy nastroj, ktery si firma zalozi, a to je presne to, co MCP resi.
*
* Krok se **neopakuje**. MCP nema idempotencni klic, takze druhy pokus po
* timeoutu by nastroj provedl podruhe - a jestli to znamena druhou objednavku,
* vi jen server, ktery neni nas.
* Krok se opakuje jen kdyz se **k nastroji vubec nedoslo**: spojeni se
* nenavazalo, nebo server volani odmitl (503, 429) driv, nez nastroj spustil.
* Timeout uz beziciho nastroje se neopakuje: MCP nema idempotencni klic, takze
* druhy pokus by nastroj provedl podruhe - a jestli to znamena druhou
* objednavku, vi jen server, ktery neni nas. Rozhoduje o tom `McpFailure.retryable`.
*/
async function runMcpTool(
inputs: Record<string, string>,
@@ -830,7 +831,15 @@ async function runMcpTool(
const outcome = await callTool(target, parsed.toolName, pageArgs);
if (!outcome.ok || !outcome.value) {
return { ok: false, summary: outcome.message, detail: outcome.detail, outputs: {} };
return {
ok: false,
summary: outcome.message,
detail: outcome.detail,
// Opakuje se jen to, co klient oznacil: nenavazane spojeni a odmitnuti
// pred spustenim nastroje. Timeout uz beziciho nastroje ne, viz vyse.
retryable: outcome.retryable,
outputs: {},
};
}
last = outcome.value;
@@ -867,7 +876,9 @@ async function runMcpTool(
return {
ok: !last.isError,
summary: truncated ? `${summary}, strop stránek vyčerpán` : summary,
detail: text === '' ? null : truncate(text, 600),
// Stejny strop jako u ostatnich kroku. Sest set znaku bylo malo: prave
// v textu odpovedi nastroj pise, proc se mu neco nepovedlo.
detail: text === '' ? null : truncate(text, DETAIL_BYTES),
/*
* Strukturovana odpoved se rozbaluje do vystupu, aby na ni sla postavit
* podminka bez psani cesty. Spolecne hodnoty se pisou az po ni: `text`
+127 -37
View File
@@ -23,13 +23,19 @@
import { defaultConnectorFor, getConnector, type Connector } from '../data/connectorStore.js';
import { matchOf, rulesOf, type FlowStep } from '../data/automationStore.js';
import { isUnary, type ConditionOperator, type ConditionRule } from '../data/conditions.js';
import {
isUnary,
type ConditionOperator,
type ConditionRule,
type FieldType,
} from '../data/conditions.js';
import { escapeHtml, renderTemplate } from '../data/templates.js';
import { actionInputsFor, type OperationField } from '../data/services.js';
import { getPath } from '../scripts/mapping.js';
import { appendTrace, type TraceInput } from '../data/ticketStore.js';
import { scriptIdFor } from '../scripts/lookup.js';
import { runScript } from '../scripts/runner.js';
import { DETAIL_BYTES, truncate } from '../scripts/util.js';
import { findBuiltinStep } from './builtinSteps.js';
/**
@@ -65,8 +71,22 @@ export interface RunOptions {
* teto tabulky se podminka nema o co oprit a tise vychazi jako prazdna.
*/
fieldNames?: Record<string, string>;
/**
* Typy parametru podle jejich ID. Podminka nad datem se musi porovnavat
* jako cas, ne jako cislo - ISO retezec by pres `Number()` vysel jako NaN.
*/
fieldTypes?: Record<string, FieldType>;
}
/**
* Pravidlo opakovani, stejne ve vsech vrstvach (skript, vnitrni krok, MCP,
* fronta): **opakuje se jen to, co muze pominout samo** - nenavazane spojeni,
* timeout, 5xx a 429 od cizi sluzby, nedostupne uloziste. Vsechno ostatni
* (401, 403, 404, spatny vstup, chybejici skript, `ctx.fail`, chyba v kodu)
* konci hned a zaklada incident. Krok, ktery nerekne nic, se **neopakuje**:
* vychozi je "ne", protoze marne opakovani petkrat za hodinu jen zopakuje
* tutez hlasku a pri tom muze cizi sluzbu zavolat podruhe.
*/
export interface StepResult {
stepId: string;
kind: 'action' | 'condition' | 'foreach';
@@ -74,12 +94,7 @@ export interface StepResult {
ok: boolean;
/** true = do logu ticketu se nezapisuje. Nic se nestalo, viz StepOutcome. */
quiet?: boolean;
/**
* false = opakovat nema smysl.
*
* Chybejici skript nebo neznama skupina za minutu existovat nezacne.
* Opakovat se ma jen to, co muze pominout: nedostupna sluzba, timeout.
*/
/** true = docasna prekazka, opakovani ma smysl. Chybi = ne. Viz pravidlo vyse. */
retryable?: boolean;
/** U podminky, kterou vetvi se slo. */
branch?: 'yes' | 'no';
@@ -102,7 +117,7 @@ export interface RunResult {
durationMs: number;
}
/** Vychozi strop kroku. Vic uz neni strom, ale program. */
/** Vychozi strop kroku **ve strome**. Vic uz neni strom, ale program. */
const MAX_STEPS = 50;
/**
@@ -114,6 +129,26 @@ const MAX_STEPS = 50;
*/
const MAX_LOOP_ITEMS = 200;
/**
* Kolik kroku se v jednom behu nejvys **vykona**, vcetne pruchodu smyckami.
*
* Jiny strop nez `MAX_STEPS`: strom o dvou krocich ve smycce nad sto polozkami
* je maly strom, ale dve ste vykonanych kroku. Driv se pocitalo obojí jednou
* hodnotou a smycka spadla na 26. polozce, i kdyz slibovala 200.
*/
const MAX_ACTIONS = 1_000;
/** Kolik kroku strom ma, vcetne vetvi podminek a tel smycek. */
function countSteps(list: FlowStep[]): number {
let total = 0;
for (const step of list) {
total += 1;
if (step.kind === 'condition') total += countSteps(step.yes) + countSteps(step.no);
else if (step.kind === 'foreach') total += countSteps(step.steps);
}
return total;
}
/**
* Vykona strom.
*
@@ -135,10 +170,25 @@ export async function runFlow(
let error: string | null = null;
// Velikost stromu se meri predem a staticky. Smycka ji nemeni.
const size = countSteps(steps);
if (size > limit) {
return {
ok: false,
retryable: false,
steps: [],
context: working,
error: `Strom má ${size} kroků, strop je ${limit}. Běh se nespustil.`,
durationMs: Date.now() - startedAt,
};
}
async function walk(list: FlowStep[]): Promise<boolean> {
for (const step of list) {
if (results.length >= limit) {
error = `Strom má víc než ${limit} kroků, běh se zastavil.`;
if (results.length >= MAX_ACTIONS) {
error =
`Běh vykonal ${MAX_ACTIONS} kroků včetně průchodů smyčkami a zastavil se. ` +
'Tolik práce v jednom běhu nikdo nečeká, rozdělte data nebo strom.';
return false;
}
@@ -298,9 +348,8 @@ export async function runFlow(
return {
ok: error === null,
// Kdyz krok nerekl jinak, opakovat se smi: vypadek cizi sluzby je
// nejcastejsi duvod chyby a ten pomine.
retryable: failed?.retryable !== false,
// Opakuje se jen to, co krok vyslovne oznacil. Viz pravidlo u StepResult.
retryable: failed?.retryable === true,
steps: results,
context: working,
error,
@@ -348,14 +397,7 @@ async function runAction(
options.ticketId = outcome.ticketId;
}
context[step.id] = outcome.outputs;
for (const [key, value] of Object.entries(outcome.outputs)) {
context[`${step.id}.${key}`] = value;
// Vystup je i pod holym jmenem, aby se v sablone dalo psat
// `{{ticketId}}` misto `{{krok1.ticketId}}`. Pozdejsi krok prepise
// starsi, coz je to, co clovek ceka.
context[key] = value;
}
publishOutputs(context, step.id, outcome.outputs);
return {
stepId: step.id,
@@ -383,6 +425,8 @@ async function runAction(
ok: false,
summary: 'krok skončil chybou',
detail: err instanceof Error ? err.message : String(err),
// Vyjimka z vnitrniho kroku je chyba v kodu nebo v datech, ne vypadek.
retryable: false,
durationMs: Date.now() - startedAt,
};
}
@@ -419,6 +463,8 @@ async function runAction(
ok: false,
summary: 'napojení se nepodařilo načíst',
detail: err instanceof Error ? err.message : String(err),
// Vypadek uloziste pomine, na rozdil od spatne nastaveneho kroku.
retryable: true,
durationMs: Date.now() - startedAt,
};
}
@@ -438,15 +484,14 @@ async function runAction(
ok: false,
summary: result.error?.message ?? 'krok selhal',
detail: result.error?.detail ?? null,
// Runner uz rozhodl: timeout a vypadek ano, 401/404/validace/ctx.fail ne.
// Driv se priznak cestou ztratil a kazda chyba se opakovala petkrat.
retryable: result.error?.retryable === true,
durationMs: Date.now() - startedAt,
};
}
// Vystupy pod jmenem kroku, aby si dva stejne kroky neprepsaly hodnoty.
context[step.id] = result.outputs;
for (const [key, value] of Object.entries(result.outputs)) {
context[`${step.id}.${key}`] = value;
}
publishOutputs(context, step.id, result.outputs);
return {
stepId: step.id,
@@ -459,6 +504,23 @@ async function runAction(
};
}
/**
* Zapise vystupy kroku do kontextu. Stejne pro vnitrni kroky i skripty.
*
* Pod jmenem kroku (`krok1.ticketId`), aby si dva stejne kroky neprepsaly
* hodnoty. Pod holym jmenem (`{{ticketId}}`) jen kdyz tam jeste nic neni:
* driv vnitrni krok prepisoval i data spoustece, takze nastroj MCP, ktery
* vratil `status` nebo `subject`, tise zmenil to, s cim beh zacal. Existujici
* stromy pisou `{{ticketId}}` za krokem zalozeni ticketu a to dal funguje.
*/
function publishOutputs(context: RunContext, stepId: string, outputs: Record<string, unknown>): void {
context[stepId] = outputs;
for (const [key, value] of Object.entries(outputs)) {
context[`${stepId}.${key}`] = value;
if (context[key] === undefined) context[key] = value;
}
}
/** Dosadi `{{jmeno}}` z kontextu do vsech poli kroku. */
function fillTemplates(
inputs: Record<string, string>,
@@ -570,7 +632,7 @@ function conditionDetail(
function matchRule(rule: ConditionRule, context: RunContext, options: RunOptions): boolean {
const raw = conditionValue(rule.fieldId, context, options);
const value = raw === undefined || raw === null ? '' : stringify(raw);
return compare(rule.operator, value, rule.value ?? '');
return compare(rule.operator, value, rule.value ?? '', options.fieldTypes?.[rule.fieldId] === 'date');
}
/**
@@ -604,9 +666,38 @@ function nameOf(fieldId: string): string {
return dot === -1 ? fieldId : fieldId.slice(dot + 1);
}
function compare(operator: ConditionOperator, value: string, expected: string): boolean {
const number = Number(value);
const expectedNumber = Number(expected);
/** Vypada to jako datum (`2026-08-20`, s casem nebo bez)? */
function isoLike(value: string): boolean {
return /^\d{4}-\d{2}-\d{2}/.test(value.trim());
}
/**
* Obe strany jako cisla k porovnani, nebo `null`, kdyz to nejde.
*
* Datum se porovnava jako cas: `Number('2026-08-20')` je NaN a podminka
* "vetsi nez" nad datem by tise nikdy neplatila. Plati to pro parametr typu
* datum i pro hodnoty, ktere jako datum vypadaji - cislo se hleda jen tam,
* kde to cislo opravdu je.
*/
function ordered(value: string, expected: string, dateTyped: boolean): [number, number] | null {
if (!dateTyped) {
const numbers: [number, number] = [Number(value), Number(expected)];
if (numbers.every(Number.isFinite)) return numbers;
}
if (dateTyped || (isoLike(value) && isoLike(expected))) {
const dates: [number, number] = [Date.parse(value), Date.parse(expected)];
if (dates.every(Number.isFinite)) return dates;
}
return null;
}
function compare(
operator: ConditionOperator,
value: string,
expected: string,
dateTyped = false,
): boolean {
const pair = ordered(value, expected, dateTyped);
switch (operator) {
case 'eq':
@@ -618,13 +709,13 @@ function compare(operator: ConditionOperator, value: string, expected: string):
case 'startsWith':
return value.toLowerCase().startsWith(expected.toLowerCase());
case 'gt':
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number > expectedNumber;
return pair !== null && pair[0] > pair[1];
case 'gte':
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number >= expectedNumber;
return pair !== null && pair[0] >= pair[1];
case 'lt':
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number < expectedNumber;
return pair !== null && pair[0] < pair[1];
case 'lte':
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number <= expectedNumber;
return pair !== null && pair[0] <= pair[1];
case 'isEmpty':
return value.trim() === '';
case 'isNotEmpty':
@@ -659,12 +750,11 @@ function describeCondition(
return `Podmínka: ${parts.join(spojka)}`;
}
/** Co krok vratil, do logu. Delsi vystup se orizne, cely je ve vysledku. */
/** Co krok vratil, do logu. Stejny strop jako u chyb, cely vystup je v kontextu. */
function describe(outputs: Record<string, unknown>): string | null {
const keys = Object.keys(outputs);
if (keys.length === 0) return null;
const text = JSON.stringify(outputs);
return text.length > 4_000 ? `${text.slice(0, 4_000)}...` : text;
return truncate(JSON.stringify(outputs), DETAIL_BYTES);
}
function toTrace(step: StepResult): TraceInput {
+25 -5
View File
@@ -35,7 +35,9 @@ export type TriggerKind =
/** Ticket se zmenil. */
| 'ticket.updated'
/** Rucni spusteni z portalu. */
| 'manual';
| 'manual'
/** Planovac: je cas se zeptat sluzby, ktera webhooky nema. */
| 'poll';
export interface QueueItem extends TenantEntity {
tenantId: string;
@@ -72,8 +74,15 @@ export const queueStore = defineStore<QueueItem>('runQueue');
const BACKOFF_MS = [30_000, 2 * 60_000, 10 * 60_000, 60 * 60_000];
export const MAX_ATTEMPTS = BACKOFF_MS.length + 1;
/** Po jake dobe se bezici beh povazuje za zaseknuty a vrati se do fronty. */
const STUCK_AFTER_MS = 10 * 60_000;
/**
* Po jake dobe se bezici beh povazuje za zaseknuty a vrati se do fronty.
*
* Meri se od posledniho tlukotu (`touchClaim`), ne od vzeti z fronty. Worker
* tluce kazdou minutu, takze tohle plati jen pro beh, jehoz proces spadl.
* Zamerne vyrazne vic nez nejdelsi legitimni cekani (uloha MCP, 10 minut) -
* driv bylo obojí deset minut a bezici uloha se rozjela podruhe.
*/
const STUCK_AFTER_MS = 30 * 60_000;
/** Kopie fronty v pameti. Cte se pri kazdem kole workeru, meni se zridka. */
let items: QueueItem[] = [];
@@ -163,12 +172,16 @@ export async function enqueue(input: EnqueueInput): Promise<QueueItem | null> {
* LOCKED`), jinak si dva workeri vezmou tentyz beh. Tady je to v pameti
* jednoho procesu, coz pro jednu instanci staci a je to videt na jednom miste.
*/
export async function claimBatch(limit: number): Promise<QueueItem[]> {
export async function claimBatch(
limit: number,
active: ReadonlySet<string> = new Set(),
): Promise<QueueItem[]> {
const now = Date.now();
// Zaseknuty beh se vraci do fronty, aby se neztratil kvuli spadlemu workeru.
// Beh, ktery tenhle proces prave vykonava, zaseknuty neni, at trva jakkoliv.
for (const item of items) {
if (item.status !== 'running' || !item.claimedAt) continue;
if (item.status !== 'running' || !item.claimedAt || active.has(item.id)) continue;
if (now - new Date(item.claimedAt).getTime() < STUCK_AFTER_MS) continue;
console.warn(`[fronta] ${item.id} visel v behu moc dlouho, vraci se do fronty`);
item.status = 'pending';
@@ -199,6 +212,13 @@ export async function claimBatch(limit: number): Promise<QueueItem[]> {
return claimed;
}
/** Tlukot beziciho behu. Posune cas, od ktereho se meri zaseknuti. */
export async function touchClaim(item: QueueItem): Promise<void> {
if (item.status !== 'running') return;
item.claimedAt = nowIso();
await save(item);
}
export async function markDone(item: QueueItem): Promise<void> {
item.status = 'done';
item.finishedAt = nowIso();
+79 -47
View File
@@ -18,7 +18,10 @@
* pravy, ne do teto funkce.
*/
import { createHash } from 'node:crypto';
import { createContext, Script } from 'node:vm';
import { getPath } from '../scripts/mapping.js';
import { parseNumber } from '../scripts/util.js';
/** Kolik ms smi skript bezet. Delsi vypocet blokuje workera ostatnim firmam. */
const TIMEOUT_MS = 2_000;
@@ -26,6 +29,14 @@ const TIMEOUT_MS = 2_000;
/** Strop na velikost vysledku. Vetsi objekt uz nikdo dal nezpracuje. */
const MAX_RESULT_BYTES = 256 * 1024;
/**
* Kolik zkompilovanych skriptu se drzi v pameti.
*
* Kompilace stoji vic nez samotny beh kratkeho prevodu a tentyz skript bezi
* pri kazde udalosti znovu. Strop, aby firma s tisicem verzi neprotahla pamet.
*/
const MAX_CACHED_SCRIPTS = 100;
export interface SandboxResult {
ok: boolean;
/** Vysledek skriptu. Vzdy objekt, i kdyz skript vrati neco jineho. */
@@ -40,29 +51,65 @@ export interface SandboxResult {
*
* Zamerne jich je malo. Kazda dalsi je vec, kterou musi ctenar skriptu znat -
* a `input.data.order.items.map(...)` umi kazdy, kdo umi JS.
*
* Sestavuji se **uvnitr kontextu skriptu** ze zdrojoveho textu, ne predanim
* hotovych funkci. Funkce hostitele by skriptu dala jeho `Function`, a pres
* `util.num.constructor('return process')()` by se dostal ven. Zdrojovy text
* je tentyz kod jako `getPath` a `parseNumber` jinde v systemu, takze cesty
* i ceska carka funguji stejne jako v transformaci dat.
*/
const util = {
/** Hodnota na ceste, `undefined` kdyz tam nic neni. `get(o, 'a.b.0.c')`. */
get(source: unknown, path: string): unknown {
let current: unknown = source;
for (const part of path.split('.')) {
if (current === null || current === undefined) return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
},
/** Cislo z cehokoliv. Ceska desetinna carka je bezna, nema smysl na ni padat. */
num(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
const parsed = Number(String(value ?? '').trim().replace(',', '.'));
return Number.isFinite(parsed) ? parsed : null;
},
/** Datum jako `2026-08-20`. Prazdne, kdyz to datum neni. */
day(value: unknown): string {
const UTIL_SOURCE = `(function () {
'use strict';
const get = (${getPath.toString()});
const num = (${parseNumber.toString()});
/* Datum jako 2026-08-20. Prazdne, kdyz to datum neni. */
const day = (value) => {
const parsed = Date.parse(String(value ?? ''));
return Number.isNaN(parsed) ? '' : new Date(parsed).toISOString().slice(0, 10);
},
};
};
return Object.freeze({ get, num, day });
})()`;
const utilScript = new Script(UTIL_SOURCE, { filename: 'pomucky.js' });
/** Zkompilovane skripty podle otisku kodu. Mapa drzi poradi, takze slouzi jako LRU. */
const compiled = new Map<string, Script>();
function compile(code: string): Script {
const key = createHash('sha256').update(code).digest('base64url');
const cached = compiled.get(key);
if (cached) {
// Posledni pouzity jde na konec, aby se pri uklidu zahodil ten nejstarsi.
compiled.delete(key);
compiled.set(key, cached);
return cached;
}
/*
* Kod se zabali do funkce, ktera se **rovnou zavola**. Dve veci najednou:
* - v kodu jde psat `return`, coz je to, co clovek ceka,
* - beh je uvnitr `runInContext`, takze na nej plati casovy limit.
*
* Zavolat funkci az potom zvenku by limit obeslo: `runInContext` by jen
* vyrobil funkci a `while (true) {}` uvnitr ni by zablokovalo workera
* navzdy. Presne to se pri prvni zkousce stalo.
*
* Vstup prichazi jako JSON text a vysledek odchazi jako JSON text. Objekt
* hostitele by skriptu dal jeho prototyp a s nim cestu ven; text nic
* takoveho nenese.
*/
const wrapped =
`JSON.stringify((function (input, util) {\n'use strict';\n${code}\n})` +
'(JSON.parse(__input), __util));';
const script = new Script(wrapped, { filename: 'skript-firmy.js' });
compiled.set(key, script);
if (compiled.size > MAX_CACHED_SCRIPTS) {
const oldest = compiled.keys().next().value;
if (oldest !== undefined) compiled.delete(oldest);
}
return script;
}
/**
* Spusti telo skriptu nad vstupem.
@@ -74,28 +121,17 @@ const util = {
export function runSandbox(code: string, input: unknown): SandboxResult {
const startedAt = Date.now();
/*
* Kod se zabali do funkce, ktera se **rovnou zavola**. Dve veci najednou:
* - v kodu jde psat `return`, coz je to, co clovek ceka,
* - beh je uvnitr `runInContext`, takze na nej plati casovy limit.
*
* Zavolat funkci az potom zvenku by limit obeslo: `runInContext` by jen
* vyrobil funkci a `while (true) {}` uvnitr ni by zablokovalo workera
* navzdy. Presne to se pri prvni zkousce stalo.
*/
const wrapped = `(function (input, util) {\n'use strict';\n${code}\n})(__input, __util);`;
try {
const script = new Script(wrapped, { filename: 'skript-firmy.js' });
const script = compile(code);
/*
* Kontext nese jen vstup a pomucky. Skript nevidi ani `console`, ani
* globalni objekty hostitele, takze nema kudy sahnout ven.
* Kontext nese jen vstup jako text a pomucky sestavene uvnitr nej. Nic
* z hostitele - zadny objekt ani funkce, jejichz prototyp by vedl ven.
*/
const context = createContext(
{
__input: structuredClone(input),
__util: util,
__input: JSON.stringify(input ?? null),
__util: undefined,
/*
* `console` v novem kontextu existuje samo od sebe a psalo by do logu
* serveru. Prevod dat nema co vypisovat - kdyz je potreba videt, co se
@@ -105,10 +141,11 @@ export function runSandbox(code: string, input: unknown): SandboxResult {
},
{ codeGeneration: { strings: false, wasm: false } },
);
context.__util = utilScript.runInContext(context, { timeout: TIMEOUT_MS }) as unknown;
const value = script.runInContext(context, { timeout: TIMEOUT_MS }) as unknown;
const json = script.runInContext(context, { timeout: TIMEOUT_MS }) as unknown;
if (value === undefined || value === null) {
if (typeof json !== 'string' || json === 'null') {
return {
ok: false,
value: null,
@@ -117,24 +154,19 @@ export function runSandbox(code: string, input: unknown): SandboxResult {
};
}
const size = JSON.stringify(value)?.length ?? 0;
if (size > MAX_RESULT_BYTES) {
if (json.length > MAX_RESULT_BYTES) {
return {
ok: false,
value: null,
error: `Výsledek má ${Math.round(size / 1024)} kB, vejde se ${MAX_RESULT_BYTES / 1024} kB.`,
error: `Výsledek má ${Math.round(json.length / 1024)} kB, vejde se ${MAX_RESULT_BYTES / 1024} kB.`,
durationMs: Date.now() - startedAt,
};
}
/*
* Vysledek vznikl uvnitr kontextu. Prevedeme ho na prosta data, aby se dal
* ulozit i porovnat - objekt z ciziho kontextu neprojde kontrolou
* `instanceof` a dal by se choval nepredvidatelne.
*/
// Vysledek prisel jako text, takze uz je to prosta data bez ciziho prototypu.
return {
ok: true,
value: JSON.parse(JSON.stringify(value)) as unknown,
value: JSON.parse(json) as unknown,
error: null,
durationMs: Date.now() - startedAt,
};
+3 -1
View File
@@ -218,7 +218,9 @@ export async function planPolled(): Promise<number> {
const item = await enqueue({
tenantId: tenant.id,
automationId: automation.id,
trigger: 'manual',
// Vlastni druh, ne `manual`: v prehledu behu ma byt videt, ze to
// spustil planovac, ne clovek tlacitkem.
trigger: 'poll',
payload: { plannedAt: new Date(now).toISOString() },
/*
* Klic drzi jeden cekajici dotaz na automatizaci. Kdyz predchozi jeste
+91 -23
View File
@@ -19,10 +19,12 @@ import { withRun } from './context.js';
import { runFlow, type RunResult } from './executor.js';
import { collectScopes } from '../data/flowScope.js';
import type { AutomationFlow } from '../data/automationStore.js';
import type { FieldType } from '../data/conditions.js';
import {
claimBatch,
markDone,
markFailed,
touchClaim,
trimQueue,
MAX_ATTEMPTS,
type QueueItem,
@@ -37,10 +39,22 @@ const IDLE_MS = 1_000;
/** Jak casto se uklizi hotove behy. */
const CLEANUP_EVERY = 200;
/**
* Jak casto bezici beh hlasi fronte, ze zije.
*
* Fronta vraci behy, ktere visi dlouho, zpatky k opakovani. Beh s pauzou nebo
* s ulohou MCP na deset minut ale nevisi, pracuje - a bez tlukotu by ho fronta
* rozjela podruhe, zatimco prvni jeste bezi.
*/
const HEARTBEAT_MS = 60_000;
let running = false;
let stopping = false;
let rounds = 0;
/** Behy, ktere prave bezi v tomto procesu. Fronta je nesmi povazovat za zaseknute. */
const active = new Set<string>();
/**
* Spusti workera.
*
@@ -61,12 +75,20 @@ export async function stopWorker(): Promise<void> {
async function loop(): Promise<void> {
while (!stopping) {
/*
* Bazen, ne davky. Volne misto se doplni hned, jak se uvolni - kdyby se
* cekalo na celou davku, jeden beh s minutovou pauzou nebo desetiminutovou
* ulohou MCP by drzel vsechna ctyri mista a fronta by stala.
*/
const free = CONCURRENCY - active.size;
let claimed: QueueItem[] = [];
try {
claimed = await claimBatch(CONCURRENCY);
} catch (err) {
// Nedostupne uloziste nesmi workera zabit. Za chvili to zkusi znovu.
console.error('[worker] frontu se nepodarilo precist:', err);
if (free > 0) {
try {
claimed = await claimBatch(free, active);
} catch (err) {
// Nedostupne uloziste nesmi workera zabit. Za chvili to zkusi znovu.
console.error('[worker] frontu se nepodarilo precist:', err);
}
}
if (claimed.length === 0) {
@@ -74,8 +96,12 @@ async function loop(): Promise<void> {
continue;
}
// Behy jdou soubezne, cekaji stejne na cizi sluzby.
await Promise.all(claimed.map((item) => runOne(item)));
for (const item of claimed) {
active.add(item.id);
void runOne(item)
.catch((err: unknown) => console.error(`[worker] beh ${item.id} spadl mimo pojistku:`, err))
.finally(() => active.delete(item.id));
}
rounds += 1;
if (rounds % CLEANUP_EVERY === 0) {
@@ -87,17 +113,41 @@ async function loop(): Promise<void> {
console.info('[worker] zastaven');
}
/** Jeden beh vcetne tlukotu, ktery fronte rika, ze beh jeste zije. */
async function runOne(item: QueueItem): Promise<void> {
const heartbeat = setInterval(() => {
void touchClaim(item).catch((err: unknown) => {
console.error(`[worker] ${item.id}: tlukot se nezapsal:`, err);
});
}, HEARTBEAT_MS);
heartbeat.unref?.();
try {
await execute(item);
} finally {
clearInterval(heartbeat);
}
}
async function execute(item: QueueItem): Promise<void> {
const automation = getAutomation(item.automationId, [item.tenantId]);
/*
* Automatizace mezitim zmizela nebo je pozastavena. Opakovat to nema smysl
* a klient se to ma dozvedet stejne jako u kazde jine marne chyby - jinak
* beh tise skonci a nikdo nevi, ze se udalost nezpracovala.
*/
if (!automation) {
// Automatizace mezitim zmizela. Neni co delat a opakovat to nema smysl.
await markFailed(item, `Automatizace ${item.automationId} neexistuje.`);
const message = `Automatizace ${item.automationId} neexistuje.`;
await markFailed(item, message, false);
reportIncident(item, item.automationId, message, null);
return;
}
if (!automation.enabled) {
await markFailed(item, `Automatizace ${automation.name} je pozastavená.`);
const message = `Automatizace ${automation.name} je pozastavená.`;
await markFailed(item, message, false);
reportIncident(item, automation.name, message, null);
return;
}
@@ -122,6 +172,7 @@ async function runOne(item: QueueItem): Promise<void> {
// Podminka si drzi ID parametru, data chodi pod jmenem. Bez teto
// tabulky by se nemela o co oprit, viz RunOptions.fieldNames.
fieldNames: fieldNamesOf(automation.flow),
fieldTypes: fieldTypesOf(automation.flow),
// Klic je stabilni na beh, takze opakovany pokus nevystavi druhou fakturu.
idempotencyKey: `run:${item.id}`,
}),
@@ -134,7 +185,7 @@ async function runOne(item: QueueItem): Promise<void> {
publish('automation.run', `Automatizace ${automation.name} proběhla`, {
automationId: automation.id,
ok: true,
});
}, item.tenantId);
return;
}
@@ -157,17 +208,20 @@ async function runOne(item: QueueItem): Promise<void> {
* klient incident u kazdeho vypadku site, ktery se sam za minutu spravi.
*/
if (!result.retryable || item.attempts >= MAX_ATTEMPTS) {
reportIncident(item, automation.name, result, failed, message);
reportIncident(item, automation.name, message, result);
}
publish('automation.run', `Automatizace ${automation.name} skončila chybou`, {
automationId: automation.id,
ok: false,
});
}, item.tenantId);
} catch (err) {
// `runFlow` chyby nevyhazuje, tohle je posledni pojistka.
// `runFlow` chyby nevyhazuje, tohle je posledni pojistka. Chyba v kodu se
// opakovanim nespravi, tak se rovnou zalozi incident.
console.error(`[worker] neocekavana chyba behu ${item.id}:`, err);
await markFailed(item, err instanceof Error ? err.message : String(err));
const message = err instanceof Error ? err.message : String(err);
await markFailed(item, message, false);
reportIncident(item, automation.name, message, null);
}
}
@@ -177,16 +231,19 @@ async function runOne(item: QueueItem): Promise<void> {
* Stejna pricina nezaklada druhy incident, dokud je ten prvni otevreny -
* jinak by deset stejnych chyb znamenalo deset incidentu a nikdo by se v tom
* nevyznal.
*
* `result` chybi, kdyz se k behu vubec nedoslo (automatizace zmizela, nebo
* spadl sam worker). I to je chyba, o ktere se klient ma dozvedet.
*/
function reportIncident(
item: QueueItem,
automationName: string,
result: RunResult,
failed: RunResult['steps'][number] | undefined,
message: string,
result: RunResult | null,
): void {
const failed = result?.steps.find((step) => !step.ok);
const source = `automation:${item.automationId}:${failed?.stepId ?? 'flow'}`;
if (findOpenIncident(source)) return;
if (findOpenIncident(source, [item.tenantId])) return;
const detail = [
`Automatizace: ${automationName} (${item.automationId})`,
@@ -196,11 +253,15 @@ function reportIncident(
'',
'Hlášení:',
message || '(bez hlášení)',
'',
'Kroky běhu:',
...result.steps.map(
(step) => ` ${step.ok ? 'ok ' : 'CHYBA'} ${step.label}: ${step.summary}`,
),
...(result
? [
'',
'Kroky běhu:',
...result.steps.map(
(step) => ` ${step.ok ? 'ok ' : 'CHYBA'} ${step.label}: ${step.summary}`,
),
]
: []),
'',
'Data na vstupu:',
JSON.stringify(item.payload, null, 2),
@@ -255,3 +316,10 @@ function fieldNamesOf(flow: AutomationFlow): Record<string, string> {
for (const [id, field] of collectScopes(flow).all) names[id] = field.name;
return names;
}
/** Typy parametru podle jejich ID. Podminka nad datem se porovnava jako cas. */
function fieldTypesOf(flow: AutomationFlow): Record<string, FieldType> {
const types: Record<string, FieldType> = {};
for (const [id, field] of collectScopes(flow).all) types[id] = field.type;
return types;
}
+18 -1
View File
@@ -39,7 +39,11 @@ export interface ResolvedTarget {
* sluzby rady vraceji v chybove hlasce.
*/
secretValues: string[];
/** Necitliva cast, skript ji vidi jako `ctx.config`. */
/**
* Pole s cilem `config`, **vcetne tajnych** (heslo SMTP, tajemstvi OAuth).
* Cte je runtime (SMTP, MCP). Skript dostava jen necitlivou cast pres
* `scriptConfig`, nikdy tohle cele.
*/
serviceConfig: Record<string, string>;
/** false = chybi povinne udaje, volat nema smysl. */
ready: boolean;
@@ -159,3 +163,16 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R
export function targetSecrets(target: ResolvedTarget): string[] {
return [...Object.values(target.headers), ...target.secretValues];
}
/**
* Nastaveni napojeni tak, jak ho smi videt skript (`ctx.config`).
*
* Bez tajnych hodnot. Skript pise spravce firmy a jeho kod i log jsou videt
* v portalu - heslo, ktere by si z `ctx.config` vypsal, by tam skoncilo taky.
*/
export function scriptConfig(target: ResolvedTarget): Record<string, string> {
const secret = new Set(target.secretValues);
return Object.fromEntries(
Object.entries(target.serviceConfig).filter(([, value]) => !secret.has(value)),
);
}
+21 -62
View File
@@ -9,6 +9,7 @@
*/
import { config } from '../config.js';
import { assertAllowedUrl, describeFetchError, readBodyLimited, tooLargeMessage } from '../net/guard.js';
import type { ResolvedTarget } from './connections.js';
import {
ScriptError,
@@ -18,7 +19,7 @@ import {
type ScriptHttpResponse,
type ScriptRequestInfo,
} from './types.js';
import { truncate } from './util.js';
import { DETAIL_BYTES, truncate } from './util.js';
/** Kody, u kterych ma smysl opakovat. Zbytek je koncova chyba. */
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
@@ -35,42 +36,12 @@ const retryableCodes = new Set([
'UND_ERR_CONNECT_TIMEOUT',
]);
const privateHostPattern =
/^(localhost|127\.|0\.0\.0\.0$|10\.|192\.168\.|169\.254\.|::1$|\[::1\]$|172\.(1[6-9]|2\d|3[01])\.)/i;
function joinUrl(baseUrl: string, path: string): string {
const base = baseUrl.replace(/\/+$/, '');
const suffix = path.startsWith('/') ? path : `/${path}`;
return `${base}${suffix}`;
}
/**
* Miri tohle jmeno do vnitrni site?
*
* Exportovane, protoze totez potrebuje odesilani e-mailu: adresu SMTP serveru
* vyplnuje firma a bez teto kontroly by si mohla nechat navazat spojeni na
* cokoliv uvnitr. Pravidlo ma byt jedno, ne dve, ktera se casem rozejdou.
*/
export function isPrivateHost(hostname: string): boolean {
return privateHostPattern.test(hostname);
}
/**
* Adresu skladame my z napojeni, ale az budou napojeni nastavovat klienti,
* je tohle to jedine, co brani volani na vnitrni sit. Proto tady, ne pozdeji.
*/
function assertAllowedUrl(url: URL): void {
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new ScriptError('config', `Adresa ${url.protocol} není povolená, jen http a https.`);
}
if (!config.allowPrivateTargets && isPrivateHost(url.hostname)) {
throw new ScriptError(
'config',
`Adresa ${url.hostname} míří do vnitřní sítě. Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.`,
);
}
}
function buildUrl(target: ResolvedTarget, path: string, options?: ScriptHttpOptions): URL {
// Obecna sluzba nema pevnou adresu, skript pak musi poslat absolutni.
const absolute = target.baseUrl === '';
@@ -94,7 +65,9 @@ function buildUrl(target: ResolvedTarget, path: string, options?: ScriptHttpOpti
url.searchParams.set(key, String(value));
}
assertAllowedUrl(url);
// Adresu skladame my z napojeni, ale nastavuji ji klienti - tohle je to
// jedine, co brani volani na vnitrni sit. Pravidlo je v net/guard.ts.
assertAllowedUrl(url, (message) => new ScriptError('config', message));
return url;
}
@@ -263,30 +236,24 @@ function statusError(
function transportError(err: unknown, request: ScriptRequestInfo): ScriptError {
if (err instanceof ScriptError) return err;
const code =
err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).code) : '';
const name = err instanceof Error ? err.name : '';
const message = err instanceof Error ? err.message : String(err);
// Puvodni chyba vcetne pricin. `cause` nese u fetch to podstatne.
const cause =
err instanceof Error && err.cause instanceof Error ? `
Příčina: ${err.cause.message}` : '';
const detail = truncate(`${name}: ${message}${cause}`, config.errorDetailBytes);
const info = describeFetchError(err, request.url);
const detail = truncate(info.detail, DETAIL_BYTES);
if (name === 'AbortError' || name === 'TimeoutError') {
if (info.timedOut) {
return new ScriptError('timeout', `Volání ${request.url} nedoběhlo v limitu.`, {
request,
detail,
cause: err,
});
}
if (retryableCodes.has(code)) {
return new ScriptError('retryable', `Nepodařilo se spojit se službou (${code}).`, {
if (retryableCodes.has(info.code)) {
return new ScriptError('retryable', `Nepodařilo se spojit se službou (${info.code}).`, {
request,
detail,
cause: err,
});
}
const message = err instanceof Error ? err.message : String(err);
return new ScriptError('retryable', `Volání ${request.url} selhalo: ${message}`, {
request,
detail,
@@ -384,23 +351,15 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
throw transportError(err, request);
}
const declaredSize = Number(response.headers.get('content-length') ?? 0);
if (declaredSize > config.scriptMaxResponseBytes) {
throw new ScriptError(
'terminal',
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
{ status: response.status },
);
}
const raw = await response.text();
if (raw.length > config.scriptMaxResponseBytes) {
throw new ScriptError(
'terminal',
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
{ status: response.status },
);
// Limit plati uz pri cteni. Bez `Content-Length` by se jinak cele telo
// nacetlo do pameti a teprve pak by se zjistilo, ze je moc velke.
const received = await readBodyLimited(response, config.scriptMaxResponseBytes);
if (received.tooLarge) {
throw new ScriptError('terminal', tooLargeMessage(config.scriptMaxResponseBytes), {
status: response.status,
});
}
const raw = received.text;
const isJson = response.headers.get('content-type')?.includes('json') ?? false;
let parsed: unknown = raw;
@@ -411,7 +370,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
throw new ScriptError('terminal', `Odpověď ${request.url} není platný JSON.`, {
status: response.status,
request,
detail: redact(truncate(raw, config.errorDetailBytes)),
detail: redact(truncate(raw, DETAIL_BYTES)),
});
}
}
@@ -425,7 +384,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
throw statusError(
response.status,
request,
redact(truncate(raw, config.errorDetailBytes)),
redact(truncate(raw, DETAIL_BYTES)),
pickResponseHeaders(response),
);
}
+6 -9
View File
@@ -16,6 +16,7 @@
*/
import { ScriptError, type ScriptJson } from './types.js';
import { parseBool, parseNumber } from './util.js';
/** Kolik urovni zanoreni se jeste prochazi. Chrani proti cyklickym datum. */
const MAX_DEPTH = 20;
@@ -122,10 +123,9 @@ function isEmpty(value: unknown): boolean {
}
function toNumber(value: unknown, label: string): number {
if (typeof value === 'number') return value;
// Ceska desetinna carka je bezna, nema smysl na ni padat.
const parsed = Number(String(value ?? '').trim().replace(',', '.'));
if (!Number.isFinite(parsed)) {
// Prazdno je tu nula, ne chyba: `sum` nad radky bez ceny ma vyjit 0.
const parsed = parseNumber(isEmpty(value) ? 0 : value);
if (parsed === null) {
throw new ScriptError('terminal', `${label}: "${String(value)}" není číslo.`);
}
return parsed;
@@ -143,11 +143,8 @@ function applyOne(value: unknown, operation: TransformOp, label: string, depth:
return value === undefined || value === null ? '' : String(value);
case 'number':
return toNumber(value, label);
case 'boolean': {
if (typeof value === 'boolean') return value;
const text = String(value ?? '').trim().toLowerCase();
return text === 'true' || text === '1' || text === 'yes' || text === 'ano';
}
case 'boolean':
return parseBool(value) ?? false;
case 'date': {
const parsed = Date.parse(value instanceof Date ? value.toISOString() : String(value ?? ''));
if (Number.isNaN(parsed)) {
+3 -7
View File
@@ -20,6 +20,7 @@ import { config } from '../config.js';
import { services, setScriptActions, type ServiceOperation } from '../data/services.js';
import { parseManifest, serviceIdOf, toServiceOperation } from './manifest.js';
import type { FieldIssue, ScriptContext, ScriptManifest, ScriptValues } from './types.js';
import type { ScriptProblem } from '../shared/scripts.js';
export type ScriptRunFn = (
inputs: ScriptValues,
@@ -33,13 +34,8 @@ export interface LoadedScript {
mtimeMs: number;
}
export interface ScriptProblem {
/** Nazev souboru, ne cela cesta - cesta na serveru nikomu nic nerekne. */
file: string;
scriptId: string | null;
message: string;
issues?: FieldIssue[];
}
/** Tvar problemu je sdileny s webem, viz src/shared/scripts.ts. */
export type { ScriptProblem };
/** ID smi byt jen tohle. Chrani zapis i cteni proti vyskoku z adresare. */
const idPattern = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/;
+3 -2
View File
@@ -13,7 +13,7 @@
import { createHash } from 'node:crypto';
import { config } from '../config.js';
import type { Connector } from '../data/connectorStore.js';
import { resolveTarget, targetSecrets } from './connections.js';
import { resolveTarget, scriptConfig, targetSecrets } from './connections.js';
import { createHttp } from './http.js';
import { serviceIdOf } from './manifest.js';
import { getScript } from './registry.js';
@@ -176,7 +176,8 @@ export async function runScript(
}),
util: scriptUtil,
log,
config: Object.freeze({ ...target.serviceConfig }),
// Bez tajemstvi. Skript je nikdy nevidi, do requestu je dosazuje runtime.
config: Object.freeze(scriptConfig(target)),
idempotencyKey,
fail(message, detail) {
throw new ScriptError('terminal', message, {
+44 -89
View File
@@ -17,23 +17,37 @@ import type { MappingRule } from './mapping.js';
// ------------------------------------------------------------------- hodnoty
/**
* Hodnota, se kterou skript pracuje. Cokoliv, co jde vyjadrit JSONem.
*
* Objekty a seznamy tu jsou kvuli transformacim: krok muze predat dal celou
* objednavku, ne jen jeji jednotliva pole. Do sablon se ale nedosazuji,
* predavaji se jen jako celek dalsimu kroku.
*/
export type ScriptJson =
| string
| number
| boolean
| null
| ScriptJson[]
| { [key: string]: ScriptJson };
import type {
FieldIssue,
ScriptErrorKind,
ScriptField,
ScriptJson,
ScriptLogEntry,
ScriptManifest,
ScriptRequestInfo,
ScriptRunError,
ScriptRunResult,
ScriptValue,
ScriptValues,
} from '../shared/scripts.js';
export type ScriptValue = ScriptJson;
export type ScriptValues = Record<string, ScriptValue>;
/**
* Manifest, hodnoty a vysledek behu jsou sdilene s webem, viz src/shared/scripts.ts.
* Zod schema nize musi jejich tvar dodrzet, hlida to `satisfies`.
*/
export type {
FieldIssue,
ScriptErrorKind,
ScriptField,
ScriptJson,
ScriptLogEntry,
ScriptManifest,
ScriptRequestInfo,
ScriptRunError,
ScriptRunResult,
ScriptValue,
ScriptValues,
};
// -------------------------------------------------------------------- schema
@@ -87,9 +101,7 @@ export const scriptFieldSchema = z
*/
maxBytes: z.number().int().min(256).max(4_000_000).optional(),
})
.strict();
export type ScriptField = z.infer<typeof scriptFieldSchema>;
.strict() satisfies z.ZodType<ScriptField>;
/**
* Manifest skriptu.
@@ -131,49 +143,16 @@ export const scriptManifestSchema = z
seen.add(field.id);
}
}
});
export type ScriptManifest = z.infer<typeof scriptManifestSchema>;
}) satisfies z.ZodType<ScriptManifest, z.ZodTypeDef, unknown>;
// --------------------------------------------------------------------- chyby
/**
* Druh selhani. Rozdeleni na `retryable` a `terminal` je to podstatne:
* timeout nebo 503 ma smysl zkusit znovu, chyba ve vstupu nebo 403 ne.
* Opakovat koncovou chybu jen vypali kvotu u cizi sluzby.
*/
export type ScriptErrorKind =
| 'not_found'
| 'config'
| 'validation'
| 'output'
| 'retryable'
| 'terminal'
| 'timeout'
| 'internal';
const retryableKinds: ScriptErrorKind[] = ['retryable', 'timeout'];
export function isRetryableKind(kind: ScriptErrorKind): boolean {
return retryableKinds.includes(kind);
}
/** Ktere volani spadlo. Bez toho je chybova zprava jen pulka informace. */
export interface ScriptRequestInfo {
method: string;
/** Cesta bez domeny. Kvuli zpetne kompatibilite, hlasky pouzivaji `url`. */
path: string;
/**
* Cela adresa **vcetne serveru**, ale bez query - v query muze byt tajemstvi.
*
* Bez serveru se hlaska neda pouzit: "/apps/idoklad/account/agenda vratilo
* 403" nerekne, jestli se to vubec trefilo na spravny stroj, nebo to zaridla
* cizi proxy cestou. A protoze zaklad adresy je z konfigurace a konektor ho
* smi prepsat, nesmi se hadat podle toho, kde je nasazeny portal.
*/
url: string;
}
export class ScriptError extends Error {
readonly kind: ScriptErrorKind;
/** HTTP kod cizi sluzby, kdyz chyba prisla z volani. */
@@ -299,6 +278,18 @@ export interface ScriptUtil {
bool(value: unknown): boolean;
/** Datum jako ISO retezec, nebo null. */
date(value: unknown): string | null;
/** Datum jako `2026-08-20`. Prazdny retezec, kdyz to datum neni. */
day(value: unknown): string;
/**
* Seznam z odpovedi bez ohledu na obalku: pole rovnou, nebo pod jednim
* z uvedenych klicu (plus `data`, `content`, `items`, `results`).
* `null`, kdyz seznam neni nikde - to je informace, ne prazdny vysledek.
*/
list(body: unknown, ...names: string[]): unknown[] | null;
/** Adresy z textu oddelene carkou nebo strednikem, bez prazdnych. */
addresses(value: unknown): string[];
/** Hodnota v jednoduchych uvozovkach pro OData/SQL filtr, apostrof zdvojeny. */
quote(value: unknown): string;
/** Zaokrouhli na dane desetinne misto. Uctuje se v halerich. */
round(value: number, decimals?: number): number;
/**
@@ -350,39 +341,3 @@ export interface ScriptModule {
// -------------------------------------------------------------------- vysledek
export interface ScriptLogEntry {
at: string;
message: string;
detail?: string;
}
export interface ScriptRunError {
kind: ScriptErrorKind;
message: string;
retryable: boolean;
status?: number;
/** Cela odpoved sluzby, uz zredigovana. Tady je napsane, co ji vadilo. */
detail?: string;
/** Ktere volani spadlo. */
request?: ScriptRequestInfo;
/** Zasobnik volani. Jen mimo produkci, stejne jako u error handleru. */
stack?: string;
/** Vyplnene jen u chyb ve vstupu nebo vystupu. */
issues?: FieldIssue[];
}
export interface FieldIssue {
field: string;
message: string;
}
export interface ScriptRunResult {
ok: boolean;
scriptId: string;
/** Prazdne, kdyz beh selhal. */
outputs: ScriptValues;
logs: ScriptLogEntry[];
durationMs: number;
httpCalls: number;
error: ScriptRunError | null;
}
+135 -11
View File
@@ -7,9 +7,74 @@
* z nich by to resil spatne.
*/
import { config } from '../config.js';
import { applyRules, fillJson, getPath } from './mapping.js';
import { ScriptError, type ScriptUtil } from './types.js';
/**
* Strop na text, ktery jde do logu nebo do `detail` chyby.
*
* Skutecne pravidlo: detail se **nezkracuje na vetu**, drzi se cela odpoved
* sluzby az do tohoto stropu. Ten je zamerne velky (viz `errorDetailBytes`),
* protoze prave v tele odpovedi je napsane, co sluzbe vadilo. Jedna konstanta
* pro vsechny vrstvy - driv mel executor 4000, MCP 600 a HTTP 8000 a nikdo
* nevedel, ktere plati.
*/
export const DETAIL_BYTES = config.errorDetailBytes;
// -------------------------------------------------------------- prevody
/** Retezce, ktere lidi i sluzby pouzivaji pro ano a ne. Sjednoceno ze vsech mist. */
const truthy = new Set(['true', '1', 'yes', 'y', 'ano', 'on']);
const falsy = new Set(['false', '0', 'no', 'n', 'ne', 'off']);
/**
* Ano, ne, nebo nejde poznat.
*
* `null` znamena "neni to ani jedno" - volajici si rozhodne, jestli je to
* chyba (kontrola parametru), nebo "nemenit" (zaskrtavatko ve strome).
*/
export function parseBool(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
const lowered = String(value ?? '').trim().toLowerCase();
if (truthy.has(lowered)) return true;
if (falsy.has(lowered)) return false;
return null;
}
/**
* Cislo z textu i s ceskou desetinnou carkou. `null`, kdyz to cislo neni.
*
* Prazdny text a ano/ne nejsou nula - tise prevest prazdno na 0 je presne
* ten druh chyby, ktera se najde az na faktuře.
*/
export function parseNumber(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string') return null;
const text = value.trim().replace(',', '.');
if (text === '') return null;
const parsed = Number(text);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* `exp` z prostredni casti JWT v ms. `null`, kdyz to JWT neni.
*
* Pro servery, ktere zivotnost tokenu nikam nepisou, ale vydavaji JWT -
* OAuth server bez `expires_in` i EasyWeb.
*/
export function jwtExpiry(token: string): number | null {
const parts = token.split('.');
if (parts.length < 2) return null;
try {
const json = Buffer.from(parts[1], 'base64url').toString('utf8');
const payload = JSON.parse(json) as { exp?: unknown };
return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
} catch {
return null;
}
}
/** Rozbali obalku odpovedi. `{ Data: x }` i `{ data: x }` vrati `x`. */
function unwrap<T = unknown>(body: unknown): T {
if (body === null || typeof body !== 'object') return body as T;
@@ -22,8 +87,11 @@ function unwrap<T = unknown>(body: unknown): T {
/**
* Prvni existujici pole bez ohledu na velikost pismen.
* `pick(invoice, 'documentNumber')` najde `DocumentNumber` i `documentNumber`.
*
* Exportovane, protoze totez potrebuje MCP klient a prihlaseni: .NET pise
* klice velkymi, jine servery malymi. Jedna funkce, ne ctyri.
*/
function pick(source: unknown, ...names: string[]): unknown {
export function pick(source: unknown, ...names: string[]): unknown {
if (source === null || typeof source !== 'object') return undefined;
const record = source as Record<string, unknown>;
@@ -40,6 +108,14 @@ function pick(source: unknown, ...names: string[]): unknown {
return undefined;
}
/** Jako `pick`, ale jen neprazdny text. Pro tokeny a adresy z odpovedi. */
export function pickText(source: unknown, ...names: string[]): string | null {
const value = pick(source, ...names);
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
}
function first<T = unknown>(value: unknown): T | null {
if (Array.isArray(value)) return (value[0] as T) ?? null;
const unwrapped = unwrap(value);
@@ -47,6 +123,34 @@ function first<T = unknown>(value: unknown): T | null {
return null;
}
/**
* Seznam z odpovedi, at ho sluzba obalila jakkoliv.
*
* Banka ho da pod `accounts`, jiny standard pod `content` nebo `items`, nekdo
* posle rovnou pole. Hadat jeden tvar by znamenalo, ze pri zmene obalky krok
* tise vrati nula zaznamu misto chyby - proto `null`, kdyz seznam neni nikde.
*/
function list(body: unknown, ...names: string[]): unknown[] | null {
if (Array.isArray(body)) return body;
const found = pick(body, ...names, 'data', 'content', 'items', 'results');
if (Array.isArray(found)) return found;
const unwrapped = unwrap(body);
return Array.isArray(unwrapped) ? unwrapped : null;
}
/** Adresy z pole "Prijemci": oddelene carkou nebo strednikem, bez prazdnych. */
function addresses(value: unknown): string[] {
return String(value ?? '')
.split(/[,;]/)
.map((item) => item.trim())
.filter((item) => item !== '');
}
/** Hodnota v jednoduchych uvozovkach pro OData/SQL filtr, apostrof zdvojeny. */
function quote(value: unknown): string {
return `'${String(value ?? '').replace(/'/g, "''")}'`;
}
function text(value: unknown, fallback: string | null = null): string | null {
if (value === undefined || value === null) return fallback;
if (typeof value === 'object') return fallback;
@@ -55,15 +159,11 @@ function text(value: unknown, fallback: string | null = null): string | null {
}
function num(value: unknown, fallback: number | null = null): number | null {
if (value === undefined || value === null || value === '') return fallback;
const parsed = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
return Number.isFinite(parsed) ? parsed : fallback;
return parseNumber(value) ?? fallback;
}
function bool(value: unknown): boolean {
if (typeof value === 'boolean') return value;
const lowered = String(value ?? '').trim().toLowerCase();
return lowered === 'true' || lowered === '1' || lowered === 'yes' || lowered === 'ano';
return parseBool(value) ?? false;
}
function date(value: unknown): string | null {
@@ -72,6 +172,11 @@ function date(value: unknown): string | null {
return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
}
/** Datum jako `2026-08-20`. Prazdne, kdyz to datum neni. */
function day(value: unknown): string {
return date(value)?.slice(0, 10) ?? '';
}
function round(value: number, decimals = 2): number {
const factor = 10 ** decimals;
return Math.round(value * factor) / factor;
@@ -94,6 +199,10 @@ export const scriptUtil: ScriptUtil = {
date,
round,
need,
day,
list,
addresses,
quote,
get: getPath,
applyRules: (source, rules) => applyRules(source, rules),
fillJson: (template, source) => fillJson(template, source),
@@ -110,9 +219,24 @@ export const scriptUtil: ScriptUtil = {
*/
export function createRedactor(secrets: Array<string | undefined>): (value: string) => string {
// Kratke hodnoty se neredigují - nahradit "1" hvezdickami by rozbilo cely text.
const values = secrets
.filter((value): value is string => typeof value === 'string' && value.length >= 6)
.sort((a, b) => b.length - a.length);
const plain = secrets.filter(
(value): value is string => typeof value === 'string' && value.length >= 6,
);
/*
* I zakodovane podoby. Tajemstvi se do odpovedi vraci v query (procentove
* kodovani) nebo uvnitr JSONu (escapovane uvozovky a lomitka) a v takove
* podobe by presna shoda neprosla.
*/
const values = [
...new Set(
plain.flatMap((secret) => [
secret,
encodeURIComponent(secret),
JSON.stringify(secret).slice(1, -1),
]),
),
].sort((a, b) => b.length - a.length);
if (values.length === 0) return (value) => value;
@@ -124,7 +248,7 @@ export function createRedactor(secrets: Array<string | undefined>): (value: stri
}
/** Zkrati text na danou delku, aby jeden log nezabral megabajt. */
export function truncate(value: string, max = 600): string {
export function truncate(value: string, max = DETAIL_BYTES): string {
if (value.length <= max) return value;
return `${value.slice(0, max)} (zkráceno, celkem ${value.length} znaků)`;
}
+6 -13
View File
@@ -13,15 +13,12 @@
import { config } from '../config.js';
import type { FieldIssue, ScriptField, ScriptJson, ScriptValue, ScriptValues } from './types.js';
import { parseBool, parseNumber } from './util.js';
export type ValidationResult =
| { ok: true; values: ScriptValues }
| { ok: false; issues: FieldIssue[] };
/** Retezce, ktere lidi i sluzby pouzivaji pro ano a ne. */
const truthy = new Set(['true', '1', 'yes', 'y', 'ano', 'on']);
const falsy = new Set(['false', '0', 'no', 'n', 'ne', 'off']);
function isMissing(value: unknown): boolean {
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
}
@@ -86,19 +83,15 @@ function coerce(field: ScriptField, raw: unknown): { value: ScriptValue } | { er
case 'number': {
if (typeof raw === 'boolean') return { error: 'Očekává se číslo, přišlo ano/ne.' };
// Ceska desetinna carka je bezna, nema smysl na ni padat.
const text = typeof raw === 'string' ? raw.trim().replace(',', '.') : raw;
const num = typeof text === 'number' ? text : Number(text);
if (!Number.isFinite(num)) return { error: `"${String(raw)}" není číslo.` };
const num = parseNumber(raw);
if (num === null) return { error: `"${String(raw)}" není číslo.` };
return { value: num };
}
case 'boolean': {
if (typeof raw === 'boolean') return { value: raw };
const text = String(raw).trim().toLowerCase();
if (truthy.has(text)) return { value: true };
if (falsy.has(text)) return { value: false };
return { error: `"${String(raw)}" není ano ani ne.` };
const parsed = parseBool(raw);
if (parsed === null) return { error: `"${String(raw)}" není ano ani ne.` };
return { value: parsed };
}
case 'date': {
+55
View File
@@ -0,0 +1,55 @@
/**
* Co uzivatel smi. Pocita server, klient si nic nedovozuje sam.
*/
import type { GroupRef } from './people.js';
import type { TenantRef } from './tenants.js';
/** Pohled na tickety. Server rozhoduje, ktery smi kdo pouzit. */
export type TicketScope = 'all' | 'tenant' | 'mine';
/** Klic zalozky. Frontend si ho mapuje na cestu a komponentu. */
export type ModuleKey =
| 'overview'
| 'automations'
| 'actions'
| 'widgets'
| 'services'
| 'connectors'
| 'scripts'
| 'tickets'
| 'helpdesk'
| 'people'
| 'incidents'
| 'settings';
/** Zalozka, kterou ma uzivatel videt. */
export interface NavItem {
key: ModuleKey;
label: string;
}
export interface Access {
/** Pohledy, ktere smi uzivatel pouzit. Klient podle toho kresli prepinac. */
scopes: TicketScope[];
/** Firmy, mezi kterymi muze prepinat. */
tenants: TenantRef[];
/** Vychozi firma, kdyz zadnou nezvoli. null = zadna, nema kam. */
defaultTenantId: string | null;
/** Smi prehazovat tickety mezi lidmi, ne jen brat na sebe. */
canAssignOthers: boolean;
/** ID resitele odpovidajiciho uzivateli, nebo null. */
personId: string | null;
/** Efektivni prava. Klient podle nich kresli tlacitka. */
permissions: string[];
/** Nazvy roli uzivatele ve vybrane firme. Jmeno role je to, co se ma ukazat. */
roleNames: string[];
/** Zalozky, ktere ma videt. Prunik toho, co firma ma, a toho, na co ma pravo. */
nav: NavItem[];
/** true = vidi napric firmami a smi platformni nastaveni. */
platformAdmin: boolean;
/** Vidi i cizi tickety, ne jen svoje a frontu? Podle toho se nabizi zalozka Vsechny. */
seesOthers: boolean;
/** Sekce, na ktere clovek vidi. Filtr v zalozce Vsechny. */
visibleGroups: GroupRef[];
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Automatizace a jeji strom akci (flow), jak je vraci API.
*/
import type { ConditionMatch, ConditionOperator, ConditionRule, FieldType } from './conditions.js';
export type AutomationKind = 'workflow' | 'voicebot' | 'integrace' | 'report';
/** Jeden vstupni parametr, ktery spoustec preda dal do stromu. */
export interface TriggerField {
id: string;
/** Klic v prichozich datech, napr. "orderTotal". Musi byt unikatni. */
name: string;
type: FieldType;
required: boolean;
/**
* Kde ta hodnota v tele je, kdyz to neni primo `name`.
* Priklady: `customer.id`, `errors.0.message`, `data.items`.
*/
path?: string;
}
export interface FlowTrigger {
serviceId: string;
operationId: string;
/** Deklarovane vstupni parametry. Podminky se odkazuji na jejich `id`. */
fields: TriggerField[];
/**
* Ukazka toho, co odesilatel opravdu posila. Z ni se odvodi model, tedy
* strom cest, ze ktereho se klikanim vklada odkaz do sablony.
*/
sample?: unknown;
/** Jak casto se ma sluzba obvolavat (v sekundach). Jen u spoustecu, ktere se musi ptat. */
intervalSec?: number;
/** Neodhadnutelny token v adrese webhooku. Generuje vyhradne server. */
webhookToken?: string;
}
/** Jedna cesta v modelu prichozich dat. Pocita ji server z ukazky. */
export interface ModelNode {
/** Cesta k hodnote, napr. `data.order.items[].name`. */
path: string;
type: FieldType;
/** Ukazkova hodnota, zkracena. */
sample: string | null;
/** Kolik polozek melo pole v ukazce. Jen u typu `list`. */
count?: number;
}
/** Krok stromu: akce nad sluzbou, podminka se dvema vetvemi, nebo smycka. */
export type FlowStep =
| {
id: string;
kind: 'action';
serviceId: string;
operationId: string;
/**
* Ktery konektor firmy se pouzije. `null` = vychozi konektor firmy pro
* tuhle sluzbu, u obecnych sluzeb vzdy null.
*/
connectorId?: string | null;
/** Klic je `OperationField.id`, hodnota sablona s `{{parametry}}`. */
inputs?: Record<string, string>;
}
| {
id: string;
kind: 'condition';
/** Otazky, ktere se ptaji naraz. Cte se vzdycky pres `rulesOf`. */
rules?: ConditionRule[];
/** `all` = a zaroven, `any` = nebo. */
match?: ConditionMatch;
/** Stara podoba: jedna otazka primo na kroku. Prevadi ji `rulesOf`. */
fieldId?: string;
operator?: ConditionOperator;
value?: string;
yes: FlowStep[];
no: FlowStep[];
}
| {
id: string;
/**
* Podstrom nad kazdou polozkou seznamu. Uvnitr je `item` a `index`,
* po skonceni `krok.results` se seznamem vysledku.
*/
kind: 'foreach';
/** Cesta k seznamu, napr. `data.order.items`. */
path: string;
steps: FlowStep[];
};
export interface AutomationFlow {
trigger: FlowTrigger | null;
steps: FlowStep[];
}
/** Polozka v seznamu automatizaci, bez celeho stromu. */
export interface Automation {
id: string;
/** Firma, ktere automatizace patri. Hranice viditelnosti. */
tenantId: string;
name: string;
kind: AutomationKind;
enabled: boolean;
/** Behy za dnesni den. Po pulnoci zacina od nuly. */
runsToday: number;
/** Behy za vcerejsi den, at je s cim srovnat. */
runsYesterday: number;
/** Behy od zalozeni. */
runsTotal: number;
/** Uspesnost dnesnich behu. Kdyz dnes zadny nebyl, posledniho dne, kdy byly. */
successRate: number;
avgDurationMs: number;
lastRunAt: string;
/** Pocet vsech kroku vcetne vnorenych vetvi. */
stepCount: number;
/** false = automatizace jeste nema spoustec, je to koncept. */
configured: boolean;
/** Co chybi k tomu, aby se dala zapnout. Prazdne = je hotova. */
issues: string[];
}
/** Jak dopadl jeden deklarovany parametr v jednom volani. */
export interface WebhookCallField {
name: string;
/** Cesta v tele. Shodna se jmenem, kdyz se nezadala vlastni. */
path: string;
required: boolean;
/** Deklarovany typ z kontraktu. */
type: string;
/** Co na te ceste opravdu bylo: text, cislo, objekt, seznam, nepreslo. */
got: string;
/** Kratky nahled prijate hodnoty. */
preview: string;
/** false = tenhle parametr je duvod, proc se volani odmitlo. */
ok: boolean;
}
/** Jedno volani webhooku, jak dopadlo, vcetne prijateho tela. */
export interface WebhookCall {
at: string;
/** true = prevzato do fronty, false = odmitnuto pro neplatna data. */
ok: boolean;
/** Duvody odmitnuti. Prazdne u prevzatych volani. */
problems: string[];
/** Beh, ktery z volani vznikl. null u odmitnutych a u duplicit. */
runId: string | null;
/** Kazdy deklarovany parametr a jak dopadl. */
fields: WebhookCallField[];
/** Cele prijate telo jako JSON. Delsi se usekne. */
body: string;
/** true = telo bylo delsi a je useknute. */
truncated: boolean;
}
export interface AutomationDetail extends Automation {
flow: AutomationFlow;
/** Poslednich par volani webhooku, nejnovejsi prvni. Drzi se v pameti. */
recentCalls: WebhookCall[];
/** Model prichozich dat z ukazky u spoustece. Pocita server pri cteni. */
model: ModelNode[];
createdAt: string;
updatedAt: string;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Typy vstupnich parametru a operatory podminek. Tabulky (ktery operator
* ke kteremu typu) zustavaji na serveru v src/data/conditions.ts a web je
* dostava v katalogu sluzeb jako `operatorsByType`.
*/
/**
* `object` a `list` jsou cele struktury, ne jednotlive hodnoty. Do sablony se
* nedosazuji, predavaji se jen jako celek dalsimu kroku.
*/
export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object' | 'list';
export type ConditionOperator =
| 'eq'
| 'neq'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'startsWith'
| 'isEmpty'
| 'isNotEmpty'
| 'isTrue'
| 'isFalse';
/** Jedna otazka v podmince. */
export interface ConditionRule {
/** ID parametru spoustece, nebo `krok.vystup`. */
fieldId: string;
operator: ConditionOperator;
/** Chybi u operatoru, ktere hodnotu nepotrebuji (isEmpty, isTrue apod.). */
value?: string;
}
/** Jak se otazky spoji. `all` = a zaroven, `any` = nebo. Zamerne jedna uroven. */
export type ConditionMatch = 'all' | 'any';
+167
View File
@@ -0,0 +1,167 @@
/**
* Konektor = napojeni jedne firmy na jednu sluzbu, jak ho vidi klient.
*
* Tohle je verejny tvar (na serveru `PublicConnector`). Hodnoty pristupovych
* udaju tu nejsou a nikdy nesmi byt, server posila jen `filled` a `missing`.
* Ulozeny tvar s hodnotami je server-only, viz src/data/connectors/types.ts.
*/
/** untested = jeste se neoverovalo, ok = posledni overeni proslo. */
export type ConnectorStatus = 'untested' | 'ok' | 'error';
/**
* Jeden zaznam v historii overeni konektoru.
* Nese cele telo odpovedi sluzby, protoze prave tam je napsane, co ji vadilo.
*/
export interface ConnectorCheck {
/** ISO cas overeni. */
at: string;
ok: boolean;
/** Co se overovalo: pristupove udaje, nebo jen dostupnost sluzby. */
checked: string;
/** HTTP kod od sluzby. null, kdyz se k volani vubec nedoslo. */
status: number | null;
message: string;
/** Cele telo odpovedi sluzby, zredigovane. */
detail: string | null;
/** Cela adresa vcetne serveru, bez query. null, kdyz se k volani nedoslo. */
request: { method: string; path: string; url: string } | null;
/** Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal. */
responseHeaders: Record<string, string> | null;
/** Odchozi IP portalu ve chvili volani. Jen u odmitnuteho pristupu. */
egressIp: string | null;
}
export interface Connector {
id: string;
tenantId: string;
serviceId: string;
/** Firma muze mit dva ucty teze sluzby, proto vlastni nazev. */
name: string;
/** Prepis adresy sluzby. null = vychozi podle `appId` a SERVICES_BASE_URL. */
baseUrl: string | null;
enabled: boolean;
status: ConnectorStatus;
lastCheckAt: string | null;
lastError: string | null;
/** Kolik zaznamu o overeni je v historii. Samotna historie se cte zvlast. */
checkCount: number;
/** Krok stromu bez vybraneho konektoru pouzije vychozi. */
isDefault: boolean;
createdAt: string;
updatedAt: string;
/** ID poli, ktera jsou vyplnena. Hodnoty se nevraci. */
filled: string[];
/** ID povinnych poli, ktera jeste chybi. */
missing: string[];
/** Necitliva nastaveni. Tajna pole tu nejsou vubec. */
config: Record<string, string>;
/** true = vsechna povinna pole jsou vyplnena, jde volat. */
ready: boolean;
/** Nastroje MCP serveru. `null` u vsech ostatnich sluzeb. */
mcp: Toolset | null;
}
export interface ToolSummary {
name: string;
label: string;
description: string;
/** Nazvy parametru, ktere nastroj prijima. Povinne maji na konci hvezdicku. */
inputs: string[];
/** Nazvy hodnot, ktere nastroj vraci. */
outputs: string[];
}
/** Nastroje jednoho MCP serveru tak, jak je vidi portal. Bez schemat. */
export interface Toolset {
/** ISO cas nacteni. Rika, jak stary seznam clovek vidi. */
at: string;
server: string;
protocolVersion: string;
tools: ToolSummary[];
}
/** Odpoved POST /connectors/:id/mcp/tools. Neuspech je vysledek, ne chyba API. */
export interface ToolsLoadResult {
ok: boolean;
checked: string;
message: string;
status?: number;
detail?: string;
request?: { method: string; path: string; url: string } | null;
server?: string;
protocolVersion?: string;
tools: ToolSummary[];
}
/** Odpoved POST /connectors/:id/test. */
export interface ConnectorTestResult {
ok: boolean;
/** Co se vlastne overilo: pristupove udaje, nebo jen dostupnost sluzby. */
checked: string;
status?: number;
message: string;
/** Ktere volani to bylo, vcetne serveru. */
request?: { method: string; path: string; url: string };
/** Cela odpoved sluzby. Tady je napsane, co ji vadilo. */
detail?: string;
/** Zaklad adresy, na kterou konektor miri. Vraci se i kdyz overeni projde. */
baseUrl?: string;
/** Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal. */
responseHeaders?: Record<string, string>;
/** Odchozi IP portalu. Jen u odmitnuteho pristupu. */
egressIp?: string;
}
// ------------------------------------------------------------- uloziste
export type StorageMode = 'postgres' | 'file' | 'memory';
/** Kam se uklada a co to prezije. */
export interface StorageStatus {
mode: StorageMode;
/** Kratke vysvetleni pro uzivatele. Vzdy vyplnene, i u databaze. */
reason: string;
/** true = restart procesu data smaze. */
ephemeral: boolean;
/** true = redeploy containeru data smaze, i kdyz restart ne. */
lostOnRedeploy: boolean;
/** Odkud je klic k sifrovani tajnych hodnot. */
secrets: 'env' | 'file' | 'none';
/** Kde data lezi. null u pameti; beznemu uzivateli chodi vzdy null. */
location: string | null;
}
// ---------------------------------------------------------- odchozi adresa
/** Jak nas vidi nase vlastni reverse proxy, kdyz se volani otoci pres domenu. */
export interface ProxyView {
/** Tohle je to cislo: adresa z X-Forwarded-For. */
ip: string | null;
/** Surova hlavicka, at je videt i to, co proxy nepripsala. */
forwardedFor: string | null;
/** Adresa druheho konce spojeni. To je sama proxy, ne volajici. */
remoteAddress: string | null;
/** Rozsah, ktery tu adresu pokryje cely. null = adresa je verejna. */
suggestedRange: string | null;
/** Kam se volalo. */
url: string;
error?: string;
}
/**
* Odchozi IP adresa portalu, tedy ta, kterou vidi volana sluzba a ktera musi
* byt na jejim seznamu povolenych. Z containeru ji videt neni, zjistuje se
* echo sluzbou.
*/
export interface EgressIp {
/** null = nepodarilo se zjistit, nebo je funkce vypnuta. */
ip: string | null;
/** Odkud to je. */
source: string;
checkedAt: string;
/** Vyplnene jen kdyz `ip` je null. */
error?: string;
/** null = nezmereno, chybi `PUBLIC_ORIGIN`, nebo se to nepodarilo. */
viaProxy: ProxyView | null;
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Souhrn prehledu a obecne obalky odpovedi.
*/
export interface SeriesPoint {
date: string;
runs: number;
failures: number;
}
/** Odpoved GET /dashboard/summary. */
export interface DashboardSummary {
openTickets: number;
activeIncidents: number;
activeAutomations: number;
runsToday: number;
savedHoursMonth: number;
uptime: number;
series: SeriesPoint[];
}
export interface ListResponse<T> {
items: T[];
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Zakladni tvar zaznamu v ulozisti. Sdilene se webem, protoze nastaveni
* (firmy, lide, typy ticketu, role) vraci zaznamy tak, jak lezi v ulozisti.
*
* Soubory v `src/shared` jsou jen typy: zadny import z Node, zadny kod.
* Web je nacita pres alias `@shared/*`, viz web/tsconfig.json.
*/
/** Kazdy zaznam ma ID a vi, kdy vznikl. */
export interface Entity {
id: string;
createdAt: string;
updatedAt: string;
}
/**
* Zaznam patrici firme.
*
* `tenantId: null` znamena platformni zaznam, tedy nas, ne zakaznikuv.
* Typicky systemova role nebo sluzba dostupna vsem.
*/
export interface TenantEntity extends Entity {
tenantId: string | null;
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Udalosti dashboardu, jak chodi klientovi pres SSE stream.
*/
/**
* Zmeny ciselniku z Nastaveni a konektoru.
*
* Portal si ciselniky drzi v pameti a bez techhle udalosti by je obnovil az
* prepnutim firmy. Payload nese cely zaznam pod klicem stejneho jmena
* (`{ person: {...} }`), aby se dal opravit na miste; u smazani jen `{ id }`.
*/
export type EntityEventKind =
| 'tenant'
| 'user'
| 'role'
| 'person'
| 'group'
| 'ticketType'
| 'action'
| 'widget'
| 'connector'
| 'feature';
export type EntityEventType = `${EntityEventKind}.${'created' | 'updated' | 'deleted'}`;
export type DashboardEventType =
| 'ticket.created'
| 'ticket.updated'
| 'ticket.assigned'
| 'ticket.resolved'
| 'incident.started'
| 'incident.updated'
| 'incident.resolved'
| 'automation.created'
| 'automation.updated'
| 'automation.deleted'
| 'automation.run'
| 'webhook.received'
/** Nekomu prislo upozorneni. Portal podle toho prekresli cislo u zalozky. */
| 'notification.created'
| EntityEventType;
export interface DashboardEvent {
id: string;
type: DashboardEventType;
at: string;
/** Kratka veta pro uzivatele, zobrazuje se v notifikaci. */
message: string;
/** Doplnkova data, napr. id ticketu. */
payload?: Record<string, unknown>;
/** Ci firmy se to tyka. `null` = cele platformy. Stream podle toho filtruje. */
tenantId: string | null;
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Incident = hlaseni o vypadku. Muze byt firemni i platformni.
*/
export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3';
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
export interface Incident {
id: string;
/** Firma, ktere se to tyka. `null` = platformni incident spolecny vsem. */
tenantId: string | null;
/** Srozumitelne pro klienta. Zadne stack trace, zadne ID kroku. */
title: string;
service: string;
severity: IncidentSeverity;
status: IncidentStatus;
/** Co s tim ma klient delat, nebo co to pro nej znamena. */
impact: string;
/** Diagnostika pro admina. Beznemu uzivateli chodi vzdy `null`. */
detail: string | null;
/** Odkud incident vznikl, napr. `run_ab12` nebo `rucne`. */
source: string | null;
/** Ticket, u ktereho to prasklo. */
ticketId: string | null;
startedAt: string;
resolvedAt: string | null;
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Typy API sdilene serverem a webem. Jen typy, zadny kod: po prekladu z
* teto slozky zbydou prazdne moduly a to je v poradku.
*
* Server: `import type { Ticket } from '../shared/tickets.js'`.
* Web: `import type { Ticket } from '@shared/tickets'`.
*/
export type * from './access.js';
export type * from './automations.js';
export type * from './conditions.js';
export type * from './connectors.js';
export type * from './dashboard.js';
export type * from './entities.js';
export type * from './events.js';
export type * from './incidents.js';
export type * from './people.js';
export type * from './permissions.js';
export type * from './scripts.js';
export type * from './services.js';
export type * from './tenants.js';
export type * from './tickets.js';
export type * from './users.js';
export type * from './widgets.js';
+34
View File
@@ -0,0 +1,34 @@
/**
* Resitele ticketu a jejich skupiny, jak je vraci API.
*/
import type { TenantEntity } from './entities.js';
/** Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail. */
export interface Person extends TenantEntity {
/** Firma, jejiz je clenem tymu. Hranice viditelnosti, proto nikdy null. */
tenantId: string;
name: string;
email: string;
/** Cim se v tymu zabyva. Jen popisek, nic nerozhoduje. */
role: string;
/** Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez. */
capacity: number;
/** Vypnuty resitel se nenabizi k prirazeni, ale stare tickety nespadnou. */
enabled: boolean;
/** ID, pod kterymi cloveka znaji cizi aplikace (voicebot, ustredna, chat). */
externalIds: string[];
}
/** Skupina resitelu tak, jak ji vidi bezny uzivatel: jen jmeno. */
export interface GroupRef {
id: string;
name: string;
}
/** Odpoved GET /people. Skupiny chodi spolu s lidmi, kdo prirazuje, potrebuje oboji. */
export interface PeopleResponse {
items: Person[];
groups: GroupRef[];
meId: string | null;
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Prava a role jako data. Katalog prav je na serveru, tady je jen jeho tvar.
*/
import type { TenantEntity } from './entities.js';
export type PermissionKey = string;
export interface PermissionDefinition {
key: PermissionKey;
label: string;
/** Do ktere skupiny patri v nastaveni role. */
group: string;
/** true = pravo je nase, klientsky admin ho neprideluje. */
platform?: boolean;
hint?: string;
}
export interface Role extends TenantEntity {
/** Stabilni klic, napr. `admin`. U vlastnich roli je stejny jako ID. */
key: string;
name: string;
description: string;
permissions: PermissionKey[];
/** true = systemova role, nejde smazat ani prejmenovat. */
system: boolean;
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Skripty konektoru: manifest, vysledek behu a odpovedi katalogu.
*
* Zdroj pravdy o manifestu je zod schema v src/scripts/types.ts. Tvar tady
* musi sedet s nim, server to hlida pri prekladu (`satisfies`).
*/
import type { FieldType } from './conditions.js';
import type { Connector } from './connectors.js';
/** Hodnota, se kterou skript pracuje. Cokoliv, co jde vyjadrit JSONem. */
export type ScriptJson =
| string
| number
| boolean
| null
| ScriptJson[]
| { [key: string]: ScriptJson };
export type ScriptValue = ScriptJson;
export type ScriptValues = Record<string, ScriptValue>;
/** Parametr skriptu. Stejny tvar pro vstup i vystup. */
export interface ScriptField {
/** Pouziva se v sablonach jako `{{id}}`. Jen pismena, cislice a podtrzitko. */
id: string;
label: string;
type: FieldType;
required: boolean;
/** Napoveda pod polem v builderu. */
hint?: string;
/** Vyber z hodnot. Jina hodnota neprojde validaci. */
options?: Array<{ value: string; label: string }>;
/** Jen u typu string: dalsi kontrola regularnim vyrazem. */
pattern?: string;
/** Jen u typu string: pole na vic radku. */
multiline?: boolean;
/** Cim se pole vyplnuje v builderu, kdyz to z typu nejde poznat. */
control?: 'mapping' | 'json' | 'object';
/** Dosadi se, kdyz hodnota chybi a parametr neni povinny. */
default?: string | number | boolean | null;
/** Jen u `object` a `list`: strop na velikost v bajtech. */
maxBytes?: number;
}
/** Manifest skriptu. `id` ma tvar `<sluzba>.<operace>`. */
export interface ScriptManifest {
id: string;
name: string;
description: string;
inputs: ScriptField[];
outputs: ScriptField[];
/** Strop na jeden beh. Kdyz chybi, pouzije se hodnota z konfigurace. */
timeoutMs?: number;
}
/** Manifest plus to, co si klient nema dopocitavat sam. Polozka katalogu. */
export interface ScriptSummary extends ScriptManifest {
serviceId: string;
serviceName: string;
operationId: string;
}
export interface FieldIssue {
field: string;
message: string;
}
/** Skript, ktery se nepodarilo nacist. */
export interface ScriptProblem {
/** Nazev souboru, ne cela cesta. */
file: string;
scriptId: string | null;
message: string;
issues?: FieldIssue[];
}
/** Odpoved GET /scripts. */
export interface ScriptCatalog {
items: ScriptSummary[];
problems: ScriptProblem[];
/** Vychozi konektor firmy podle ID sluzby. Chybi = sluzba neni napojena. */
connectors: Record<string, Connector>;
tenantId: string | null;
/** Slozka se skripty. Jen pro spravce platformy, ostatnim chodi null. */
directory: string | null;
}
/** Odpoved GET /scripts/:id. */
export interface ScriptDetail {
id: string;
serviceId: string;
operationId: string;
serviceName: string;
/** null = soubor je rozbity, manifest se nepodarilo precist. */
manifest: ScriptManifest | null;
code: string;
problem: ScriptProblem | null;
/** Vychozi konektor firmy, pod kterym se skript da vyzkouset. */
connector: Connector | null;
}
// --------------------------------------------------------------------- beh
/**
* Druh selhani. `retryable` a `terminal` je to podstatne: timeout nebo 503
* ma smysl zkusit znovu, chyba ve vstupu nebo 403 ne.
*/
export type ScriptErrorKind =
| 'not_found'
| 'config'
| 'validation'
| 'output'
| 'retryable'
| 'terminal'
| 'timeout'
| 'internal';
/** Ktere volani spadlo. Bez toho je chybova zprava jen pulka informace. */
export interface ScriptRequestInfo {
method: string;
/** Cesta bez domeny. */
path: string;
/** Cela adresa vcetne serveru, ale bez query. */
url: string;
}
export interface ScriptLogEntry {
at: string;
message: string;
detail?: string;
}
export interface ScriptRunError {
kind: ScriptErrorKind;
message: string;
retryable: boolean;
status?: number;
/** Cela odpoved sluzby, uz zredigovana, nebo zasobnik volani u chyby ve skriptu. */
detail?: string;
request?: ScriptRequestInfo;
/** Zasobnik volani. Jen mimo produkci. */
stack?: string;
/** Vyplnene jen u chyb ve vstupu nebo vystupu. */
issues?: FieldIssue[];
}
export interface ScriptRunResult {
ok: boolean;
scriptId: string;
/** Prazdne, kdyz beh selhal. */
outputs: ScriptValues;
logs: ScriptLogEntry[];
durationMs: number;
httpCalls: number;
error: ScriptRunError | null;
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Katalog sluzeb: co umime. Sluzba ma operace a rika, co je potreba k napojeni.
* Napojeni jedne firmy je konektor, viz ./connectors.ts.
*/
import type { ConditionOperator, FieldType } from './conditions.js';
export type ServiceCategory =
/** Obecne veci, ktere ma kazdy. Nepotrebuji konektor. */
| 'obecne'
| 'crm'
| 'ekonomika'
| 'logistika'
| 'komunikace'
| 'analytika'
| 'ai'
/** Prevody dat mezi kroky. Vlastni kategorie, je jich hodne. */
| 'transformace';
/** available = sluzbu umime, planned = je na roadmape. */
export type ServiceStatus = 'available' | 'planned';
/** Polozka nabidky kategorii, jak ji posila katalog. */
export interface ServiceCategoryEntry {
id: ServiceCategory;
label: string;
}
/**
* Kdo sluzbu vidi. `everyone` vsichni, `restricted` jen uvedene firmy a lide,
* `admin` jen spravce platformy.
*/
export interface ServiceVisibility {
mode: 'everyone' | 'restricted' | 'admin';
tenantIds: string[];
userIds: string[];
}
/** Jeden udaj, ktery je potreba vyplnit pri zakladani konektoru. */
export interface ServiceCredentialField {
id: string;
label: string;
target: 'header' | 'config';
/** Jmeno hlavicky u `header`, jmeno klice v `ctx.config` u `config`. */
name: string;
required: boolean;
/** true = hodnota se ze serveru nikdy nevraci. */
secret: boolean;
hint?: string;
/** Text, ktery se pred hodnotu doplni az pri sestaveni hlavicky (`Bearer `). */
prefix?: string;
/** true = hodnotu spravuje portal, ve formulari se nezobrazuje. */
managed?: boolean;
}
/**
* Parametr, ktery spoustec nebo akce preda do stromu sama. Tvarem odpovida
* `TriggerField` bez cesty; ID musi byt stabilni, odkazuji se na nej podminky.
*/
export interface ProvidedField {
id: string;
name: string;
type: FieldType;
required: boolean;
}
/**
* Nastavitelne pole akce. Hodnota je sablona, `{{nazev}}` se nahradi
* parametrem spoustece.
*/
export interface OperationField {
id: string;
label: string;
/**
* `json` a `mapping` nesou strukturu zapsanou jako JSON v retezci.
* `object` je odkaz na parametr typu objekt nebo seznam.
* `lookup` je ciselnik a zaroven volny text.
*/
kind: 'text' | 'longtext' | 'html' | 'choice' | 'lookup' | 'json' | 'mapping' | 'object';
required: boolean;
options?: Array<{ value: string; label: string }>;
/** Odkud se doplni nabidka za behu. Server ji dosadi pri kazdem cteni katalogu. */
optionsFrom?: 'people' | 'groups' | 'scripts' | 'ticketTypes';
hint?: string;
}
export interface ServiceOperation {
id: string;
name: string;
description: string;
/** Popis toho, co bude potreba nastavit. Jen napoveda, nikam se neuklada. */
fields?: string[];
/** Jen u akci: skutecne nastavitelna pole. Kdyz chybi, akci nejde nastavit. */
inputs?: OperationField[];
/** Jen u akci: co krok vrati dalsim krokum. Podminka se na to muze ptat. */
outputFields?: ProvidedField[];
/** Jen u triggeru: true = vstupni parametry si definuje uzivatel. */
customPayload?: boolean;
/** Jen u triggeru: parametry, ktere sluzba predava sama. Uzivatel je nemeni. */
providedFields?: ProvidedField[];
/** `script` = operaci obsluhuje skript, tedy se opravdu vykona. */
implementation?: 'script';
/** Ktery skript operaci obsluhuje. Vyplnene spolu s `implementation`. */
scriptId?: string;
}
export interface Service {
id: string;
name: string;
category: ServiceCategory;
description: string;
/** Klic ikony, mapuje se na klientovi (lib/serviceIcons.ts). */
icon: string;
status: ServiceStatus;
/** true = funguje bez konektoru (webhook, pauza, transformace dat). */
general: boolean;
/** ID aplikace v services.csbot.cz/apps. null = sluzba nema jednu pevnou adresu. */
appId: string | null;
/**
* `smtp` = sluzba se nevola pres HTTP, adresu nese konektor v udajich.
* `mcp` = totez, a navic zadne operace v katalogu nema, rekne je az server.
*/
transport?: 'http' | 'smtp' | 'mcp';
/** Absolutni adresa sluzby, ktera nebezi u nas. Typicky OpenAI. */
baseUrl?: string;
visibility: ServiceVisibility;
credentials: ServiceCredentialField[];
/** Cteci volani, kterym se overi konektor. Kdyz chybi, overi se jen `/health`. */
verifyPath?: string;
triggers: ServiceOperation[];
actions: ServiceOperation[];
}
/** Sluzba ocima jedne firmy, tedy uz vcetne toho, co ma napojene. */
export interface ServiceWithUsage extends Service {
baseUrl: string;
/** Kolik konektoru na ni firma ma. 0 = muze si napojit. */
connectorCount: number;
/** Kolik akci uz opravdu neco dela, tedy ma skript. */
implementedActions: number;
}
/** Odpoved GET /connectors/services. */
export interface ServiceOverview {
categories: ServiceCategoryEntry[];
items: ServiceWithUsage[];
tenantId: string | null;
}
/**
* Sluzba v katalogu pro builder. `connected` doplnuje server podle konektoru
* firmy: katalog se kvuli tomu nefiltruje, log ticketu podle nej preklada ID
* operaci na jmena. Vybirat z nenapojene sluzby ale nema smysl.
*/
export interface CatalogService extends Service {
/** true = firma ma k teto sluzbe aspon jedno napojeni. */
connected: boolean;
}
/** Odpoved GET /dashboard/services, katalog pro builder. */
export interface ServiceCatalog {
categories: ServiceCategoryEntry[];
items: CatalogService[];
/** Ktere operatory server povoli pro ktery typ parametru. */
operatorsByType: Record<FieldType, ConditionOperator[]>;
/** Zaklad adresy webhooku, napr. "https://api.automia.cz/webhook". */
webhookBaseUrl: string;
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Firma (tenant) = nas zakaznik, ktery servicedesk provozuje.
* Neplest s `ticket.customer`, to je zakaznik teto firmy.
*/
import type { TenantEntity } from './entities.js';
/** Odkaz na firmu v seznamu prav (`Access.tenants`). Jen to, co je videt v prepinaci. */
export interface TenantRef {
id: string;
name: string;
}
/** Cely zaznam firmy, jak ho vraci nastaveni (`/settings/tenants`). */
export interface Tenant extends TenantEntity {
name: string;
/** 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 stromu.
*/
intakeToken: string;
/**
* Firma, ktera teto firme resi pozadavky z helpdesku. `null` = helpdesk nema
* komu poslat. Nastavuje spravce platformy.
*/
helpdeskProviderId: string | null;
/**
* Udaje z registru ARES, vsechny nepovinne: firma zalozena rucne je nema.
* Starsi zaznamy pole nemaji vubec, proto se ctou jako `undefined`.
*/
ico?: string | null;
dic?: string | null;
/** Textova adresa sidla. */
address?: string | null;
/** Nazev pravni formy, napr. "Spolecnost s rucenim omezenym". */
legalForm?: string | null;
}
+225
View File
@@ -0,0 +1,225 @@
/**
* Ticket = prichozi pozadavek odkudkoliv (WhatsApp, e-mail, hlasova linka,
* formular, portal). Tvar, ktery API vraci klientovi.
*/
import type { TicketScope } from './access.js';
import type { FieldType } from './conditions.js';
import type { TenantEntity } from './entities.js';
import type { Person } from './people.js';
/**
* Stav ticketu. Volny retezec, ne ciselnik: tickety chodi z cizich aplikaci,
* ktere maji svoje stavy.
*/
export type TicketStatus = string;
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
/** Odkud pozadavek prisel. */
export type TicketChannel =
| 'whatsapp'
| 'facebook'
| 'instagram'
| 'email'
| 'voice'
| 'form'
| 'portal';
export interface TicketCustomer {
/** ID firmy v CRM. null = zakaznika se nepodarilo dohledat. */
id: string | null;
company: string;
/** Konkretni clovek, ktery pozadavek poslal. */
contact: string;
/** Adresa nebo cislo, odkud to prislo a kam se odpovida. */
reply: string;
}
/** Odkaz na resitele v odpovedi API. Uvnitr se drzi jen jeho ID. */
export interface TicketAssignee {
id: string;
name: string;
}
export type TraceStatus = 'ok' | 'error' | 'skipped' | 'info';
export type TraceKind = 'trigger' | 'action' | 'condition' | 'note';
/** Jeden radek v logu ticketu. Strom se sklada pres `parentId`. */
export interface TicketTraceEntry {
id: string;
/** null = zaznam v hlavni sekvenci. */
parentId: string | null;
kind: TraceKind;
/** Ktera sluzba to byla. null u poznamek a podminek. */
serviceId: string | null;
operationId: string | null;
label: string;
status: TraceStatus;
/** Co sluzba vratila. Tohle je duvod, proc log existuje. */
response: string | null;
durationMs: number | null;
at: string;
}
/** Prichozi udalost k ticketu. Fakt zvenku, na rozdil od radku logu. */
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. */
payload: Record<string, unknown>;
at: string;
/** Kolikrat prislo totez. Vic nez 1 znamena, ze odesilatel posila opakovane. */
repeats: number;
/** Kdy prisla naposledy. Same jako `at`, dokud se neopakuje. */
lastAt: string;
}
export interface Ticket {
id: string;
/** Firma, ktere ticket patri. Hranice viditelnosti. */
tenantId: string;
/** Firma, ze ktere pozadavek prisel pres helpdesk. `null` u beznych ticketu. */
helpdeskSourceId: string | null;
/**
* ID u odesilatele, napr. cislo objednavky. Unikatni v ramci firmy.
* Dalsi zprava se stejnym ID se navesi na tenhle ticket.
*/
externalId: string | null;
/** Odkud externi ID pochazi. Jen informace, do klice nevstupuje. */
externalSource: string | null;
subject: string;
/** Cely text pozadavku. Prazdny = krok "Zalozit ticket" obsah nenaplnil. */
body: string;
/** Odkaz na zdrojovou zpravu u poskytovatele. null u rucne zalozenych. */
sourceRef: string | null;
channel: TicketChannel;
customer: TicketCustomer;
status: TicketStatus;
priority: TicketPriority;
/** Kdo ma ticket u sebe. null = ceka ve fronte. */
assignee: TicketAssignee | null;
/** Skupina resitelu, ktera ma ticket u sebe. */
assigneeGroupId: string | null;
/** Typ ticketu. Rozhoduje o vlastnich polich a o tom, ktere akce se ukazou. */
typeId: string | null;
/** Hodnoty vlastnich poli typu. Klic je `TicketTypeField.key`. */
fields: Record<string, string | number | boolean | null>;
/**
* Je vyrizeny? Nastavuje se vyslovne, neodvozuje se ze stavu.
* Fronta, vytizeni i statistiky se ptaji na tenhle priznak.
*/
closed: boolean;
/** Volne tagy. Akce se muze vazat na typ nebo na tag. */
tags: string[];
/** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */
automationId: string | null;
/** Ucet, ktery ticket zalozil rucne. null u ticketu z automatizace. */
createdById: string | null;
/** Kdy se ticketu prvne nekdo venoval. */
firstResponseAt: string | null;
/** Kdy byl vyresen. Znovuotevreni to vynuluje. */
resolvedAt: string | null;
/** Kdo ho vyresil. */
resolvedById: string | null;
/** Kolikrat se vratil z vyreseno zpatky do prace. */
reopenCount: number;
createdAt: string;
updatedAt: string;
}
export interface TicketDetail extends Ticket {
trace: TicketTraceEntry[];
/** Prichozi udalosti, nejstarsi nahore. */
events: TicketEvent[];
}
/** Odpoved seznamu ticketu. `meId` rika, ktery resitel je prihlaseny uzivatel. */
export interface TicketListResponse {
items: Ticket[];
/** Kolik ticketu odpovida filtru, bez ohledu na strankovani. */
total: number;
/** Stavy, ktere se v datech vyskytuji. Filtr z nich sklada nabidku. */
statuses: string[];
meId: string | null;
/** Pohled, ktery server skutecne pouzil. */
scope: TicketScope;
/** Firma, na kterou je pohled zamereny. null u pohledu `all`. */
tenantId: string | null;
}
// ------------------------------------------------------------ typy ticketu
/** Vlastni pole typu ticketu. */
export interface TicketTypeField {
/** Stabilni, odkazuji se na nej podminky. Prejmenovani je nerozbije. */
id: string;
/** Pouziva se v sablonach jako `{{orderNumber}}`. */
key: string;
label: string;
type: FieldType;
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
}
/** Typ ticketu: za nim stoji vlastni pole, stavy a akce. */
export interface TicketType extends TenantEntity {
tenantId: string;
/** Kratky klic, napr. `order`. Pouziva se ve filtrech a podminkach. */
key: string;
name: string;
/** Klic ikony, mapuje se na klientovi. */
icon: string;
/** Vlastni stavy. Prazdne = vychozi doporucena ctverice. */
statuses: string[];
fields: TicketTypeField[];
}
// ------------------------------------------------------- vytizeni a vykon
export interface WorkloadRow {
person: Person;
/** Nevyresene tickety. */
open: number;
/** Vcetne vyresenych. */
total: number;
critical: number;
/** Nejstarsi nevyreseny ticket. null = nic nevisi. */
oldestOpenAt: string | null;
/** true = ma vic nevyrizenych, nez je jeho kapacita. */
overloaded: boolean;
}
/** Prehled nad firmou: kdo co ma u sebe. */
export interface Workload {
rows: WorkloadRow[];
unassigned: number;
openTotal: number;
}
/** Vykon jednoho resitele: vedle poctu i casy a vraceni. */
export interface AgentStatsRow {
personId: string;
name: string;
/** Kolik vyresil za sledovane obdobi. */
resolved: number;
/** Kolik ma prave ted nevyrizenych. */
open: number;
/** Kolik z nevyrizenych je vyssi priority. */
critical: number;
/** Medianovy cas do vyreseni v milisekundach. null = nic nevyresil. */
medianResolveMs: number | null;
/** Medianovy cas do prvni reakce. null = nikde nereagoval. */
medianResponseMs: number | null;
/** Kolikrat se mu ticket vratil z vyreseno zpatky. */
reopened: number;
/** Nejstarsi nevyrizeny, aby bylo videt, co lezi. */
oldestOpenAt: string | null;
/** Ma vic prace, nez unese. */
overloaded: boolean;
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Ucet portalu tak, jak ho vidi klient. Hash hesla tu neni a nikdy nebude:
* serverovy `User` v src/types.ts ho ma navic a na klienta se nikdy neposila.
*
* Uzivatel muze patrit do vic firem najednou (napr. externista, ktery dela
* servicedesk dvema klientum). Proto `memberships`, ne jedno `tenantId`.
*/
/**
* Clenstvi ve firme.
*
* `roleIds` je seznam, ne jedna role. Duvod: `'admin' | 'agent'` na ucetni,
* skladnika a vedouciho nestacilo a pridavat dalsi hodnoty do unionu je slepa
* ulicka, kazdy klient chce jine. Role jsou proto zaznamy, viz `data/permissions.ts`.
*
* Odkaz smi byt ID role, nebo klic systemove role (`admin`, `agent`), aby slo
* zapsat oboji a stara data dal fungovala.
*/
export interface Membership {
tenantId: string;
roleIds: string[];
/**
* Vidi vsechny tickety firmy, ne jen svoje a svych sekci.
*
* Je to **postaveni uctu ve firme**, ne vlastnost resitele, proto to visi
* tady a ne na `Person`: clovek muze byt ve dvou firmach jednou reditel
* a jednou brigadnik.
*
* `undefined` u zaznamu ulozenych driv. Co pak plati, resi `visibilityFor`
* v data/access.ts - nemuze to byt tise `false`, jinak by pri nasazeni
* kazdemu spravci zmizela vetsina ticketu.
*/
seesAllTenant?: boolean;
}
/** Verze uzivatele bezpecna pro odeslani na klienta. */
export interface PublicUser {
id: string;
email: string;
name: string;
/** Vidi napric vsemi firmami a muze mezi nimi prepinat. */
platformAdmin: boolean;
memberships: Membership[];
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Widgety prehledu: katalog, rozlozeni a data vlastnich widgetu.
*/
import type { AgentStatsRow, TicketChannel, TicketStatus } from './tickets.js';
export type WidgetSize = 'third' | 'half' | 'full';
/** Jak se vlastni widget kresli. Odkud bere data, rika `WidgetSource`. */
export type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge';
/**
* Pevny widget z katalogu. Vlastni widget pouziva `WidgetRender`, protoze
* u nej je kresleni a zdroj dat rozdelene.
*/
export type WidgetKind =
| 'stat'
| 'chart'
| 'ticketList'
| 'incidentList'
| 'workload'
/** Vykon resitelu. Data chodi z `/widget-data`, stejne jako u vlastnich. */
| 'agents'
| WidgetRender;
/** Jen u `stat`: ktere cislo ze souhrnu ukazat. Klic odpovida poli v `/summary`. */
export type WidgetMetric =
| 'openTickets'
| 'activeIncidents'
| 'activeAutomations'
| 'runsToday'
| 'savedHoursMonth'
| 'uptime';
export interface WidgetDefinition {
id: string;
name: string;
description: string;
kind: WidgetKind;
/** Ktere sirky ma smysl nabizet. */
sizes: WidgetSize[];
defaultSize: WidgetSize;
metric?: WidgetMetric;
}
/** Podle ceho se da seskupovat. */
export type WidgetGroupBy =
| 'assignee'
| 'group'
/** Stav ticketu. Volny retezec, u ticketu z cizich aplikaci jejich vlastni. */
| 'status'
| 'type'
| 'tag'
| 'channel';
export type WidgetPeriod = 'today' | '7d' | '30d' | 'month' | 'all';
/** Filtr nad tickety. Tentyz, ktery umi `GET /api/dashboard/tickets`. */
export interface WidgetTicketFilter {
status?: TicketStatus[];
channel?: TicketChannel[];
typeIds?: string[];
tags?: string[];
/** `me` = prihlaseny, `unassigned` = fronta, jinak ID resitelu. */
assignee?: string[];
groupIds?: string[];
period?: WidgetPeriod;
/** Jen vyrizene, nebo jen nevyrizene. Nevyplnene = oboji. */
closed?: boolean;
}
/** Odkud vlastni widget bere data. Uzivatel nepise dotazy, vybira z filtru. */
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[] }
/** Vykon resitelu. `period` plati jen na vyresene. */
| { kind: 'agentStats'; period?: WidgetPeriod }
/**
* Data z napojene sluzby. Vola se tentyz skript jako v kroku automatizace,
* vysledek se cachuje (`ttlSec`).
*/
| {
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;
};
/** Polozka katalogu tak, jak ji vidi klient. Vlastni widget nese i zdroj dat. */
export interface CatalogWidget extends WidgetDefinition {
/** true = widget si firma nadefinovala sama, data chodi z `/widget-data`. */
custom: boolean;
render?: WidgetRender;
source?: WidgetSource;
/** Porovnavaci hodnota u ukazatele. */
target?: number;
}
// ---------------------------------------------------------------- rozlozeni
export interface LayoutItem {
/** Instance widgetu. Tentyz widget muze byt na dashboardu vickrat. */
id: string;
widgetId: string;
size: WidgetSize;
}
export interface LayoutResponse {
tenantId: string;
items: LayoutItem[];
/** false = uzivatel kouka na vychozi rozlozeni, nic si neulozil. */
custom: boolean;
}
// --------------------------------------------------------------------- data
/**
* Hodnota vlastniho widgetu ze serveru. Podle `kind` se pozna, co s tim.
* `href` sklada server, protoze on jediny zna filtr widgetu.
*/
export type WidgetValue =
| { kind: 'number'; value: number; href?: string }
| {
kind: 'groups';
rows: Array<{ key: string; label: string; value: number; href?: string }>;
}
| { kind: 'series'; points: Array<{ date: string; value: number }>; href?: string }
| {
kind: 'tickets';
items: Array<{ id: string; subject: string; status: TicketStatus; assignee: string | null }>;
href?: string;
}
| {
kind: 'workload';
rows: Array<{ personId: string; name: string; open: number; overCapacity: boolean; href: string }>;
href?: string;
}
| { kind: 'agents'; rows: AgentStatsRow[] }
/** Vysledek z konektoru. `stale` rika, ze je to z mezipameti serveru. */
| {
kind: 'external';
value: number | null;
text: string | null;
rows: Array<{ key: string; label: string; value: number }>;
at: string;
stale: boolean;
/** Kdy se data z konektoru natahnou znovu. */
nextAt: string;
/** Jak dlouho se drzi, v sekundach. Nastavuje se u widgetu. */
ttlSec: number;
};
export interface WidgetResult {
id: string;
ok: boolean;
value?: WidgetValue;
error?: string;
}
/** Odpoved davkoveho nacteni dat vlastnich widgetu. */
export interface WidgetDataResponse {
items: WidgetResult[];
}

Some files were not shown because too many files have changed in this diff Show More