Struktura podle zasad: rozdeleni souboru, lint, testy, prisny TypeScript

Projekt srovnan se zasadami v D:\GitHubRepository\CLAUDE.md bez zmeny chovani.

Struktura: scripts/ (skripty konektoru) -> connectors/, src/scripts ->
src/runtime/scripts; src/index.ts jen startuje, novy src/app.ts s createApp();
routes/dashboard.ts a routes/settings.ts rozdeleny do slozek; openapi.ts
rozdelen na openapi/{index,helpers,components} a paths/* (98 cest overeno
shodnych); ticketStore, automationStore a services jsou fasady nad slozkami
data/tickets, data/automations a data/services/catalog. process.env se cte
jen v config.ts. Web: hooky v hooks/, sdilena ui/Table a ui/ServiceIcon,
surove inputy nahrazeny komponentami, sedm velkych souboru rozdeleno.

Nastroje: eslint (typescript-eslint, react-hooks v7), prettier, editorconfig,
nvmrc, .env.example, vitest; skripty lint, format, test. Lint je cisty bez
jedineho eslint-disable (nove hooky useLatest a useSyncFromSource, odvozeny
stav misto setState v effectu). noUncheckedIndexedAccess v obou tsconfig,
84 mist zuzeno bez non-null operatoru; odhalilo zalohu backoffu fronty pri
nule pokusu a Retry-After NaN pri max 0. Cely kod naformatovan prettierem.

Testy: 8 souboru, 105 testu (prava, viditelnost, podminky a opakovani
v executoru, redaktor tajemstvi, sitove guardy, migrace resitelu, tickety,
health a prihlaseni pres supertest). Testy odhalily dve chyby ve vyhodnoceni
podminek, obe opravene: chybejici castka se porovnavala jako nula a podminka
nad vystupem druheho kroku cetla hodnotu prvniho se stejnym nazvem.

Pojmenovane konstanty misto magickych hodnot, ctx.util.base64 pro skripty
konektoru, README a dokumentace aktualizovany vcetne znamych odchylek.
This commit is contained in:
JiriUhlir
2026-09-09 15:11:02 +02:00
parent 42b3da8303
commit 22dda2d139
312 changed files with 21085 additions and 15319 deletions
+297
View File
@@ -0,0 +1,297 @@
/**
* Sestaveni Express aplikace.
*
* Jen sklada middleware, routery, Swagger a SPA. Zadne `listen`, zadny
* bootstrap dat, zadne signaly - to vsechno je v `index.ts`. Diky tomu jde
* aplikaci postavit v testu (supertest) bez otevreneho portu.
*/
import cors from 'cors';
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';
import swaggerUi from 'swagger-ui-express';
import { config } from './config.js';
import { storageStatus } from './data/connectorStore.js';
import { databaseHealth } from './db/pool.js';
import { safeRouter } from './middleware/asyncHandler.js';
import { buildOpenApiDocument } from './openapi/index.js';
import { adminRouter } from './routes/admin.js';
import { authRouter } from './routes/auth.js';
import { contactRouter } from './routes/contact.js';
import { dashboardRouter } from './routes/dashboard/index.js';
import { publicInviteRouter } from './routes/invites.js';
import { webhookRouter } from './routes/webhook.js';
const here = path.dirname(fileURLToPath(import.meta.url));
/** Zbuildovana SPA. Vite ji zapisuje do dist/public, viz vite.config.ts. */
const webRoot = path.join(here, 'public');
/** Strop tela JSON. Nejvetsi bezne telo je ukazka spoustece (100 kB), zbytek je rezerva. */
const JSON_BODY_LIMIT = '256kb';
/** Rok. Soubory buildu maji hash v nazvu, takze se muzou cachovat na maximum. */
const STATIC_MAX_AGE_SEC = 31_536_000;
/**
* 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...');
}
function isOriginAllowed(origin: string): boolean {
if (config.corsOrigins.includes(origin)) return true;
// V dev rezimu si Vite pri obsazenem portu vezme jiny, proto cely localhost.
if (!config.isProduction && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
return true;
}
return false;
}
/**
* Do index.html se za behu vklada base pro prohlizec.
*
* Prohlizec vidi adresu /apps/<app-id>/..., ale Vite build ma relativni cesty.
* Bez <base> by se soubory na vnorenych cestach hledaly ve spatne slozce.
* Prefix se bere z ROOT_PATH, nikdy neni v kodu natvrdo.
*/
function renderIndexHtml(): string {
const file = path.join(webRoot, 'index.html');
const html = fs.readFileSync(file, 'utf8');
const base = `${config.rootPath}/`;
const injected =
`<base href="${base}">\n` +
` <script>window.__BASE_PATH__ = ${JSON.stringify(config.rootPath)};</script>`;
return html.replace('<head>', `<head>\n ${injected}`);
}
/** Zakladni middleware: proxy, CORS, JSON, bezpecnostni hlavicky a log requestu. */
function applyBaseMiddleware(app: express.Express): void {
/*
* 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({
origin(origin, callback) {
// Bez Origin (curl, server-to-server) i stejna domena projdou vzdy.
if (!origin || isOriginAllowed(origin)) return callback(null, true);
console.warn(`[cors] zablokovan origin: ${origin}`);
return callback(null, false);
},
credentials: true,
}),
);
app.use(express.json({ limit: JSON_BODY_LIMIT }));
/*
* 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();
});
app.use((req, _res, next) => {
// Loguje se jen metoda a cesta, nikdy hlavicky ani telo - obsahuji secrets.
console.info(`[req] ${req.method} ${maskSecretsInUrl(req.originalUrl)}`);
next();
});
}
/** Health, whoami, Swagger a vsechny API routery pod jednim routerem. */
function buildApiRouter(): express.Router {
/**
* 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
* misto toho, aby request visel a chyba skoncila jako unhandledRejection.
*/
const api = safeRouter({ strict: true });
/**
* Liveness. Zamerne **nezavisi na databazi**: kratky vypadek DB by jinak vedl
* k restartovani containeru, coz nic nespravi (AGENTS.md).
*/
api.get('/health', (_req, res) => {
res.json({ status: 'ok', uptimeSec: Math.round(process.uptime()) });
});
/**
* Readiness. Tady uz databaze zalezi, a proto je to zvlast.
* Vysledek se par sekund cachuje, aby monitoring nedelal dotaz pri kazdem pingu.
*/
api.get('/health/ready', async (_req, res) => {
const database = await databaseHealth();
const storage = storageStatus();
const ready = !database.enabled || database.ok;
res.status(ready ? 200 : 503).json({
status: ready ? 'ok' : 'degraded',
database,
storage,
});
});
/**
* Jak nas vidi ten, kdo nam vola.
*
* Vraci volajicimu jeho vlastni adresu tak, jak dorazila k serveru. Zni to
* zbytecne, ale je to jediny zpusob, jak zmerit, **s jakou zdrojovou adresou
* doruci reverse proxy volani, ktere vyslo z naseho containeru**. Container
* sam to nevidi, echo sluzba na internetu odpovi verejnou adresu, jenze
* volani na vlastni domenu se otaci zpatky na tentyz stroj a proxy pak muze
* videt adresu docker bridge, ne tu verejnou. A prave to rozhoduje o tom,
* jestli nas seznam povolenych IP pusti.
*
* Bez prihlaseni zamerne: neprozradi to nic, co by volajici uz nevedel,
* dostane svoji vlastni adresu. Stejne jako kterakoliv echo sluzba.
*/
api.get('/whoami', (req, res) => {
res.json({
// `req.ip` uz je po `trust proxy`, tedy hodnota z X-Forwarded-For.
ip: req.ip ?? null,
// Surove, aby bylo videt i to, co proxy pripsala nebo nepripsala.
forwardedFor: req.headers['x-forwarded-for'] ?? null,
remoteAddress: req.socket.remoteAddress ?? null,
});
});
/**
* Swagger UI. Cesta bez lomitka presmerujeme na variantu s lomitkem,
* jinak by se relativni odkazy na CSS a JS skladaly o uroven vys
* a za reverse proxy by se nenacetly.
*/
const openApiDocument = buildOpenApiDocument();
const swaggerOptions: swaggerUi.SwaggerUiOptions = {
customSiteTitle: `${config.brandName} API`,
swaggerOptions: { persistAuthorization: true },
};
api.get('/docs', (_req, res) => res.redirect(`${config.rootPath}/docs/`));
api.get('/openapi.json', (_req, res) => res.json(openApiDocument));
api.use(
'/docs',
swaggerUi.serveFiles(openApiDocument, swaggerOptions),
swaggerUi.setup(openApiDocument, swaggerOptions),
);
api.use('/api/auth', authRouter);
api.use('/api/dashboard', dashboardRouter);
// Verejne: kdo dostal odkaz na pozvanku, neni jeste prihlaseny.
api.use('/api/invites', publicInviteRouter);
api.use('/api/admin', adminRouter);
api.use('/api/contact', contactRouter);
api.use('/webhook', webhookRouter);
return api;
}
/** Staticke soubory buildu a SPA fallback. Bez buildu jen JSON s vysvetlenim. */
function applyWeb(app: express.Express): void {
let cachedIndexHtml: string | null = null;
const hasWebBuild = fs.existsSync(path.join(webRoot, 'index.html'));
if (!hasWebBuild) {
// Bez buildu webu nesmi aplikace tise vracet prazdno.
console.warn(`[start] build webu nenalezen v ${webRoot}, bezi jen API`);
app.get('/', (_req, res) => {
res.json({
name: 'csbot-prototype',
status: 'ok',
note: 'Build webu chybi, dostupne je jen API a /docs.',
});
});
return;
}
const serveStatic = express.static(webRoot, {
index: false,
// Soubory maji hash v nazvu, muzou se cachovat dlouho. index.html ne.
setHeaders(res, filePath) {
if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache');
else res.setHeader('Cache-Control', `public, max-age=${STATIC_MAX_AGE_SEC}, immutable`);
},
});
app.use(serveStatic);
if (config.rootPath) app.use(config.rootPath, serveStatic);
// Vsechny ostatni cesty obsluhuje SPA, routovani si resi React Router.
app.get('*', (_req, res) => {
if (!cachedIndexHtml) cachedIndexHtml = renderIndexHtml();
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.send(cachedIndexHtml);
});
}
// Centralni error handler - nic nesmi propadnout bez logu.
function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction): void {
/*
* Rozbite telo pozadavku neni nase chyba, je to spatne polozeny dotaz.
*
* `express.json` na nej vyhodi vyjimku, ta propadla sem a uzivatel videl
* "Interni chyba serveru" - hlasku, ktera rika, ze je neco spatne u nas,
* a poslala ho hledat na spatnou stranu. Stalo to jedno odpoledne.
*/
const status = (err as { status?: number } | null)?.status;
const type = (err as { type?: string } | null)?.type;
if (status === 400 && typeof type === 'string' && type.startsWith('entity.')) {
console.warn(`[error] ${req.method} ${req.path}: neplatne telo pozadavku (${type})`);
res.status(400).json({
error: 'bad_request',
message: 'Tělo požadavku není platný JSON objekt.',
});
return;
}
console.error('[error]', err);
const message = err instanceof Error ? err.message : 'Neznama chyba.';
res.status(500).json({
error: 'internal_error',
message: config.isProduction ? 'Interni chyba serveru.' : message,
});
}
/** Postavi aplikaci. Bez vedlejsich efektu: nic neposloucha a nic se nenacita. */
export function createApp(): express.Express {
const app = express();
applyBaseMiddleware(app);
const api = buildApiRouter();
// Mount na koren i na prefix proxy. Caddy prefix pres handle_path odstranuje,
// ale takhle aplikace funguje i kdyby ho nechal - a lokalne bez proxy taky.
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 (apiPathPattern.test(req.path)) {
console.warn(`[404] ${req.method} ${req.originalUrl}`);
return res.status(404).json({ error: 'not_found', message: 'Endpoint neexistuje.' });
}
return next();
});
applyWeb(app);
app.use(errorHandler);
return app;
}
+14 -4
View File
@@ -104,7 +104,9 @@ function record(value: unknown): 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) : [];
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> {
@@ -134,7 +136,8 @@ async function call(path: string, init?: RequestInit): Promise<unknown> {
response.status,
);
}
if (body.json === undefined) throw new AresError('ARES vrátil odpověď, která není JSON.', response.status);
if (body.json === undefined)
throw new AresError('ARES vrátil odpověď, která není JSON.', response.status);
return body.json;
}
@@ -170,7 +173,11 @@ export async function searchCompanies(name: string, limit = 10): Promise<AresCom
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) }),
body: JSON.stringify({
obchodniJmeno: query,
start: 0,
pocet: Math.min(Math.max(limit, 1), 50),
}),
});
return list(record(json)?.ekonomickeSubjekty)
.map(toCompany)
@@ -181,7 +188,10 @@ export async function searchCompanies(name: string, limit = 10): Promise<AresCom
function properCase(value: string): string {
return value
.toLocaleLowerCase('cs-CZ')
.replace(/(^|[\s\-'])(\p{L})/gu, (_, sep: string, ch: string) => sep + ch.toLocaleUpperCase('cs-CZ'));
.replace(
/(^|[\s\-'])(\p{L})/gu,
(_, sep: string, ch: string) => sep + ch.toLocaleUpperCase('cs-CZ'),
);
}
/**
+15 -2
View File
@@ -133,9 +133,9 @@ export const config = {
/**
* Adresar se skripty konektoru. Relativne k adresari, ze ktereho aplikace
* bezi, aby to fungovalo v containeru (`/app/scripts`) i lokalne.
* bezi, aby to fungovalo v containeru (`/app/connectors`) i lokalne.
*/
scriptsDir: path.resolve(process.env.SCRIPTS_DIR ?? path.join(process.cwd(), 'scripts')),
scriptsDir: path.resolve(process.env.SCRIPTS_DIR ?? path.join(process.cwd(), 'connectors')),
/**
* Zaklad adres napojenych sluzeb, napr. "https://services.csbot.cz/apps".
* Konkretni konektor lze presmerovat pres `<KONEKTOR>_BASE_URL`.
@@ -230,6 +230,19 @@ export const config = {
* interval znamena jen vic dotazu do fronty.
*/
schedulerIntervalSec: positiveNumber(process.env.SCHEDULER_INTERVAL_SEC, 30),
/**
* Presmerovani jedne sluzby promennou `<SLUZBA>_BASE_URL`, napr.
* `OPENAI_BASE_URL`. K cemu to je, rika `runtime/scripts/connections.ts`.
*
* Jedine misto s dynamickym nazvem promenne: sluzby pribyvaji v katalogu
* a vypisovat kazdou sem by znamenalo dve mista, ktera se rozejdou. Nazev
* sklada volajici, tady se hodnota jen cte a normalizuje - bez mezer, bez
* lomitka na konci. Prazdna nebo chybejici promenna je `null`.
*/
serviceBaseUrlOverride(variable: string): string | null {
const value = (process.env[variable] ?? '').trim().replace(/\/+$/, '');
return value === '' ? null : value;
},
};
/** Zaklad verejne adresy aplikace vcetne prefixu proxy. */
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
/**
* Uloziste automatizaci vcetne jejich stromu akci (flow).
*
* 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.
*
* Slozka je rozdelena podle odpovednosti: `model` (tvar), `state` (pamet),
* `persist` (zapis a nacteni), `validation` (co se ze stromu dopocitava),
* `webhook` (token a volani), `runs` (historie behu), `store` (cteni a
* zapisy), `seed`, `seedDemo` a `remap`. Zvenku se importuje jen tenhle
* soubor, a to pres `data/automationStore.ts`.
*/
import { config } from '../../config.js';
import { seedRealAutomations } from './seed.js';
import { seedDemoAutomations } from './seedDemo.js';
export type {
Automation,
AutomationDetail,
AutomationFlow,
AutomationKind,
FlowStep,
FlowTrigger,
TriggerField,
WebhookCall,
WebhookCallField,
} from './model.js';
export { matchOf, rulesOf } from './model.js';
export {
bodyForCall,
generateWebhookToken,
recentWebhookCalls,
recordWebhookCall,
} from './webhook.js';
export { collectFlowIssues, countSteps } from './validation.js';
export { initAutomations } from './persist.js';
export {
createAutomation,
deleteAutomation,
findByWebhookToken,
getAutomation,
listAutomations,
recordRun,
regenerateWebhookToken,
updateAutomation,
} from './store.js';
export { remapPersonIds } from './remap.js';
seedRealAutomations();
if (config.seedDemo) {
seedDemoAutomations();
} else {
console.info('[automatizace] ukazkova data vypnuta (SEED_DEMO neni 1)');
}
if (!config.seedWebhookToken) {
console.warn(
'[automatizace] WEBHOOK_TOKEN_TEST neni nastaveny, adresa webhooku se pri kazdem ' +
'nasazeni zmeni. Nastavte ji jako promennou aplikace.',
);
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Tvar automatizace v ulozisti a cteni kroku podminky.
*
* Tvar automatizace a stromu je sdileny s webem, viz src/shared/automations.ts.
* Tady je jen to, co web nevidi: zaznam v ulozisti.
*/
import type { ConditionMatch, ConditionRule } from '../conditions.js';
import type {
Automation,
AutomationDetail,
AutomationFlow,
AutomationKind,
FlowStep,
FlowTrigger,
TriggerField,
WebhookCall,
WebhookCallField,
} from '../../shared/automations.js';
/**
* Tvar automatizace a stromu je sdileny s webem, viz src/shared/automations.ts.
* Tady zustava uloziste a prace se stromem.
*/
export type {
Automation,
AutomationDetail,
AutomationFlow,
AutomationKind,
FlowStep,
FlowTrigger,
TriggerField,
WebhookCall,
WebhookCallField,
};
/**
* Otazky podminky, at uz je krok v jakekoliv podobe.
*
* **Jedine misto, kde se stara podoba prevadi.** Kdyby se `fieldId` cetlo
* primo, krok ulozeny driv by po zmene modelu prisel o svou otazku a vetvil
* by vzdycky stejne - tise a bez chyby.
*/
export function rulesOf(step: Extract<FlowStep, { kind: 'condition' }>): ConditionRule[] {
if (step.rules && step.rules.length > 0) return step.rules;
if (!step.fieldId || !step.operator) return [];
return [{ fieldId: step.fieldId, operator: step.operator, value: step.value }];
}
/** Jak se otazky spoji. Jedna otazka nema co spojovat, bere se `all`. */
export function matchOf(step: Extract<FlowStep, { kind: 'condition' }>): ConditionMatch {
return step.match ?? 'all';
}
export interface StoredAutomation {
id: string;
tenantId: string;
name: string;
kind: AutomationKind;
enabled: boolean;
/**
* Behy po dnech, nejnovejsi nakonec. Drzi se poslednich `KEEP_DAYS`.
*
* Proc historie a ne jen citac pro dnesek: bez ni po pulnoci neni s cim
* srovnat a nikdo nepozna, jestli je dnesnich devet malo nebo hodne.
*/
days?: Array<{ day: string; runs: number; ok: number }>;
/** Behy od zalozeni. */
runsTotal?: number;
runsToday: number;
successRate: number;
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;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Zapis automatizace do uloziste a nacteni pri startu.
*
* Po kazde zmene se cela automatizace zapise (`withMirror`). Kam - databaze,
* soubor, nebo nikam - rozhoduje `data/store/index.ts`; viz
* documentation/14-databaze.md.
*/
import { defineStore } from '../store/index.js';
import { withMirror } from '../store/mirror.js';
import type { StoredAutomation } from './model.js';
import { continueCounter, store } from './state.js';
import { withDerived } from './validation.js';
/**
* Uloziste. Automatizace se drzi v pameti kvuli synchronnimu cteni (webhook
* hleda podle tokenu pri kazdem requestu) a po kazde zmene se cela zapise.
* Kam - databaze, soubor, nebo nikam - rozhoduje `data/store/index.ts`.
*/
export const mirror = withMirror(defineStore<StoredAutomation>('automation'));
/** Zapise do pameti i do uloziste. Kazda zmena jde skrz tohle. */
export function save(automation: StoredAutomation): void {
store.set(automation.id, automation);
mirror.save(automation);
}
/**
* Nacte automatizace z uloziste. Vola se pri startu, viz data/bootstrap.ts.
*
* Kdyz uloziste nic nema, ulozi se ukazkova sada, ktera je v tuhle chvili
* v pameti.
*/
export async function initAutomations(): Promise<void> {
const rows = await mirror.load(() => [...store.values()]);
store.clear();
/*
* 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));
continueCounter();
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Preznaceni resitelu pri migraci, viz data/migratePeople.ts.
*/
import type { AutomationFlow } from '../../shared/automations.js';
import { save } from './persist.js';
import { store } from './state.js';
import { withDerived } from './validation.js';
/**
* Prepise ID resitelu ve stromech podle mapy stare -> nove. Vraci pocet
* zmenenych automatizaci.
*
* Jen pro migraci (data/migratePeople.ts). ID resitele muze byt v kroku
* `ticket/assign`, ve vstupu `assigneeId` u zalozeni ticketu i v podmince,
* proto se nahrazuje v JSON podobe celeho stromu, ne po znamych polich -
* nove pole by se jinak zapomnelo. Nahrazuje se jen cely retezec `"ppl_x"`,
* ne podretezec.
*/
export function remapPersonIds(map: Map<string, string>): number {
let changed = 0;
for (const automation of store.values()) {
const before = JSON.stringify(automation.flow);
let after = before;
for (const [oldId, newId] of map) {
after = after.split(JSON.stringify(oldId)).join(JSON.stringify(newId));
}
if (after === before) continue;
changed += 1;
save(withDerived({ ...automation, flow: JSON.parse(after) as AutomationFlow }));
}
return changed;
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Historie behu po dnech.
*
* Bez ni po pulnoci neni s cim srovnat a nikdo nepozna, jestli je dnesnich
* devet malo nebo hodne. Zapis behu je v `store.ts` (`recordRun`), tady je
* jen cteni a hranice, kolik se drzi.
*/
import type { StoredAutomation } from './model.js';
/** Jeden den v milisekundach. */
const DAY_MS = 86_400_000;
/** Kolik dni zpatky se drzi. Dva tydny staci na "je to dnes jinak nez obvykle". */
export const KEEP_DAYS = 14;
/** Dnesni den jako `2026-08-17`. Podle nej se pozna prelom dne. */
export function today(): string {
return new Date().toISOString().slice(0, 10);
}
function dayBefore(day: string): string {
return new Date(new Date(`${day}T00:00:00Z`).getTime() - DAY_MS).toISOString().slice(0, 10);
}
/**
* Statistika za dnesek z ulozene historie.
*
* Kdyz dnes jeste nic nebezelo, vraci nulu - **ne vcerejsi cislo**. Ukazat
* vcerejsi pocet jako dnesni je to, co bylo spatne.
*/
export function statsOf(stored: StoredAutomation): {
runsToday: number;
runsYesterday: number;
runsTotal: number;
successRate: number;
} {
const days = stored.days ?? [];
const now = today();
const mine = days.find((entry) => entry.day === now);
const before = days.find((entry) => entry.day === dayBefore(now));
/*
* Uspesnost dnesnich behu. Kdyz dnes zadny nebyl, bere se posledni den, kdy
* byly - nula procent u automatizace, ktera dnes jen nemela co delat, by
* vypadala jako porucha.
*/
const source = mine ?? [...days].reverse().find((entry) => entry.runs > 0);
return {
runsToday: mine?.runs ?? 0,
runsYesterday: before?.runs ?? 0,
runsTotal: stored.runsTotal ?? stored.runsToday,
successRate:
source && source.runs > 0
? Math.round((source.ok / source.runs) * 1000) / 10
: stored.successRate,
};
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Vychozi sada automatizaci pro prazdne uloziste.
*
* Skutecne automatizace (`seedRealAutomations`) se nasypou vzdy, ukazkove
* jsou v `seedDemo.ts` a zapinaji se pres `SEED_DEMO=1`. O obojim rozhoduje
* `index.ts`, tady je jen zapis do pameti bez uloziste.
*/
import { config } from '../../config.js';
import { minutesAgo } from '../store/index.js';
import type { StoredAutomation } from './model.js';
import { nextId, store } from './state.js';
import { deriveKind, withDerived } from './validation.js';
import { generateWebhookToken } from './webhook.js';
/** Ukazkova automatizace vznikla pred 90 dny a naposledy se menila pred 12 hodinami. */
const SEED_CREATED_MINUTES_AGO = 60 * 24 * 90;
const SEED_UPDATED_MINUTES_AGO = 60 * 12;
export function seed(
automation: Omit<StoredAutomation, 'id' | 'createdAt' | 'updatedAt' | 'kind' | 'tenantId'> & {
tenantId?: string;
},
) {
const id = nextId();
store.set(
id,
withDerived({
// Ukazkova data patri Automii, kdyz neni receno jinak.
tenantId: 'tnt_automia',
...automation,
id,
kind: deriveKind(automation.flow),
createdAt: minutesAgo(SEED_CREATED_MINUTES_AGO),
updatedAt: minutesAgo(SEED_UPDATED_MINUTES_AGO),
}),
);
}
/**
* Automatizace, ktere na instanci opravdu bezi.
*
* 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 ztrate dat
* nemenila a odesilatel ji nemusel prepisovat.
*
* Opsano z bezici instance 2026-09-02. Kdyz se strom na instanci zmeni a ma
* prezit i ztratu dat, patri ta zmena sem.
*/
export function seedRealAutomations(): void {
seed({
name: 'TEST',
enabled: true,
runsToday: 0,
successRate: 100,
avgDurationMs: 0,
lastRunAt: minutesAgo(0),
flow: {
trigger: {
serviceId: 'webhook',
operationId: 'received',
fields: [
{ id: 'f_callsid', name: 'callSid', type: 'string', required: true },
{ id: 'f_status', name: 'status', type: 'string', required: true },
{ id: 'f_voicebot', name: 'voicebotId', type: 'string', required: true },
{ id: 'f_mtjqv4qj_1', name: 'result', type: 'string', required: false, path: 'data.result' },
{ id: 'f_mtjqv4zn_2', name: 'rating', type: 'string', required: false, path: 'data.rating' },
// Objekt a nepovinne: telo ho posila jako strukturu a prvni zprava
// hovoru ho jeste nema. Deklarace `string` a povinny odmitala oboji.
{ id: 'f_mtjqvws7_3', name: 'data', type: 'object', required: false, path: 'data' },
],
webhookToken: config.seedWebhookToken || generateWebhookToken(),
},
steps: [
{
id: 'st_upsert',
kind: 'action',
serviceId: 'ticket',
operationId: 'upsert',
inputs: {
externalId: '{{callSid}}',
body: '{{voicebotId}}, {{data}}',
status: '{{result}}',
tags: '{{voicebotId}}',
priority: 'low',
},
},
/*
* **Nejdriv se ptame, jestli vysledek vubec prisel.**
*
* Jeden hovor posle vic zprav a ta prvni jen ohlasi, ze zacal:
* `data` je null, takze `{{result}}` je prazdne. Bez teto podminky
* spadlo prazdno rovnou do vetve "neni to Chybejici informace"
* a ticket se zavrel uz pri zvoneni. Na instanci to znamenalo, ze
* vsech 131 ticketu bylo vyrizenych a ve frontě nezustalo nic.
*/
{
id: 'st_mtjqwey5_4',
kind: 'condition',
fieldId: 'f_mtjqv4qj_1',
operator: 'isNotEmpty',
value: '',
yes: [
/*
* Az ted se rozhoduje podle vysledku, a **kladne**: hledame
* "Chybejici informace". Puvodni `neq` znamenalo "vsechno
* ostatni vcetne toho, co jeste nevime".
*/
{
id: 'st_mtml9001_2',
kind: 'condition',
fieldId: 'f_mtjqv4qj_1',
operator: 'eq',
value: 'Chybějící informace',
// Chybejici informace jde na servicedesk a s vyssi prioritou.
yes: [
{
id: 'st_mtjqxs41_6',
kind: 'action',
serviceId: 'ticket',
operationId: 'upsert',
inputs: {
externalId: '{{callSid}}',
priority: 'high',
groupId: 'grp_servicedesk',
},
},
{
id: 'st_mtml8hx0_1',
kind: 'action',
serviceId: 'ticket',
operationId: 'assign-group',
inputs: {
groupId: 'grp_servicedesk',
autoAssign: 'true',
},
},
],
// Hovor dopadl, ticket se zavira.
no: [
{
id: 'st_mtjqx6t1_5',
kind: 'action',
serviceId: 'ticket',
operationId: 'upsert',
inputs: {
externalId: '{{callSid}}',
closed: 'true',
},
},
],
},
],
// Vysledek jeste nedorazil. Ticket uz existuje, vic se ted delat nema.
no: [],
},
],
},
});
}
+467
View File
@@ -0,0 +1,467 @@
/**
* Ukazkove automatizace. Nasypou se jen se `SEED_DEMO=1`, viz `index.ts`.
*/
import { minutesAgo } from '../store/index.js';
import { seed } from './seed.js';
import { generateWebhookToken } from './webhook.js';
/**
* Ukazkove automatizace.
*
* Nasypou se **jen se `SEED_DEMO=1`**. Na instanci, kde uz nekdo pracuje,
* jsou to cizi zaznamy, ktere se po kazdem redeployi vraceji - a mazat je
* porad dokola nikoho nebavi.
*/
export function seedDemoAutomations(): void {
seed({
name: 'Objednávka, sklad a fakturace',
enabled: true,
runsToday: 428,
successRate: 99.3,
avgDurationMs: 1_240,
lastRunAt: minutesAgo(3),
flow: {
trigger: {
serviceId: 'eshop',
operationId: 'order-created',
fields: [
{ id: 'f_1', name: 'orderId', type: 'string', required: true },
{ id: 'f_2', name: 'total', type: 'number', required: true },
{ id: 'f_3', name: 'customerEmail', type: 'string', required: true },
],
},
steps: [
{ id: 'st_1', kind: 'action', serviceId: 'transform', operationId: 'map-fields' },
{ id: 'st_2', kind: 'action', serviceId: 'eshop', operationId: 'update-stock' },
{
id: 'st_3',
kind: 'condition',
fieldId: 'f_2',
operator: 'gte',
value: '5000',
yes: [
{ id: 'st_4', kind: 'action', serviceId: 'idoklad', operationId: 'create-proforma' },
{ id: 'st_5', kind: 'action', serviceId: 'email', operationId: 'send' },
],
no: [{ id: 'st_6', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' }],
},
{ id: 'st_7', kind: 'action', serviceId: 'ppl', operationId: 'create-shipment' },
],
},
});
seed({
name: 'Voicebot: příjem poptávek 24/7',
enabled: true,
runsToday: 137,
successRate: 96.1,
avgDurationMs: 74_000,
lastRunAt: minutesAgo(11),
flow: {
trigger: {
serviceId: 'voicebot',
operationId: 'call-received',
fields: [
{ id: 'f_11', name: 'callerNumber', type: 'string', required: true },
{ id: 'f_12', name: 'wantsOperator', type: 'boolean', required: false },
],
},
steps: [
{ id: 'st_11', kind: 'action', serviceId: 'voicebot', operationId: 'play-scenario' },
{ id: 'st_12', kind: 'action', serviceId: 'transcription', operationId: 'transcribe' },
{ id: 'st_13', kind: 'action', serviceId: 'openai', operationId: 'chat' },
{
id: 'st_14',
kind: 'condition',
fieldId: 'f_12',
operator: 'isTrue',
yes: [{ id: 'st_15', kind: 'action', serviceId: 'voicebot', operationId: 'transfer' }],
no: [
{ id: 'st_16', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' },
{ id: 'st_17', kind: 'action', serviceId: 'email', operationId: 'send' },
],
},
],
},
});
seed({
name: 'Synchronizace CRM a účetnictví',
enabled: true,
runsToday: 96,
successRate: 98.9,
avgDurationMs: 2_050,
lastRunAt: minutesAgo(26),
flow: {
trigger: {
serviceId: 'raynet',
operationId: 'company-changed',
fields: [{ id: 'f_21', name: 'companyId', type: 'string', required: true }],
},
steps: [
{ id: 'st_21', kind: 'action', serviceId: 'transform', operationId: 'deduplicate' },
{ id: 'st_22', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' },
{ id: 'st_23', kind: 'action', serviceId: 'log', operationId: 'write' },
],
},
});
seed({
name: 'Noční report pro management',
enabled: false,
runsToday: 0,
successRate: 100,
avgDurationMs: 18_400,
lastRunAt: minutesAgo(1_020),
flow: {
trigger: { serviceId: 'scheduler', operationId: 'interval', fields: [] },
steps: [
{ id: 'st_31', kind: 'action', serviceId: 'ga4', operationId: 'run-report' },
{ id: 'st_32', kind: 'action', serviceId: 'google-ads', operationId: 'campaign-report' },
{ id: 'st_33', kind: 'action', serviceId: 'sklik', operationId: 'campaign-report' },
{ id: 'st_34', kind: 'action', serviceId: 'openai', operationId: 'chat' },
{ id: 'st_35', kind: 'action', serviceId: 'email', operationId: 'send' },
],
},
});
// Ukazka webhooku s deklarovanymi parametry a podminkou nad cislem.
seed({
name: 'Webhook: hodnocení z dotazníku',
enabled: true,
runsToday: 61,
successRate: 100,
avgDurationMs: 640,
lastRunAt: minutesAgo(18),
flow: {
trigger: {
serviceId: 'webhook',
operationId: 'received',
webhookToken: generateWebhookToken(),
fields: [
{ id: 'f_41', name: 'customer', type: 'string', required: true },
{ id: 'f_42', name: 'score', type: 'number', required: true },
{ id: 'f_43', name: 'comment', type: 'string', required: false },
],
},
steps: [
{
id: 'st_41',
kind: 'condition',
fieldId: 'f_42',
operator: 'gte',
value: '15',
yes: [{ id: 'st_42', kind: 'action', serviceId: 'raynet', operationId: 'add-activity' }],
no: [
{
id: 'st_43',
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
inputs: {
subject: 'Nízké hodnocení od {{customer}}',
body: 'Hodnocení {{score}} z dotazníku. Komentář: {{comment}}',
company: '{{customer}}',
priority: 'high',
assigneeId: 'usr_2',
},
},
{ id: 'st_44', kind: 'action', serviceId: 'microsoft365', operationId: 'post-teams' },
],
},
],
},
});
/*
* Prijem z kanalu: jedna automatizace na kanal, zadne rozhodovani o resiteli.
* Smerovani resi jedna spolecna automatizace nize - viz documentation/06-tickety.md.
*/
seed({
name: 'WhatsApp: zpráva do ticketu',
enabled: true,
runsToday: 34,
successRate: 100,
avgDurationMs: 1_950,
lastRunAt: minutesAgo(7),
flow: {
trigger: {
serviceId: 'whatsapp',
operationId: 'message-received',
fields: [
{ id: 'whatsapp.phone', name: 'phone', type: 'string', required: true },
{ id: 'whatsapp.profileName', name: 'profileName', type: 'string', required: false },
{ id: 'whatsapp.text', name: 'text', type: 'string', required: true },
{ id: 'whatsapp.hasMedia', name: 'hasMedia', type: 'boolean', required: false },
{ id: 'whatsapp.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
steps: [
// Predvalidace: nejdriv se zeptame CRM, teprve podle odpovedi zakladame.
{
id: 'st_51',
kind: 'action',
serviceId: 'raynet',
operationId: 'find-company',
inputs: { phone: '{{phone}}' },
},
{
id: 'st_52',
kind: 'condition',
// Odkaz na vystup kroku st_51, ne na parametr spoustece.
fieldId: 'st_51.raynet.customerKnown',
operator: 'isTrue',
yes: [
{
id: 'st_53',
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
inputs: {
subject: 'WhatsApp od {{profileName}}',
body: '{{text}}',
company: '{{companyName}}',
contact: '{{profileName}}',
reply: '{{phone}}',
priority: 'normal',
assigneeId: '',
},
},
],
no: [
{
id: 'st_54',
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
inputs: {
subject: 'WhatsApp od neznámého čísla',
body: '{{text}}',
contact: '{{profileName}}',
reply: '{{phone}}',
priority: 'normal',
assigneeId: '',
},
},
{ id: 'st_55', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' },
],
},
],
},
});
seed({
name: 'Facebook: zpráva do ticketu',
enabled: true,
runsToday: 11,
successRate: 100,
avgDurationMs: 720,
lastRunAt: minutesAgo(52),
flow: {
trigger: {
serviceId: 'facebook',
operationId: 'message-received',
fields: [
{ id: 'facebook.senderId', name: 'senderId', type: 'string', required: true },
{ id: 'facebook.senderName', name: 'senderName', type: 'string', required: false },
{ id: 'facebook.text', name: 'text', type: 'string', required: true },
{ id: 'facebook.pageName', name: 'pageName', type: 'string', required: true },
{ id: 'facebook.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
// Bez predvalidace. Z Messengeru nemame e-mail ani telefon, podle ceho
// by se firma dohledala, takze zakladame rovnou a dohledani nechavame na cloveku.
steps: [
{
id: 'st_61',
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
inputs: {
subject: 'Facebook od {{senderName}}',
body: '{{text}}',
contact: '{{senderName}}',
reply: '{{senderId}}',
priority: 'normal',
assigneeId: '',
},
},
],
},
});
seed({
name: 'E-mail: požadavky do ticketu',
enabled: true,
runsToday: 58,
successRate: 99.1,
avgDurationMs: 2_310,
lastRunAt: minutesAgo(14),
flow: {
trigger: {
serviceId: 'email',
operationId: 'received',
fields: [
{ id: 'email.from', name: 'from', type: 'string', required: true },
{ id: 'email.subject', name: 'subject', type: 'string', required: true },
{ id: 'email.body', name: 'body', type: 'string', required: false },
{ id: 'email.hasAttachment', name: 'hasAttachment', type: 'boolean', required: false },
{ id: 'email.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
steps: [
{
id: 'st_65',
kind: 'action',
serviceId: 'raynet',
operationId: 'find-company',
inputs: { email: '{{from}}' },
},
{
id: 'st_66',
kind: 'condition',
fieldId: 'st_65.raynet.customerKnown',
operator: 'isTrue',
yes: [
{
id: 'st_67',
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
inputs: {
subject: '{{subject}}',
body: '{{body}}',
company: '{{companyName}}',
contact: '{{from}}',
reply: '{{from}}',
priority: 'normal',
assigneeId: '',
},
},
],
no: [
{
id: 'st_68',
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
inputs: {
subject: '{{subject}}',
body: '{{body}}',
contact: '{{from}}',
reply: '{{from}}',
priority: 'low',
assigneeId: '',
},
},
{
id: 'st_69',
kind: 'action',
serviceId: 'email',
operationId: 'send',
inputs: {
to: '{{from}}',
subject: 'Přijali jsme váš požadavek',
body: 'Dobrý den, požadavek jsme zaevidovali a ozveme se. Tým podpory.',
},
},
],
},
],
},
});
/*
* Jedna spolecna automatizace nad vsemi tickety, at vznikly odkudkoliv.
* Tohle je to misto, kde se dela logika zpracovani na miru zakaznikovi.
*/
seed({
name: 'Směrování ticketů na řešitele',
/*
* Ukazkova automatizace, ktera **meni data**, je vypnuta.
*
* Zapnuta by prebirala tickety, ktere uz nekomu patri podle skutecne
* automatizace zakaznika - ukazkova data nemaji sahat na zivy provoz.
* Kdo si ji chce vyzkouset, zapne si ji.
*/
enabled: false,
runsToday: 103,
successRate: 100,
avgDurationMs: 310,
lastRunAt: minutesAgo(4),
flow: {
trigger: {
serviceId: 'ticket',
operationId: 'created',
fields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ id: 'ticket.body', name: 'body', type: 'string', required: false },
{ id: 'ticket.channel', name: 'channel', type: 'string', required: true },
{ id: 'ticket.company', name: 'company', type: 'string', required: false },
{ id: 'ticket.contact', name: 'contact', type: 'string', required: false },
{ id: 'ticket.priority', name: 'priority', type: 'string', required: true },
{ id: 'ticket.knownCustomer', name: 'knownCustomer', type: 'boolean', required: true },
{ id: 'ticket.assigned', name: 'assigned', type: 'boolean', required: true },
],
},
steps: [
{
id: 'st_71',
kind: 'condition',
// Uz prirazeny ticket nepreberame, jinak bychom prepsali rucni rozhodnuti.
fieldId: 'ticket.assigned',
operator: 'isFalse',
yes: [
{
id: 'st_72',
kind: 'condition',
fieldId: 'ticket.body',
operator: 'contains',
value: 'faktur',
yes: [
{
id: 'st_73',
kind: 'action',
serviceId: 'ticket',
operationId: 'assign',
inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_3' },
},
],
no: [
{
id: 'st_74',
kind: 'condition',
fieldId: 'ticket.body',
operator: 'contains',
value: 'voicebot',
yes: [
{
id: 'st_75',
kind: 'action',
serviceId: 'ticket',
operationId: 'assign',
inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_novakova' },
},
],
no: [
{
id: 'st_76',
kind: 'action',
serviceId: 'ticket',
operationId: 'assign',
inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_2' },
},
],
},
],
},
],
no: [],
},
],
},
});
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Pamet automatizaci a citac ID.
*
* Automatizace se drzi v pameti kvuli synchronnimu cteni (webhook hleda
* podle tokenu pri kazdem requestu). Zapis do uloziste je v `persist.ts`.
*/
import { highestNumber, writableOrWarn } from '../store/index.js';
import type { StoredAutomation } from './model.js';
/** Na kolik mist se cislo v ID doplnuje nulami: `AUT-01`. */
const ID_DIGITS = 2;
export const store = new Map<string, StoredAutomation>();
let idCounter = 0;
export function nextId(): string {
idCounter += 1;
return `AUT-${String(idCounter).padStart(ID_DIGITS, '0')}`;
}
/**
* Po nacteni z uloziste. Citac musi pokracovat za nejvyssim ulozenym cislem,
* jinak by nova automatizace prepsala starou.
*/
export function continueCounter(): void {
idCounter = Math.max(idCounter, highestNumber(store.keys(), 'AUT'));
}
/** Automatizace z povolenych firem. Cizi se chova jako neexistujici. */
export function findWritable(id: string, tenantIds: string[]): StoredAutomation | undefined {
return writableOrWarn(store.get(id), id, tenantIds, 'automations');
}
+241
View File
@@ -0,0 +1,241 @@
/**
* Cteni a zapisy automatizaci: seznam, detail, zalozeni, uprava, token
* webhooku, zaznam behu, smazani.
*
* Kazdy zapis jde pres `save` a posle udalost na sbernici.
*/
import { publish } from '../../events/bus.js';
import { timingSafeEqualString } from '../../lib/secure.js';
import { describeModel } from '../model.js';
import type { Automation, AutomationDetail, AutomationFlow } from '../../shared/automations.js';
import type { StoredAutomation } from './model.js';
import { mirror, save } from './persist.js';
import { KEEP_DAYS, statsOf, today } from './runs.js';
import { findWritable, nextId, store } from './state.js';
import { collectFlowIssues, countSteps, deriveKind, withDerived } from './validation.js';
import { generateWebhookToken, recentWebhookCalls, withWebhookToken } from './webhook.js';
function toSummary(stored: StoredAutomation): Automation {
const {
flow: _flow,
createdAt: _createdAt,
updatedAt: _updatedAt,
days: _days,
...rest
} = stored;
return {
...rest,
/*
* Cisla se pocitaji z historie po dnech, ne z ulozeneho citace. Po pulnoci
* je "dnes" nula, dokud opravdu neco nebezi.
*/
...statsOf(stored),
// 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: stored.issues ?? collectFlowIssues(stored.flow),
};
}
function toDetail(stored: StoredAutomation): AutomationDetail {
return {
...toSummary(stored),
flow: stored.flow,
model: stored.flow.trigger?.sample === undefined
? []
: describeModel(stored.flow.trigger.sample),
recentCalls: recentWebhookCalls(stored.id),
createdAt: stored.createdAt,
updatedAt: stored.updatedAt,
};
}
/** Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse". */
export function listAutomations(tenantIds: string[]): Automation[] {
// Nejnovejsi nahoru, aby prave vytvorena automatizace byla hned videt.
return [...store.values()]
.filter((stored) => tenantIds.includes(stored.tenantId))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map(toSummary);
}
export function getAutomation(id: string, tenantIds: string[]): AutomationDetail | undefined {
const stored = store.get(id);
if (!stored) return undefined;
if (!tenantIds.includes(stored.tenantId)) {
console.warn(`[automations] pokus o cteni ${id} mimo povolene firmy`);
return undefined;
}
return toDetail(stored);
}
export function createAutomation(name: string, tenantId: string): AutomationDetail {
const id = nextId();
const now = new Date().toISOString();
const stored = withDerived({
id,
tenantId,
name,
kind: 'workflow',
enabled: false,
runsToday: 0,
successRate: 100,
avgDurationMs: 0,
lastRunAt: now,
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 }, tenantId);
return toDetail(stored);
}
export function updateAutomation(
id: string,
patch: { name?: string; enabled?: boolean; flow?: AutomationFlow },
tenantIds: string[],
): AutomationDetail | undefined {
const stored = findWritable(id, tenantIds);
if (!stored) {
console.warn(`[automations] pokus o upravu nedostupne automatizace: ${id}`);
return undefined;
}
const flow = withWebhookToken(patch.flow ?? stored.flow, stored.flow, id);
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 = updated.issues ?? [];
if (updated.enabled && issues.length > 0) {
console.warn(`[automations] ${id}: zapnuti odmitnuto - ${issues.join(' ')}`);
updated.enabled = false;
}
save(updated);
console.info(
`[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);
}
/** Vygeneruje novy token - stara adresa okamzite prestane fungovat. */
export function regenerateWebhookToken(
id: string,
tenantIds: string[],
): AutomationDetail | undefined {
const stored = findWritable(id, tenantIds);
if (!stored) {
console.warn(`[automations] regenerace tokenu pro nedostupnou automatizaci: ${id}`);
return undefined;
}
if (stored.flow.trigger?.serviceId !== 'webhook') {
console.warn(`[automations] ${id}: regenerace tokenu, ale spoustec neni webhook`);
return undefined;
}
// 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);
}
/** 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 && timingSafeEqualString(stored.flow.trigger.webhookToken, token)) return toDetail(stored);
}
return undefined;
}
/** Zapise beh automatizace - drzi metriky i graf zive. */
export function recordRun(id: string, ok = true, tenantIds?: string[]): AutomationDetail | undefined {
// Bez omezeni volá webhook, ktery se autorizuje tokenem, ne prihlasenim.
const stored = tenantIds ? findWritable(id, tenantIds) : store.get(id);
if (!stored) {
console.warn(`[automations] recordRun pro nedostupnou automatizaci: ${id}`);
return undefined;
}
/*
* Zapisuje se do dnesniho dne. Po pulnoci vznikne novy zaznam, takze citac
* nepokracuje pres den - to byla puvodni chyba.
*/
const now = today();
const days = [...(stored.days ?? [])];
const index = days.findIndex((entry) => entry.day === now);
const current = index === -1 ? undefined : days[index];
if (current === undefined) {
days.push({ day: now, runs: 1, ok: ok ? 1 : 0 });
} else {
days[index] = {
day: now,
runs: current.runs + 1,
ok: current.ok + (ok ? 1 : 0),
};
}
if (days.length > KEEP_DAYS) days.splice(0, days.length - KEEP_DAYS);
const total = (stored.runsTotal ?? stored.runsToday) + 1;
const dayEntry = days.find((entry) => entry.day === now)!;
const updated: StoredAutomation = {
...stored,
days,
runsTotal: total,
runsToday: dayEntry.runs,
successRate: Math.round((dayEntry.ok / dayEntry.runs) * 1000) / 10,
lastRunAt: new Date().toISOString(),
};
save(updated);
publish(
'automation.run',
ok
? `Automatizace ${id} proběhla: ${updated.name}`
: `Automatizace ${id} skončila chybou: ${updated.name}`,
{ automationId: id, ok },
updated.tenantId,
);
return toDetail(updated);
}
export function deleteAutomation(id: string, tenantIds: string[]): boolean {
const stored = findWritable(id, tenantIds);
const name = stored?.name;
const existed = stored !== undefined && store.delete(id);
if (existed) mirror.drop(id);
if (!existed) {
console.warn(`[automations] pokus o smazani nedostupne automatizace: ${id}`);
} else {
console.info(`[automations] smazana automatizace ${id}`);
publish('automation.deleted', `Automatizace ${id} smazána: ${name ?? ''}`, {
automationId: id,
}, stored?.tenantId ?? null);
}
return existed;
}
+227
View File
@@ -0,0 +1,227 @@
/**
* Co se ze stromu dopocitava: pocet kroku, nedodelky a druh automatizace.
*
* Vsechno jsou ciste funkce nad stromem. Uklada se to s automatizaci
* (`withDerived`), aby se strom neprochazel pri kazdem cteni seznamu.
*/
import { isUnary } from '../conditions.js';
import { collectScopes, duplicateNames, scopeFor } from '../flowScope.js';
import { rootsOf } from '../model.js';
import { actionInputsFor, findService } from '../services.js';
import { referencedFields, rootOf } from '../templates.js';
import type {
AutomationFlow,
AutomationKind,
FlowStep,
TriggerField,
} from '../../shared/automations.js';
import { rulesOf, type StoredAutomation } from './model.js';
/** Rekurzivne secte kroky vcetne obou vetvi podminek. */
export function countSteps(steps: FlowStep[]): number {
return steps.reduce((sum, step) => {
if (step.kind === 'condition') {
return sum + 1 + countSteps(step.yes) + countSteps(step.no);
}
if (step.kind === 'foreach') {
return sum + 1 + countSteps(step.steps);
}
return sum + 1;
}, 0);
}
/**
* Nedodelky v nastaveni jednoho kroku: nevyplnene povinne pole a odkaz
* na parametr, ktery u spoustece neexistuje.
*
* Druhy pripad nastane hlavne po prejmenovani parametru. Sablonu proto
* nezahazujeme ani tise neopravujeme, jen rekneme, kde se ma doplnit.
*/
function actionInputIssues(
step: Extract<FlowStep, { kind: 'action' }>,
available: TriggerField[],
/**
* Koreny, ktere jsou v poradku i kdyz nejsou deklarovanym parametrem:
* klice z ukazky tela, ID predchozich kroku, `item` a `index` uvnitr smycky.
*/
extraRoots: Set<string>,
): string[] {
const knownNames = new Set(available.map((field) => field.name));
const catalog = actionInputsFor(step.serviceId, step.operationId);
if (catalog.length === 0) return [];
const issues: string[] = [];
const operationName = findService(step.serviceId)?.name ?? step.serviceId;
for (const field of catalog) {
const value = step.inputs?.[field.id] ?? '';
if (field.required && value.trim().length === 0) {
issues.push(`Krok "${operationName}": chybí ${field.label.toLowerCase()}.`);
continue;
}
for (const reference of referencedFields(value)) {
/*
* Overuje se jen **prvni cast** odkazu. Zbytek je cesta do struktury
* a tu predem overit nejde - co presne prijde v tele, vime az pri behu.
*/
const root = rootOf(reference);
if (!knownNames.has(root) && !extraRoots.has(root)) {
issues.push(
`Krok "${operationName}", pole ${field.label.toLowerCase()}: parametr "${root}" u spouštěče neexistuje.`,
);
}
}
}
return issues;
}
/**
* Co brani zapnuti automatizace. Zamerne to NENI chyba pri ukladani -
* rozdelanou praci chceme ulozit, jen ji nesmime pustit do provozu.
*/
export function collectFlowIssues(flow: AutomationFlow): string[] {
const issues: string[] = [];
if (!flow.trigger) {
issues.push('Chybí spouštěč.');
return issues;
}
if (flow.steps.length === 0) {
issues.push('Automatizace nemá žádný krok.');
}
// Webhook bez registrovaneho tokenu nelze zavolat.
const isWebhook = flow.trigger.serviceId === 'webhook';
if (isWebhook && !flow.trigger.webhookToken) {
issues.push('Webhook nemá vygenerovanou adresu.');
}
const scopes = collectScopes(flow);
/*
* Co dalsiho smi stat na zacatku odkazu. Klice z ukazky tela, protoze prave
* kvuli nim se ukazka vlepuje, a `_body` s celym telem.
*/
const sampleRoots = rootsOf(flow.trigger.sample);
sampleRoots.add('_body');
const walk = (steps: FlowStep[], roots: Set<string>) => {
for (const step of steps) {
const available = scopeFor(scopes, step.id);
for (const name of duplicateNames(available)) {
issues.push(
`Parametr "${name}" je v tomto místě stromu dvakrát, v šabloně by nešlo poznat který.`,
);
}
if (step.kind === 'action') {
issues.push(...actionInputIssues(step, available, roots));
// ID kroku smi stat na zacatku odkazu: `{{st_faktura.invoiceId}}`.
roots.add(step.id);
continue;
}
if (step.kind === 'foreach') {
if (step.path.trim().length === 0) {
issues.push('Smyčka nemá vyplněnou cestu k seznamu.');
}
// Uvnitr smycky pribyva polozka a poradi, po ni vysledky za cely seznam.
walk(step.steps, new Set([...roots, 'item', 'index']));
roots.add(step.id);
continue;
}
// Kazda otazka podminky zvlast. Jedna spatna nesmi schovat ostatni.
const rules = rulesOf(step);
if (rules.length === 0) issues.push('Podmínka nemá žádnou otázku.');
for (const rule of rules) {
const field = available.find((candidate) => candidate.id === rule.fieldId);
if (!field) {
// Rozlisujeme "neexistuje" od "vznikne az pozdeji". Druhy pripad nastane
// po presunuti kroku a chce jinou radu nez smazat podminku.
issues.push(
scopes.all.has(rule.fieldId)
? 'Podmínka se ptá na parametr, který vzniká až v pozdějším kroku. Posuňte ji níž.'
: 'Podmínka se odkazuje na parametr, který už neexistuje.',
);
} else if (!isUnary(rule.operator) && (rule.value ?? '').trim().length === 0) {
issues.push(`Podmínka nad parametrem "${field.name}" nemá vyplněnou hodnotu.`);
} else if (field.type === 'number' && !isUnary(rule.operator)) {
if (Number.isNaN(Number(rule.value))) {
issues.push(`Podmínka nad parametrem "${field.name}" má nečíselnou hodnotu.`);
}
}
}
/*
* Koreny z vetvi se **nesou dal**. Vystup z vetve je za podminkou
* k dispozici, jen nepovinne - viz data/flowScope.ts.
*/
walk(step.yes, roots);
walk(step.no, roots);
}
};
walk(flow.steps, sampleRoots);
// Stejny nedodelek muze vyjit z vic kroku (typicky duplicitni jmeno parametru).
// Uzivateli staci rict jednou.
return [...new Set(issues)];
}
/** Pouziva strom nekde konektor z dane kategorie? */
function flowUsesCategory(steps: FlowStep[], category: string): boolean {
return steps.some((step) => {
if (step.kind === 'condition') {
return flowUsesCategory(step.yes, category) || flowUsesCategory(step.no, category);
}
if (step.kind === 'foreach') return flowUsesCategory(step.steps, category);
return findService(step.serviceId)?.category === category;
});
}
/**
* Druh automatizace se dopocitava ze stromu - klient ho nezadava.
* Je to jen stitek v seznamu, proto zamerne jednoducha heuristika:
* rozhoduje spoustec, u planovace jeste to, zda se ve krocich pracuje s analytikou.
*/
export function deriveKind(flow: AutomationFlow): AutomationKind {
if (!flow.trigger) return 'workflow';
const connector = findService(flow.trigger.serviceId);
if (!connector) {
console.warn(`[automations] spoustec odkazuje na neznama sluzba: ${flow.trigger.serviceId}`);
return 'workflow';
}
if (connector.id === 'voicebot') return 'voicebot';
if (connector.category === 'analytika') return 'report';
// Planovac + prace s analytikou = pravidelny report, ne obecne workflow.
if (connector.id === 'scheduler' && flowUsesCategory(flow.steps, 'analytika')) return 'report';
if (
connector.category === 'crm' ||
connector.category === 'ekonomika' ||
connector.category === 'logistika'
) {
return 'integrace';
}
return 'workflow';
}
/** Doplni to, co se ze stromu dopocitava a uklada s nim (nedodelky, pocet kroku). */
export function withDerived(automation: StoredAutomation): StoredAutomation {
return {
...automation,
stepCount: countSteps(automation.flow.steps),
issues: collectFlowIssues(automation.flow),
};
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Webhook automatizace: token v adrese a zaznamy poslednich volani.
*
* Token spravuje vyhradne server, viz `withWebhookToken`. Volani se drzi jen
* v pameti - je to napoveda pri ladeni, ne historie.
*/
import { randomBytes } from 'node:crypto';
import type { AutomationFlow, WebhookCall } from '../../shared/automations.js';
/** Delka tokenu v bajtech. 24 bajtu je 32 znaku base64url, dost na neuhodnutelnost. */
const WEBHOOK_TOKEN_BYTES = 24;
/** Neodhadnutelny token do adresy webhooku (32 znaku, base64url). */
export function generateWebhookToken(): string {
return randomBytes(WEBHOOK_TOKEN_BYTES).toString('base64url');
}
/** Kolik znaku tela se u volani drzi. Vic uz je v pameti zbytecne. */
const MAX_BODY = 8_000;
/** Telo do zaznamu: zmrazene na retezec, aby se pozdeji nezmenilo pod rukama. */
export function bodyForCall(body: unknown): { body: string; truncated: boolean } {
let text: string;
try {
text = JSON.stringify(body, null, 2) ?? '';
} catch {
// Cyklicka struktura nebo neco, co JSON neumi. Radeji nic nez pad.
text = '';
}
if (text.length <= MAX_BODY) return { body: text, truncated: false };
return { body: text.slice(0, MAX_BODY), truncated: true };
}
/** Kolik poslednich volani se u automatizace drzi. */
const MAX_CALLS = 10;
const calls = new Map<string, WebhookCall[]>();
/** Zapise, jak dopadlo jedno volani webhooku. Nejnovejsi je prvni. */
export function recordWebhookCall(automationId: string, call: WebhookCall): void {
const list = calls.get(automationId) ?? [];
list.unshift(call);
if (list.length > MAX_CALLS) list.length = MAX_CALLS;
calls.set(automationId, list);
}
/** Poslednich par volani. Cte se jen pres detail automatizace, ktery hlida firmu. */
export function recentWebhookCalls(automationId: string): WebhookCall[] {
return calls.get(automationId) ?? [];
}
/**
* Token webhooku spravuje VYHRADNE server:
* - webhook spoustec bez tokenu ho dostane vygenerovany,
* - existujici token se prevezme z ulozene verze (klient ho nemuze zmenit),
* - pri zmene spoustece na neco jineho se token zahodi.
*/
export function withWebhookToken(
next: AutomationFlow,
previous: AutomationFlow,
id: string,
): AutomationFlow {
if (!next.trigger) return next;
if (next.trigger.serviceId !== 'webhook') {
if (next.trigger.webhookToken) {
console.info(`[automations] ${id}: spoustec neni webhook, zahazuji token`);
}
return { ...next, trigger: { ...next.trigger, webhookToken: undefined } };
}
// Existujici token drzime, aby se uz zaregistrovana adresa nezmenila pod rukama.
const keptToken =
previous.trigger?.serviceId === 'webhook' ? previous.trigger.webhookToken : undefined;
if (keptToken) {
return { ...next, trigger: { ...next.trigger, webhookToken: keptToken } };
}
const token = generateWebhookToken();
console.info(`[automations] ${id}: vygenerovana adresa webhooku`);
return { ...next, trigger: { ...next.trigger, webhookToken: token } };
}
+13 -3
View File
@@ -41,6 +41,16 @@ interface ConnectorRow {
updated_at: Date;
}
/**
* Radek z `RETURNING`. Zapis, ktery nic nevrati, je chyba databaze, ne stav,
* ktery by mel volajici resit - proto vyjimka s jasnou hlaskou.
*/
function returnedRow(rows: ConnectorRow[], operation: string): ConnectorRow {
const row = rows[0];
if (row === undefined) throw new Error(`Databaze nevratila radek konektoru (${operation}).`);
return row;
}
function toConnector(row: ConnectorRow): Connector {
return {
id: row.id,
@@ -149,7 +159,7 @@ export const postgresConnectors: ConnectorRepository = {
],
);
return toConnector(row.rows[0]);
return toConnector(returnedRow(row.rows, 'create'));
});
},
@@ -205,7 +215,7 @@ export const postgresConnectors: ConnectorRepository = {
],
);
return toConnector(updated.rows[0]);
return toConnector(returnedRow(updated.rows, 'update'));
});
},
@@ -282,7 +292,7 @@ export const postgresConnectors: ConnectorRepository = {
RETURNING ${COLUMNS}`,
[id, JSON.stringify(sealAll(merged))],
);
return toConnector(updated.rows[0]);
return toConnector(returnedRow(updated.rows, 'setManagedValues'));
});
},
+1
View File
@@ -43,6 +43,7 @@ export function suggestRange(raw: string | null): string | null {
const parts = ip.split('.');
if (parts.length !== 4) return null;
const [first, second] = parts.map((part) => Number(part));
if (first === undefined || second === undefined) return null;
if (!Number.isInteger(first) || !Number.isInteger(second)) return null;
if (first === 127) return '127.0.0.0/8';
+3 -2
View File
@@ -34,8 +34,9 @@ export function getSummary(tenantIds: string[]): DashboardSummary {
// Dnesni sloupec grafu doplnujeme o skutecne behy automatizaci.
const runsToday = automations.reduce((sum, a) => sum + a.runsToday, 0);
if (series.length > 0) {
series[series.length - 1] = { ...series[series.length - 1], runs: runsToday };
const last = series.at(-1);
if (last !== undefined) {
series[series.length - 1] = { ...last, runs: runsToday };
}
return {
+4 -2824
View File
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
/**
* AI a hlas: voicebot, prepis hovoru, OpenAI.
*/
import type { Service } from '../../../shared/services.js';
export const aiServices: Service[] = [
{
id: 'voicebot',
name: 'Voicebot',
category: 'ai',
description: 'Hlasová linka: příjem hovorů, rozpoznání záměru, předání operátorovi.',
icon: 'PhoneCall',
status: 'available',
general: false,
appId: 'voicebot',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [
{
id: 'call-received',
name: 'Příchozí hovor',
description: 'Spustí se při přijetí hovoru na hlasovou linku.',
providedFields: [
{ id: 'voicebot.callerNumber', name: 'callerNumber', type: 'string', required: true },
{ id: 'voicebot.line', name: 'line', type: 'string', required: true },
{ id: 'voicebot.wantsOperator', name: 'wantsOperator', type: 'boolean', required: false },
{ id: 'voicebot.startedAt', name: 'startedAt', type: 'date', required: true },
],
},
{
id: 'call-ended',
name: 'Hovor ukončen',
description: 'Spustí se po skončení hovoru, k dispozici je přepis i záměr.',
},
],
actions: [
{
id: 'play-scenario',
name: 'Přehrát scénář',
description: 'Provede volajícího hlasovým scénářem a vrátí odpovědi.',
fields: ['Scénář', 'Jazyk'],
},
{
id: 'transfer',
name: 'Předat operátorovi',
description: 'Přepojí hovor na člověka a předá mu souhrn.',
fields: ['Skupina', 'Souhrn'],
},
{
id: 'outbound-call',
name: 'Zavolat zákazníkovi',
description: 'Zahájí odchozí hovor podle scénáře.',
fields: ['Telefon', 'Scénář'],
},
],
},
{
id: 'transcription',
name: 'Přepis hovoru',
category: 'ai',
description: 'Přepis zvuku na text (Deepgram + Whisper) se sloučením výsledků.',
icon: 'FileAudio',
status: 'available',
general: false,
appId: 'audio-transcription',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'deepgramApiKey',
label: 'Deepgram API klíč',
target: 'header',
name: 'X-Deepgram-Api-Key',
required: true,
secret: true,
hint: 'Deepgram Console, sekce API Keys.',
},
{
id: 'openaiApiKey',
label: 'OpenAI API klíč',
target: 'header',
name: 'X-OpenAI-Api-Key',
required: true,
secret: true,
hint: 'platform.openai.com, sekce API keys. Použije se na Whisper i na sloučení.',
},
],
// Sluzba nema zadne cteci volani s autorizaci: prepis se uctuje a jiny
// endpoint neni. Overi se proto jen dostupnost a rekne se to nahlas.
triggers: [],
actions: [
{
id: 'transcribe',
name: 'Přepsat nahrávku',
description:
'Přepíše nahrávku dvěma enginy naráz a oba přepisy sloučí do jednoho výsledku.',
fields: ['Nahrávka', 'Jazyk'],
},
],
},
{
id: 'openai',
name: 'OpenAI',
category: 'ai',
description:
'Dotazy na jazykové modely, práce se soubory a přepis zvuku pod vlastním API klíčem.',
icon: 'Bot',
status: 'available',
general: false,
appId: null,
baseUrl: 'https://api.openai.com/v1',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'apiKey',
label: 'API klíč',
target: 'header',
name: 'Authorization',
// Uzivatel vlepi klic tak, jak ho dostal. Slovo Bearer dopise runtime.
prefix: 'Bearer ',
required: true,
secret: true,
hint: 'platform.openai.com, sekce API keys. Vložte jen klíč, slovo Bearer doplní portál.',
},
{
id: 'organization',
label: 'ID organizace',
target: 'header',
name: 'OpenAI-Organization',
required: false,
secret: false,
hint: 'Jen když účet patří do víc organizací a útrata se má počítat konkrétní z nich.',
},
{
id: 'project',
label: 'ID projektu',
target: 'header',
name: 'OpenAI-Project',
required: false,
secret: false,
hint: 'Rozliší útratu mezi projekty jedné organizace.',
},
],
// Seznam modelu: nejlevnejsi cteci volani, ktere vyzaduje platny klic.
verifyPath: '/models',
triggers: [],
actions: [
{
id: 'chat',
name: 'Zeptat se modelu',
description: 'Pošle otázku vybranému modelu a vrátí odpověď i spotřebu tokenů.',
fields: ['Model', 'Instrukce', 'Otázka'],
},
{
id: 'ask-about-file',
name: 'Zeptat se na soubor',
description: 'Odpoví na otázku nad nahraným souborem nebo obrázkem.',
fields: ['Model', 'ID souboru', 'Otázka'],
},
{
id: 'upload-file',
name: 'Nahrát soubor',
description: 'Odešle soubor do OpenAI a vrátí jeho ID pro další kroky.',
fields: ['Název souboru', 'Obsah', 'Účel'],
},
{
id: 'transcribe-audio',
name: 'Přepsat zvuk',
description: 'Přepíše nahrávku na text jedním z přepisovacích modelů.',
fields: ['Nahrávka', 'Model', 'Jazyk'],
},
{
id: 'list-models',
name: 'Načíst seznam modelů',
description: 'Vrátí modely, na které účet dosáhne. Nic nemění a nic nestojí.',
},
],
},
];
+273
View File
@@ -0,0 +1,273 @@
/**
* Analytika a reklama: GA4, Search Console, Google Ads, Sklik, Meta Ads.
*/
import type { Service } from '../../../shared/services.js';
export const analyticsServices: Service[] = [
{
id: 'ga4',
name: 'Google Analytics 4',
category: 'analytika',
description: 'Návštěvnost, konverze a chování uživatelů.',
icon: 'BarChart3',
status: 'available',
general: false,
appId: 'analytics',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'credentials',
label: 'Klíč service accountu (Base64)',
target: 'header',
name: 'X-GA-Credentials',
required: false,
secret: true,
hint: 'JSON klíč service accountu zakódovaný do Base64. Vyplňte tohle, nebo access token.',
},
{
id: 'accessToken',
label: 'Access token',
target: 'header',
name: 'X-GA-Access-Token',
required: false,
secret: true,
hint: 'Hotový OAuth2 token. Platí zhruba hodinu, takže na provoz se hodí service account.',
},
{
id: 'quotaProject',
label: 'Projekt pro kvótu',
target: 'header',
name: 'X-GA-Quota-Project',
required: false,
secret: false,
hint: 'ID projektu v Google Cloud, na který se má počítat kvóta.',
},
],
// Seznam uctu: cteci volani, ktere bez platnych udaju neprojde.
verifyPath: '/ga/admin/accountSummaries',
triggers: [],
actions: [
{
id: 'run-report',
name: 'Načíst report',
description: 'Stáhne metriky za období pro další zpracování nebo report.',
fields: ['Property', 'Metriky', 'Dimenze', 'Období'],
},
],
},
{
id: 'search-console',
name: 'Search Console',
category: 'analytika',
description: 'Pozice ve vyhledávání, dotazy a prokliky.',
icon: 'Search',
status: 'available',
general: false,
appId: 'analytics',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'credentials',
label: 'Klíč service accountu (Base64)',
target: 'header',
name: 'X-GSC-Credentials',
required: false,
secret: true,
hint: 'JSON klíč service accountu zakódovaný do Base64. Vyplňte tohle, nebo access token.',
},
{
id: 'accessToken',
label: 'Access token',
target: 'header',
name: 'X-GSC-Access-Token',
required: false,
secret: true,
hint: 'Hotový OAuth2 token se scope webmasters.readonly.',
},
{
id: 'quotaProject',
label: 'Projekt pro kvótu',
target: 'header',
name: 'X-GSC-Quota-Project',
required: false,
secret: false,
},
],
// Seznam webu v uctu: cteci volani, ktere bez platnych udaju neprojde.
verifyPath: '/gsc/sites',
triggers: [],
actions: [
{
id: 'run-report',
name: 'Načíst výkon ve vyhledávání',
description: 'Vrátí dotazy, prokliky, zobrazení a průměrnou pozici.',
fields: ['Web', 'Období'],
},
],
},
{
id: 'google-ads',
name: 'Google Ads',
category: 'analytika',
description: 'Výkon kampaní a náklady na reklamu.',
icon: 'Megaphone',
status: 'available',
general: false,
appId: 'analytics',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'developerToken',
label: 'Developer token',
target: 'header',
name: 'X-GAds-Developer-Token',
required: true,
secret: true,
hint: 'Ze správcovského (MCC) účtu: Tools, API Center. Musí mít schválený přístup.',
},
{
id: 'accessToken',
label: 'Access token',
target: 'header',
name: 'X-GAds-Access-Token',
required: false,
secret: true,
hint: 'OAuth2 token se scope adwords. Vyplňte tohle, nebo klíč service accountu.',
},
{
id: 'credentials',
label: 'Klíč service accountu (Base64)',
target: 'header',
name: 'X-GAds-Credentials',
required: false,
secret: true,
hint: 'Funguje jen se zapnutou domain-wide delegation.',
},
{
id: 'loginCustomerId',
label: 'ID správcovského účtu',
target: 'header',
name: 'X-GAds-Login-Customer-Id',
required: false,
secret: false,
hint: 'MCC účet, přes který se přistupuje k podřízenému účtu. Bez pomlček.',
},
],
// Seznam uctu, na ktere udaje dosahnou. Nic nemeni.
verifyPath: '/googleads/customers:listAccessibleCustomers',
triggers: [],
actions: [
{
id: 'campaign-report',
name: 'Načíst výkon kampaní',
description: 'Stáhne náklady, konverze a ROAS podle kampaní.',
fields: ['Účet', 'Období'],
},
],
},
{
id: 'sklik',
name: 'Sklik',
category: 'analytika',
description: 'Kampaně a náklady v Skliku.',
icon: 'MousePointer',
status: 'available',
general: false,
appId: 'analytics',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'token',
label: 'Token API Drak',
target: 'header',
name: 'X-Sklik-Token',
required: true,
secret: true,
hint:
'Sklik: uživatelské jméno, Nastavení, Přístup k API Drak. ' +
'Vygenerování nového tokenu zneplatní ten předchozí.',
},
{
id: 'userId',
label: 'ID cizího účtu',
target: 'header',
name: 'X-Sklik-User-Id',
required: false,
secret: false,
hint: 'Jen pro agenturní přístup ke spravovanému účtu.',
},
],
// Kvoty a limity uctu: cteci volani, ktere bez platneho tokenu neprojde.
verifyPath: '/sklik/limits',
triggers: [],
actions: [
{
id: 'campaign-report',
name: 'Načíst výkon kampaní',
description: 'Stáhne statistiky kampaní za období.',
fields: ['Účet', 'Období'],
},
],
},
{
id: 'meta-ads',
name: 'Meta Ads',
category: 'analytika',
description: 'Výkon reklam na Facebooku a Instagramu: účty, kampaně, sestavy a insighty.',
icon: 'Facebook',
status: 'available',
general: false,
appId: 'meta',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'accessToken',
label: 'Access token',
target: 'header',
name: 'X-Meta-Access-Token',
required: true,
secret: true,
hint:
'Token systémového uživatele z Business Manageru. Nepřestane platit, ' +
'když někdo odejde z firmy, na rozdíl od uživatelského tokenu.',
},
{
id: 'appSecret',
label: 'App Secret',
target: 'header',
name: 'X-Meta-App-Secret',
required: false,
secret: true,
hint:
'Se zapnutým appsecret_proof je povinný, jinak Meta volání odmítne. ' +
'Služba z něj podpis dopočítá sama.',
},
{
id: 'apiVersion',
label: 'Verze Graph API',
target: 'header',
name: 'X-Meta-Api-Version',
required: false,
secret: false,
hint: 'Například v25.0. Bez vyplnění se použije verze nastavená ve službě.',
},
],
// Seznam reklamnich uctu, na ktere token dosahne. Nic nemeni.
verifyPath: '/ads/me/adaccounts',
triggers: [],
actions: [
{
id: 'list-accounts',
name: 'Načíst reklamní účty',
description: 'Vrátí účty, na které přihlašovací údaje dosáhnou.',
},
{
id: 'insights',
name: 'Načíst výkon reklam',
description: 'Stáhne útratu, prokliky a konverze za období pro účet, kampaň nebo sestavu.',
fields: ['Objekt', 'Období', 'Úroveň'],
},
],
},
];
+114
View File
@@ -0,0 +1,114 @@
/**
* CRM: Raynet.
*/
import type { Service } from '../../../shared/services.js';
export const crmServices: Service[] = [
{
id: 'raynet',
name: 'RAYNET CRM',
category: 'crm',
description: 'Firmy, kontakty, obchodní případy a aktivity v RAYNET CRM.',
icon: 'Users',
status: 'available',
general: false,
appId: 'raynet',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'apiKey',
label: 'API klíč',
target: 'header',
name: 'X-Api-Key',
required: true,
secret: true,
hint: 'RAYNET CRM: Nastavení, Klíč k API.',
},
{
id: 'email',
label: 'E-mail uživatele',
target: 'header',
name: 'X-Raynet-Email',
required: true,
secret: false,
hint: 'Přihlašovací e-mail. S API klíčem tvoří Basic Auth.',
},
{
id: 'instanceName',
label: 'Název instance',
target: 'header',
name: 'X-Instance-Name',
required: true,
secret: false,
hint: 'Subdoména účtu, tedy část před .raynetcrm.com.',
},
],
// Seznam firem o jedne polozce: nic nemeni a bez platnych udaju neprojde.
verifyPath: '/company?limit=1',
triggers: [
{
id: 'lead-created',
name: 'Nový obchodní případ',
description: 'Spustí se při založení nového obchodního případu.',
},
{
id: 'company-changed',
name: 'Změna firmy',
description: 'Spustí se při úpravě údajů firmy.',
},
],
actions: [
{
id: 'create-lead',
name: 'Založit obchodní případ',
description: 'Vytvoří nový obchodní případ včetně napojení na firmu.',
fields: ['Název', 'Firma', 'Vlastník', 'Fáze'],
outputFields: [{ id: 'raynet.leadId', name: 'leadId', type: 'string', required: true }],
},
{
id: 'find-company',
name: 'Dohledat firmu',
description:
'Zjistí, jestli odesílatele známe. Nic nezakládá. Podle výsledku se pak strom větví.',
inputs: [
{
id: 'email',
label: 'E-mail',
kind: 'text',
required: false,
hint: 'Například {{from}} u e-mailu.',
},
{
id: 'phone',
label: 'Telefon',
kind: 'text',
required: false,
hint: 'Například {{phone}} u WhatsApp.',
},
],
outputFields: [
{ id: 'raynet.customerKnown', name: 'customerKnown', type: 'boolean', required: true },
{ id: 'raynet.companyId', name: 'companyId', type: 'string', required: false },
{ id: 'raynet.companyName', name: 'companyName', type: 'string', required: false },
{ id: 'raynet.ownerName', name: 'ownerName', type: 'string', required: false },
],
},
{
id: 'upsert-contact',
name: 'Založit nebo aktualizovat kontakt',
description: 'Podle e-mailu kontakt najde a doplní, jinak vytvoří nový.',
fields: ['E-mail', 'Jméno', 'Telefon', 'Firma'],
outputFields: [
{ id: 'raynet.contactId', name: 'contactId', type: 'string', required: true },
],
},
{
id: 'add-activity',
name: 'Přidat aktivitu',
description: 'Zapíše hovor, e-mail nebo poznámku k záznamu.',
fields: ['Typ aktivity', 'Text', 'Vazba na záznam'],
},
],
},
];
+185
View File
@@ -0,0 +1,185 @@
/**
* E-mail: prijem i odeslani.
*/
import type { Service } from '../../../shared/services.js';
export const emailServices: Service[] = [
{
id: 'email',
name: 'E-mail',
category: 'komunikace',
description: 'Příjem i odesílání e-mailů včetně příloh.',
icon: 'Mail',
status: 'available',
general: false,
// Posmovni server neni nase aplikace za /apps a adresa je u konektoru,
// protoze kazda firma odesila ze sve schranky.
appId: null,
transport: 'smtp',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'host',
label: 'SMTP server',
target: 'config',
name: 'host',
required: true,
secret: false,
hint: 'Například smtp.seznam.cz nebo smtp.gmail.com.',
},
{
id: 'port',
label: 'Port',
target: 'config',
name: 'port',
required: true,
secret: false,
hint: '587 pro STARTTLS, 465 pro šifrované spojení od začátku, 25 bez šifrování.',
},
{
id: 'security',
label: 'Šifrování',
target: 'config',
name: 'security',
required: false,
secret: false,
hint:
'Prázdné se řídí portem: 465 je ssl, jinak starttls. ' +
'Přepsat jde hodnotou ssl, starttls nebo zadne.',
},
{
id: 'user',
label: 'Uživatel',
target: 'config',
name: 'user',
required: true,
secret: false,
hint: 'Přihlašovací jméno ke schránce, obvykle celá e-mailová adresa.',
},
{
id: 'password',
label: 'Heslo',
target: 'config',
name: 'password',
required: true,
secret: true,
hint:
'U schránek s dvoufázovým ověřením to musí být heslo pro aplikaci, ' +
'ne heslo k účtu.',
},
{
id: 'from',
label: 'Adresa odesílatele',
target: 'config',
name: 'from',
required: true,
secret: false,
hint: 'Server ji musí povolit. Obvykle stejná jako uživatel.',
},
{
id: 'fromName',
label: 'Jméno odesílatele',
target: 'config',
name: 'fromName',
required: false,
secret: false,
hint: 'Co uvidí příjemce místo holé adresy, například Podpora Automia.',
},
{
id: 'replyTo',
label: 'Adresa pro odpovědi',
target: 'config',
name: 'replyTo',
required: false,
secret: false,
hint: 'Kam mají chodit odpovědi, když jinam než na adresu odesílatele.',
},
],
triggers: [
{
id: 'received',
name: 'Přijat e-mail',
description:
'Spustí se při doručení e-mailu do sledované schránky. Typický začátek ticketu.',
providedFields: [
{ id: 'email.from', name: 'from', type: 'string', required: true },
{ id: 'email.subject', name: 'subject', type: 'string', required: true },
{ id: 'email.body', name: 'body', type: 'string', required: false },
{ id: 'email.hasAttachment', name: 'hasAttachment', type: 'boolean', required: false },
{ id: 'email.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
],
actions: [
{
id: 'send',
name: 'Odeslat e-mail',
description:
'Odešle zprávu ze schránky uvedené v konektoru. Předmět, příjemce ' +
'i tělo se skládají z parametrů spouštěče a výstupů předchozích kroků.',
inputs: [
{
id: 'to',
label: 'Příjemce',
kind: 'text',
required: true,
hint: 'Adresy oddělené čárkou. Například {{from}}, když odpovídáte na příchozí e-mail.',
},
{
id: 'cc',
label: 'Kopie',
kind: 'text',
required: false,
hint: 'Adresy oddělené čárkou.',
},
{
id: 'bcc',
label: 'Skrytá kopie',
kind: 'text',
required: false,
hint: 'Příjemci se navzájem neuvidí.',
},
{
id: 'subject',
label: 'Předmět',
kind: 'text',
required: true,
hint: 'Například Ticket {{ticketId}}: {{subject}}.',
},
{
id: 'html',
label: 'Tělo zprávy (HTML)',
kind: 'html',
required: true,
hint:
'Píše se jako HTML. Parametry se dosazují stejně jako jinde, ' +
'tedy {{jmeno}}, a dosazuje se bezpečně - ostré závorky v hodnotě ' +
'rozvržení nerozhodí.',
},
{
id: 'text',
label: 'Textová verze',
kind: 'longtext',
required: false,
hint:
'Pro klienty, kteří HTML nezobrazí. Bez vyplnění se vyrobí z HTML ' +
'odstraněním značek.',
},
{
id: 'replyTo',
label: 'Adresa pro odpovědi',
kind: 'text',
required: false,
hint: 'Přebije adresu z konektoru. Hodí se, když má odpověď zamířit do ticketu.',
},
],
outputFields: [
{ id: 'email.messageId', name: 'messageId', type: 'string', required: true },
{ id: 'email.accepted', name: 'accepted', type: 'number', required: true },
{ id: 'email.rejected', name: 'rejected', type: 'number', required: true },
],
},
],
},
];
+280
View File
@@ -0,0 +1,280 @@
/**
* Ekonomika a banky: iDoklad, CSOB, SAP Business One.
*/
import type { Service } from '../../../shared/services.js';
export const financeServices: Service[] = [
{
id: 'idoklad',
name: 'iDoklad',
category: 'ekonomika',
description: 'Fakturace: vydané i přijaté doklady, kontakty, úhrady.',
icon: 'Receipt',
status: 'available',
general: false,
appId: 'idoklad',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'clientId',
label: 'Client ID',
target: 'header',
name: 'X-ClientId',
required: true,
secret: false,
hint: 'Z vývojářského portálu iDokladu.',
},
{
id: 'clientSecret',
label: 'Client Secret',
target: 'header',
name: 'X-ClientSecret',
required: true,
secret: true,
hint: 'Uloží se jen pro odesílání a nikdy se nevrací zpátky.',
},
{
id: 'applicationId',
label: 'Application ID',
target: 'header',
name: 'X-ApplicationId',
required: false,
secret: false,
hint: 'Jen partnerské aplikace. Běžná aplikace ho nepotřebuje.',
},
{
id: 'language',
label: 'Jazyk odpovědí',
target: 'header',
name: 'X-Idoklad-Language',
required: false,
secret: false,
hint: 'Cz, Sk nebo En.',
},
],
// Vrati udaje o agende: nic nemeni a bez platnych udaju neprojde.
verifyPath: '/account/agenda',
triggers: [
{
id: 'invoice-paid',
name: 'Faktura uhrazena',
description: 'Spustí se, jakmile je vydaná faktura označená jako zaplacená.',
},
{
id: 'invoice-overdue',
name: 'Faktura po splatnosti',
description: 'Spustí se v den, kdy faktura překročí splatnost.',
},
],
actions: [
{
id: 'create-invoice',
name: 'Vystavit fakturu',
description: 'Vytvoří vydanou fakturu včetně položek a odešle ji odběrateli.',
fields: ['Odběratel', 'Položky', 'Splatnost', 'Odeslat e-mailem'],
},
{
id: 'create-proforma',
name: 'Vystavit proforma fakturu',
description: 'Vytvoří zálohovou fakturu.',
fields: ['Odběratel', 'Položky'],
},
{
id: 'mark-paid',
name: 'Označit jako uhrazenou',
description: 'Zapíše úhradu k existující faktuře.',
fields: ['Číslo faktury', 'Datum úhrady'],
},
],
},
{
id: 'csob',
name: 'ČSOB (PSD2)',
category: 'ekonomika',
description: 'Bankovní pohyby a zůstatky přes PSD2 rozhraní ČSOB.',
icon: 'Landmark',
status: 'available',
general: false,
appId: 'csob',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'certificate',
label: 'Certifikát QWAC (Base64 PFX)',
target: 'header',
name: 'X-CSOB-Certificate',
required: true,
secret: true,
hint: 'eIDAS certifikát jako PFX zakódovaný do Base64. Slouží k mutual TLS.',
},
{
id: 'certificatePassword',
label: 'Heslo k certifikátu',
target: 'header',
name: 'X-CSOB-Certificate-Password',
required: false,
secret: true,
hint: 'Jen když je PFX chráněný heslem.',
},
{
id: 'apiKey',
label: 'API klíč aplikace',
target: 'header',
name: 'X-API-Key',
required: true,
secret: true,
hint: 'Z vývojářského portálu ČSOB. Posílá se dál jako APIKEY.',
},
{
id: 'tppName',
label: 'Název TPP',
target: 'header',
name: 'X-TPP-Name',
required: true,
secret: false,
hint: 'Název registrované organizace, posílá se dál jako TPP-Name.',
},
{
id: 'accessToken',
label: 'Access token klienta',
target: 'header',
name: 'X-Access-Token',
required: true,
secret: true,
hint:
'OAuth2 token konkrétního klienta banky. Získá se přes /oauth/* a je ' +
'časově omezený, takže po vypršení se musí přepsat.',
},
{
id: 'clientId',
label: 'OAuth Client ID',
target: 'header',
name: 'X-CSOB-Client-Id',
required: false,
secret: false,
hint: 'Jen pro obnovu tokenu přes OAuth endpointy.',
},
{
id: 'clientSecret',
label: 'OAuth Client Secret',
target: 'header',
name: 'X-CSOB-Client-Secret',
required: false,
secret: true,
hint: 'Jen pro obnovu tokenu přes OAuth endpointy.',
},
],
// Seznam uctu klienta: cteci volani, ktere bez platneho tokenu neprojde.
verifyPath: '/accounts?size=1',
triggers: [
{
id: 'payment-received',
name: 'Přijatá platba',
description: 'Spustí se při nové příchozí platbě na účtu.',
},
],
actions: [
{
id: 'list-transactions',
name: 'Načíst pohyby',
description: 'Stáhne transakce za zvolené období pro další zpracování.',
fields: ['Účet', 'Období'],
},
{
id: 'match-payment',
name: 'Spárovat platbu s fakturou',
description: 'Podle variabilního symbolu a částky najde odpovídající fakturu.',
fields: ['Tolerance částky'],
},
],
},
{
id: 'sap-bo',
name: 'SAP Business One',
category: 'ekonomika',
description: 'Obchodní partneři, položky, objednávky a doklady v SAP Business One.',
icon: 'Database',
status: 'available',
general: false,
appId: 'sap-bo',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'serviceLayerUrl',
label: 'Adresa Service Layer',
target: 'header',
name: 'X-SAP-B1-BaseUrl',
required: true,
secret: false,
hint: 'Například https://sap.firma.cz:50000. Adresa musí být dostupná z internetu.',
},
{
id: 'companyDb',
label: 'Databáze firmy',
target: 'header',
name: 'X-SAP-B1-CompanyDB',
required: true,
secret: false,
hint: 'Název company databáze, například SBODEMOCZ.',
},
{
id: 'username',
label: 'Uživatel',
target: 'header',
name: 'X-SAP-B1-Username',
required: true,
secret: false,
},
{
id: 'password',
label: 'Heslo',
target: 'header',
name: 'X-SAP-B1-Password',
required: true,
secret: true,
},
{
id: 'language',
label: 'Jazyk',
target: 'header',
name: 'X-SAP-B1-Language',
required: false,
secret: false,
hint: 'Kód jazyka Service Layer, například cs-CZ.',
},
{
id: 'rejectUnauthorized',
label: 'Kontrolovat certifikát',
target: 'header',
name: 'X-SAP-B1-Reject-Unauthorized',
required: false,
secret: false,
hint: 'false povolí self-signed certifikát Service Layer. Výchozí je kontrolovat.',
},
],
// Prihlasi se a vrati verzi Service Layer. Nic nezaklada.
verifyPath: '/api/system/info',
triggers: [],
actions: [
{
id: 'find-business-partner',
name: 'Najít obchodního partnera',
description: 'Dohledá partnera podle kódu, IČO nebo názvu. Nic nezakládá.',
fields: ['Kód partnera', 'IČO', 'Název'],
},
{
id: 'list-orders',
name: 'Načíst objednávky',
description: 'Vrátí objednávky partnera nebo za období.',
fields: ['Partner', 'Období'],
},
{
id: 'create-order',
name: 'Založit objednávku',
description: 'Vytvoří prodejní objednávku včetně řádků.',
fields: ['Partner', 'Položky', 'Datum dodání'],
},
],
},
];
+55
View File
@@ -0,0 +1,55 @@
/**
* Incidenty. Zvlast od ticketu, protoze incident je udalost provozu, ne pozadavek cloveka.
*/
import type { Service } from '../../../shared/services.js';
export const incidentServices: Service[] = [
{
id: 'incident',
name: 'Incidenty',
category: 'obecne',
description:
'Výpadek nebo porucha, která se týká víc lidí najednou. Na rozdíl od ticketu ' +
'neřeší jednoho zákazníka, ale stav služby.',
icon: 'AlarmClock',
status: 'available',
general: true,
appId: null,
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [],
actions: [
{
id: 'create',
name: 'Založit incident',
description:
'Když se chyba netýká jednoho ticketu, ale celé služby. Typicky navazuje ' +
'na ticket typu chyba.',
implementation: 'script',
inputs: [
{ id: 'title', label: 'Název', kind: 'text', required: true },
{
id: 'service',
label: 'Čeho se týká',
kind: 'text',
required: false,
hint: 'Název služby nebo aplikace, například Web nebo Voicebot.',
},
{
id: 'severity',
label: 'Závažnost',
kind: 'choice',
required: false,
options: [
{ value: 'sev1', label: 'SEV1, kritická' },
{ value: 'sev2', label: 'SEV2, vážná' },
{ value: 'sev3', label: 'SEV3, menší' },
],
},
],
outputFields: [{ id: 'incidentId', name: 'incidentId', type: 'string', required: true }],
},
],
},
];
+50
View File
@@ -0,0 +1,50 @@
/**
* Katalog sluzeb slozeny ze skupin. Poradi tady je poradi v nabidce, proto
* se nemeni jen tak - a proto je zakazkova integrace az za MCP, kde byla.
*/
import type { Service, ServiceCategoryEntry } from '../../../shared/services.js';
import { triggerServices } from './triggers.js';
import { incidentServices } from './incident.js';
import { ticketServices } from './ticket.js';
import { crmServices } from './crm.js';
import { financeServices } from './finance.js';
import { logisticsServices } from './logistics.js';
import { emailServices } from './email.js';
import { socialServices } from './social.js';
import { officeServices } from './office.js';
import { messagingServices } from './messaging.js';
import { analyticsServices } from './analytics.js';
import { aiServices } from './ai.js';
import { mcpServices } from './mcp.js';
import { polstrynServices } from './polstryn.js';
import { toolServices } from './tools.js';
export const serviceCategories: ServiceCategoryEntry[] = [
{ id: 'obecne', label: 'Obecné' },
{ id: 'crm', label: 'CRM' },
{ id: 'ekonomika', label: 'Ekonomika a banky' },
{ id: 'logistika', label: 'Logistika' },
{ id: 'komunikace', label: 'Komunikace' },
{ id: 'analytika', label: 'Analytika' },
{ id: 'ai', label: 'AI a hlas' },
{ id: 'transformace', label: 'Transformace dat' },
];
export const services: Service[] = [
...triggerServices,
...incidentServices,
...ticketServices,
...crmServices,
...financeServices,
...logisticsServices,
...emailServices,
...socialServices,
...officeServices,
...messagingServices,
...analyticsServices,
...aiServices,
...mcpServices,
...polstrynServices,
...toolServices,
];
+125
View File
@@ -0,0 +1,125 @@
/**
* Logistika: PPL a e-shop.
*/
import type { Service } from '../../../shared/services.js';
export const logisticsServices: Service[] = [
{
id: 'ppl',
name: 'PPL CPL',
category: 'logistika',
description: 'Zásilky, štítky a svozy v systému PPL.',
icon: 'Truck',
status: 'available',
general: false,
appId: 'pplcplapi',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'clientId',
label: 'Client ID',
target: 'header',
name: 'X-Client-Id',
required: true,
secret: false,
hint: 'Z vývojářského portálu PPL CPL.',
},
{
id: 'clientSecret',
label: 'Client Secret',
target: 'header',
name: 'X-Client-Secret',
required: true,
secret: true,
},
{
id: 'environment',
label: 'Prostředí',
target: 'header',
name: 'X-Environment',
required: false,
secret: false,
hint: 'production (výchozí) nebo test. Test nevytváří skutečné zásilky.',
},
],
// Udaje o zakaznikovi: nic nezaklada a bez platnych udaju neprojde.
verifyPath: '/customer',
triggers: [
{
id: 'shipment-delivered',
name: 'Zásilka doručena',
description: 'Spustí se při změně stavu zásilky na doručeno.',
},
],
actions: [
{
id: 'create-shipment',
name: 'Vytvořit zásilku',
description: 'Založí zásilku a vrátí číslo balíku i štítek k tisku.',
fields: ['Příjemce', 'Adresa', 'Hmotnost', 'Služba'],
},
{
id: 'order-pickup',
name: 'Objednat svoz',
description: 'Objedná svoz na zvolený den a adresu.',
fields: ['Datum svozu', 'Adresa', 'Počet zásilek'],
},
{
id: 'track',
name: 'Zjistit stav zásilky',
description: 'Vrátí aktuální stav a historii zásilky.',
fields: ['Číslo zásilky'],
},
],
},
{
id: 'eshop',
name: 'E-shop',
category: 'logistika',
description: 'Objednávky, sklad a zákazníci z e-shopu (Shoptet, WooCommerce, vlastní).',
icon: 'ShoppingCart',
status: 'available',
general: false,
appId: 'eshop',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [
{
id: 'order-created',
name: 'Nová objednávka',
description:
'Spustí se při vytvoření objednávky v e-shopu. Celá objednávka projde ' +
'dál jako objekt, takže se dá přemapovat na doklad.',
providedFields: [
{ id: 'eshop.orderNumber', name: 'orderNumber', type: 'string', required: true },
{ id: 'eshop.orderTotal', name: 'orderTotal', type: 'number', required: true },
{ id: 'eshop.customerEmail', name: 'customerEmail', type: 'string', required: false },
// Cela objednavka. Do sablony se nedosazuje, predava se dalsimu kroku
// jako celek - typicky do transformace dat.
{ id: 'eshop.order', name: 'order', type: 'object', required: true },
{ id: 'eshop.items', name: 'items', type: 'list', required: true },
],
},
{
id: 'order-status-changed',
name: 'Změna stavu objednávky',
description: 'Spustí se při přechodu objednávky do jiného stavu.',
},
],
actions: [
{
id: 'update-order',
name: 'Změnit stav objednávky',
description: 'Nastaví objednávce nový stav a volitelně informuje zákazníka.',
fields: ['Číslo objednávky', 'Nový stav'],
},
{
id: 'update-stock',
name: 'Upravit stav skladu',
description: 'Naskladní nebo odepíše položky.',
fields: ['SKU', 'Množství'],
},
],
},
];
+160
View File
@@ -0,0 +1,160 @@
/**
* MCP servery. Nastroje se zjisti az od serveru, viz data/mcpTools.ts.
*/
import { MCP_EASYWEB_SERVICE_ID, MCP_SERVICE_ID } from '../../../mcp/dialect.js';
import type { Service } from '../../../shared/services.js';
export const mcpServices: Service[] = [
{
id: MCP_SERVICE_ID,
name: 'MCP server',
category: 'ai',
description:
'Napojení na libovolný MCP server. Portál si od něj vyžádá seznam nástrojů a ty se pak dají použít jako kroky automatizace.',
icon: 'Plug',
status: 'available',
general: false,
appId: null,
transport: 'mcp',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'serverUrl',
label: 'Adresa MCP serveru',
target: 'config',
name: 'serverUrl',
required: true,
secret: false,
hint: 'Celá adresa endpointu, například https://mcp.firma.cz/mcp. Musí být dostupná z internetu.',
},
{
id: 'token',
label: 'Token',
target: 'config',
name: 'token',
required: false,
secret: true,
hint: 'Když jste od provozovatele dostali hotový token. Portál ho pošle tak, jak je, a nic dalšího neřeší.',
},
{
id: 'clientId',
label: 'ID aplikace',
target: 'config',
name: 'clientId',
required: false,
secret: false,
hint: 'Druhá možnost: server má přihlášení přes OAuth. Portál si pak přístup vyzvedne sám a obnovuje ho.',
},
{
id: 'clientSecret',
label: 'Tajemství aplikace',
target: 'config',
name: 'clientSecret',
required: false,
secret: true,
hint: 'Patří k ID aplikace.',
},
{
id: 'tokenUrl',
label: 'Adresa pro přihlášení',
target: 'config',
name: 'tokenUrl',
required: false,
secret: false,
hint: 'Nechte prázdné. Vyplňuje se jen tehdy, když ji portál u serveru sám nenajde.',
},
{
id: 'scope',
label: 'Rozsah oprávnění',
target: 'config',
name: 'scope',
required: false,
secret: false,
hint: 'Nechte prázdné, pokud vám provozovatel serveru neřekl konkrétní hodnotu.',
},
],
triggers: [],
// Prazdne zamerne: vsechny operace jsou nastroje ze serveru.
actions: [],
},
{
id: MCP_EASYWEB_SERVICE_ID,
name: 'MCP EasyWeb',
category: 'ai',
description:
'Napojení na MCP server EasyWebu. Stačí adresa, jméno a heslo - portál si vyžádá seznam nástrojů a ty se dají použít jako kroky automatizace.',
icon: 'Plug',
status: 'available',
general: false,
appId: null,
transport: 'mcp',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'serverUrl',
label: 'Adresa MCP serveru',
target: 'config',
name: 'serverUrl',
required: true,
secret: false,
hint: 'Endpoint bez koncového lomítka, například https://web.firmy.cz/centaur/mcp. Přihlašovací adresy si portál odvodí sám.',
},
{
id: 'username',
label: 'Jméno',
target: 'config',
name: 'username',
required: true,
secret: false,
hint: 'Uživatel, pod kterým se má portál k serveru hlásit.',
},
{
id: 'password',
label: 'Heslo',
target: 'config',
name: 'password',
required: true,
secret: true,
hint: 'Použije se jednou, na registraci zařízení. Dál se portál hlásí klíčem, který si vyrobí sám.',
},
{
id: 'deviceName',
label: 'Název zařízení',
target: 'config',
name: 'deviceName',
required: false,
secret: false,
hint: 'Pod tímhle názvem uvidíte přihlášení v logu serveru. Prázdné znamená WorkNuke.',
},
/*
* Klic zarizeni. Vyrabi ho portal pri prvnim prihlaseni a od te chvile je
* to identita, kterou server pozna - jmeno a heslo uz se nepouziva.
*
* Je to pole konektoru, a ne zvlastni tabulka, protoze udaje konektoru se
* uz ukladaji zasifrovane a tohle je privatni klic. `managed` znamena, ze
* ho ve formulari nikdo nevidi a nevyplnuje.
*/
{
id: 'deviceJwk',
label: 'Klíč zařízení',
target: 'config',
name: 'deviceJwk',
required: false,
secret: true,
managed: true,
},
{
id: 'deviceFingerprint',
label: 'Otisk zařízení',
target: 'config',
name: 'deviceFingerprint',
required: false,
secret: false,
managed: true,
},
],
triggers: [],
actions: [],
},
];
+50
View File
@@ -0,0 +1,50 @@
/**
* Kratke zpravy: SMS a Slack.
*/
import type { Service } from '../../../shared/services.js';
export const messagingServices: Service[] = [
{
id: 'sms',
name: 'SMS',
category: 'komunikace',
description: 'Odesílání SMS zpráv zákazníkům nebo obsluze.',
icon: 'MessageSquare',
status: 'available',
general: false,
appId: 'sms',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [],
actions: [
{
id: 'send',
name: 'Odeslat SMS',
description: 'Odešle krátkou zprávu na telefonní číslo.',
fields: ['Telefon', 'Text'],
},
],
},
{
id: 'slack',
name: 'Slack',
category: 'komunikace',
description: 'Notifikace a interní komunikace v Slacku.',
icon: 'Hash',
status: 'planned',
general: false,
appId: 'slack',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [],
actions: [
{
id: 'post-message',
name: 'Poslat zprávu do kanálu',
description: 'Odešle zprávu do zvoleného kanálu.',
fields: ['Kanál', 'Text'],
},
],
},
];
+160
View File
@@ -0,0 +1,160 @@
/**
* Kancelarske baliky: Microsoft 365 a Google Workspace.
*/
import type { Service } from '../../../shared/services.js';
export const officeServices: Service[] = [
{
id: 'microsoft365',
name: 'Microsoft 365',
category: 'komunikace',
description: 'Outlook, kalendář, Teams, SharePoint a OneDrive.',
icon: 'Building2',
status: 'available',
general: false,
appId: 'microsoft-365-service',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'tenantId',
label: 'Tenant ID',
target: 'header',
name: 'X-MS365-Tenant-Id',
required: true,
secret: false,
hint: 'ID adresáře v Entra ID (dříve Azure AD).',
},
{
id: 'clientId',
label: 'Client ID',
target: 'header',
name: 'X-MS365-Client-Id',
required: true,
secret: false,
hint: 'ID registrované aplikace.',
},
{
id: 'clientSecret',
label: 'Client Secret',
target: 'header',
name: 'X-MS365-Client-Secret',
required: true,
secret: true,
hint: 'Tajný klíč aplikace. Má omezenou platnost, po vypršení se přepíše.',
},
],
// Stav napojeni na Graph: prihlasi se udaji z hlavicek, nic nemeni.
verifyPath: '/status',
triggers: [
{
id: 'calendar-event',
name: 'Nová schůzka v kalendáři',
description: 'Spustí se při založení schůzky ve sledovaném kalendáři.',
},
],
actions: [
{
id: 'create-event',
name: 'Vytvořit schůzku',
description: 'Založí schůzku a pozve účastníky.',
fields: ['Kalendář', 'Termín', 'Účastníci'],
},
{
id: 'upload-file',
name: 'Uložit soubor',
description: 'Nahraje dokument do SharePointu nebo OneDrive.',
fields: ['Knihovna', 'Cesta', 'Soubor'],
},
{
id: 'post-teams',
name: 'Poslat zprávu do Teams',
description: 'Odešle zprávu do kanálu nebo konkrétnímu člověku.',
fields: ['Kanál', 'Text zprávy'],
},
],
},
{
id: 'google',
name: 'Google Workspace',
category: 'komunikace',
description: 'Gmail, Kalendář, Disk, Tabulky, Dokumenty a Úkoly pod jedním napojením.',
icon: 'Chrome',
status: 'available',
general: false,
appId: 'google-service',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'serviceAccountJson',
label: 'JSON klíč service accountu',
target: 'header',
name: 'X-Google-Service-Account-Json',
required: false,
secret: true,
hint:
'Celý obsah staženého JSON souboru. Tohle je cesta pro provoz bez člověka: ' +
'službě z něj sama vznikne token. Alternativou je hotový access token.',
},
{
id: 'serviceAccountScopes',
label: 'Oprávnění (scopes)',
target: 'header',
name: 'X-Google-Service-Account-Scopes',
required: false,
secret: false,
hint:
'Oddělené mezerou. Ověření konektoru čte Disk, takže potřebuje aspoň ' +
'https://www.googleapis.com/auth/drive.readonly.',
},
{
id: 'serviceAccountSubject',
label: 'Zastupovaný uživatel',
target: 'header',
name: 'X-Google-Service-Account-Subject',
required: false,
secret: false,
hint: 'E-mail uživatele Workspace při domain-wide delegation. Bez něj jedná service account sám za sebe.',
},
{
id: 'accessToken',
label: 'Access token',
target: 'header',
name: 'X-Google-Access-Token',
required: false,
secret: true,
hint: 'Hotový OAuth2 token. Platí zhruba hodinu, takže na trvalý provoz se nehodí.',
},
],
// Seznam souboru na Disku: cteci volani, ktere bez platnych udaju neprojde.
// Predpoklada scope drive.readonly, viz napoveda u pole s opravnenimi.
verifyPath: '/google/drive/files',
triggers: [],
actions: [
{
id: 'send-email',
name: 'Odeslat e-mail',
description: 'Pošle e-mail přes Gmail účtu, pod kterým je napojení.',
fields: ['Příjemce', 'Předmět', 'Text'],
},
{
id: 'append-sheet-row',
name: 'Přidat řádek do tabulky',
description: 'Připíše řádek na konec listu v Google Tabulkách.',
fields: ['Tabulka', 'List', 'Hodnoty'],
},
{
id: 'create-event',
name: 'Vytvořit událost v kalendáři',
description: 'Založí událost a pozve účastníky.',
fields: ['Kalendář', 'Termín', 'Účastníci'],
},
{
id: 'find-file',
name: 'Najít soubor na Disku',
description: 'Dohledá soubor podle názvu nebo dotazu. Nic nemění.',
fields: ['Dotaz'],
},
],
},
];
+53
View File
@@ -0,0 +1,53 @@
/**
* Zakazkova integrace jednoho klienta na SAP.
*/
import type { Service } from '../../../shared/services.js';
export const polstrynServices: Service[] = [
{
id: 'polstryn-sap',
name: 'Polstryn SAP',
category: 'ekonomika',
description: 'Zakázková integrace na podnikový systém jednoho klienta.',
icon: 'Boxes',
status: 'planned',
general: false,
appId: 'polstryn-sap',
visibility: { mode: 'restricted', tenantIds: ['tnt_logitrans'], userIds: [] },
credentials: [
{
id: 'apiKey',
label: 'API klíč',
target: 'header',
name: 'X-Api-Key',
required: true,
secret: true,
},
{
id: 'plant',
label: 'Číslo závodu',
target: 'config',
name: 'plant',
required: true,
secret: false,
hint: 'Předá se skriptu jako ctx.config.plant.',
},
],
triggers: [
{
id: 'order-released',
name: 'Uvolněna výrobní zakázka',
description: 'Spustí se, jakmile SAP uvolní zakázku do výroby.',
},
],
actions: [
{
id: 'post-goods-issue',
name: 'Zaúčtovat výdej materiálu',
description: 'Zapíše výdej materiálu k zakázce.',
fields: ['Číslo zakázky', 'Materiál', 'Množství'],
},
],
},
];
+142
View File
@@ -0,0 +1,142 @@
/**
* Chatovaci kanaly: WhatsApp, Facebook Messenger, Instagram.
*/
import type { Service } from '../../../shared/services.js';
export const socialServices: Service[] = [
{
id: 'whatsapp',
name: 'WhatsApp',
category: 'komunikace',
description: 'Příjem a odesílání zpráv přes WhatsApp Business. Nejrychlejší cesta k ticketu.',
icon: 'MessageCircle',
status: 'available',
general: false,
appId: 'whatsapp',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [
{
id: 'message-received',
name: 'Přijata zpráva',
description: 'Spustí se při doručení zprávy na firemní číslo.',
providedFields: [
{ id: 'whatsapp.phone', name: 'phone', type: 'string', required: true },
{ id: 'whatsapp.profileName', name: 'profileName', type: 'string', required: false },
{ id: 'whatsapp.text', name: 'text', type: 'string', required: true },
{ id: 'whatsapp.hasMedia', name: 'hasMedia', type: 'boolean', required: false },
{ id: 'whatsapp.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
],
actions: [
{
id: 'send',
name: 'Odeslat zprávu',
description: 'Odpoví na číslo, ze kterého zpráva přišla, nebo na zadané číslo.',
inputs: [
{
id: 'phone',
label: 'Telefon',
kind: 'text',
required: true,
hint: 'Například {{phone}} pro odpověď odesílateli.',
},
{ id: 'text', label: 'Text', kind: 'longtext', required: true },
],
},
{
id: 'send-template',
name: 'Odeslat schválenou šablonu',
description: 'Pošle předschválenou šablonu. Nutné mimo 24hodinové okno konverzace.',
fields: ['Telefon', 'Šablona', 'Proměnné'],
},
],
},
{
id: 'facebook',
name: 'Facebook Messenger',
category: 'komunikace',
description: 'Zprávy z firemní stránky na Facebooku.',
icon: 'Facebook',
status: 'available',
general: false,
appId: 'facebook',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [
{
id: 'message-received',
name: 'Přijata zpráva',
description: 'Spustí se při doručení zprávy do schránky stránky.',
providedFields: [
{ id: 'facebook.senderId', name: 'senderId', type: 'string', required: true },
{ id: 'facebook.senderName', name: 'senderName', type: 'string', required: false },
{ id: 'facebook.text', name: 'text', type: 'string', required: true },
{ id: 'facebook.pageName', name: 'pageName', type: 'string', required: true },
{ id: 'facebook.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
],
actions: [
{
id: 'send',
name: 'Odeslat zprávu',
description: 'Odpoví do konverzace, ze které zpráva přišla.',
inputs: [
{
id: 'recipientId',
label: 'Příjemce',
kind: 'text',
required: true,
hint: 'Například {{senderId}} pro odpověď odesílateli.',
},
{ id: 'text', label: 'Text', kind: 'longtext', required: true },
],
},
],
},
{
id: 'instagram',
name: 'Instagram',
category: 'komunikace',
description: 'Přímé zprávy na firemním účtu Instagramu.',
icon: 'Instagram',
status: 'available',
general: false,
appId: 'instagram',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [
{
id: 'message-received',
name: 'Přijata zpráva',
description: 'Spustí se při doručení přímé zprávy.',
providedFields: [
{ id: 'instagram.senderId', name: 'senderId', type: 'string', required: true },
{ id: 'instagram.username', name: 'username', type: 'string', required: false },
{ id: 'instagram.text', name: 'text', type: 'string', required: true },
{ id: 'instagram.receivedAt', name: 'receivedAt', type: 'date', required: true },
],
},
],
actions: [
{
id: 'send',
name: 'Odeslat zprávu',
description: 'Odpoví do konverzace, ze které zpráva přišla.',
inputs: [
{
id: 'recipientId',
label: 'Příjemce',
kind: 'text',
required: true,
hint: 'Například {{senderId}} pro odpověď odesílateli.',
},
{ id: 'text', label: 'Text', kind: 'longtext', required: true },
],
},
],
},
];
+520
View File
@@ -0,0 +1,520 @@
/**
* Tickety nejsou jen akce na konci stromu. Jsou to obe strany:
* kanaly do nich ustuji (WhatsApp, e-mail, hlas) a zalozeny ticket
* je zase spoustecem navazne automatizace - typicky "mame zakaznika?".
*/
import type { Service } from '../../../shared/services.js';
/**
* Nabidka resitelu do vyberu u akci.
*
* Seznam se **nezapisuje do katalogu**, protoze resitele se nacitaji az za behu
* z uloziste, kdezto katalog vznika pri importu modulu. Misto hodnot je tu
* priznak `optionsFrom` a doplni je `serviceCatalog()` pri kazdem volani.
* Diky tomu novy clovek v tymu neni potreba nikde registrovat.
*/
const assigneeOptions: Array<{ value: string; label: string }> = [
{ value: '', label: 'Nechat ve frontě' },
];
const priorityOptions = [
{ value: 'low', label: 'Nízká' },
{ value: 'normal', label: 'Běžná' },
{ value: 'high', label: 'Vysoká' },
{ value: 'critical', label: 'Kritická' },
];
export const ticketServices: Service[] = [
{
id: 'ticket',
name: 'Tickety',
category: 'obecne',
description:
'Servicedesk. Požadavek od zákazníka, který má svého řešitele a dohledatelný průběh.',
icon: 'LifeBuoy',
status: 'available',
general: true,
appId: null,
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [
{
id: 'created',
name: 'Založen ticket',
description:
'Spustí se při každém novém ticketu, ať vznikl z kanálu nebo ručně. ' +
'Podle parametru knownCustomer se pozná, jestli se firma dohledala v CRM.',
providedFields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ id: 'ticket.body', name: 'body', type: 'string', required: false },
{ id: 'ticket.channel', name: 'channel', type: 'string', required: true },
{ id: 'ticket.company', name: 'company', type: 'string', required: false },
{ id: 'ticket.contact', name: 'contact', type: 'string', required: false },
{ id: 'ticket.priority', name: 'priority', type: 'string', required: true },
{ id: 'ticket.knownCustomer', name: 'knownCustomer', type: 'boolean', required: true },
{ id: 'ticket.assigned', name: 'assigned', type: 'boolean', required: true },
],
},
{
id: 'unknown-customer',
name: 'Ticket bez zákazníka',
description:
'Spustí se, když se k ticketu nepodařilo dohledat firmu. Sem patří založení ' +
'obchodního případu nebo dotaz zpět na zadavatele.',
providedFields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ id: 'ticket.body', name: 'body', type: 'string', required: false },
{ id: 'ticket.channel', name: 'channel', type: 'string', required: true },
{ id: 'ticket.contact', name: 'contact', type: 'string', required: false },
{ id: 'ticket.reply', name: 'reply', type: 'string', required: true },
],
},
{
id: 'assigned',
name: 'Ticket přiřazen řešiteli',
description: 'Spustí se, jakmile ticket dostane konkrétního člověka.',
providedFields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ id: 'ticket.assignee', name: 'assignee', type: 'string', required: true },
{ id: 'ticket.assigneeEmail', name: 'assigneeEmail', type: 'string', required: true },
{ id: 'ticket.priority', name: 'priority', type: 'string', required: true },
],
},
{
id: 'changed',
name: 'Ticket vznikl nebo se změnil',
description:
'Spustí se při každé změně ticketu, včetně vzniku. Na tomhle stojí ' +
'automatické přidělování práce: podle typu a štítku se rozhodne, kdo to dostane.',
providedFields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.externalId', name: 'externalId', type: 'string', required: false },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ id: 'ticket.status', name: 'status', type: 'string', required: true },
{ id: 'ticket.priority', name: 'priority', type: 'string', required: true },
{ id: 'ticket.typeId', name: 'typeId', type: 'string', required: false },
{ id: 'ticket.closed', name: 'closed', type: 'boolean', required: true },
{ id: 'ticket.tags', name: 'tags', type: 'list', required: false },
{ id: 'ticket.assigneeId', name: 'assigneeId', type: 'string', required: false },
{ id: 'ticket.company', name: 'company', type: 'string', required: false },
],
},
{
id: 'status-changed',
name: 'Změna stavu ticketu',
description: 'Spustí se při přechodu do jiného stavu, včetně vyřešení.',
providedFields: [
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
{ id: 'ticket.status', name: 'status', type: 'string', required: true },
{ id: 'ticket.previousStatus', name: 'previousStatus', type: 'string', required: true },
{ id: 'ticket.assignee', name: 'assignee', type: 'string', required: false },
],
},
],
actions: [
{
id: 'upsert',
name: 'Založit nebo doplnit ticket',
description:
'Podle externího ID buď založí nový ticket, nebo na existující navěsí událost. ' +
'Externí ID je unikátní v rámci firmy, takže druhá zpráva o téže objednávce ' +
'skončí na jednom místě. Co je tady vyplněné, zapíše se i na existující ticket - ' +
'prázdná hodnota nikdy nic nesmaže, takže data můžou chodit po částech.',
implementation: 'script',
inputs: [
{
id: 'externalId',
label: 'Externí ID',
kind: 'text',
required: false,
hint: 'ID u odesílatele, typicky číslo objednávky. Bez něj vznikne vždy nový ticket.',
},
{ id: 'subject', label: 'Předmět', kind: 'text', required: false },
{
id: 'body',
label: 'Obsah',
kind: 'longtext',
required: false,
hint: 'Text může dorazit až druhou zprávou, doplní se i na existující ticket.',
},
{
id: 'typeId',
label: 'Typ ticketu',
kind: 'lookup',
optionsFrom: 'ticketTypes',
required: false,
hint:
'Vyberte typ ze seznamu, nebo hodnotu dosaďte z dat, například ' +
'{{data.typ}}. Za typem stojí vlastní pole, ze kterých pak akce čerpají.',
},
{
id: 'tags',
label: 'Štítky',
kind: 'text',
required: false,
hint: 'Oddělené čárkou. Přidají se, existující se nemažou.',
},
{
id: 'priority',
label: 'Priorita',
kind: 'choice',
required: false,
options: [
{ value: 'low', label: 'Nízká' },
{ value: 'normal', label: 'Běžná' },
{ value: 'high', label: 'Vysoká' },
{ value: 'critical', label: 'Kritická' },
],
},
{
id: 'status',
label: 'Stav',
kind: 'text',
required: false,
hint: 'Cokoliv chcete, například ringing nebo Připraveno k expedici.',
},
{
id: 'closed',
label: 'Vyřízený',
kind: 'choice',
required: false,
options: [
{ value: 'true', label: 'Ano' },
{ value: 'false', label: 'Ne' },
],
hint: 'Prázdné = nechat, jak je. Podle tohohle se počítá fronta a statistiky.',
},
{
id: 'fields',
label: 'Vlastní pole typu',
kind: 'json',
required: false,
hint:
'JSON s klíči podle typu ticketu, například {"orderNumber":"{{orderId}}"}. ' +
'Klíče se sčítají: co přinesla minulá zpráva, zůstane.',
},
{
id: 'company',
label: 'Zákazník: firma',
kind: 'text',
required: false,
hint: 'Prázdná hodnota jméno nesmaže, takže zpráva bez firmy nic nepokazí.',
},
{ id: 'contact', label: 'Zákazník: kontakt', kind: 'text', required: false },
{
id: 'reply',
label: 'Zákazník: kam odpovídat',
kind: 'text',
required: false,
hint: 'E-mail nebo číslo, odkud to přišlo.',
},
{
id: 'channel',
label: 'Odkud požadavek přišel',
kind: 'choice',
required: false,
hint:
'Jen pro filtrování a ikonu v seznamu ticketů. Na chování ' +
'automatizace to nemá vliv, vyplňovat se nemusí.',
options: [
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'facebook', label: 'Facebook Messenger' },
{ value: 'instagram', label: 'Instagram' },
{ value: 'email', label: 'E-mail' },
{ value: 'voice', label: 'Hlasová linka' },
{ value: 'form', label: 'Webový formulář' },
{ value: 'portal', label: 'Portál' },
],
},
{
id: 'sourceRef',
label: 'Odkaz na zdroj',
kind: 'text',
required: false,
hint: 'ID zprávy u odesílatele, ať je dohledatelná.',
},
{
id: 'assigneeId',
label: 'Řešitel',
kind: 'choice',
required: false,
options: [],
optionsFrom: 'people',
hint: 'Když víte rovnou, komu to patří. Jinak použijte samostatný krok.',
},
{
id: 'groupId',
label: 'Skupina',
kind: 'choice',
required: false,
options: [],
optionsFrom: 'groups',
},
{ id: 'event', label: 'Typ události', kind: 'text', required: false },
{ id: 'label', label: 'Popisek do časové osy', kind: 'text', required: false },
],
outputFields: [
{ id: 'ticketId', name: 'ticketId', type: 'string', required: true },
{ id: 'created', name: 'created', type: 'boolean', required: true },
],
},
{
id: 'assign-group',
name: 'Předat skupině',
description:
'Ticket se objeví ve frontě skupiny a kdo má čas, si ho převezme. ' +
'Když zaškrtnete rovnou přiřadit, dostane ho hned ten, kdo má nejmíň práce - ' +
'to se hodí tam, kde se čeká na rychlou reakci.',
implementation: 'script',
inputs: [
{
id: 'groupId',
label: 'Skupina',
kind: 'choice',
required: true,
options: [],
optionsFrom: 'groups',
},
{
id: 'autoAssign',
label: 'Rovnou přiřadit nejvolnějšímu',
kind: 'choice',
required: false,
options: [
{ value: 'true', label: 'Ano' },
{ value: 'false', label: 'Ne, nechat ve frontě skupiny' },
],
hint: 'Prázdné = nechat ve frontě, ať si to lidé vezmou sami.',
},
{ id: 'ticketId', label: 'Ticket', kind: 'text', required: false },
],
outputFields: [
{ id: 'groupId', name: 'groupId', type: 'string', required: true },
{ id: 'groupName', name: 'groupName', type: 'string', required: true },
],
},
{
id: 'assign-least-busy',
name: 'Předat nejvolnějšímu ze skupiny',
description:
'Najde ve skupině toho, kdo má nejmíň nevyřízených ticketů, a předá mu to. ' +
'Při shodě rozhoduje podíl ke kapacitě. Vypnutí lidé se přeskočí.',
implementation: 'script',
inputs: [
{
id: 'groupId',
label: 'Skupina',
kind: 'choice',
required: true,
options: [],
optionsFrom: 'groups',
hint: 'Například sklad nebo IT. Skupiny se spravují v Nastavení.',
},
{
id: 'ticketId',
label: 'Ticket',
kind: 'text',
required: false,
hint: 'Prázdné = ticket, kvůli kterému běh vznikl.',
},
],
outputFields: [
{ id: 'assigneeId', name: 'assigneeId', type: 'string', required: true },
{ id: 'assigneeName', name: 'assigneeName', type: 'string', required: true },
],
},
{
id: 'assign-by-external',
name: 'Předat podle ID z cizí aplikace',
description:
'Najde řešitele, který má u sebe uvedené externí ID, a předá mu ticket. ' +
'Typicky voicebotId nebo klapka. Vazba se nastavuje u řešitele, takže ' +
'při změně člověka se opravuje na jednom místě.',
implementation: 'script',
inputs: [
{
id: 'value',
label: 'Hodnota',
kind: 'text',
required: true,
hint: 'Například {{voicebotId}}.',
},
{
id: 'fallbackGroupId',
label: 'Náhradní skupina',
kind: 'text',
required: false,
hint: 'Když se nikdo nenajde, předá se nejvolnějšímu z této skupiny.',
},
{ id: 'ticketId', label: 'Ticket', kind: 'text', required: false },
],
outputFields: [
{ id: 'assigneeId', name: 'assigneeId', type: 'string', required: true },
{ id: 'assigneeName', name: 'assigneeName', type: 'string', required: true },
],
},
{
id: 'set-type',
name: 'Nastavit typ ticketu',
description: 'Za typem stojí vlastní pole a podle typu se ukazují akce.',
implementation: 'script',
inputs: [
{
id: 'typeId',
label: 'Typ ticketu',
kind: 'lookup',
optionsFrom: 'ticketTypes',
required: true,
hint: 'Vyberte ze seznamu, nebo dosaďte z dat.',
},
{ id: 'ticketId', label: 'Ticket', kind: 'text', required: false },
],
},
{
id: 'add-tags',
name: 'Přidat štítky',
description: 'Existující štítky zůstanou, jinak by se dva kroky přebíjely.',
implementation: 'script',
inputs: [
{ id: 'tags', label: 'Štítky', kind: 'text', required: true, hint: 'Oddělené čárkou.' },
{ id: 'ticketId', label: 'Ticket', kind: 'text', required: false },
],
},
/*
* "Posunout do dalsi faze" tady bylo, ale fazi nema ani ticket, ani typ
* ticketu - v modelu nikdy nevznikla. Krok nemel co vykonat a pole Faze
* u zalozeni ticketu se tise zahazovalo. To, co mela faze delat, uz umi
* stav: je prave jeden, je to volny retezec a typ ticketu si k nemu muze
* nabidnout svoje hodnoty.
*/
{
id: 'set-status',
name: 'Změnit stav ticketu',
description:
'Stav je libovolný text, žádný číselník. Jestli je ticket vyřízený, ' +
'říká samostatné pole - podle něj se počítá fronta a statistiky.',
implementation: 'script',
inputs: [
{
id: 'status',
label: 'Stav',
kind: 'text',
required: true,
hint: 'Cokoliv chcete, například completed nebo Předáno dopravci.',
},
{
id: 'closed',
label: 'Vyřízený',
kind: 'choice',
required: false,
options: [
{ value: 'true', label: 'Ano' },
{ value: 'false', label: 'Ne' },
],
hint: 'Prázdné = nechat, jak je.',
},
{ id: 'ticketId', label: 'Ticket', kind: 'text', required: false },
],
},
{
id: 'create',
name: 'Založit ticket',
description: 'Vytvoří požadavek. Co se kam uloží, určíte v nastavení kroku.',
inputs: [
{
id: 'subject',
label: 'Předmět',
kind: 'text',
required: true,
hint: 'Krátké shrnutí. Typicky předmět e-mailu nebo začátek zprávy.',
},
{
id: 'body',
label: 'Obsah',
kind: 'longtext',
required: false,
hint: 'Celý text požadavku. Sem patří tělo e-mailu nebo zpráva z WhatsApp.',
},
{ id: 'company', label: 'Firma', kind: 'text', required: false },
{ id: 'contact', label: 'Kontakt', kind: 'text', required: false },
{
id: 'reply',
label: 'Adresa pro odpověď',
kind: 'text',
required: false,
hint: 'E-mail nebo telefon, odkud to přišlo.',
},
{
id: 'priority',
label: 'Priorita',
kind: 'choice',
required: true,
options: priorityOptions,
},
{
id: 'assigneeId',
label: 'Řešitel',
kind: 'choice',
required: false,
options: assigneeOptions,
optionsFrom: 'people',
},
],
outputFields: [{ id: 'ticket.newId', name: 'newTicketId', type: 'string', required: true }],
},
{
id: 'assign',
name: 'Přiřadit řešiteli',
description:
'Předá ticket konkrétnímu člověku. Objeví se mu mezi jeho tickety a dostane ' +
'upozornění. Když nechcete vybírat ručně, použijte Předat nejvolnějšímu ze skupiny.',
implementation: 'script',
inputs: [
{
id: 'ticketId',
label: 'ID ticketu',
kind: 'text',
required: true,
hint: 'Obvykle {{ticketId}} ze spouštěče.',
},
{
id: 'assigneeId',
label: 'Řešitel',
kind: 'choice',
required: true,
options: assigneeOptions,
optionsFrom: 'people',
},
],
},
/*
* 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',
description: 'Doplní ticketu firmu z CRM. Používá se poté, co se zákazník dohledá.',
inputs: [
{ id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true },
{ id: 'companyId', label: 'ID firmy v CRM', kind: 'text', required: true },
],
},
{
id: 'comment',
name: 'Přidat komentář',
description: 'Zapíše komentář do logu ticketu, aby byl na stejné časové ose jako běh.',
inputs: [
{ id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true },
{ id: 'author', label: 'Autor', kind: 'text', required: false },
{ id: 'text', label: 'Text', kind: 'longtext', required: true },
],
},
],
},
];
+153
View File
@@ -0,0 +1,153 @@
/**
* Obecne nastroje: HTTP pozadavek, transformace dat, pauza, zapis do logu.
*/
import type { Service } from '../../../shared/services.js';
export const toolServices: Service[] = [
{
id: 'http',
name: 'HTTP požadavek',
category: 'obecne',
description: 'Zavolá libovolné API, které nemá vlastní konektor.',
icon: 'Globe',
status: 'available',
general: true,
appId: null,
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [],
actions: [
{
id: 'request',
name: 'Zavolat API',
description: 'Odešle HTTP požadavek a vrátí odpověď dalším krokům.',
fields: ['Metoda', 'URL', 'Hlavičky', 'Tělo'],
},
],
},
{
id: 'transform',
name: 'Transformace dat',
category: 'transformace',
description: 'Přemapování polí, formátování a čištění dat mezi kroky.',
icon: 'Shuffle',
status: 'available',
general: true,
appId: null,
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [],
actions: [
{
id: 'map-fields',
name: 'Přemapovat pole',
description: 'Přeloží data z jednoho tvaru do druhého.',
fields: ['Mapování polí'],
},
{
id: 'deduplicate',
name: 'Odstranit duplicity',
description: 'Vyřadí záznamy, které už systémem prošly.',
fields: ['Klíč pro srovnání'],
},
{
id: 'custom',
name: 'Vlastní skript',
description:
'Převod dat napsaný v JavaScriptu. Hodí se, když je pravidel tolik, ' +
'že je kód čitelnější než jejich seznam.',
fields: ['Skript', 'Zdrojová data'],
inputs: [
{
id: 'scriptId',
label: 'Skript',
kind: 'choice',
required: true,
optionsFrom: 'scripts',
hint: 'Skripty firmy se píšou v záložce Skripty.',
},
{
id: 'source',
label: 'Zdrojová data',
kind: 'object',
required: true,
hint: 'Objekt, který skript dostane jako input. Třeba {{_body}}.',
},
{
id: 'extra',
label: 'Co přidat ke vstupu',
kind: 'json',
required: false,
hint:
'Hodnoty z předchozích kroků, například ' +
'{"partnerId": "{{st_kontakt.contactId}}"}. Skript je najde v input.extra.',
},
],
outputFields: [
{ id: 'transform.result', name: 'result', type: 'object', required: true },
],
},
],
},
{
id: 'delay',
name: 'Pauza',
category: 'obecne',
description: 'Pozdrží běh o daný čas nebo do konkrétního okamžiku.',
icon: 'Timer',
status: 'available',
general: true,
appId: null,
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [],
actions: [
{
id: 'wait',
name: 'Počkat',
description: 'Pozastaví běh na zadanou dobu.',
fields: ['Doba čekání'],
inputs: [
{
id: 'seconds',
label: 'Sekund',
kind: 'text',
required: true,
hint: 'Nejvýš 60. Delší čekání patří do plánovače, ne do běhu.',
},
],
},
],
},
{
id: 'log',
name: 'Zápis do logu',
category: 'obecne',
description: 'Uloží zprávu do provozního logu automatizace.',
icon: 'ScrollText',
status: 'available',
general: true,
appId: null,
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [],
actions: [
{
id: 'write',
name: 'Zapsat zprávu',
description: 'Přidá záznam do historie běhu, užitečné při ladění.',
fields: ['Zpráva'],
inputs: [
{
id: 'message',
label: 'Zpráva',
kind: 'text',
required: true,
hint: 'Může obsahovat odkazy, například {{data.order.code}}.',
},
],
},
],
},
];
+92
View File
@@ -0,0 +1,92 @@
/**
* Obecne spoustece: webhook, planovac, rucni spusteni, formular.
*/
import type { Service } from '../../../shared/services.js';
export const triggerServices: Service[] = [
{
id: 'webhook',
name: 'Webhook',
category: 'obecne',
description: 'Spustí automatizaci příchozím HTTP požadavkem z libovolného systému.',
icon: 'Webhook',
status: 'available',
general: true,
appId: null,
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [
{
id: 'received',
name: 'Přijat požadavek',
description:
'Portál vygeneruje neodhadnutelnou adresu. Vy určíte, jaké parametry na ni budou přicházet.',
customPayload: true,
},
],
actions: [],
},
{
id: 'scheduler',
name: 'Plánovač',
category: 'obecne',
description: 'Spouštění podle času, každou hodinu, denně, nebo podle cron výrazu.',
icon: 'Clock',
status: 'available',
general: true,
appId: null,
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [
{
id: 'interval',
name: 'V pravidelném intervalu',
description: 'Například každých 15 minut nebo každý den v 6:00.',
fields: ['Interval / cron výraz', 'Časová zóna'],
},
],
actions: [],
},
{
id: 'manual',
name: 'Ruční spuštění',
category: 'obecne',
description: 'Automatizaci spustí člověk tlačítkem v portálu. Vhodné pro testování.',
icon: 'MousePointerClick',
status: 'available',
general: true,
appId: null,
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [
{
id: 'button',
name: 'Spuštěno z portálu',
description: 'Spustí se stiskem tlačítka na detailu automatizace.',
},
],
actions: [],
},
{
id: 'form',
name: 'Webový formulář',
category: 'obecne',
description: 'Odeslání formuláře z webu: poptávka, registrace, reklamace.',
icon: 'FileInput',
status: 'available',
general: true,
appId: null,
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [],
triggers: [
{
id: 'submitted',
name: 'Formulář odeslán',
description: 'Spustí se po odeslání formuláře. Pole formuláře si definujete sami.',
customPayload: true,
},
],
actions: [],
},
];
+334
View File
@@ -0,0 +1,334 @@
/**
* Katalog SLUZEB = zdroj pravdy o tom, co lze v automatizaci a v akcich pouzit.
*
* Pozor na dve veci, ktere se snadno pletou:
* - **Sluzba** je to, co umime. iDoklad, Shoptet, tickety, HTTP pozadavek.
* Definujeme ji my, ma svoje operace a rika, co je potreba k napojeni.
* - **Konektor** je napojeni jedne firmy na jednu sluzbu vcetne jejich
* pristupovych udaju. Zaklada si ho firma, uloziste je `connectorStore.ts`.
*
* Sluzba tedy rika "iDoklad potrebuje X-ClientId a X-ClientSecret",
* konektor rika "a tohle jsou nase".
*
* Kazda sluzba ma:
* - triggers: udalosti, kterymi muze automatizace ZACIT (spoustec)
* - actions: co se s ni da UDELAT uprostred behu
*
* Sluzba muze mit jen triggery (webhook), jen akce (odeslani e-mailu), nebo obojí.
* Jak pridat sluzbu: documentation/12-sluzby-a-konektory.md
*
* Katalog samotny je ve slozce `catalog/` po skupinach, tady je to, co se
* z nej pocita: hledani operaci, prekryvy ze skriptu a z MCP, viditelnost.
* Zvenku se importuje pres `data/services.ts`.
*/
import type { User } from '../../types.js';
import { isMcpService } from '../../mcp/dialect.js';
import { tenantHasService } from '../tenantFeatures.js';
import { services } from './catalog/index.js';
import type {
OperationField,
ProvidedField,
Service,
ServiceCategory,
ServiceCategoryEntry,
ServiceCredentialField,
ServiceOperation,
ServiceStatus,
ServiceVisibility,
} from '../../shared/services.js';
/**
* Tvar sluzby je sdileny s webem, viz src/shared/services.ts. Tady je katalog
* samotny a to, co se z nej pocita.
*/
export type {
OperationField,
ProvidedField,
Service,
ServiceCategory,
ServiceCategoryEntry,
ServiceCredentialField,
ServiceOperation,
ServiceStatus,
ServiceVisibility,
};
export { serviceCategories, services } from './catalog/index.js';
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
/**
* Vidi uzivatel tuhle sluzbu?
*
* Obecne sluzby vidi vzdy vsichni - webhook, pauza nebo transformace dat nejsou
* nic, co by se komu odepiralo. Spravce platformy vidi vzdy vsechno.
*
* Sluzba, kterou uzivatel nevidi, se **nevraci vubec**, ne se stavem "nemate
* pravo". Firma nema z odpovedi poznat, ze taková sluzba existuje - stejne
* pravidlo jako u ticketu v documentation/07-firmy-a-prava.md.
*/
export function canSeeService(service: Service, user: User, tenantId: string | null): boolean {
if (service.general) return true;
switch (service.visibility.mode) {
case 'everyone':
return true;
case 'admin':
return user.platformAdmin;
case 'restricted': {
// Zakazkova integrace pro konkretniho cloveka, at uz je prepnuty kamkoliv.
if (service.visibility.userIds.includes(user.id)) return true;
/*
* **Rozhoduje firma, ne clovek.** Spravce platformy driv videl vsechny
* sluzby vzdycky, i po prepnuti do firmy, ktera je nema - takze si mohl
* do jeji automatizace vybrat zakazkovou integraci jineho klienta.
* Kdyz je firma vybrana, plati jeji seznam; bez vybrane firmy spravuje
* spravce platformy katalog a vidi vsechno.
*/
if (tenantId === null) return user.platformAdmin;
return service.visibility.tenantIds.includes(tenantId) || tenantHasService(tenantId, service.id);
}
default:
return false;
}
}
export function visibleServices(user: User, tenantId: string | null): Service[] {
return services.filter((service) => canSeeService(service, user, tenantId));
}
// ------------------------------------------------------- akce ze skriptu
/**
* Akce domerene ze skriptu sluzby. Plni to `src/scripts/registry.ts`
* pri kazdem nacteni skriptu.
*
* Je to prekryv, ne zapis do `services`. Dva duvody: staticky katalog zustane
* citelny a z operace jde poznat, odkud je (`implementation`).
*
* Prekryv je zamerne tady, ne ve zvlastnim modulu. Vsechno ostatni v aplikaci
* uz se pta pres `findOperation`, takze tim se skripty naraz objevi ve validaci
* stromu, ve vypoctu scope i v sablonach - bez toho, aby se to psalo trikrat.
*/
const scriptActions = new Map<string, ServiceOperation[]>();
/** Nahradi cely prekryv. Volani je idempotentni, poradi nezalezi. */
export function setScriptActions(byService: Map<string, ServiceOperation[]>): void {
scriptActions.clear();
for (const [serviceId, operations] of byService) {
scriptActions.set(serviceId, operations);
}
}
/**
* Nastroje MCP serveru.
*
* Druhy prekryv katalogu, a jineho druhu nez skripty. Skript je nas kod, takze
* je znamy pri prekladu. Nastroj MCP je **cizi a zjisti se az od serveru**,
* proto s sebou nese firmu: co ma jedna firma na svem serveru, druhe do
* katalogu nepatri.
*
* Plni to `src/data/mcpTools.ts`.
*/
let mcpOperations: Array<{ tenantId: string; serviceId: string; operation: ServiceOperation }> = [];
/** Nahradi cely seznam nastroju. */
export function setMcpOperations(
items: Array<{ tenantId: string; serviceId: string; operation: ServiceOperation }>,
): void {
mcpOperations = items;
}
const byName = (a: ServiceOperation, b: ServiceOperation): number =>
a.name.localeCompare(b.name, 'cs');
/**
* Nastroje **jedne firmy**. `null` znamena zadne, ne vsechny.
*
* Firma bez vybrane firmy v adrese nema videt nastroje cizich serveru, a to ani
* jmenem. Nazev nastroje umi prozradit dost: `zrus_objednavku_v_soap_bridge`
* rekne o cizi firme vic, nez by melo.
*/
export function mcpActionsFor(tenantId: string | null, serviceId: string): ServiceOperation[] {
if (tenantId === null) return [];
return mcpOperations
.filter((item) => item.tenantId === tenantId && item.serviceId === serviceId)
.map((item) => item.operation)
.sort(byName);
}
/**
* Nastroje napric firmami.
*
* Jen pro vnitrni dohledani operace (`findOperation`, dosazovani sablon).
* Ven se to neposila - od toho je `mcpActionsFor`.
*/
function allMcpActions(serviceId: string): ServiceOperation[] {
return mcpOperations
.filter((item) => item.serviceId === serviceId)
.map((item) => item.operation)
.sort(byName);
}
/**
* Akce sluzby vcetne tech ze skriptu.
* Kdyz skript nese ID operace, ktera uz v katalogu je, **skript vyhrava**.
* Staticky zapis je popis toho, co umime, skript je to, co se opravdu stane.
*/
export function actionsFor(serviceId: string): ServiceOperation[] {
const service = findService(serviceId);
if (!service) return [];
// MCP nema skripty, ma nastroje serveru. Napric firmami, viz `allMcpActions`.
if (isMcpService(serviceId)) return [...service.actions, ...allMcpActions(serviceId)];
const fromScripts = scriptActions.get(serviceId);
if (!fromScripts || fromScripts.length === 0) return service.actions;
const replaced = new Set(fromScripts.map((operation) => operation.id));
return [
...service.actions.filter((action) => !replaced.has(action.id)),
...fromScripts,
].sort((a, b) => a.name.localeCompare(b.name, 'cs'));
}
/**
* Doplni nabidky, ktere se nedaji zapsat do katalogu.
*
* Katalog vznika pri importu modulu, ale resitele a skupiny se nacitaji
* z uloziste az pozdeji. Pole s `optionsFrom` proto dostane hodnoty az tady.
*/
export function withRuntimeOptions(
items: Service[],
options: {
people: Array<{ id: string; name: string }>;
groups: Array<{ id: string; name: string }>;
scripts?: Array<{ id: string; name: string }>;
/** Typy ticketu **teto firmy**. Kazda firma ma svoje, ID se nemaji opisovat. */
ticketTypes?: Array<{ id: string; name: string }>;
},
): Service[] {
/** Prazdna volba nahore. U kazdeho zdroje znamena neco jineho. */
const empty: Record<NonNullable<OperationField['optionsFrom']>, string> = {
people: 'Nechat ve frontě',
groups: 'Bez skupiny',
scripts: '- vyberte skript -',
ticketTypes: 'Bez typu',
};
const fill = (field: OperationField): OperationField => {
if (!field.optionsFrom) return field;
const source =
field.optionsFrom === 'people'
? options.people
: field.optionsFrom === 'groups'
? options.groups
: field.optionsFrom === 'ticketTypes'
? (options.ticketTypes ?? [])
: (options.scripts ?? []);
return {
...field,
options: [
{ value: '', label: empty[field.optionsFrom] },
...source.map((item) => ({ value: item.id, label: item.name })),
],
};
};
const fillOperation = (operation: ServiceOperation): ServiceOperation =>
operation.inputs ? { ...operation, inputs: operation.inputs.map(fill) } : operation;
return items.map((service) => ({
...service,
triggers: service.triggers.map(fillOperation),
actions: service.actions.map(fillOperation),
}));
}
/**
* Katalog sluzeb vcetne akci ze skriptu. Nemodifikuje `services`.
*
* `tenantId` je potreba kvuli MCP: nastroje jsou vlastnost napojeni jedne
* firmy, ne sluzby. Bez nej se zadne nevraci, coz je spravna vychozi hodnota -
* zapomenuty argument tak neznamena "vsechny".
*/
export function serviceCatalog(tenantId: string | null = null): Service[] {
return services.map((service) => {
if (isMcpService(service.id)) {
return { ...service, actions: [...service.actions, ...mcpActionsFor(tenantId, service.id)] };
}
return scriptActions.has(service.id) ? { ...service, actions: actionsFor(service.id) } : service;
});
}
/**
* Overi, ze sluzba existuje a ma danou operaci pozadovaneho druhu.
* Pouziva se pri ukladani stromu, aby se do nej nedostaly neexistujici kroky.
*/
export function findOperation(
serviceId: string,
operationId: string,
type: 'trigger' | 'action',
): ServiceOperation | undefined {
const service = findService(serviceId);
if (!service) return undefined;
const pool = type === 'trigger' ? service.triggers : actionsFor(serviceId);
return pool.find((operation) => operation.id === operationId);
}
/**
* Parametry, ktere spoustec predava sam. `undefined` znamena, ze si je
* deklaruje uzivatel (webhook, formular) - katalog do toho nemluvi.
*/
export function providedFieldsFor(
serviceId: string,
operationId: string,
): ProvidedField[] | undefined {
return findOperation(serviceId, operationId, 'trigger')?.providedFields;
}
/** Nastavitelna pole akce. Prazdne pole = akci zatim nejde konfigurovat. */
export function actionInputsFor(serviceId: string, operationId: string): OperationField[] {
return findOperation(serviceId, operationId, 'action')?.inputs ?? [];
}
/** Co akce vrati dalsim krokum. Prazdne pole = nic, na co by se dalo ptat. */
export function actionOutputsFor(serviceId: string, operationId: string): ProvidedField[] {
return findOperation(serviceId, operationId, 'action')?.outputFields ?? [];
}
+7 -4
View File
@@ -91,13 +91,15 @@ export function createLocalStore<T extends TenantEntity>(
async update(id, patch, listOptions) {
const index = rows.findIndex((item) => item.id === id);
if (index === -1 || !isVisible(rows[index], listOptions)) return undefined;
const current = index === -1 ? undefined : rows[index];
if (current === undefined || !isVisible(current, listOptions)) return undefined;
// ID ani cas vzniku se prepsat nesmi, i kdyby prisly v patchi.
const { id: _id, createdAt: _createdAt, ...rest } = patch as Partial<TenantEntity>;
rows[index] = { ...rows[index], ...(rest as Partial<T>), updatedAt: nowIso() };
const updated = { ...current, ...(rest as Partial<T>), updatedAt: nowIso() };
rows[index] = updated;
persist();
return copy(rows[index]);
return copy(updated);
},
async updateMany(ids, patch, listOptions) {
@@ -116,7 +118,8 @@ export function createLocalStore<T extends TenantEntity>(
async remove(id, listOptions) {
const index = rows.findIndex((item) => item.id === id);
if (index === -1 || !isVisible(rows[index], listOptions)) return false;
const current = index === -1 ? undefined : rows[index];
if (current === undefined || !isVisible(current, listOptions)) return false;
rows.splice(index, 1);
persist();
return true;
+1 -1
View File
@@ -18,7 +18,7 @@
* Server je autorita, kopie na klientovi existuje jen kvuli napovede v UI.
*/
import { getPath } from '../scripts/mapping.js';
import { getPath } from '../runtime/scripts/mapping.js';
/**
* `{{nazev}}` nebo `{{cesta.do.struktury}}` i s indexy `{{items[0].name}}`.
+4 -1848
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
/**
* Uloziste ticketu. Zmeny posilaji udalost na sbernici, takze se projevi
* v dashboardu okamzite bez obnoveni stranky.
*
* Ticket je prichozi pozadavek odkudkoliv (WhatsApp, e-mail, hlasova linka,
* formular, portal). NENI to bug ani wish - vyvojarska agenda ma vlastni
* evidenci a s ticketem se plete jen v hlave.
*
* Dve veci, na kterych model stoji:
* - ticket ma vzdy jednoho resitele (nebo zadneho), aby slo rict "mas to u sebe",
* - ticket si nese strom zaznamu o tom, co se s nim delo a co ktera sluzba vratila.
*
* Data se drzi v pameti a po kazde zmene se cely ticket zapise do uloziste
* (`withMirror`). Pri startu se cte uloziste, ukazkova sada nize se pouzije jen
* kdyz je prazdne. Kam se zapisuje - databaze, soubor, nebo nikam - rozhoduje
* `data/store/index.ts`, tady se to neresi.
*
* Slozka je rozdelena podle odpovednosti: `model` (tvar), `state` (pamet),
* `persist` (zapis a nacteni), `trace` (log), `queries` (cteni), `store`
* (zapisy), `intake` (prijem udalosti), `stats`, `seed` a `remap`. Zvenku
* se importuje jen tenhle soubor, a to pres `data/ticketStore.ts`.
*/
import { config } from '../../config.js';
import { seedDemoTickets } from './seed.js';
export type {
AgentStatsRow,
Ticket,
TicketAssignee,
TicketChannel,
TicketCustomer,
TicketDetail,
TicketEvent,
TicketPriority,
TicketStatus,
TicketTraceEntry,
TraceKind,
TraceStatus,
Workload,
WorkloadRow,
} from './model.js';
export { channelLabels, defaultStatuses } from './model.js';
export { initTickets } from './persist.js';
export { appendTrace, type TraceInput } from './trace.js';
export {
findByExternalId,
findTicket,
firstOpenTicket,
getTicket,
listTickets,
ticketWithinVisibility,
type TicketFilter,
} from './queries.js';
export { getAgentStats, getWorkload } from './stats.js';
export { intakeEvent, type IntakeInput, type IntakeResult, type TicketApply } from './intake.js';
export {
addComment,
assignTicket,
assignTicketGroup,
claimTicket,
createTicket,
setTicketTags,
setTicketType,
ticketAssignee,
updateTicketStatus,
type CreateTicketInput,
} from './store.js';
export { remapPersonIds } from './remap.js';
if (config.seedDemo) seedDemoTickets();
+332
View File
@@ -0,0 +1,332 @@
/**
* Prijem udalosti zvenku.
*
* Vstup do ticketovaciho systemu: jakakoliv udalost se muze stat ticketem,
* nebo se navesit na existujici podle externiho ID.
*/
import { publish } from '../../events/bus.js';
import { currentRun } from '../../runtime/context.js';
import { findPerson } from '../people.js';
import type { Ticket, TicketChannel, TicketEvent, TicketPriority } from '../../shared/tickets.js';
import { toTicket, type StoredTicket } from './model.js';
import { persist, touch } from './persist.js';
import { events, externalKey, nextEventId, ticketsByExternal, ticketsById, traces } from './state.js';
import { createTicket, type CreateTicketInput } from './store.js';
import { appendTrace, describePayload, lastTraceId } from './trace.js';
/**
* Co jde na ticketu nastavit prichozi udalosti.
*
* Zakaznik je rozepsany po polozkach schvalne. Kdyby se predaval cely, prepsala
* by zprava, ktera zna jen telefon, i jmeno firmy - prazdnou hodnotou.
*/
export interface TicketApply {
subject?: string;
body?: string;
typeId?: string;
priority?: TicketPriority;
channel?: TicketChannel;
sourceRef?: string;
company?: string;
contact?: string;
reply?: string;
assigneeId?: string;
assigneeGroupId?: string;
}
export interface IntakeInput {
tenantId: string;
/** Bez nej se navazani nema o co oprit a zalozi se novy ticket. */
externalId: string | null;
externalSource?: string | null;
/** Typ udalosti od odesilatele, napr. `order.created`. */
type: string;
/** Popisek do casove osy. Bez nej se pouzije typ udalosti. */
label?: string;
/** Cela prijata data. */
payload?: Record<string, unknown>;
/**
* Hodnoty, ktere maji smysl **jen pri vzniku** ticketu.
*
* Zbylo jich malo a je to zamer: predmet jako zaloha, kdyz ho odesilatel
* neposlal, a vychozi stav. Vsechno ostatni patri do `apply`.
*/
create?: Partial<Pick<CreateTicketInput, 'subject' | 'status' | 'fields' | 'tags' | 'automationId'>>;
/**
* Hodnoty, ktere se zapisi **pri kazde udalosti**, ne jen pri zalozeni.
*
* Driv bylo skoro vsechno jen pro vznik ticketu a nedava to smysl: krok se
* jmenuje "zalozit **nebo doplnit**" a data casto nechodi najednou. Prvni
* zprava jen oznami, ze se neco deje - u hovoru nese cislo a `in-progress`,
* a prave ta ticket zaklada. Predmet, obsah, typ i zakaznik dorazi az tou
* posledni, kdy uz ticket existoval, a tise se zahazovaly.
*
* **Prazdna hodnota nikdy nemaze**, co uz na ticketu je. To je ta pojistka,
* kvuli ktere se drive zapisovalo jen pri zalozeni: pozdejsi zprava bez
* jmena zakaznika je bezna a smazat kvuli ni jmeno by bylo horsi nez ho
* nedoplnit. Prepise se jen to, co odesilatel opravdu poslal.
*/
apply?: TicketApply;
/** Vlastni pole, ktera se doplni i na existujici ticket. */
fields?: Record<string, string | number | boolean | null>;
/** Tagy, ktere se **pridaji**. Existujici se nemazou. */
addTags?: string[];
}
export interface IntakeResult {
ticket: Ticket;
/** true = ticket teprve ted vznikl, false = udalost se navesila na existujici. */
created: boolean;
event: TicketEvent;
/**
* true = prislo presne totez co posledne, takze se jen pricetlo k pocitadlu.
* Ticket se **nemenil**: nesmi se kvuli tomu prepsat `updatedAt`, ani
* rozeslat zmena - jinak by kazdy duplikat rozblikal dashboard a mohl
* spustit automatizaci navazanou na zmenu ticketu.
*/
repeated: boolean;
}
/**
* Prijem udalosti zvenku.
*
* Tohle je vstup do ticketovaciho systemu: **jakakoliv udalost se muze stat
* ticketem**. Kdyz uz ticket se stejnym externim ID ve **stejne firme** je,
* udalost se na nej navesi misto zalozeni druheho. Diky tomu muze odesilatel
* poslat "objednavka 3 vznikla" a za hodinu "objednavka 3 vyfakturovana"
* a obojí skonci na jednom miste.
*
* Bez externiho ID se vzdy zaklada novy ticket - nemame podle ceho navazovat
* a hadat podle predmetu by slucovalo veci, ktere spolu nesouvisi.
*/
export function intakeEvent(input: IntakeInput): IntakeResult {
const timestamp = new Date().toISOString();
const event: TicketEvent = {
id: nextEventId(),
type: input.type,
source: input.externalSource ?? 'webhook',
label: input.label ?? input.type,
payload: input.payload ?? {},
at: timestamp,
repeats: 1,
lastAt: timestamp,
};
const existing = input.externalId
? ticketsByExternal.get(externalKey(input.tenantId, input.externalId))
: undefined;
if (existing) {
/*
* Prislo presne totez co posledne? Pak se to jen pricte. Zadny novy radek,
* zadny zapis do logu, zadna zmena ticketu - nic se totiz nestalo.
*/
const repeat = sameAsLast(existing.id, event);
if (repeat) {
repeat.repeats += 1;
repeat.lastAt = event.at;
persist(existing);
return { ticket: toTicket(existing), created: false, event: repeat, repeated: true };
}
appendEvent(existing, event);
if (input.fields) existing.fields = mergeFields(existing.fields, input.fields);
if (input.addTags && input.addTags.length > 0) {
existing.tags = [...new Set([...(existing.tags ?? []), ...input.addTags])];
}
applyValues(existing, input.apply);
touch(existing);
/*
* Rodic musi byt prazdny, jinak by se radek udalosti zaradil pod udalost
* predchozi - `appendTrace` doplnuje rodice z behu.
*/
const run = currentRun();
const parent = run?.traceParent;
if (parent) parent.id = null;
appendTrace(existing.id, [
{
kind: 'trigger',
status: 'info',
label: `Přijata událost: ${event.label}${originOf(run, event)}`,
response: describePayload(event.payload),
},
]);
// Dalsi zapisy tohohle behu uz patri pod tenhle radek.
if (parent) parent.id = lastTraceId(existing.id);
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 };
}
/*
* Pri zalozeni plati totez co u doplneni, jen se navic dosadi zaloha tam,
* kde odesilatel nic neposlal. Dva ruzne seznamy poli by se rozesly a zase
* by nekde neco chybelo.
*/
const apply = input.apply ?? {};
const created = createTicket({
tenantId: input.tenantId,
externalId: input.externalId,
externalSource: input.externalSource ?? null,
subject: apply.subject || input.create?.subject || event.label,
/*
* Stav uz pri vzniku. Volajici ho posilal, ale sem se nepredaval, takze
* ticket vznikl s vychozim "Nový" a hned se prepsal - v logu pak stalo
* "stav Nový -> completed" u ticketu, ktery v nem nikdy nebyl.
*/
status: input.create?.status,
body: apply.body ?? '',
sourceRef: apply.sourceRef ?? null,
channel: apply.channel ?? 'form',
customer: {
id: null,
company: apply.company ?? '',
contact: apply.contact ?? '',
reply: apply.reply ?? '',
},
priority: apply.priority ?? 'normal',
assigneeId: apply.assigneeId ?? null,
assigneeGroupId: apply.assigneeGroupId ?? null,
typeId: apply.typeId ?? null,
fields: mergeFields(input.create?.fields, input.fields ?? {}),
tags: [...new Set([...(input.create?.tags ?? []), ...(input.addTags ?? [])])],
automationId: input.create?.automationId ?? null,
trace: [
{
kind: 'trigger',
status: 'info',
label: `Ticket vznikl z události: ${event.label}${originOf(currentRun(), event)}`,
response: describePayload(event.payload),
},
],
});
const stored = ticketsById.get(created.id);
if (stored) {
appendEvent(stored, event);
persist(stored);
}
// Zmeny, ktere beh udela dal, patri pod radek o vzniku ticketu.
const parent = currentRun()?.traceParent;
if (parent) parent.id = traces.get(created.id)?.[0]?.id ?? null;
return {
ticket: toTicket(stored ?? (created as unknown as StoredTicket)),
created: true,
event,
repeated: false,
};
}
/**
* Slouci vlastni pole typu ticketu.
*
* Klice se **scitaji**: kdyz prvni zprava prinese `data` a druha `data2`, ma
* ticket obe. Odesilatel nemusi posilat vsechno pokazde a nemusi se predem
* dohodnout, co vsechno posle.
*
* **Prazdny retezec nemaze.** Sablona, ktera na nic neukazuje, se dosadi
* prazdnem, takze `{"vysledek":"{{result}}"}` u zpravy bez vysledku posle
* prazdno - a to by prepsalo hodnotu z minule zpravy. Vymazat pole jde
* poslanim `null`: to uz je zamer, ne vedlejsi ucinek nevyplnene sablony.
*/
function mergeFields(
current: Record<string, string | number | boolean | null> | undefined,
incoming: Record<string, string | number | boolean | null>,
): Record<string, string | number | boolean | null> {
const merged = { ...(current ?? {}) };
for (const [key, value] of Object.entries(incoming)) {
if (value === '') continue;
merged[key] = value;
}
return merged;
}
/**
* Zapise na ticket to, co prinesla udalost.
*
* Jedno pravidlo pro vsechna pole: **neprazdna hodnota prepise, prazdna nemaze**.
* Pozdejsi zprava o teze veci je upresneni, ne druhy zaznam - a zaroven nesmi
* smazat to, co uz na ticketu je, jen proto, ze o tom nic nevi.
*
* Stav tudy zamerne nechodi. Ma svoje `updateTicketStatus`, ktere resi i priznak
* vyrizeni, cas vyreseni a pocet znovuotevreni - obejit ho by ta cisla rozbilo.
*/
function applyValues(ticket: StoredTicket, apply: TicketApply | undefined): void {
if (!apply) return;
if (apply.subject) ticket.subject = apply.subject;
if (apply.body) ticket.body = apply.body;
if (apply.typeId) ticket.typeId = apply.typeId;
if (apply.priority) ticket.priority = apply.priority;
if (apply.channel) ticket.channel = apply.channel;
if (apply.sourceRef) ticket.sourceRef = apply.sourceRef;
if (apply.assigneeGroupId) ticket.assigneeGroupId = apply.assigneeGroupId;
// Stejne jako pri zalozeni: mrtvy odkaz na resitele radeji nez ulozit.
if (apply.assigneeId) {
if (findPerson(apply.assigneeId, ticket.tenantId)) ticket.assigneeId = apply.assigneeId;
else console.warn(`[tickets] neznamy resitel ${apply.assigneeId}, ticket zustava jak byl`);
}
// Po polozkach, at zprava, ktera zna jen telefon, neprepise jmeno firmy.
const customer = ticket.customer;
if (apply.company) customer.company = apply.company;
if (apply.contact) customer.contact = apply.contact;
if (apply.reply) customer.reply = apply.reply;
}
/** Kolik udalosti se u jednoho ticketu drzi. Starsi se odmazavaji. */
const MAX_EVENTS = 200;
function appendEvent(ticket: StoredTicket, event: TicketEvent): void {
const list = events.get(ticket.id) ?? [];
list.push(event);
// Nekonecne rostouci ticket by pri kazdem zapisu prepisoval vic a vic dat.
if (list.length > MAX_EVENTS) list.splice(0, list.length - MAX_EVENTS);
events.set(ticket.id, list);
}
/**
* Je to totez, co prislo naposledy?
*
* Porovnava se **jen s posledni** udalosti, ne s celou historii. "Objednavka
* pripravena" muze legitimne prijit znovu za hodinu, kdyz se mezitim stalo
* neco jineho - to je novy fakt. Dvacet stejnych zprav v rade uz ne.
*
* Data se srovnavaji pres JSON, protoze na poradi klicu v prijate zprave
* nezalezi jen vyjimecne a levnejsi porovnani neni potreba.
*/
function sameAsLast(ticketId: string, event: TicketEvent): TicketEvent | null {
const list = events.get(ticketId);
const last = list && list.length > 0 ? list[list.length - 1] : undefined;
if (!last) return null;
if (last.type !== event.type || last.source !== event.source || last.label !== event.label) {
return null;
}
if (JSON.stringify(last.payload) !== JSON.stringify(event.payload)) return null;
return last;
}
/**
* Kdo udalost prinesl.
*
* V logu je to to nejdulezitejsi: kdyz se ticket zmenil, prvni otazka je "kdo
* mi do toho sahl". Jmeno automatizace na to odpovi, `webhook` uz ne.
*/
function originOf(run: { automationName: string } | undefined, event: TicketEvent): string {
if (run) return ` (automatizace ${run.automationName})`;
if (event.source && event.source !== 'webhook') return ` (${event.source})`;
return '';
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Tvar ticketu v ulozisti a prevod navenek.
*
* Tvar ticketu je sdileny s webem, viz src/shared/tickets.ts. Tady zustava
* to, co web nevidi: jak vypada zaznam v ulozisti a jak se z nej sklada
* uplny `Ticket` s doplnenymi vychozimi hodnotami.
*/
import { findPerson } from '../people.js';
import type {
AgentStatsRow,
Ticket,
TicketAssignee,
TicketChannel,
TicketCustomer,
TicketDetail,
TicketEvent,
TicketPriority,
TicketStatus,
TicketTraceEntry,
TraceKind,
TraceStatus,
Workload,
WorkloadRow,
} from '../../shared/tickets.js';
/**
* 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 {
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;
/**
* Tvar v ulozisti.
*
* Nova pole jsou nepovinna zamerne: vychozi sada ticketu je psana jako literaly
* a doplnovat do kazdeho `tags: []` by byl sum. Chybejici hodnotu dosadi
* `toTicket`, takze navenek je `Ticket` uplny.
*/
export interface StoredTicket
extends Omit<
Ticket,
| 'assignee'
| 'typeId'
| 'fields'
| 'tags'
| 'assigneeGroupId'
| 'externalId'
| 'closed'
| 'externalSource'
| 'firstResponseAt'
| 'resolvedAt'
| 'resolvedById'
| 'reopenCount'
| 'helpdeskSourceId'
| 'createdById'
> {
assigneeId: string | null;
helpdeskSourceId?: string | null;
assigneeGroupId?: string | null;
typeId?: string | null;
fields?: Record<string, string | number | boolean | null>;
tags?: string[];
closed?: boolean;
externalId?: string | null;
externalSource?: string | null;
firstResponseAt?: string | null;
resolvedAt?: string | null;
resolvedById?: string | null;
reopenCount?: number;
createdById?: string | null;
events?: TicketEvent[];
}
export const channelLabels: Record<TicketChannel, string> = {
whatsapp: 'WhatsApp',
facebook: 'Facebook Messenger',
instagram: 'Instagram',
email: 'E-mail',
voice: 'Hlasová linka',
form: 'Webový formulář',
portal: 'Portál',
};
/**
* 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
* pri kazdem kroku automatizace a moznost, ze jeden z nich selze.
*/
export interface PersistedTicket extends StoredTicket {
trace: TicketTraceEntry[];
}
// ------------------------------------------------------------------ prevody
export function toTicket(stored: StoredTicket): Ticket {
const { assigneeId, events: _events, ...rest } = stored;
// Chybejici nova pole dostanou vychozi hodnotu, aby navenek byl Ticket uplny.
const base = {
...rest,
assigneeGroupId: stored.assigneeGroupId ?? null,
typeId: stored.typeId ?? null,
fields: stored.fields ?? {},
tags: stored.tags ?? [],
closed: stored.closed ?? false,
externalId: stored.externalId ?? null,
externalSource: stored.externalSource ?? null,
firstResponseAt: stored.firstResponseAt ?? null,
resolvedAt: stored.resolvedAt ?? null,
resolvedById: stored.resolvedById ?? null,
reopenCount: stored.reopenCount ?? 0,
helpdeskSourceId: stored.helpdeskSourceId ?? null,
createdById: stored.createdById ?? null,
};
if (!assigneeId) return { ...base, assignee: null };
const person = findPerson(assigneeId, stored.tenantId);
if (!person) {
// Resitel uz neni clenem firmy - ticket nesmi spadnout, ale chceme o tom vedet.
console.warn(`[tickets] ${stored.id}: resitel ${assigneeId} uz ve firme neni`);
return { ...base, assignee: null };
}
return { ...base, assignee: { id: person.id, name: person.name } };
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Zapis ticketu do uloziste a nacteni pri startu.
*
* Po kazde zmene se cely ticket vcetne logu a udalosti zapise do uloziste
* (`withMirror`). Kam se zapisuje - databaze, soubor, nebo nikam - rozhoduje
* `data/store/index.ts`, tady se to neresi.
*/
import { defineStore } from '../store/index.js';
import { withMirror } from '../store/mirror.js';
import { onTicketChanged } from '../ticketHooks.js';
import { toTicket, type PersistedTicket, type StoredTicket } from './model.js';
import {
continueCounters,
events,
index,
tickets,
ticketsByExternal,
ticketsById,
traces,
} from './state.js';
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. */
export function persist(ticket: StoredTicket): void {
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) ?? [],
});
}
}
/**
* Oznaci ticket jako zmeneny a ulozi ho.
*
* Kazda zmena jde skrz tohle, aby neslo upravit ticket a zapomenout na
* `updatedAt` nebo na zapis. Pary "prirad radek, uloz" se jinak rozejdou.
*/
export function touch(ticket: StoredTicket): void {
ticket.updatedAt = new Date().toISOString();
persist(ticket);
// Automatizace navazane na zmenu ticketu. Necekana se a chyby nevyhazuje,
// jinak by rozbita fronta rozbila ukladani ticketu.
onTicketChanged('ticket.updated', toTicket(ticket));
}
/**
* Nacte tickety z uloziste. Vola se pri startu, viz data/bootstrap.ts.
*
* Kdyz uloziste nic nema, ulozi se ukazkova sada, ktera je v tuhle chvili
* v pameti. Po prvnim startu je tedy uloziste jediny zdroj pravdy.
*/
export async function initTickets(): Promise<void> {
const rows = await mirror.load(() =>
tickets.map((ticket) => ({ ...ticket, trace: traces.get(ticket.id) ?? [] })),
);
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
* se doplni pri nacteni - jinak by se v portalu ukazovalo "undefinedx".
*/
events.set(
row.id,
(rowEvents ?? []).map((event) => ({
...event,
repeats: event.repeats ?? 1,
lastAt: event.lastAt ?? event.at,
})),
);
}
continueCounters();
}
+198
View File
@@ -0,0 +1,198 @@
/**
* Cteni ticketu: seznam, detail, hledani podle ID.
*
* Kazdy dotaz bere povolene firmy a strop viditelnosti. Oboji je povinne,
* viz `TicketFilter` - nepovinny filtr na prava je filtr, ktery jednou chybi.
*/
import type { Visibility } from '../access.js';
import type { Ticket, TicketChannel, TicketDetail, TicketStatus } from '../../shared/tickets.js';
import { toTicket, type StoredTicket } from './model.js';
import { events, externalKey, tickets, ticketsByExternal, ticketsById, traces } from './state.js';
export interface TicketFilter {
/**
* Firmy, ze kterych se smi vracet. Povinne - kdyby to slo vynechat,
* driv nebo pozdeji nekdo zapomene a endpoint vrati cizi data.
*/
tenantIds: string[];
/**
* Strop viditelnosti uvnitr firmy. **Povinny ze stejneho duvodu** jako
* `tenantIds`: nepovinny filtr na prava je filtr, ktery jednou nekde chybi.
*
* Poklada ho `resolveScope`, viz data/access.ts. Kdo ma videt vsechno,
* posila `{ kind: 'all' }` - vyslovne, ne vynechanim.
*/
visibility: Visibility;
/**
* Firmy, ze kterych pozadavek prisel pres helpdesk.
*
* Vyplnene **nahrazuje** filtr podle vlastnika: zadavatel vlastnikem neni,
* takze by mu jinak nezbylo nic. Prazdne pole tady znamena "nefiltrovat
* podle zdroje", ne "nic" - vlastnicky filtr plati dal.
*/
helpdeskSourceIds?: string[];
/** ID resitele, nebo 'unassigned' pro nepridelene. */
assignee?: string;
/**
* Ucet, ktery ticket zalozil. Pouziva **jen helpdesk**: tam je "moje" to,
* co jsem poslal, ne to, co mam prirazene.
*/
createdById?: string;
status?: TicketStatus;
channel?: TicketChannel;
/** Typ ticketu. `none` = tickety bez typu. */
typeId?: string;
/** Jeden tag. `none` = tickety bez tagu. */
tag?: string;
/** Skupina resitelu. `none` = bez skupiny. */
groupId?: string;
}
/**
* Vejde se ticket do stropu?
*
* Sjednoceni, ne prunik: bud ho ma u sebe nekdo, na koho vidim, nebo lezi
* ve fronte sekce, kterou vedu. Fronta bez resitele patri do druhe podminky -
* bez ni by vedouci nemel co rozdelovat.
*/
function withinVisibility(ticket: StoredTicket, visibility: Visibility): boolean {
if (visibility.kind === 'all') return true;
/*
* **Nezarazene vidi kazdy ve firme.** Prvni verze stropu je schovavala
* a byla to diera v provozu: prichozi ticket, ktery jeste nikdo nesmeroval,
* nepatri do zadne sekce, takze by ho nevidel nikdo krome vedeni - a nikdo
* by si ho nevzal. Fronta je spolecna, prave proto je to fronta.
*/
if (ticket.assigneeId === null) return true;
if (visibility.personIds.includes(ticket.assigneeId)) return true;
if (ticket.assigneeGroupId && visibility.groupIds.includes(ticket.assigneeGroupId)) return true;
return false;
}
export function listTickets(filter: TicketFilter): Ticket[] {
const sources = filter.helpdeskSourceIds;
const selected = tickets.filter((ticket) => {
if (sources && sources.length > 0) {
// Pohled zadavatele: vidi svoje pozadavky bez ohledu na to, kdo je resi.
if (!ticket.helpdeskSourceId || !sources.includes(ticket.helpdeskSourceId)) return false;
} else if (!filter.tenantIds.includes(ticket.tenantId)) {
return false;
} else if (!withinVisibility(ticket, filter.visibility)) {
/*
* Strop se pta jen u vlastnickeho pohledu. Pohled zadavatele nad nim
* neni: pozadavek posila firma dodavateli, zadavatel u nej neni resitel
* ani clen zadne skupiny, takze by mu strop vzal i to, co sam poslal.
*/
return false;
}
if (filter.status && ticket.status !== filter.status) return false;
if (filter.channel && ticket.channel !== filter.channel) return false;
if (filter.typeId === 'none' ? ticket.typeId : filter.typeId && ticket.typeId !== filter.typeId)
return false;
if (filter.tag === 'none') {
if ((ticket.tags ?? []).length > 0) return false;
} else if (filter.tag && !(ticket.tags ?? []).includes(filter.tag)) {
return false;
}
if (
filter.groupId === 'none'
? ticket.assigneeGroupId
: filter.groupId && ticket.assigneeGroupId !== filter.groupId
) {
return false;
}
if (filter.createdById && ticket.createdById !== filter.createdById) return false;
if (filter.assignee === 'unassigned') return ticket.assigneeId === null;
if (filter.assignee && ticket.assigneeId !== filter.assignee) return false;
return true;
});
// Nejdriv nevyrizene, uvnitr od nejnovejsi upravy.
return selected
.sort((a, b) => {
// Hotove dolu. Ptame se na priznak, ne na text stavu - ten je volny.
if (a.closed && !b.closed) return 1;
if (b.closed && !a.closed) return -1;
return b.updatedAt.localeCompare(a.updatedAt);
})
.map(toTicket);
}
/**
* Vraci ticket jen z povolenych firem. Cizi se tvari jako neexistujici.
*
* `helpdeskSourceIds` je druha cesta dovnitr: firma, ktera pozadavek poslala,
* ho smi cist, i kdyz ho nevlastni. Bez toho by zadavatel videl v seznamu
* pozadavek, ktery si nemuze otevrit.
*/
export function getTicket(
id: string,
tenantIds: string[],
helpdeskSourceIds: string[] = [],
visibility: Visibility = { kind: 'all' },
): TicketDetail | undefined {
const stored = ticketsById.get(id);
if (!stored) return undefined;
const owns = tenantIds.includes(stored.tenantId);
const asked =
stored.helpdeskSourceId !== null &&
stored.helpdeskSourceId !== undefined &&
helpdeskSourceIds.includes(stored.helpdeskSourceId);
if (!owns && !asked) {
console.warn(`[tickets] pokus o cteni ticketu ${id} mimo povolene firmy`);
return undefined;
}
/*
* Strop plati i na jeden ticket, ne jen na seznam. Bez toho by staciloa
* znat ID: seznam by ho neukazal, ale adresa detailu by ho vydala.
*
* Pohledu zadavatele se to netyka, ten stoji na `helpdeskSourceIds` - viz
* `listTickets`.
*/
if (owns && !asked && !withinVisibility(stored, visibility)) {
console.warn(`[tickets] pokus o cteni ticketu ${id} mimo strop viditelnosti`);
return undefined;
}
return {
...toTicket(stored),
trace: traces.get(id) ?? [],
events: events.get(id) ?? [],
};
}
/**
* Vejde se ten ticket do stropu?
*
* Pouziva detail a prevzeti, kde se firma ticketu pozna az po nalezeni -
* odkaz z pohledu "vse" muze vest do jine firmy uzivatele a strop se pocita
* za firmu ticketu, ne za prave prepnutou.
*/
export function ticketWithinVisibility(id: string, visibility: Visibility): boolean {
const stored = ticketsById.get(id);
return stored ? withinVisibility(stored, visibility) : false;
}
/** Prvni nevyrizeny ticket. */
export function firstOpenTicket(tenantIds: string[]): Ticket | undefined {
const stored = tickets.find(
(t) => !t.closed && tenantIds.includes(t.tenantId),
);
return stored ? toTicket(stored) : undefined;
}
/** Ticket podle naseho ID, jen z povolenych firem. */
export function findTicket(id: string, tenantIds: string[]): Ticket | undefined {
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 = ticketsByExternal.get(externalKey(tenantId, externalId));
return stored ? toTicket(stored) : undefined;
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Preznaceni resitelu pri migraci, viz data/migratePeople.ts.
*/
import { persist } from './persist.js';
import { tickets } from './state.js';
/**
* Prepise ID resitelu podle mapy stare -> nove. Vraci pocet zmenenych ticketu.
*
* Jen pro migraci (data/migratePeople.ts): resitel byval vlastni zaznam
* `ppl_...`, dnes je to ID uctu. Meni se jen odkazy, `updatedAt` ani hooky
* se nespousteji - ticket se vecne nezmenil, jen se preznacil.
*/
export function remapPersonIds(map: Map<string, string>): number {
let changed = 0;
for (const ticket of tickets) {
let touched = false;
if (ticket.assigneeId && map.has(ticket.assigneeId)) {
ticket.assigneeId = map.get(ticket.assigneeId)!;
touched = true;
}
if (ticket.resolvedById && map.has(ticket.resolvedById)) {
ticket.resolvedById = map.get(ticket.resolvedById)!;
touched = true;
}
if (touched) {
changed += 1;
persist(ticket);
}
}
return changed;
}
+433
View File
@@ -0,0 +1,433 @@
/**
* Ukazkove tickety.
*
* Nasypou se jen se `SEED_DEMO=1`, o tom rozhoduje `index.ts`. Tady je jen
* sada samotna a zapis do pameti bez uloziste - uloziste se plni az pri
* `initTickets`, kdyz je prazdne.
*/
import { minutesAgo } from '../store/index.js';
import type { StoredTicket } from './model.js';
import { index, tickets, traces } from './state.js';
import { flattenTrace, type TraceInput } from './trace.js';
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`**, 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.
*/
export function seedDemoTickets(): void {
seed(
{
id: 'TK-4821',
tenantId: 'tnt_automia',
subject: 'Voicebot neodpovídá na volání po 18:00',
body:
'Dobrý den, po šesté hodině to nikdo nebere. Zkoušeli jsme to včera i dnes, ' +
'linka jen vyzvání a pak to spadne. Přes den to funguje normálně.',
sourceRef: 'cl_88213',
channel: 'voice',
customer: {
id: 'crm_1042',
company: 'Firma s.r.o.',
contact: 'Petra Klientová',
reply: '+420 601 118 224',
},
status: 'V řešení',
priority: 'high',
assigneeId: 'usr_novakova',
automationId: 'AUT-02',
createdAt: minutesAgo(310),
updatedAt: minutesAgo(42),
},
[
{
kind: 'trigger',
serviceId: 'voicebot',
operationId: 'call-received',
label: 'Příchozí hovor na linku 800 100 200',
status: 'ok',
response: '{ "callId": "cl_88213", "from": "+420601118224", "durationSec": 96 }',
durationMs: 120,
agoMinutes: 310,
},
{
kind: 'action',
serviceId: 'transcription',
operationId: 'transcribe',
label: 'Přepis nahrávky',
status: 'ok',
response:
'{ "language": "cs", "confidence": 0.94, "text": "Dobrý den, po šesté hodině to nikdo nebere..." }',
durationMs: 4_180,
agoMinutes: 309,
},
{
kind: 'action',
serviceId: 'openai',
operationId: 'chat',
label: 'Zařazení do kategorie',
status: 'ok',
response: '{ "category": "porucha", "priority": "high", "confidence": 0.88 }',
durationMs: 910,
agoMinutes: 309,
},
{
kind: 'action',
serviceId: 'raynet',
operationId: 'upsert-contact',
label: 'Dohledání firmy podle telefonu',
status: 'ok',
response: '{ "companyId": "crm_1042", "name": "Firma s.r.o.", "matchedBy": "phone" }',
durationMs: 640,
agoMinutes: 309,
},
{
kind: 'condition',
label: 'knownCustomer je splněno',
status: 'ok',
response: 'true, pokračuje větev ANO',
agoMinutes: 309,
children: [
{
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
label: 'Založení ticketu',
status: 'ok',
response: '{ "ticketId": "TK-4821", "priority": "high" }',
durationMs: 85,
agoMinutes: 309,
},
{
kind: 'action',
serviceId: 'ticket',
operationId: 'assign',
label: 'Přiřazení řešitele podle služby',
status: 'ok',
response: '{ "assignee": "Eva Nováková", "rule": "voicebot -> voiceboti" }',
durationMs: 40,
agoMinutes: 309,
},
],
},
{
kind: 'action',
serviceId: 'microsoft365',
operationId: 'post-teams',
label: 'Upozornění do Teams',
status: 'error',
response: 'HTTP 429 Too Many Requests, kanál "Servicedesk" překročil limit, zpráva neodešla',
durationMs: 2_400,
agoMinutes: 308,
},
{
kind: 'note',
label: 'Eva Nováková: Reprodukováno, chyba je v nočním režimu scénáře.',
status: 'info',
agoMinutes: 42,
},
],
);
seed(
{
id: 'TK-4820',
tenantId: 'tnt_automia',
subject: 'Přidat pole IČO do synchronizace CRM a fakturace',
body:
'Zdravím, potřebovali bychom, aby se při synchronizaci přenášelo i IČO. ' +
'Teď ho musíme do faktur doplňovat ručně a občas se na to zapomene. ' +
'Kolik by to bylo práce?',
sourceRef: '<9f21c4@nordis.cz>',
channel: 'email',
customer: {
id: 'crm_2210',
company: 'Nordis a.s.',
contact: 'Tomáš Beran',
reply: 'tomas.beran@nordis.cz',
},
status: 'Čeká na klienta',
priority: 'normal',
assigneeId: 'usr_3',
automationId: 'AUT-03',
createdAt: minutesAgo(1_180),
updatedAt: minutesAgo(190),
},
[
{
kind: 'trigger',
serviceId: 'email',
operationId: 'received',
label: 'Přijat e-mail do schránky podpora@',
status: 'ok',
response: '{ "from": "tomas.beran@nordis.cz", "subject": "IČO v synchronizaci", "attachments": 0 }',
durationMs: 60,
agoMinutes: 1_180,
},
{
kind: 'action',
serviceId: 'openai',
operationId: 'chat',
label: 'Vytažení údajů z textu',
status: 'ok',
response: '{ "type": "pozadavek na zmenu", "system": "CRM + iDoklad", "urgent": false }',
durationMs: 1_120,
agoMinutes: 1_180,
},
{
kind: 'action',
serviceId: 'raynet',
operationId: 'upsert-contact',
label: 'Dohledání firmy podle e-mailu',
status: 'ok',
response: '{ "companyId": "crm_2210", "name": "Nordis a.s.", "matchedBy": "emailDomain" }',
durationMs: 520,
agoMinutes: 1_180,
},
{
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
label: 'Založení ticketu',
status: 'ok',
response: '{ "ticketId": "TK-4820", "priority": "normal" }',
durationMs: 74,
agoMinutes: 1_179,
},
{
kind: 'action',
serviceId: 'email',
operationId: 'send',
label: 'Potvrzení zadavateli',
status: 'ok',
response: '{ "messageId": "<a41c@automia.cz>", "to": "tomas.beran@nordis.cz" }',
durationMs: 380,
agoMinutes: 1_179,
},
{
kind: 'note',
label: 'Martin Kříž: Čekáme na potvrzení rozsahu od zákazníka.',
status: 'info',
agoMinutes: 190,
},
],
);
seed(
{
id: 'TK-4819',
tenantId: 'tnt_automia',
subject: 'Chybí denní report objednávek v e-mailu',
body: 'Dobrý den, už třetí den nechodí ranní report. Můžete se na to prosím podívat?',
sourceRef: 'wamid.HBgLNDIwNzc0OTAyMzMx',
channel: 'whatsapp',
customer: {
id: null,
company: 'Neznámá firma',
contact: 'Bistro Kolektiv',
reply: '+420 774 902 331',
},
status: 'Nový',
priority: 'normal',
assigneeId: null,
automationId: 'AUT-01',
createdAt: minutesAgo(95),
updatedAt: minutesAgo(95),
},
[
{
kind: 'trigger',
serviceId: 'whatsapp',
operationId: 'message-received',
label: 'Přijata zpráva z WhatsApp',
status: 'ok',
response:
'{ "from": "+420774902331", "profileName": "Bistro Kolektiv", "text": "Dobrý den, už třetí den nechodí ranní report." }',
durationMs: 55,
agoMinutes: 95,
},
{
kind: 'action',
serviceId: 'openai',
operationId: 'chat',
label: 'Zařazení do kategorie',
status: 'ok',
response: '{ "category": "vypadek reportu", "priority": "normal", "confidence": 0.79 }',
durationMs: 870,
agoMinutes: 95,
},
{
kind: 'action',
serviceId: 'raynet',
operationId: 'upsert-contact',
label: 'Dohledání firmy podle telefonu',
status: 'error',
response: '{ "matches": 0, "searchedBy": "phone", "value": "+420774902331" }',
durationMs: 610,
agoMinutes: 95,
},
{
kind: 'condition',
label: 'knownCustomer není splněno',
status: 'skipped',
response: 'false, pokračuje větev NE',
agoMinutes: 95,
children: [
{
kind: 'action',
serviceId: 'ticket',
operationId: 'create',
label: 'Založení ticketu bez napojení na firmu',
status: 'ok',
response: '{ "ticketId": "TK-4819", "customerId": null }',
durationMs: 68,
agoMinutes: 95,
},
{
kind: 'action',
serviceId: 'raynet',
operationId: 'create-lead',
label: 'Založení obchodního případu k dohledání',
status: 'ok',
response: '{ "leadId": "lead_7781", "stage": "k overeni" }',
durationMs: 940,
agoMinutes: 95,
},
{
kind: 'action',
serviceId: 'ticket',
operationId: 'assign',
label: 'Přiřazení řešitele',
status: 'skipped',
response: 'Přeskočeno, bez známé firmy nelze určit garanta a ticket zůstal nepřiřazený',
agoMinutes: 95,
},
],
},
],
);
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: 'usr_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: 'usr_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,
},
],
);
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Pamet ticketu: pole, indexy, log, udalosti a citace.
*
* Vsechno, co ostatni moduly slozky sdileji. Zadna logika, jen data a to
* nejnutnejsi kolem nich - indexy a pridelovani dalsich ID.
*/
import { highestNumber } from '../store/index.js';
import type { TicketEvent, TicketTraceEntry } from '../../shared/tickets.js';
import type { StoredTicket } from './model.js';
export 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.
*/
export const ticketsById = new Map<string, StoredTicket>();
export const ticketsByExternal = new Map<string, StoredTicket>();
/** Log ticketu drzime zvlast - je to jina zivotnost i jiny objem dat. */
export const traces = new Map<string, TicketTraceEntry[]>();
/** Klic externiho ID: unikatni je v ramci firmy, ne globalne. */
export function externalKey(tenantId: string, externalId: string): string {
return `${tenantId}:${externalId}`;
}
/** Zaradi ticket do indexu. Vola se vsude, kde ticket pribyva do pole. */
export function index(ticket: StoredTicket): void {
ticketsById.set(ticket.id, ticket);
if (ticket.externalId) ticketsByExternal.set(externalKey(ticket.tenantId, ticket.externalId), ticket);
}
/** Udalosti drzime u ticketu stejne jako log - jina zivotnost, stejny zaznam. */
export const events = new Map<string, TicketEvent[]>();
// -------------------------------------------------------------------- citace
/** Prvni cislo ticketu. Ukazkova sada konci na TK-4821, nove tickety pokracuji za ni. */
const FIRST_TICKET_NUMBER = 4_821;
let ticketCounter = FIRST_TICKET_NUMBER;
let traceCounter = 0;
let eventCounter = 0;
export function nextTicketId(): string {
ticketCounter += 1;
return `TK-${ticketCounter}`;
}
export function nextTraceId(): string {
traceCounter += 1;
return `tr_${traceCounter}`;
}
export function nextEventId(): string {
eventCounter += 1;
return `tev_${eventCounter.toString(36)}`;
}
/**
* Po nacteni z uloziste. Citac musi pokracovat za nejvyssim ulozenym cislem,
* jinak by nove tickety prepisovaly stare.
*/
export function continueCounters(): void {
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);
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Statistiky nad tickety: vytizeni tymu a vykon resitelu.
*
* Pocita se vzdy pres `listTickets`, aby strop viditelnosti platil i tady.
*/
import type { Visibility } from '../access.js';
import type { Person } from '../people.js';
import type { AgentStatsRow, Ticket, Workload } from '../../shared/tickets.js';
import { listTickets } from './queries.js';
export function getWorkload(
everyone: Person[],
tenantIds: string[],
visibility: Visibility,
): Workload {
// Pres listTickets, aby strop platil i tady. Driv se sahalo primo do pole
// 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 = byAssignee.get(person.id) ?? [];
const open = mine.filter((t) => !t.closed);
return {
person,
open: open.length,
total: mine.length,
critical: open.filter((t) => t.priority === 'critical').length,
oldestOpenAt: oldestCreatedAt(open),
overloaded: open.length > person.capacity,
};
});
// Nejvytizenejsi nahoru - prehled ma odpovedet na "kdo toho ma nejvic".
rows.sort((a, b) => b.open - a.open || a.person.name.localeCompare(b.person.name, 'cs'));
return {
rows,
unassigned: visible.filter((t) => t.assignee === null && !t.closed).length,
openTotal: visible.filter((t) => !t.closed).length,
};
}
/** 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,
);
}
/** Median, ne prumer: jeden ticket zapomenuty pres dovolenou jinak prebije vsechno. */
function median(values: number[]): number | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
const upper = sorted[middle];
const lower = sorted[middle - 1];
// Seznam neni prazdny, stredni prvek tedy existuje. Kontrola je jen kvuli typum.
if (upper === undefined) return null;
if (sorted.length % 2 === 1 || lower === undefined) return upper;
return Math.round((lower + upper) / 2);
}
/**
* Vykon resitelu za obdobi.
*
* `since` je hranice pro **vyresene** tickety, tedy "kolik toho odbavil za
* poslednich 30 dni". Nevyrizene se pocitaji vzdy vsechny - fronta neni
* vec obdobi, lezi tam bez ohledu na to, na co se zrovna divame.
*/
export function getAgentStats(
everyone: Person[],
tenantIds: string[],
since: number | null,
visibility: Visibility,
): 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 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 = mine
.filter((t) => at(t.firstResponseAt) !== null)
.map((t) => (at(t.firstResponseAt) ?? 0) - (at(t.createdAt) ?? 0))
.filter((value) => value >= 0);
const oldest = oldestCreatedAt(open);
return {
personId: person.id,
name: person.name,
resolved: resolved.length,
open: open.length,
critical: open.filter((t) => t.priority === 'critical').length,
medianResolveMs: median(resolveTimes),
medianResponseMs: median(responseTimes),
reopened: resolved.reduce((sum, t) => sum + (t.reopenCount ?? 0), 0),
oldestOpenAt: oldest,
overloaded: open.length > person.capacity,
};
});
// Nejvic odbavenych nahoru. Prehled ma odpovedet na "kdo toho udelal nejvic".
rows.sort((a, b) => b.resolved - a.resolved || a.name.localeCompare(b.name, 'cs'));
return rows;
}
+405
View File
@@ -0,0 +1,405 @@
/**
* Zapisy do ticketu: zalozeni, stav, resitel, typ, tagy, skupina, komentar.
*
* Kazda zmena jde pres `touch`, aby neslo zapomenout na `updatedAt` a zapis.
* Zmeny posilaji udalost na sbernici, takze se projevi v dashboardu okamzite
* bez obnoveni stranky.
*/
import { publish } from '../../events/bus.js';
import { notify } from '../notifications.js';
import { findPerson } from '../people.js';
import { writableOrWarn } from '../store/index.js';
import { onTicketChanged } from '../ticketHooks.js';
import type {
Ticket,
TicketChannel,
TicketCustomer,
TicketPriority,
TicketStatus,
} from '../../shared/tickets.js';
import { defaultStatuses, toTicket, type StoredTicket } from './model.js';
import { persist, touch } from './persist.js';
import { events, index, nextTicketId, tickets, ticketsById, traces } from './state.js';
import { appendTrace, flattenTrace, type TraceInput } from './trace.js';
/**
* Prvni reakce se zapisuje jednou a uz se neprepisuje.
*
* Je to cas, kdy zakaznik prestal cekat. Kdyby se prepisoval pri kazde zmene,
* merilo by to posledni dotek, coz je uplne jina velicina.
*/
function markResponded(ticket: StoredTicket): void {
if (!ticket.firstResponseAt) ticket.firstResponseAt = new Date().toISOString();
}
export interface CreateTicketInput {
tenantId: string;
/** ID u odesilatele. Unikatni za firmu, viz `Ticket.externalId`. */
externalId?: string | null;
externalSource?: string | null;
subject: string;
body?: string;
sourceRef?: string | null;
channel: TicketChannel;
/**
* Zakaznik je **nepovinny**. U pozadavku z helpdesku dava smysl vedet, kdo
* ho poslal, u ticketu zalozeneho rucne casto nikdo takovy neni - je to
* ukol, ne pozadavek od nekoho zvenku.
*/
customer?: TicketCustomer;
/** Firma, ktera pozadavek poslala pres helpdesk. Vlastnikem je ta, ktera resi. */
helpdeskSourceId?: string | null;
priority: TicketPriority;
/** Vychozi stav, kdyz se nezada. Volny retezec, ne ciselnik. */
status?: string;
/** Je uz vyrizeny? Vychozi ne. */
closed?: boolean;
assigneeId?: string | null;
assigneeGroupId?: string | null;
typeId?: string | null;
fields?: Record<string, string | number | boolean | null>;
tags?: string[];
automationId?: string | null;
/** Ucet, ktery ho zaklada rucne. null u automatizace a prijmu zvenku. */
createdById?: string | null;
/** Log toho, jak ticket vznikl. Bez nej je ticket nedohledatelny. */
trace?: TraceInput[];
}
export function createTicket(input: CreateTicketInput): Ticket {
const now = new Date().toISOString();
// Neexistujiciho resitele radeji zahodime, nez abychom ulozili mrtvy odkaz.
let assigneeId = input.assigneeId ?? null;
if (assigneeId && !findPerson(assigneeId, input.tenantId)) {
console.warn(`[tickets] neznamy resitel ${assigneeId}, ticket zustava neprirazeny`);
assigneeId = null;
}
const stored: StoredTicket = {
id: nextTicketId(),
tenantId: input.tenantId,
externalId: input.externalId ?? null,
externalSource: input.externalSource ?? null,
firstResponseAt: null,
resolvedAt: null,
resolvedById: null,
reopenCount: 0,
subject: input.subject,
body: input.body ?? '',
sourceRef: input.sourceRef ?? null,
channel: input.channel,
customer: input.customer ?? { id: null, company: '', contact: '', reply: '' },
helpdeskSourceId: input.helpdeskSourceId ?? null,
// Vychozi stav je jen doporuceni. Kdo posle vlastni, ma vlastni.
status: input.status ?? defaultStatuses[0],
priority: input.priority,
assigneeId,
// Tyhle ctyri se driv zahazovaly: vstup je nabizel, ale zaznam je nemel.
// Ticket zalozeny s typem tak zustaval bez typu a bez vlastnich poli.
assigneeGroupId: input.assigneeGroupId ?? null,
typeId: input.typeId ?? null,
fields: input.fields ?? {},
tags: input.tags ?? [],
closed: input.closed ?? false,
automationId: input.automationId ?? null,
createdById: input.createdById ?? null,
createdAt: now,
updatedAt: now,
};
tickets.unshift(stored);
index(stored);
traces.set(stored.id, flattenTrace(input.trace ?? [], null, []));
events.set(stored.id, []);
persist(stored);
onTicketChanged('ticket.created', toTicket(stored));
publish('ticket.created', `Nový ticket ${stored.id}: ${stored.subject}`, {
ticketId: stored.id,
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 {
return writableOrWarn(ticketsById.get(id), id, tenantIds, 'tickets');
}
/**
* Zmeni stav, pripadne i priznak vyrizeni.
*
* Stav je **volny retezec** a neoveruje se proti nicemu. Jestli je ticket
* vyrizeny, rika `closed` - vyslovne, ne odvozene ze jmena stavu. Kdyz se
* nepreda, priznak zustava, jak byl: zmena textu stavu sama o sobe neznamena,
* ze je hotovo.
*/
export function updateTicketStatus(
id: string,
status: TicketStatus,
tenantIds: string[],
closed?: boolean,
): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
if (!ticket) {
console.warn(`[tickets] zmena stavu nedostupneho ticketu: ${id}`);
return undefined;
}
const previous = ticket.status;
const wasClosed = ticket.closed ?? false;
ticket.status = status;
if (closed !== undefined) ticket.closed = closed;
if (ticket.closed && !wasClosed) {
ticket.resolvedAt = new Date().toISOString();
// Vyresil ten, kdo ho mel u sebe. Kdyz nikdo, zustane to nekomu nepripsane -
// radeji nez pripsat vyreseni cloveku, ktery s tim nic nemel.
ticket.resolvedById = ticket.assigneeId;
} else if (!ticket.closed && wasClosed) {
// Navrat z vyreseno je nejlepsi ukazatel toho, ze hotovo nebylo.
ticket.reopenCount = (ticket.reopenCount ?? 0) + 1;
ticket.resolvedAt = null;
ticket.resolvedById = null;
}
markResponded(ticket);
touch(ticket);
/*
* Poznamka jen kdyz se neco zmenilo. "Stav zmenen z completed na completed"
* je rada, ktera se ctenari pise do historie u kazde prichozi zpravy, a po
* dvaceti takovych se v logu neda nic najit.
*/
const statusChanged = previous !== status;
const closedChanged = closed !== undefined && closed !== wasClosed;
if (statusChanged || closedChanged) {
const parts: string[] = [];
if (statusChanged) parts.push(`stav ${previous} -> ${status}`);
if (closedChanged) parts.push(ticket.closed ? 'oznaceno jako vyrizene' : 'znovu otevreno');
appendTrace(id, [{ kind: 'note', label: parts.join(', '), status: 'info' }]);
}
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);
}
/** Prirazeni resitele. `null` ticket vrati zpatky do fronty. */
export function assignTicket(
id: string,
assigneeId: string | null,
tenantIds: string[],
): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
if (!ticket) {
console.warn(`[tickets] prirazeni nedostupneho ticketu: ${id}`);
return undefined;
}
// Resitel musi byt clenem firmy ticketu. Jinak by ticket zmizel z prehledu
// firmy a objevil se nekomu, kdo do ni nepatri. Necleny `findPerson` nevrati.
const person = assigneeId ? findPerson(assigneeId, ticket.tenantId) : null;
if (assigneeId && !person) {
console.warn(`[tickets] ${id}: prirazeni na ${assigneeId}, ktery neni clenem firmy`);
return undefined;
}
const previousAssignee = ticket.assigneeId;
ticket.assigneeId = person?.id ?? null;
if (person) markResponded(ticket);
touch(ticket);
// Komu ticket prisel, ten se to musi dozvedet. Znovu prirazeni tomu samemu
// cloveku upozorneni negeneruje, jinak by mu chodilo pri kazde drobnosti.
if (person && person.id !== previousAssignee) {
notify({
tenantId: ticket.tenantId,
userId: person.id,
kind: 'ticket.assigned',
title: `Máte nový ticket ${ticket.id}: ${ticket.subject}`,
href: `/dashboard/tickety/${ticket.id}`,
ticketId: ticket.id,
});
}
appendTrace(id, [
{
kind: 'note',
label: person ? `Ticket přiřazen: ${person.name}.` : 'Ticket vrácen do fronty.',
status: 'info',
},
]);
publish(
'ticket.assigned',
person
? `Ticket ${ticket.id} přiřazen: ${person.name}`
: `Ticket ${ticket.id} vrácen do fronty`,
{ ticketId: ticket.id, assigneeId: ticket.assigneeId, ticket: toTicket(ticket) },
ticket.tenantId,
);
return toTicket(ticket);
}
/** Komentar je jen dalsi radek logu - at je vsechno na jedne casove ose. */
/**
* Zmena typu ticketu.
*
* Vlastni pole se **nezahazuji**, jen prestanou byt videt. Kdyby se mazala,
* omylem prepnuty typ by znamenal ztratu dat bez cesty zpatky.
*/
export function setTicketType(
id: string,
typeId: string | null,
fields: Record<string, string | number | boolean | null> | undefined,
tenantIds: string[],
): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
if (!ticket) return undefined;
ticket.typeId = typeId;
if (fields) ticket.fields = { ...(ticket.fields ?? {}), ...fields };
touch(ticket);
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, ticket: toTicket(ticket) }, ticket.tenantId);
return toTicket(ticket);
}
/** Tagy se prepisuji cele. Prirustkova zmena by u vic lidi naraz kolidovala. */
export function setTicketTags(id: string, tags: string[], tenantIds: string[]): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
if (!ticket) return undefined;
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, ticket: toTicket(ticket) }, ticket.tenantId);
return toTicket(ticket);
}
/**
* Prehozeni na skupinu.
*
* Prirazeni cloveku se **zrusi**: kdyby zustalo, ticket by byl ve fronte skupiny
* i u konkretniho cloveka a nikdo by nevedel, kdo to ma resit.
*/
export function assignTicketGroup(
id: string,
groupId: string | null,
tenantIds: string[],
/** false = nechat resitele, jak je. Pouziva to prevzeti ticketu. */
clearAssignee = true,
): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
if (!ticket) return undefined;
ticket.assigneeGroupId = groupId;
if (groupId && clearAssignee) ticket.assigneeId = null;
touch(ticket);
appendTrace(id, [
{
kind: 'note',
status: 'info',
label: groupId ? `Přehozeno na skupinu ${groupId}` : 'Odebráno ze skupiny',
},
]);
publish('ticket.assigned', `Ticket ${ticket.id} přehozen na skupinu`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
return toTicket(ticket);
}
/**
* Prevzeti ticketu.
*
* Clovek si vezme praci sam, misto aby cekal, az mu ji nekdo prideli. Smi to
* jen u ticketu **ze sve skupiny**, nebo u takoveho, ktery nema nikoho -
* brat cizi rozdelanou praci by znamenalo, ze o ni prijde ten, kdo ji resi.
*
* Vraci `undefined`, kdyz to nejde; duvod rekne volajici, ktery zna kontext.
*/
export function claimTicket(
id: string,
personId: string,
tenantIds: string[],
): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
if (!ticket) return undefined;
// Necleny firmy `findPerson` nevrati, takze tohle je i kontrola firmy.
const person = findPerson(personId, ticket.tenantId);
if (!person) return undefined;
ticket.assigneeId = person.id;
markResponded(ticket);
touch(ticket);
appendTrace(id, [
{ kind: 'note', status: 'info', label: `${person.name} si ticket převzal` },
]);
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 = ticketsById.get(id);
if (!ticket || !tenantIds.includes(ticket.tenantId)) return undefined;
return ticket.assigneeId;
}
/**
* Komentar k ticketu.
*
* `helpdeskSourceIds` pusti ke slovu i zadavatele z helpdesku. Je to jedina
* zmena, kterou nad cizim ticketem smi - doplnit, co zapomnel napsat, je presne
* to, kvuli cemu se pozadavek otevira. Prehazovat resitele nebo menit stav uz
* ne, na to se ho nikdo neptal.
*/
export function addComment(
id: string,
author: string,
text: string,
tenantIds: string[],
helpdeskSourceIds: string[] = [],
): Ticket | undefined {
const found = ticketsById.get(id);
const asked =
found?.helpdeskSourceId !== null &&
found?.helpdeskSourceId !== undefined &&
helpdeskSourceIds.includes(found.helpdeskSourceId);
const ticket = asked ? found : findWritable(id, tenantIds);
if (!ticket) {
console.warn(`[tickets] komentar k nedostupnemu ticketu: ${id}`);
return undefined;
}
markResponded(ticket);
touch(ticket);
appendTrace(id, [{ kind: 'note', label: `${author}: ${text}`, status: 'info' }]);
publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
return toTicket(ticket);
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Log prubehu ticketu.
*
* Ticket si nese strom zaznamu o tom, co se s nim delo a co ktera sluzba
* vratila. Zapisuje se zanorene, uklada se zplostele s `parentId`.
*/
import { currentRun } from '../../runtime/context.js';
import { minutesAgo } from '../store/index.js';
import type { TicketTraceEntry, TraceKind, TraceStatus } from '../../shared/tickets.js';
import { persist } from './persist.js';
import { nextTraceId, ticketsById, traces } from './state.js';
/** Zaznam v logu tak, jak se zapisuje - strom je zanoreny, ulozeni ho zplosti. */
export interface TraceInput {
kind: TraceKind;
label: string;
status: TraceStatus;
serviceId?: string | null;
operationId?: string | null;
response?: string | null;
durationMs?: number | null;
/** Posun proti "ted" v minutach. Pouziva jen ukazkova data. */
agoMinutes?: number;
children?: TraceInput[];
}
/**
* Zplosti zanoreny zapis do seznamu s `parentId`.
* Poradi se zachovava, aby se strom dal vykreslit jednim pruchodem.
*/
export function flattenTrace(
inputs: TraceInput[],
parentId: string | null,
into: TicketTraceEntry[],
): TicketTraceEntry[] {
for (const input of inputs) {
const entry: TicketTraceEntry = {
id: nextTraceId(),
parentId,
kind: input.kind,
serviceId: input.serviceId ?? null,
operationId: input.operationId ?? null,
label: input.label,
status: input.status,
response: input.response ?? null,
durationMs: input.durationMs ?? null,
at: minutesAgo(input.agoMinutes ?? 0),
};
into.push(entry);
if (input.children && input.children.length > 0) {
flattenTrace(input.children, entry.id, into);
}
}
return into;
}
/** Prida zaznamy do logu ticketu. Vraci, kolik radku pribylo. */
export function appendTrace(ticketId: string, inputs: TraceInput[]): number {
const existing = traces.get(ticketId);
if (!existing) {
console.warn(`[tickets] zapis do logu neexistujiciho ticketu: ${ticketId}`);
return 0;
}
const before = existing.length;
/*
* Kdyz zapis patri behu automatizace, radi se pod jeho radek udalosti.
* Diky tomu je v logu videt "prislo tohle -> zmenilo to tohle" misto dvou
* vet vedle sebe, u kterych se jen hada, jestli spolu souvisi.
*/
flattenTrace(inputs, currentRun()?.traceParent.id ?? null, existing);
// Log je soucast ulozeneho ticketu, takze zapis do logu je zmena ticketu.
const ticket = ticketsById.get(ticketId);
if (ticket) persist(ticket);
return existing.length - before;
}
/** ID posledniho radku logu. Pod nej se radi zmeny, ktere z udalosti plynou. */
export function lastTraceId(ticketId: string): string | null {
return traces.get(ticketId)?.at(-1)?.id ?? null;
}
/** Kolik znaku dat se ukaze v logu. Cela data zustavaji v udalosti. */
const MAX_PAYLOAD_PREVIEW_CHARS = 2_000;
/** Kratky popis dat do logu. Cela data zustavaji v udalosti. */
export function describePayload(payload: Record<string, unknown>): string | null {
const keys = Object.keys(payload);
if (keys.length === 0) return null;
const text = JSON.stringify(payload);
return text.length > MAX_PAYLOAD_PREVIEW_CHARS ? `${text.slice(0, MAX_PAYLOAD_PREVIEW_CHARS)}...` : text;
}
+1 -1
View File
@@ -99,7 +99,7 @@ export async function runMigrations(): Promise<MigrationResult> {
await client.query('ROLLBACK').catch(() => undefined);
// Rozbita migrace nesmi projit potichu. Bez schematu nema smysl bezet.
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Migrace ${file} selhala: ${message}`);
throw new Error(`Migrace ${file} selhala: ${message}`, { cause: err });
}
applied.push(file);
+19 -265
View File
@@ -1,273 +1,22 @@
import cors from 'cors';
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';
import swaggerUi from 'swagger-ui-express';
/**
* Start serveru: nacteni dat, worker, `listen` a ukonceni. Nic jineho.
* Aplikace sama se sklada v `app.ts`, aby sla postavit i v testu.
*/
import { createApp } from './app.js';
import { config } from './config.js';
import { buildOpenApiDocument } from './openapi.js';
import { authRouter } from './routes/auth.js';
import { contactRouter } from './routes/contact.js';
import { dashboardRouter } from './routes/dashboard.js';
import { publicInviteRouter } from './routes/invites.js';
import { webhookRouter } from './routes/webhook.js';
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';
import { startWorker, stopWorker } from './runtime/worker.js';
import { closeDatabase, isDatabaseEnabled } from './db/pool.js';
import { ensureLoaded, scriptsDir } from './runtime/scripts/registry.js';
import { startScheduler, stopScheduler } from './runtime/triggers.js';
import { startWorker, stopWorker } from './runtime/worker.js';
const here = path.dirname(fileURLToPath(import.meta.url));
/** Zbuildovana SPA. Vite ji zapisuje do dist/public, viz vite.config.ts. */
const webRoot = path.join(here, 'public');
const app = express();
/*
* 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({
origin(origin, callback) {
// Bez Origin (curl, server-to-server) i stejna domena projdou vzdy.
if (!origin || isOriginAllowed(origin)) return callback(null, true);
console.warn(`[cors] zablokovan origin: ${origin}`);
return callback(null, false);
},
credentials: true,
}),
);
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} ${maskSecretsInUrl(req.originalUrl)}`);
next();
});
function isOriginAllowed(origin: string): boolean {
if (config.corsOrigins.includes(origin)) return true;
// V dev rezimu si Vite pri obsazenem portu vezme jiny, proto cely localhost.
if (!config.isProduction && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
return true;
}
return false;
}
// ---------------------------------------------------------------- API router
/**
* 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 = safeRouter({ strict: true });
/**
* Liveness. Zamerne **nezavisi na databazi**: kratky vypadek DB by jinak vedl
* k restartovani containeru, coz nic nespravi (AGENTS.md).
*/
api.get('/health', (_req, res) => {
res.json({ status: 'ok', uptimeSec: Math.round(process.uptime()) });
});
/**
* Readiness. Tady uz databaze zalezi, a proto je to zvlast.
* Vysledek se par sekund cachuje, aby monitoring nedelal dotaz pri kazdem pingu.
*/
api.get('/health/ready', async (_req, res) => {
const database = await databaseHealth();
const storage = storageStatus();
const ready = !database.enabled || database.ok;
res.status(ready ? 200 : 503).json({
status: ready ? 'ok' : 'degraded',
database,
storage,
});
});
/**
* Jak nas vidi ten, kdo nam vola.
*
* Vraci volajicimu jeho vlastni adresu tak, jak dorazila k serveru. Zni to
* zbytecne, ale je to jediny zpusob, jak zmerit, **s jakou zdrojovou adresou
* doruci reverse proxy volani, ktere vyslo z naseho containeru**. Container
* sam to nevidi, echo sluzba na internetu odpovi verejnou adresu, jenze
* volani na vlastni domenu se otaci zpatky na tentyz stroj a proxy pak muze
* videt adresu docker bridge, ne tu verejnou. A prave to rozhoduje o tom,
* jestli nas seznam povolenych IP pusti.
*
* Bez prihlaseni zamerne: neprozradi to nic, co by volajici uz nevedel,
* dostane svoji vlastni adresu. Stejne jako kterakoliv echo sluzba.
*/
api.get('/whoami', (req, res) => {
res.json({
// `req.ip` uz je po `trust proxy`, tedy hodnota z X-Forwarded-For.
ip: req.ip ?? null,
// Surove, aby bylo videt i to, co proxy pripsala nebo nepripsala.
forwardedFor: req.headers['x-forwarded-for'] ?? null,
remoteAddress: req.socket.remoteAddress ?? null,
});
});
/**
* Swagger UI. Cesta bez lomitka presmerujeme na variantu s lomitkem,
* jinak by se relativni odkazy na CSS a JS skladaly o uroven vys
* a za reverse proxy by se nenacetly.
*/
const openApiDocument = buildOpenApiDocument();
const swaggerOptions: swaggerUi.SwaggerUiOptions = {
customSiteTitle: `${config.brandName} API`,
swaggerOptions: { persistAuthorization: true },
};
api.get('/docs', (_req, res) => res.redirect(`${config.rootPath}/docs/`));
api.get('/openapi.json', (_req, res) => res.json(openApiDocument));
api.use(
'/docs',
swaggerUi.serveFiles(openApiDocument, swaggerOptions),
swaggerUi.setup(openApiDocument, swaggerOptions),
);
api.use('/api/auth', authRouter);
api.use('/api/dashboard', dashboardRouter);
// Verejne: kdo dostal odkaz na pozvanku, neni jeste prihlaseny.
api.use('/api/invites', publicInviteRouter);
api.use('/api/admin', adminRouter);
api.use('/api/contact', contactRouter);
api.use('/webhook', webhookRouter);
// Mount na koren i na prefix proxy. Caddy prefix pres handle_path odstranuje,
// ale takhle aplikace funguje i kdyby ho nechal - a lokalne bez proxy taky.
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 (apiPathPattern.test(req.path)) {
console.warn(`[404] ${req.method} ${req.originalUrl}`);
return res.status(404).json({ error: 'not_found', message: 'Endpoint neexistuje.' });
}
return next();
});
// ---------------------------------------------------------------- SPA
/**
* Do index.html se za behu vklada base pro prohlizec.
*
* Prohlizec vidi adresu /apps/<app-id>/..., ale Vite build ma relativni cesty.
* Bez <base> by se soubory na vnorenych cestach hledaly ve spatne slozce.
* Prefix se bere z ROOT_PATH, nikdy neni v kodu natvrdo.
*/
function renderIndexHtml(): string {
const file = path.join(webRoot, 'index.html');
const html = fs.readFileSync(file, 'utf8');
const base = `${config.rootPath}/`;
const injected =
`<base href="${base}">\n` +
` <script>window.__BASE_PATH__ = ${JSON.stringify(config.rootPath)};</script>`;
return html.replace('<head>', `<head>\n ${injected}`);
}
let cachedIndexHtml: string | null = null;
const hasWebBuild = fs.existsSync(path.join(webRoot, 'index.html'));
if (hasWebBuild) {
const serveStatic = express.static(webRoot, {
index: false,
// Soubory maji hash v nazvu, muzou se cachovat dlouho. index.html ne.
setHeaders(res, filePath) {
if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache');
else res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
},
});
app.use(serveStatic);
if (config.rootPath) app.use(config.rootPath, serveStatic);
// Vsechny ostatni cesty obsluhuje SPA, routovani si resi React Router.
app.get('*', (_req, res) => {
if (!cachedIndexHtml) cachedIndexHtml = renderIndexHtml();
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.send(cachedIndexHtml);
});
} else {
// Bez buildu webu nesmi aplikace tise vracet prazdno.
console.warn(`[start] build webu nenalezen v ${webRoot}, bezi jen API`);
app.get('/', (_req, res) => {
res.json({
name: 'csbot-prototype',
status: 'ok',
note: 'Build webu chybi, dostupne je jen API a /docs.',
});
});
}
// Centralni error handler - nic nesmi propadnout bez logu.
app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
/*
* Rozbite telo pozadavku neni nase chyba, je to spatne polozeny dotaz.
*
* `express.json` na nej vyhodi vyjimku, ta propadla sem a uzivatel videl
* "Interni chyba serveru" - hlasku, ktera rika, ze je neco spatne u nas,
* a poslala ho hledat na spatnou stranu. Stalo to jedno odpoledne.
*/
const status = (err as { status?: number } | null)?.status;
const type = (err as { type?: string } | null)?.type;
if (status === 400 && typeof type === 'string' && type.startsWith('entity.')) {
console.warn(`[error] ${req.method} ${req.path}: neplatne telo pozadavku (${type})`);
return res.status(400).json({
error: 'bad_request',
message: 'Tělo požadavku není platný JSON objekt.',
});
}
console.error('[error]', err);
const message = err instanceof Error ? err.message : 'Neznama chyba.';
res.status(500).json({
error: 'internal_error',
message: config.isProduction ? 'Interni chyba serveru.' : message,
});
});
// Aplikace se sklada pred nactenim dat, stejne jako driv: routery na datech
// pri sestaveni nezavisi, ctou je az za requestu.
const app = createApp();
/**
* Skripty konektoru se nactou jeste pred prijimanim provozu, protoze doplnuji
@@ -324,7 +73,9 @@ const server = app.listen(config.port, '0.0.0.0', () => {
console.info(`[start] health: ${config.rootPath}/health, docs: ${config.rootPath}/docs`);
console.info(`[start] skripty konektoru: ${scriptsDir()}`);
const storage = storageStatus();
console.info(`[start] uloziste konektoru: ${storage.mode}${storage.location ? ` (${storage.location})` : ''}`);
console.info(
`[start] uloziste konektoru: ${storage.mode}${storage.location ? ` (${storage.location})` : ''}`,
);
});
// Pool se pri ukonceni zavre, at se spojeni neopousti otevrena.
@@ -354,7 +105,10 @@ for (const signal of ['SIGTERM', 'SIGINT'] as const) {
* ho nastartuje znovu.
*/
process.on('unhandledRejection', (reason: unknown) => {
console.error('[fatal] neosetreny odmitnuty promise:', reason instanceof Error ? reason.stack : reason);
console.error(
'[fatal] neosetreny odmitnuty promise:',
reason instanceof Error ? reason.stack : reason,
);
});
process.on('uncaughtException', (err: Error) => {
+13 -8
View File
@@ -16,9 +16,9 @@
import nodemailer from 'nodemailer';
import { config } from '../config.js';
import type { ResolvedTarget } from '../scripts/connections.js';
import type { ResolvedTarget } from '../runtime/scripts/connections.js';
import { hostProblem } from '../net/guard.js';
import { createRedactor, truncate } from '../scripts/util.js';
import { createRedactor, truncate } from '../runtime/scripts/util.js';
export interface SmtpSettings {
host: string;
@@ -70,7 +70,9 @@ export function smtpTargetUrl(settings: SmtpSettings): string {
* Vraci `null` a duvod, kdyz udaje nedavaji smysl. Padat na tom nemuzeme:
* spatne vyplneny konektor je bezny stav, ne chyba aplikace.
*/
export function smtpSettings(target: ResolvedTarget): { settings: SmtpSettings } | { error: string } {
export function smtpSettings(
target: ResolvedTarget,
): { settings: SmtpSettings } | { error: string } {
const value = (key: string): string => (target.serviceConfig[key] ?? '').trim();
const host = value('host');
@@ -159,7 +161,12 @@ function failure(err: unknown, settings: SmtpSettings, what: string): MailResult
// Redaguje se heslo, ne uzivatel. Uzivatel je adresa schranky a prave ta
// v hlasce pomaha - skrtnout ji by z vety udelalo hadanku.
const redact = createRedactor([settings.password]);
const error = err as { code?: string; responseCode?: number; response?: string; message?: string };
const error = err as {
code?: string;
responseCode?: number;
response?: string;
message?: string;
};
const code = error?.code ?? '';
const status = typeof error?.responseCode === 'number' ? error.responseCode : null;
@@ -265,15 +272,13 @@ export async function sendMail(target: ResolvedTarget, message: MailMessage): Pr
try {
const info = await transport.sendMail({
from: settings.fromName
? { name: settings.fromName, address: settings.from }
: settings.from,
from: settings.fromName ? { name: settings.fromName, address: settings.from } : settings.from,
to: message.to,
...(message.cc ? { cc: message.cc } : {}),
...(message.bcc ? { bcc: message.bcc } : {}),
// Krok smi adresu pro odpovedi prebit: odpoved casto ma zamirit do
// ticketu, ne do schranky, ze ktere se odeslalo.
...(message.replyTo ?? settings.replyTo
...((message.replyTo ?? settings.replyTo)
? { replyTo: message.replyTo ?? settings.replyTo ?? undefined }
: {}),
subject: message.subject,
+16 -10
View File
@@ -23,8 +23,8 @@
import { config } from '../config.js';
import { describeFetchError, readJsonLimited } from '../net/guard.js';
import type { ResolvedTarget } from '../scripts/connections.js';
import { jwtExpiry, pickText, truncate } from '../scripts/util.js';
import type { ResolvedTarget } from '../runtime/scripts/connections.js';
import { jwtExpiry, pickText, truncate } from '../runtime/scripts/util.js';
import { dialectFor, normalizeServerUrl } from './dialect.js';
import { ensureAccess, forgetSession, type EasyWebCredentials } from './easyweb/session.js';
import { AuthFailure } from './errors.js';
@@ -54,10 +54,7 @@ const CLIENT_INFO = 'worknuke/1.0';
/** Jak se portal prihlasil. Jde to do hlasky u konektoru. */
export type AuthMethod =
| 'bez přihlášení'
| 'vyplněný token'
| 'OAuth jako aplikace'
| 'klíč zařízení, EasyWeb';
'bez přihlášení' | 'vyplněný token' | 'OAuth jako aplikace' | 'klíč zařízení, EasyWeb';
export interface Authorization {
headers: Record<string, string>;
@@ -130,7 +127,9 @@ function credentialsOf(target: ResolvedTarget): Credentials {
* a sezeni jedne nesmi obslouzit volani druhe.
*/
function cacheKey(target: ResolvedTarget, credentials: Credentials): string {
return target.connectorId ?? `${credentials.serverUrl}|${credentials.username}${credentials.clientId}`;
return (
target.connectorId ?? `${credentials.serverUrl}|${credentials.username}${credentials.clientId}`
);
}
/**
@@ -207,7 +206,11 @@ async function readMetadata(
signal: AbortSignal,
): Promise<Record<string, unknown> | null> {
try {
const response = await fetch(url, { method: 'GET', signal, headers: { Accept: 'application/json' } });
const response = await fetch(url, {
method: 'GET',
signal,
headers: { Accept: 'application/json' },
});
if (!response.ok) return null;
const body = await readJsonLimited(response, config.scriptMaxResponseBytes);
const parsed = body.json;
@@ -241,7 +244,10 @@ function wellKnown(base: URL, suffix: string): string[] {
* volani navic, ale bez toho by nesel napojit server, ktery si metadata dal
* jinam a oznamuje je jen timhle zpusobem.
*/
async function challengeMetadataUrl(serverUrl: string, signal: AbortSignal): Promise<string | null> {
async function challengeMetadataUrl(
serverUrl: string,
signal: AbortSignal,
): Promise<string | null> {
try {
const response = await fetch(serverUrl, {
method: 'POST',
@@ -253,7 +259,7 @@ async function challengeMetadataUrl(serverUrl: string, signal: AbortSignal): Pro
const challenge = response.headers.get('www-authenticate') ?? '';
const match = /resource_metadata\s*=\s*"([^"]+)"/i.exec(challenge);
return match ? match[1] : null;
return match?.[1] ?? null;
} catch {
return null;
}
+16 -8
View File
@@ -29,8 +29,8 @@ import {
readJsonLimited,
tooLargeMessage,
} from '../net/guard.js';
import { targetSecrets, type ResolvedTarget } from '../scripts/connections.js';
import { createRedactor, pick, truncate } from '../scripts/util.js';
import { targetSecrets, type ResolvedTarget } from '../runtime/scripts/connections.js';
import { createRedactor, pick, truncate } from '../runtime/scripts/util.js';
import { AuthFailure, authorize, type AuthMethod } from './auth.js';
import { dialectFor, normalizeServerUrl, type McpDialect } from './dialect.js';
@@ -235,7 +235,8 @@ function messageFromBlock(block: string): Record<string, unknown> | null {
const message = parsed as Record<string, unknown>;
// Notifikace o prubehu nas nezajimaji, ceka se na vysledek.
if (typeof message.method === 'string' && message.method.startsWith('notifications/')) return null;
if (typeof message.method === 'string' && message.method.startsWith('notifications/'))
return null;
return 'result' in message || 'error' in message ? message : null;
}
@@ -586,7 +587,8 @@ async function attempt<T>(
};
const signal = controller.signal;
// Posledni sestavene sezeni. Jeho redakce zna i token, ne jen udaje konektoru.
let session: Session | null = null;
// Bez pocatecni hodnoty: prvni prirazeni je v cyklu, chyba pred nim ho necha prazdne.
let session: Session | undefined;
try {
let force = false;
@@ -737,7 +739,10 @@ function delay(ms: number, signal: AbortSignal): Promise<void> {
}
/** Prevede odpoved serveru na vysledek kroku. */
function toCallResult(result: Record<string, unknown> | null, taskId: string | null): McpCallResult {
function toCallResult(
result: Record<string, unknown> | null,
taskId: string | null,
): McpCallResult {
const content = result?.content;
const text = Array.isArray(content)
? content
@@ -847,9 +852,12 @@ export function callTool(
await ensureInitialized(session, signal);
try {
const result = (await rpc(session, 'tools/call', { name, arguments: args }, signal)) as
| Record<string, unknown>
| null;
const result = (await rpc(
session,
'tools/call',
{ name, arguments: args },
signal,
)) as Record<string, unknown> | null;
return toCallResult(result, null);
} catch (err) {
/*
+2 -1
View File
@@ -53,7 +53,8 @@ export interface McpDialect {
useSessionHeader: boolean;
}
const dialects: Record<string, McpDialect> = {
// Oficialni chovani je v typu povinne: je to zaloha pro vsechny ostatni sluzby.
const dialects: Record<string, McpDialect> & Record<typeof MCP_SERVICE_ID, McpDialect> = {
[MCP_SERVICE_ID]: {
auth: 'oauth',
protocolVersion: '2025-06-18',
+4 -1
View File
@@ -35,7 +35,10 @@ export interface DeviceKey {
/** Vyrobi novy par klicu. Dela se **jednou za konektor**, pak uz se jen nacita. */
export function generateDeviceKey(): { key: DeviceKey; jwk: string } {
const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
return { key: fromPrivateKey(privateKey), jwk: JSON.stringify(privateKey.export({ format: 'jwk' })) };
return {
key: fromPrivateKey(privateKey),
jwk: JSON.stringify(privateKey.export({ format: 'jwk' })),
};
}
/**
+4 -1
View File
@@ -73,7 +73,10 @@ export async function deviceFor(connectorId: string, tenantId: string): Promise<
* nemelo tentyz otisk jako to stare. Server by jinak videl znamy otisk
* s jinym klicem, a to je presne obraz pokusu o podvrzeni.
*/
const suffix = key.thumbprint.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8).toLowerCase();
const suffix = key.thumbprint
.replace(/[^a-zA-Z0-9]/g, '')
.slice(0, 8)
.toLowerCase();
const fingerprint = `${FINGERPRINT_PREFIX}-${connectorId}-${suffix}`;
await setManagedValues(
+9 -4
View File
@@ -26,7 +26,7 @@
import { config } from '../../config.js';
import { describeFetchError, readBodyLimited } from '../../net/guard.js';
import { jwtExpiry, pickText, truncate } from '../../scripts/util.js';
import { jwtExpiry, pickText, truncate } from '../../runtime/scripts/util.js';
import { AuthFailure } from '../errors.js';
import {
KEY_REGISTRATION_CONTEXT,
@@ -143,7 +143,11 @@ async function post(
response = await fetch(url, {
method: 'POST',
signal,
headers: { 'Content-Type': 'application/json; charset=utf-8', Accept: 'application/json', ...headers },
headers: {
'Content-Type': 'application/json; charset=utf-8',
Accept: 'application/json',
...headers,
},
body: JSON.stringify(body),
});
} catch (err) {
@@ -188,7 +192,8 @@ function tokensFrom(
accessToken,
accessExpiresAt: accessExpiry(accessToken),
refreshToken: (body ? pickText(body, 'RefreshToken') : null) ?? previous?.refreshToken ?? '',
refreshNonce: (body ? pickText(body, 'RefreshTokenNonce') : null) ?? previous?.refreshNonce ?? '',
refreshNonce:
(body ? pickText(body, 'RefreshTokenNonce') : null) ?? previous?.refreshNonce ?? '',
deviceToken: (body ? pickText(body, 'DeviceToken') : null) ?? previous?.deviceToken ?? '',
deviceNonce: (body ? pickText(body, 'DeviceTokenNonce') : null) ?? previous?.deviceNonce ?? '',
};
@@ -237,7 +242,7 @@ async function login(
throw new EasyWebAuthError(
result.status === 401 || result.status === 400
? 'Server přihlášení nepřijal. Ověřte jméno a heslo a to, že účet na tomhle ' +
'serveru existuje a smí zakládat zařízení.'
'serveru existuje a smí zakládat zařízení.'
: `Přihlášení na ${url} vrátilo HTTP ${result.status}.`,
result.status,
result.detail === '' ? null : result.detail,
+12 -4
View File
@@ -21,7 +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';
import { parseBool } from '../runtime/scripts/util.js';
/**
* Vystupy, ktere ma **kazdy** nastroj bez ohledu na schema.
@@ -49,7 +49,9 @@ function propertiesOf(schema: JsonSchema | undefined): Array<[string, JsonSchema
function requiredOf(schema: JsonSchema | undefined): Set<string> {
const required = schema?.required;
return new Set(Array.isArray(required) ? required.filter((item) => typeof item === 'string') : []);
return new Set(
Array.isArray(required) ? required.filter((item) => typeof item === 'string') : [],
);
}
/**
@@ -153,7 +155,10 @@ export function fieldsFromSchema(schema: JsonSchema | undefined): OperationField
return { id: name, label, kind: 'text', required: isRequired, hint };
});
return [...fields.filter((field) => field.required), ...fields.filter((field) => !field.required)];
return [
...fields.filter((field) => field.required),
...fields.filter((field) => !field.required),
];
}
/** Typ pole pro podminky. Seznam je `list`, zbytek se mapuje primo. */
@@ -315,7 +320,10 @@ export function argumentsFrom(
issues.push(`${name}: má to být seznam, tedy [...]`);
continue;
}
if (type === 'object' && (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))) {
if (
type === 'object' &&
(parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
) {
issues.push(`${name}: má to být objekt, tedy {...}`);
continue;
}
+9 -1
View File
@@ -6,7 +6,14 @@
* 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';
import {
Router,
type NextFunction,
type Request,
type RequestHandler,
type Response,
type RouterOptions,
} from 'express';
type AnyHandler = (req: Request, res: Response, next: NextFunction) => unknown;
@@ -38,6 +45,7 @@ export function safeRouter(options?: RouterOptions): Router {
for (const method of METHODS) {
const original = target[method];
if (original === undefined) throw new Error(`Router nema metodu ${method}, nejde ji obalit.`);
target[method] = (...args: unknown[]) => original.apply(router, args.map(wrapArgument));
}
+3 -1
View File
@@ -62,7 +62,9 @@ export function requireAuth(req: Request, res: Response, next: NextFunction) {
return next();
} catch (err) {
console.warn('[auth] neplatny token:', err instanceof Error ? err.message : err);
return res.status(401).json({ error: 'unauthorized', message: 'Neplatný nebo expirovaný token.' });
return res
.status(401)
.json({ error: 'unauthorized', message: 'Neplatný nebo expirovaný token.' });
}
}
+3 -1
View File
@@ -42,7 +42,9 @@ export function rateLimit(options: RateLimitOptions): RequestHandler {
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);
// Nejstarsi pokus v okne. Bez pokusu (max = 0) plati cele okno.
const oldest = stamps[0] ?? now;
const retryAfterSec = Math.ceil((oldest + 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({
+6 -2
View File
@@ -75,7 +75,9 @@ export function tenantOrDeny(req: Request, res: Response): string | 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.' });
res
.status(404)
.json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
return null;
}
return tenantId;
@@ -97,7 +99,9 @@ export function optionalTenantOrDeny(
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.' });
res
.status(404)
.json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
return null;
}
return { tenantId };
+5 -2
View File
@@ -68,10 +68,13 @@ export interface FetchErrorInfo {
*/
export function describeFetchError(err: unknown, where: string): FetchErrorInfo {
const code =
err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).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 cause =
err instanceof Error && err.cause instanceof Error ? `\nPříčina: ${err.cause.message}` : '';
const timedOut = name === 'AbortError' || name === 'TimeoutError';
return {
-3370
View File
File diff suppressed because it is too large Load Diff
+612
View File
@@ -0,0 +1,612 @@
/** Schemata a zabezpeceni. Jen popis tvaru dat, zadna logika. */
export const components = {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Token z POST /api/auth/login. Vlozte samotny token bez slova Bearer.',
},
},
schemas: {
AresCompany: {
type: 'object',
properties: {
ico: { type: 'string', example: '27074358' },
name: { type: 'string', example: 'Asseco Central Europe, a.s.' },
dic: { type: 'string', nullable: true, example: 'CZ27074358' },
address: { type: 'string', example: 'Budejovicka 778/3a, Michle, 14000 Praha 4' },
legalFormCode: { type: 'string', example: '121' },
legalForm: { type: 'string', example: 'Akciova spolecnost' },
existingTenantId: {
type: 'string',
nullable: true,
description: 'ID firmy v portalu, kdyz uz je zalozena.',
},
},
},
Tenant: {
type: 'object',
properties: {
id: { type: 'string', example: 'tnt_automia' },
name: { type: 'string' },
note: { type: 'string' },
enabled: { type: 'boolean' },
helpdeskProviderId: { type: 'string', nullable: true },
ico: { type: 'string', nullable: true },
dic: { type: 'string', nullable: true },
address: { type: 'string', nullable: true },
legalForm: { type: 'string', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
Error: {
type: 'object',
properties: {
error: { type: 'string', example: 'validation_error' },
message: { type: 'string', example: 'Zadejte platny e-mail.' },
issues: {
type: 'array',
description:
'Jen u validation_error: vsechny problemy vstupu. `field` je cesta ' +
'k poli spojena teckou, prazdna u chyby celeho tela.',
items: {
type: 'object',
properties: {
field: { type: 'string', example: 'memberships.0.roleIds' },
message: { type: 'string' },
},
},
},
},
},
User: {
type: 'object',
description:
'Uzivatel muze patrit do vic firem. Role je vzdy az uvnitr firmy, ' +
'pristup napric firmami je zvlast jako platformAdmin.',
properties: {
id: { type: 'string', example: 'usr_1' },
email: { type: 'string', example: 'admin@automia.cz' },
name: { type: 'string', example: 'Jiri Uhlir' },
platformAdmin: {
type: 'boolean',
description: 'Vidi napric vsemi firmami a muze mezi nimi prepinat.',
},
memberships: {
type: 'array',
items: {
type: 'object',
properties: {
tenantId: { type: 'string', example: 'tnt_automia' },
role: { type: 'string', enum: ['admin', 'agent'] },
},
},
},
},
},
Access: {
type: 'object',
description: 'Co uzivatel smi. Klient podle toho kresli prepinac pohledu.',
properties: {
scopes: {
type: 'array',
items: { type: 'string', enum: ['all', 'tenant', 'mine'] },
},
tenants: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'tnt_automia' },
name: { type: 'string', example: 'Automia' },
},
},
},
defaultTenantId: { type: 'string', nullable: true },
canAssignOthers: {
type: 'boolean',
description: 'Smi prehazovat tickety mezi lidmi, ne jen brat na sebe.',
},
personId: { type: 'string', nullable: true },
permissions: {
type: 'array',
items: { type: 'string' },
description: 'Efektivni prava ve vybrane firme. Klient podle nich kresli tlacitka.',
},
roleNames: {
type: 'array',
items: { type: 'string' },
example: ['Spravce firmy'],
description:
'Nazvy roli uzivatele ve vybrane firme. Tohle se ukazuje jako popis uctu, ' +
'ne odhad z poctu prav.',
},
nav: { type: 'array', items: { type: 'object' }, description: 'Zalozky, ktere ma videt.' },
platformAdmin: { type: 'boolean' },
seesOthers: { type: 'boolean', description: 'Vidi i cizi tickety, ne jen svoje.' },
visibleGroups: {
type: 'array',
items: {
type: 'object',
properties: { id: { type: 'string' }, name: { type: 'string' } },
},
},
},
},
Connector: {
type: 'object',
description:
'Napojeni firmy na jednu sluzbu. Hodnoty pristupovych udaju tady zamerne ' +
'nejsou a nikdy nebudou - secrets se z beznych endpointu nevraci.',
properties: {
id: { type: 'string', example: 'con_1a2b3c4d' },
tenantId: { type: 'string', example: 'tnt_automia' },
serviceId: { type: 'string', example: 'idoklad' },
name: { type: 'string', example: 'iDoklad Automia' },
baseUrl: { type: 'string', nullable: true },
enabled: { type: 'boolean' },
status: { type: 'string', enum: ['untested', 'ok', 'error'] },
lastCheckAt: { type: 'string', format: 'date-time', nullable: true },
lastError: { type: 'string', nullable: true },
checkCount: {
type: 'integer',
description:
'Kolik zaznamu o overeni je v historii. Samotna historie se cte pres ' +
'/api/dashboard/connectors/{id}/checks - v seznamu by to byla tela odpovedi navic.',
},
isDefault: {
type: 'boolean',
description: 'Krok stromu bez vybraneho konektoru pouzije tenhle.',
},
filled: {
type: 'array',
items: { type: 'string' },
description: 'ID poli, ktera jsou vyplnena. Hodnoty se nevraci.',
},
missing: {
type: 'array',
items: { type: 'string' },
description: 'ID povinnych poli, ktera chybi.',
},
config: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Necitliva nastaveni. Tajna pole tu nejsou vubec.',
},
ready: { type: 'boolean' },
},
},
ScriptField: {
type: 'object',
description:
'Parametr skriptu. Stejny tvar pro vstup i vystup - kontrola je pak ' +
'jedna funkce, ne dve skoro stejne.',
required: ['id', 'label', 'type', 'required'],
properties: {
id: {
type: 'string',
example: 'invoiceId',
description: 'Pouziva se v sablonach jako {{invoiceId}}.',
},
label: { type: 'string', example: 'ID faktury v iDokladu' },
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
required: { type: 'boolean' },
hint: { type: 'string' },
options: {
type: 'array',
description: 'Vyber z hodnot. Jina hodnota neprojde kontrolou.',
items: {
type: 'object',
properties: { value: { type: 'string' }, label: { type: 'string' } },
},
},
pattern: { type: 'string', description: 'Jen u typu string.' },
multiline: { type: 'boolean', description: 'Jen u typu string.' },
default: { description: 'Dosadi se, kdyz hodnota chybi a parametr neni povinny.' },
},
},
ScriptManifest: {
type: 'object',
description: 'Co skript umi. Podle nej s nim umi pracovat strom automatizace.',
properties: {
id: {
type: 'string',
example: 'idoklad.get-issued-invoice',
description: 'Tvar sluzba.operace. Nazev souboru musi byt <id>.js.',
},
serviceId: { type: 'string', example: 'idoklad' },
serviceName: { type: 'string', example: 'iDoklad' },
operationId: { type: 'string', example: 'get-issued-invoice' },
name: { type: 'string', example: 'Získat vydanou fakturu' },
description: { type: 'string' },
inputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
outputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
timeoutMs: { type: 'integer', example: 15000 },
},
},
ScriptProblem: {
type: 'object',
description: 'Rozbity skript. Nesmi shodit ostatni ani tise zmizet, proto se vraci sem.',
properties: {
file: { type: 'string', example: 'idoklad.get-issued-invoice.js' },
scriptId: { type: 'string', nullable: true },
message: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
ConnectionStatus: {
type: 'object',
description:
'Stav napojeni konektoru. Hodnoty pristupovych udaju se nevraci nikdy, ' +
'jen jmena promennych, ktere chybi.',
properties: {
connectorId: { type: 'string', example: 'idoklad' },
baseUrl: { type: 'string', example: 'https://services.csbot.cz/apps/idoklad' },
ready: { type: 'boolean' },
missing: {
type: 'array',
items: { type: 'string' },
example: ['IDOKLAD_CLIENT_SECRET'],
},
headers: { type: 'array', items: { type: 'string' }, example: ['X-ClientId'] },
},
},
ScriptRunResult: {
type: 'object',
description:
'Vysledek behu skriptu. `retryable` rika, jestli ma smysl zkusit to znovu - ' +
'timeout ano, spatny vstup ne.',
properties: {
ok: { type: 'boolean' },
scriptId: { type: 'string' },
outputs: {
type: 'object',
additionalProperties: true,
description: 'Prazdne, kdyz beh selhal.',
},
logs: {
type: 'array',
items: {
type: 'object',
properties: {
at: { type: 'string', format: 'date-time' },
message: { type: 'string' },
detail: { type: 'string' },
},
},
},
durationMs: { type: 'integer' },
httpCalls: { type: 'integer' },
error: {
type: 'object',
nullable: true,
properties: {
kind: {
type: 'string',
enum: [
'not_found',
'config',
'validation',
'output',
'retryable',
'terminal',
'timeout',
'internal',
],
},
message: { type: 'string' },
retryable: { type: 'boolean' },
status: { type: 'integer' },
detail: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
},
},
LoginRequest: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email', example: 'admin@automia.cz' },
password: { type: 'string', format: 'password', example: 'demo1234' },
},
},
LoginResponse: {
type: 'object',
properties: {
token: { type: 'string' },
user: { $ref: '#/components/schemas/User' },
},
},
Person: {
type: 'object',
description:
'Resitel ticketu = clen firmy. Neni to vlastni zaznam: `id` je ID uctu, `tenantId` ' +
'firma clenstvi. Jmeno a e-mail jsou z uctu, role, kapacita a externi ID z clenstvi.',
properties: {
id: { type: 'string', example: 'usr_2', description: 'ID uctu.' },
tenantId: { type: 'string', example: 'tnt_automia' },
name: { type: 'string', example: 'Karel Vomacka' },
email: { type: 'string', format: 'email' },
role: { type: 'string', example: 'Servicedesk', description: 'Popisek, nic nerozhoduje.' },
capacity: {
type: 'integer',
description: 'Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez.',
},
enabled: {
type: 'boolean',
description:
'Zapnute clenstvi v teto firme. Vypnuty se nenabizi k prirazeni, ucet jinde bezi dal.',
},
externalIds: { type: 'array', items: { type: 'string' } },
roleIds: {
type: 'array',
items: { type: 'string' },
description: 'Role clenstvi v teto firme.',
},
},
},
TicketCustomer: {
type: 'object',
properties: {
id: {
type: 'string',
nullable: true,
description: 'ID firmy v CRM. null = zakaznika se nepodarilo dohledat.',
example: 'crm_1042',
},
company: { type: 'string', example: 'Firma s.r.o.' },
contact: { type: 'string', example: 'Petra Klientova' },
reply: {
type: 'string',
description: 'Adresa nebo cislo, odkud pozadavek prisel a kam se odpovida.',
},
},
},
Ticket: {
type: 'object',
properties: {
id: { type: 'string', example: 'TK-4821' },
subject: { type: 'string' },
body: {
type: 'string',
description:
'Cely text pozadavku. Prazdny retezec = krok "Zalozit ticket" obsah nenaplnil.',
},
sourceRef: {
type: 'string',
nullable: true,
description: 'Odkaz na zdrojovou zpravu u poskytovatele.',
example: 'wamid.HBgLNDIwNzc0OTAyMzMx',
},
channel: {
type: 'string',
enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'],
description: 'Odkud pozadavek prisel.',
},
customer: { $ref: '#/components/schemas/TicketCustomer' },
status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] },
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
assignee: {
type: 'object',
nullable: true,
description: 'Kdo ma ticket u sebe. null = ceka ve fronte.',
properties: {
id: { type: 'string', example: 'usr_2', description: 'ID uctu resitele.' },
name: { type: 'string', example: 'Karel Vomacka' },
},
},
automationId: {
type: 'string',
nullable: true,
description: 'Automatizace, ktera ticket zalozila. null = zalozeno rucne.',
},
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
TicketTraceEntry: {
type: 'object',
description:
'Jeden radek logu ticketu. Strom se sklada pres parentId - vetev podminky ' +
'visi na zaznamu te podminky.',
properties: {
id: { type: 'string', example: 'tr_12' },
parentId: {
type: 'string',
nullable: true,
description: 'null = zaznam v hlavni sekvenci.',
},
kind: { type: 'string', enum: ['trigger', 'action', 'condition', 'note'] },
connectorId: { type: 'string', nullable: true, example: 'raynet' },
operationId: { type: 'string', nullable: true, example: 'upsert-contact' },
label: { type: 'string' },
status: { type: 'string', enum: ['ok', 'error', 'skipped', 'info'] },
response: {
type: 'string',
nullable: true,
description: 'Co sluzba vratila. Kvuli tomuhle log existuje.',
},
durationMs: { type: 'integer', nullable: true },
at: { type: 'string', format: 'date-time' },
},
},
TicketDetail: {
allOf: [
{ $ref: '#/components/schemas/Ticket' },
{
type: 'object',
properties: {
trace: {
type: 'array',
items: { $ref: '#/components/schemas/TicketTraceEntry' },
},
},
},
],
},
Workload: {
type: 'object',
description: 'Prehled nad firmou - kdo ma kolik ticketu u sebe.',
properties: {
rows: {
type: 'array',
items: {
type: 'object',
properties: {
person: { $ref: '#/components/schemas/Person' },
open: { type: 'integer', description: 'Nevyresene tickety.' },
total: { type: 'integer' },
critical: { type: 'integer' },
oldestOpenAt: { type: 'string', format: 'date-time', nullable: true },
overloaded: { type: 'boolean' },
},
},
},
unassigned: { type: 'integer', description: 'Nevyresene tickety bez resitele.' },
openTotal: { type: 'integer' },
},
},
Incident: {
type: 'object',
properties: {
id: { type: 'string', example: 'INC-231' },
title: { type: 'string' },
service: { type: 'string' },
severity: { type: 'string', enum: ['sev1', 'sev2', 'sev3'] },
status: {
type: 'string',
enum: ['investigating', 'identified', 'monitoring', 'resolved'],
},
startedAt: { type: 'string', format: 'date-time' },
resolvedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
TriggerField: {
type: 'object',
required: ['id', 'name', 'type', 'required'],
properties: {
id: { type: 'string', example: 'f_42' },
name: {
type: 'string',
example: 'score',
description: 'Klic v prichozich datech, pismena, cislice a podtrzitko.',
},
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
required: { type: 'boolean' },
},
},
FlowStep: {
type: 'object',
description: 'Krok stromu. Bud akce nad konektorem, nebo podminka se dvema vetvemi.',
properties: {
id: { type: 'string' },
kind: { type: 'string', enum: ['action', 'condition'] },
connectorId: { type: 'string', example: 'email' },
operationId: { type: 'string', example: 'send' },
inputs: {
type: 'object',
additionalProperties: { type: 'string' },
description:
'Nastaveni akce. Klic je ID pole z katalogu, hodnota je sablona - ' +
'{{nazev}} se nahradi parametrem spoustece. Neznamy klic vraci 400.',
example: { subject: 'Reklamace od {{profileName}}', body: '{{text}}' },
},
fieldId: { type: 'string', example: 'f_42' },
operator: {
type: 'string',
enum: [
'eq',
'neq',
'gt',
'gte',
'lt',
'lte',
'contains',
'startsWith',
'isEmpty',
'isNotEmpty',
'isTrue',
'isFalse',
],
},
value: { type: 'string', example: '15' },
yes: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
no: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
},
},
AutomationFlow: {
type: 'object',
properties: {
trigger: {
type: 'object',
nullable: true,
properties: {
connectorId: { type: 'string', example: 'webhook' },
operationId: { type: 'string', example: 'received' },
fields: {
type: 'array',
items: { $ref: '#/components/schemas/TriggerField' },
},
webhookToken: {
type: 'string',
readOnly: true,
description: 'Generuje vyhradne server, hodnota od klienta se ignoruje.',
},
},
},
steps: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
},
},
Automation: {
type: 'object',
properties: {
id: { type: 'string', example: 'AUT-01' },
name: { type: 'string' },
kind: { type: 'string', enum: ['workflow', 'voicebot', 'integrace', 'report'] },
enabled: { type: 'boolean' },
runsToday: { type: 'integer' },
runsYesterday: { type: 'integer' },
runsTotal: { type: 'integer' },
successRate: { type: 'number' },
avgDurationMs: { type: 'integer' },
lastRunAt: { type: 'string', format: 'date-time' },
stepCount: { type: 'integer' },
configured: { type: 'boolean' },
issues: {
type: 'array',
items: { type: 'string' },
description: 'Co chybi k zapnuti. Prazdne pole znamena hotovo.',
},
},
},
AutomationDetail: {
allOf: [
{ $ref: '#/components/schemas/Automation' },
{
type: 'object',
properties: {
flow: { $ref: '#/components/schemas/AutomationFlow' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
],
},
},
};
+140
View File
@@ -0,0 +1,140 @@
/**
* Sdilene kousky popisu: fabrika na CRUD petici a opakujici se parametry,
* tela a odpovedi. Jedno misto, aby se popisy v paths/** nerozesly.
*/
/**
* Sprava zaznamu ma u kazde entity stejnou petici endpointu, protoze ji na
* serveru dela jedna fabrika (`routes/crud.ts`). Popisovat ji devetkrat rucne
* by znamenalo devet mist, ktere se casem rozejdou.
*/
export function crudPaths(entity: {
/** Cast cesty, napr. `roles`. */
path: string;
/** Jak se o tom mluvi v popisu, napr. `roli`. */
label: string;
/** Pravo, ktere je na zapis potreba. */
permission: string;
}) {
const id = { name: 'id', in: 'path', required: true, schema: { type: 'string' } };
const body = {
required: true,
content: { 'application/json': { schema: { type: 'object' } } },
};
const record = {
description: 'Zaznam',
content: { 'application/json': { schema: { type: 'object' } } },
};
const denied = { '403': { description: `Chybi pravo ${entity.permission}` } };
const base = `/api/dashboard/settings/${entity.path}`;
return {
[base]: {
get: {
tags: ['Nastaveni'],
summary: `Seznam - ${entity.label}`,
description: 'Vraci jen zaznamy firem, do kterych volajici patri.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Seznam',
content: {
'application/json': {
schema: {
type: 'object',
properties: { items: { type: 'array', items: { type: 'object' } } },
},
},
},
},
...denied,
},
},
post: {
tags: ['Nastaveni'],
summary: `Vytvorit - ${entity.label}`,
security: [{ bearerAuth: [] }],
requestBody: body,
responses: { '201': record, '400': { description: 'Neplatny vstup' }, ...denied },
},
},
[`${base}/{id}`]: {
get: {
tags: ['Nastaveni'],
summary: `Detail - ${entity.label}`,
security: [{ bearerAuth: [] }],
parameters: [id],
responses: { '200': record, '404': { description: 'Neexistuje' }, ...denied },
},
patch: {
tags: ['Nastaveni'],
summary: `Upravit - ${entity.label}`,
description: 'Posilaji se jen menena pole. ID a cas vzniku se prepsat nedaji.',
security: [{ bearerAuth: [] }],
parameters: [id],
requestBody: body,
responses: {
'200': record,
'400': { description: 'Neplatny vstup' },
'404': { description: 'Neexistuje' },
...denied,
},
},
delete: {
tags: ['Nastaveni'],
summary: `Smazat - ${entity.label}`,
security: [{ bearerAuth: [] }],
parameters: [id],
responses: {
'204': { description: 'Smazano' },
'404': { description: 'Neexistuje' },
...denied,
},
},
},
};
}
export const bearer = [{ bearerAuth: [] }];
export const idParam = { name: 'id', in: 'path', required: true, schema: { type: 'string' } };
export const tenantParam = {
name: 'tenantId',
in: 'query',
schema: { type: 'string' },
description: 'Firma. Bez ni prvni, do ktere volajici patri. Cizi firma vraci 404.',
};
/** Strankovani. Odpoved zustava seznam, pocet pred orezem je v hlavicce X-Total-Count. */
export const pagingParams = [
{
name: 'limit',
in: 'query',
schema: { type: 'integer', minimum: 1, maximum: 500 },
description: 'Kolik polozek nejvys. Bez hodnoty vsechny (u behu poslednich 50).',
},
{
name: 'offset',
in: 'query',
schema: { type: 'integer', minimum: 0, default: 0 },
description: 'Kolik polozek preskocit.',
},
];
export const totalCountHeader = {
'X-Total-Count': {
schema: { type: 'integer' },
description: 'Pocet polozek pred strankovanim.',
},
};
export const jsonBody = (schema: Record<string, unknown>, required = true) => ({
required,
content: { 'application/json': { schema } },
});
export const jsonResponse = (
description: string,
schema: Record<string, unknown> = { type: 'object' },
) => ({
description,
content: { 'application/json': { schema } },
});
export const tooMany = {
'429': { description: 'Prilis mnoho pokusu z jedne adresy, viz Retry-After' },
};
+72
View File
@@ -0,0 +1,72 @@
import { config } from '../config.js';
import { components } from './components.js';
import { opsPaths } from './paths/ops.js';
import { authPaths } from './paths/auth.js';
import { dashboardPaths } from './paths/dashboard.js';
import { ticketsPaths } from './paths/tickets.js';
import { automationsPaths } from './paths/automations.js';
import { settingsPaths } from './paths/settings.js';
import { adminPaths } from './paths/admin.js';
import { connectorsPaths } from './paths/connectors.js';
import { scriptsPaths } from './paths/scripts.js';
import { webhookPaths } from './paths/webhook.js';
import { helpdeskPaths } from './paths/helpdesk.js';
import { invitesPaths } from './paths/invites.js';
import { contactPaths } from './paths/contact.js';
/**
* OpenAPI popis API.
*
* `servers` MUSI obsahovat prefix reverse proxy, jinak Swagger "Try it out"
* vola endpointy na root domene a dostane 404 (viz AGENTS.md).
* Prefix se bere z ROOT_PATH, nikdy se nehardcoduje.
*/
export function buildOpenApiDocument() {
const server = config.rootPath === '' ? '/' : config.rootPath;
return {
openapi: '3.0.3',
info: {
title: `${config.brandName} - portal a API`,
version: '1.0.0',
description:
'Webova prezentace a klientsky portal. Automatizace, voiceboti, integrace, ' +
'tickety a incidenty. Aplikace bezi za reverse proxy AppFactory.',
},
servers: [{ url: server, description: 'Verejna adresa vcetne prefixu proxy' }],
tags: [
{ name: 'Provoz', description: 'Health a zakladni informace' },
{ name: 'Autentizace', description: 'Prihlaseni do portalu' },
{ name: 'Dashboard', description: 'Data klientskeho portalu' },
{ name: 'Tickety', description: 'Pozadavky, jejich resitele a log prubehu' },
{ name: 'Automatizace', description: 'Sprava automatizaci a stromu akci' },
{ name: 'Sluzby', description: 'Katalog toho, co umime napojit' },
{ name: 'Konektory', description: 'Napojeni firmy na sluzbu vcetne pristupovych udaju' },
{ name: 'Skripty', description: 'Vykonna cast sluzby: manifest, kod a zkusebni beh' },
{ name: 'Nastaveni', description: 'Firmy, lide, role a prava, typy ticketu, akce, widgety' },
{ name: 'Sprava platformy', description: 'Audit a prepnuti na jiny ucet' },
{ name: 'Webhook', description: 'Verejny prijem dat do automatizace a do ticketu' },
{ name: 'Helpdesk', description: 'Pozadavky, ktere firma posila svemu dodavateli' },
{ name: 'Pozvanky', description: 'Pozvanky do firmy a jejich prijeti' },
{ name: 'Portal', description: 'Pomocne endpointy klienta' },
{ name: 'Kontakt', description: 'Poptavkovy formular z webu' },
],
components,
paths: {
// Poradi urcuje, jak jdou endpointy za sebou v Swagger UI.
...opsPaths,
...authPaths,
...dashboardPaths,
...ticketsPaths,
...automationsPaths,
...settingsPaths,
...adminPaths,
...connectorsPaths,
...scriptsPaths,
...webhookPaths,
...helpdeskPaths,
...invitesPaths,
...contactPaths,
},
};
}
+87
View File
@@ -0,0 +1,87 @@
/** Sprava platformy: audit a prepnuti na jiny ucet. */
export const adminPaths: Record<string, unknown> = {
'/api/admin/impersonate': {
post: {
tags: ['Sprava platformy'],
summary: 'Prepnout se na jiny ucet',
description:
'Vraci novy token s narokem `act`. Bez `writes` projde **jen GET**, cokoliv ' +
'jineho vrati 403. Prepnuti i jeho ukonceni je v auditu.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['userId'],
properties: {
userId: { type: 'string' },
allowWrites: {
type: 'boolean',
default: false,
description: 'true = i zapis. Musi se zapnout vedome.',
},
},
},
},
},
},
responses: {
'200': {
description: 'Token na cizi ucet',
content: { 'application/json': { schema: { type: 'object' } } },
},
'403': { description: 'Neni spravce platformy' },
'404': { description: 'Ucet neexistuje' },
},
},
},
'/api/admin/impersonate/stop': {
post: {
tags: ['Sprava platformy'],
summary: 'Ukoncit prepnuti',
description: 'Jen zaznam do auditu. Svuj puvodni token si drzi klient, server o nem nevi.',
security: [{ bearerAuth: [] }],
responses: { '204': { description: 'Zapsano' } },
},
},
'/api/admin/impersonate/candidates': {
get: {
tags: ['Sprava platformy'],
summary: 'Koho lze prepnout',
description: 'Spravci platformy se nenabizeji.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Ucty',
content: { 'application/json': { schema: { type: 'object' } } },
},
'403': { description: 'Neni spravce platformy' },
},
},
},
'/api/admin/audit': {
get: {
tags: ['Sprava platformy'],
summary: 'Audit',
description:
'Kdo co udelal, vcetne odepreni a vcetne toho, kdo se za koho vydaval. ' +
'Nejnovejsi nahore.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'action', in: 'query', schema: { type: 'string' } },
{ name: 'result', in: 'query', schema: { type: 'string', enum: ['ok', 'denied'] } },
{ name: 'limit', in: 'query', schema: { type: 'integer', maximum: 500, default: 200 } },
],
responses: {
'200': {
description: 'Zaznamy',
content: { 'application/json': { schema: { type: 'object' } } },
},
'403': { description: 'Neni spravce platformy' },
},
},
},
};
+60
View File
@@ -0,0 +1,60 @@
/** Autentizace: prihlaseni a token. */
import { tooMany } from '../helpers.js';
export const authPaths: Record<string, unknown> = {
'/api/auth/login': {
post: {
tags: ['Autentizace'],
summary: 'Prihlaseni',
requestBody: {
required: true,
content: {
'application/json': { schema: { $ref: '#/components/schemas/LoginRequest' } },
},
},
responses: {
'200': {
description: 'Token a udaje uzivatele',
content: {
'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } },
},
},
'401': {
description: 'Nespravny e-mail nebo heslo',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
...tooMany,
},
},
},
'/api/auth/me': {
get: {
tags: ['Autentizace'],
summary: 'Prihlaseny uzivatel',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Udaje uzivatele',
content: {
'application/json': {
schema: {
type: 'object',
properties: { user: { $ref: '#/components/schemas/User' } },
},
},
},
},
'401': { description: 'Chybi nebo neplatny token' },
},
},
},
'/api/auth/logout': {
post: {
tags: ['Autentizace'],
summary: 'Odhlaseni',
security: [{ bearerAuth: [] }],
responses: { '204': { description: 'Odhlaseno' } },
},
},
};
+177
View File
@@ -0,0 +1,177 @@
/** Automatizace: strom akci, webhook a fronta behu. */
import { tenantParam, pagingParams, totalCountHeader } from '../helpers.js';
export const automationsPaths: Record<string, unknown> = {
'/api/dashboard/automations': {
get: {
tags: ['Automatizace'],
summary: 'Seznam automatizaci',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Automatizace',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/Automation' } },
},
},
},
},
},
},
},
post: {
tags: ['Automatizace'],
summary: 'Zalozit automatizaci',
description: 'Do prave prepnute firmy. Chce pravo automation.edit.',
security: [{ bearerAuth: [] }],
parameters: [tenantParam],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['name'],
properties: { name: { type: 'string', minLength: 3 } },
},
},
},
},
responses: {
'201': {
description: 'Vytvoreno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } },
},
},
'400': { description: 'Neplatny nazev' },
'403': { description: 'Chybi pravo automation.edit' },
},
},
},
'/api/dashboard/automations/{id}': {
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' }, example: 'AUT-01' },
],
get: {
tags: ['Automatizace'],
summary: 'Detail vcetne stromu akci',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Detail',
content: {
'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } },
},
},
'404': { description: 'Neexistuje' },
},
},
put: {
tags: ['Automatizace'],
summary: 'Ulozit automatizaci',
description:
'Validuje strom proti katalogu konektoru. Nedokoncenou automatizaci server ' +
'nezapne ani pri enabled=true, duvody vraci v poli issues.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
name: { type: 'string', minLength: 3 },
enabled: { type: 'boolean' },
flow: { $ref: '#/components/schemas/AutomationFlow' },
},
},
},
},
},
responses: {
'200': {
description: 'Ulozeno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } },
},
},
'400': { description: 'Neplatny strom' },
'403': { description: 'Chybi pravo automation.edit' },
'404': { description: 'Neexistuje' },
},
},
delete: {
tags: ['Automatizace'],
summary: 'Smazat automatizaci',
security: [{ bearerAuth: [] }],
responses: {
'204': { description: 'Smazano' },
'403': { description: 'Chybi pravo automation.edit' },
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/automations/{id}/webhook/regenerate': {
post: {
tags: ['Automatizace'],
summary: 'Nova adresa webhooku',
description: 'Stara adresa okamzite prestane fungovat. Chce pravo automation.edit.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Novy token',
content: {
'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } },
},
},
'403': { description: 'Chybi pravo automation.edit' },
'404': { description: 'Neexistuje nebo spoustecem neni webhook' },
},
},
},
'/api/dashboard/runs': {
get: {
tags: ['Automatizace'],
summary: 'Stav fronty behu',
description:
'Kdyz neco nefunguje, tohle je prvni misto, kam se clovek podiva: ceka fronta, ' +
'nebo uz to nekolikrat selhalo? U kazdeho behu je cele chybove hlaseni. ' +
'Bez `limit` poslednich 50.',
security: [{ bearerAuth: [] }],
parameters: [tenantParam, ...pagingParams],
responses: {
'200': {
description: 'Fronta a posledni behy',
headers: totalCountHeader,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
stats: {
type: 'object',
properties: {
pending: { type: 'integer' },
running: { type: 'integer' },
done: { type: 'integer' },
failed: { type: 'integer' },
oldestPendingAt: { type: 'string', nullable: true },
},
},
items: { type: 'array', items: { type: 'object' } },
},
},
},
},
},
},
},
},
};
+396
View File
@@ -0,0 +1,396 @@
/** Sluzby a konektory: katalog, napojeni firmy, testy a MCP. */
import { tenantParam } from '../helpers.js';
export const connectorsPaths: Record<string, unknown> = {
'/api/dashboard/services': {
get: {
tags: ['Sluzby'],
summary: 'Katalog sluzeb pro builder',
description:
'Vraci jen sluzby, ktere uzivatel vidi. Neviditelna sluzba v odpovedi neni ' +
'vubec, ne se stavem "nemate pravo". Operace, ktere obsluhuje skript, nesou ' +
'implementation: script a maji skutecne inputs a outputFields.',
security: [{ bearerAuth: [] }],
parameters: [tenantParam],
responses: {
'200': { description: 'Sluzby, kategorie a operatory podminek' },
'404': { description: 'Firma neexistuje, nebo do ni volajici nepatri' },
},
},
},
'/api/dashboard/connectors/services': {
get: {
tags: ['Sluzby'],
summary: 'Katalog sluzeb ocima firmy',
description:
'Jako /services, navic connectorCount, tedy kolik konektoru na sluzbu firma ma. ' +
'Podle toho se rozlisi napojeno od muzete si napojit. Neni to vlastnost sluzby, ' +
'ale te firmy.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'tenantId', in: 'query', schema: { type: 'string' } }],
responses: { '200': { description: 'Sluzby vcetne pouziti ve firme' } },
},
},
'/api/dashboard/connectors': {
get: {
tags: ['Konektory'],
summary: 'Konektory firmy',
description:
'Hodnoty pristupovych udaju se NIKDY nevraci, jen filled (co je vyplnene) ' +
'a missing (ktera povinna pole chybi).',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'tenantId', in: 'query', schema: { type: 'string' } },
{ name: 'serviceId', in: 'query', schema: { type: 'string' } },
],
responses: {
'200': {
description: 'Konektory firmy',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: {
type: 'array',
items: { $ref: '#/components/schemas/Connector' },
},
tenantId: { type: 'string' },
},
},
},
},
},
},
},
post: {
tags: ['Konektory'],
summary: 'Zalozit konektor',
description:
'Pristupove udaje se posilaji ve values s klici podle Service.credentials. ' +
'Nevyplnene povinne pole neni chyba, konektor se ulozi a jen nepujde pouzit.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['serviceId', 'name'],
properties: {
serviceId: { type: 'string', example: 'idoklad' },
name: { type: 'string', example: 'iDoklad Celo' },
baseUrl: { type: 'string', nullable: true },
values: {
type: 'object',
additionalProperties: { type: 'string' },
},
},
},
},
},
},
responses: {
'201': {
description: 'Zalozeno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Connector' } },
},
},
'400': { description: 'Obecna sluzba konektor nepotrebuje, nebo nezname pole' },
'403': { description: 'Chybi pravo connector.manage' },
'404': { description: 'Sluzba neexistuje nebo ji uzivatel nevidi' },
},
},
},
'/api/dashboard/connectors/{id}': {
get: {
tags: ['Konektory'],
summary: 'Detail konektoru',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: { '200': { description: 'Konektor' }, '404': { description: 'Neexistuje' } },
},
patch: {
tags: ['Konektory'],
summary: 'Upravit konektor',
description:
'Ve values staci poslat jen to, co se meni. PRAZDNY RETEZEC hodnotu smaze, ' +
'chybejici klic ji nechava - diky tomu jde ulozit formular, ktery tajne hodnoty ' +
'neposila. Zmena udaju vzdy zrusi predchozi overeni.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
name: { type: 'string' },
baseUrl: { type: 'string', nullable: true },
values: { type: 'object', additionalProperties: { type: 'string' } },
enabled: { type: 'boolean' },
isDefault: { type: 'boolean', enum: [true] },
},
},
},
},
},
responses: {
'200': { description: 'Upraveno' },
'403': { description: 'Chybi pravo connector.manage' },
'404': { description: 'Neexistuje' },
},
},
delete: {
tags: ['Konektory'],
summary: 'Smazat konektor',
description:
'Kdyz zmizel vychozi konektor, prevezme to prvni zbyly - jinak by kroky bez ' +
'vybraneho konektoru prestaly fungovat. Chce pravo connector.manage.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'204': { description: 'Smazano' },
'403': { description: 'Chybi pravo connector.manage' },
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/connectors/egress-ip': {
get: {
tags: ['Konektory'],
summary: 'Odchozi IP adresa portalu',
description:
'Adresa, kterou vidi volana sluzba, tedy ta, ktera musi byt na jejim seznamu ' +
'povolenych IP. Z containeru videt neni, zjistuje se echo sluzbou podle ' +
'EGRESS_IP_URL a vysledek se drzi v pameti po EGRESS_IP_TTL_MS. Neni to ' +
'tajemstvi: kazda volana sluzba tuhle adresu stejne vidi.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Odchozi adresa, nebo duvod, proc se nezjistila',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ip: { type: 'string', nullable: true },
source: { type: 'string' },
checkedAt: { type: 'string', format: 'date-time' },
error: { type: 'string' },
viaProxy: {
type: 'object',
nullable: true,
description:
'Jak nas vidi nase vlastni reverse proxy. Zmeri se volanim na ' +
'vlastni verejnou adresu (PUBLIC_ORIGIN + ROOT_PATH + /whoami), ' +
'ktere se otoci zpatky na tentyz stroj. Byva jina nez ta verejna ' +
'a prave ji porovnava seznam povolenych IP u sluzeb za toutez proxy.',
properties: {
ip: { type: 'string', nullable: true },
forwardedFor: { type: 'string', nullable: true },
remoteAddress: {
type: 'string',
nullable: true,
description:
'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.',
},
suggestedRange: {
type: 'string',
nullable: true,
description:
'CIDR rozsah, ktery tu adresu pokryje cely (napr. 172.16.0.0/12 ' +
'nebo 127.0.0.0/8). Do seznamu povolenych patri on, ne jedna ' +
'adresa: docker prideluje z bloku a pri prekresleni site se cisla ' +
'meni. null = adresa je verejna, zadny blok se nenabizi.',
},
url: { type: 'string' },
error: { type: 'string' },
},
},
},
},
},
},
},
},
},
},
'/api/dashboard/connectors/{id}/test': {
post: {
tags: ['Konektory'],
summary: 'Overit napojeni',
description:
'Zavola verifyPath sluzby, coz je zamerne cteci volani vyzadujici autorizaci. ' +
'Kdyz ho sluzba nema, overi se jen /health a odpoved to v checked rekne - aby ' +
'si nikdo nemyslel, ze jsou overene i pristupove udaje. Neuspesne overeni neni ' +
'chyba API, vraci se 200 s ok: false.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Vysledek overeni',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean' },
checked: { type: 'string' },
status: { type: 'integer' },
message: { type: 'string' },
baseUrl: {
type: 'string',
description:
'Kam konektor miri. Vraci se i pri uspechu - zaklad adresy je ' +
'z konfigurace a konektor ho smi prepsat, takze bez nej nerika ' +
'kod odpovedi nic o tom, jestli se to trefilo na spravny stroj.',
},
},
},
},
},
},
'403': { description: 'Chybi pravo connector.manage' },
'404': { description: 'Konektor neexistuje' },
},
},
},
'/api/dashboard/connectors/{id}/mcp/tools': {
post: {
tags: ['Konektory'],
summary: 'Nacist nastroje MCP serveru',
description:
'Zepta se MCP serveru na tools/list a ulozi vysledek ke konektoru. Plati pro obe ' +
'sluzby MCP (obecnou i EasyWeb) - jsou to jedine sluzby, u kterych seznam operaci ' +
'neurcuje katalog, ale az sam server - teprve tim ' +
'vzniknou kroky, ktere jde davat do automatizaci, vcetne toho, jake promenne ' +
'prijimaji a jake vraceji. Zaroven to je overeni konektoru, proto se zapisuje do ' +
'historie: kdyz server odpovi seznamem, adresa i token sedi. Cteci volani, nic ' +
'nemeni. Prazdny vysledek se ulozi (server uz nastroje nenabizi), chyba nemeni nic ' +
'- vypadek serveru nesmi vymazat kroky z hotovych automatizaci. Neuspech neni ' +
'chyba API, vraci se 200 s ok: false.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Vysledek nacteni',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean' },
checked: { type: 'string' },
message: { type: 'string' },
status: { type: 'integer' },
detail: { type: 'string' },
server: { type: 'string', description: 'Jak se server predstavil.' },
protocolVersion: { type: 'string' },
tools: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
label: { type: 'string' },
description: { type: 'string' },
inputs: {
type: 'array',
items: { type: 'string' },
description: 'Nazvy parametru, povinne s hvezdickou na konci.',
},
outputs: { type: 'array', items: { type: 'string' } },
},
},
},
},
},
},
},
},
'400': { description: 'Sluzba neni MCP server' },
'403': { description: 'Chybi pravo connector.manage' },
'404': { description: 'Konektor neexistuje' },
},
},
},
'/api/dashboard/connectors/{id}/checks': {
get: {
tags: ['Konektory'],
summary: 'Historie overeni konektoru',
description:
'Poslednich pet overeni, nejnovejsi prvni. U neuspechu nese zaznam cele telo ' +
'odpovedi sluzby v poli detail - prave tam sluzba pise, co ji vadilo, a bez ' +
'toho se neda rozlisit spatny udaj od zakazane IP adresy. Texty jsou uz ' +
'zredigovane, pristupovy udaj v nich neni. Historie je zvlast a ne v seznamu ' +
'konektoru proto, ze telo odpovedi byva o rady velikosti vetsi nez zbytek radku.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Zaznamy o overeni',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
limit: { type: 'integer' },
items: {
type: 'array',
items: {
type: 'object',
properties: {
at: { type: 'string', format: 'date-time' },
ok: { type: 'boolean' },
checked: { type: 'string' },
status: { type: 'integer', nullable: true },
message: { type: 'string' },
detail: { type: 'string', nullable: true },
request: {
type: 'object',
nullable: true,
description:
'url je cela adresa vcetne serveru, bez query - v query muze byt tajemstvi.',
properties: {
method: { type: 'string' },
path: { type: 'string' },
url: { type: 'string' },
},
},
responseHeaders: {
type: 'object',
nullable: true,
additionalProperties: { type: 'string' },
description:
'Vybrane hlavicky odpovedi (server, via, content-type, ' +
'www-authenticate, retry-after, x-request-id, date). Rikaji, kdo ' +
'odpoved vydal - aplikace, nebo proxy pred ni. U kodu bez tela ' +
'je to jedina stopa, ktera zbyde. Allowlist, ne vsechno: ' +
'Set-Cookie a podobne do zaznamu nepatri.',
},
egressIp: {
type: 'string',
nullable: true,
description:
'Odchozi IP adresa portalu ve chvili volani. Vyplnena jen ' +
'u odmitnuteho pristupu (401, 403) - tam je to prvni otazka, ' +
'jinde nema co rict.',
},
},
},
},
},
},
},
},
},
'404': { description: 'Konektor neexistuje' },
},
},
},
};
+39
View File
@@ -0,0 +1,39 @@
/** Kontaktni formular z webu. */
import { tooMany } from '../helpers.js';
export const contactPaths: Record<string, unknown> = {
'/api/contact': {
post: {
tags: ['Kontakt'],
summary: 'Odeslat poptavku z webu',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['name', 'email', 'topic', 'message'],
properties: {
name: { type: 'string', minLength: 2 },
email: { type: 'string', format: 'email' },
company: { type: 'string' },
phone: { type: 'string' },
topic: {
type: 'string',
enum: ['automatizace', 'voicebot', 'integrace', 'dashboard', 'podpora', 'jine'],
},
message: { type: 'string', minLength: 10 },
},
},
},
},
},
responses: {
'202': { description: 'Prijato' },
'400': { description: 'Neplatny vstup' },
...tooMany,
},
},
},
};
+355
View File
@@ -0,0 +1,355 @@
/** Dashboard: prehled, rozlozeni, incidenty, upozorneni a hlaseni padu klienta. */
import { idParam, tenantParam, jsonBody, jsonResponse } from '../helpers.js';
export const dashboardPaths: Record<string, unknown> = {
'/api/dashboard/summary': {
get: {
tags: ['Dashboard'],
summary: 'Souhrn pro prehled',
security: [{ bearerAuth: [] }],
responses: { '200': { description: 'Souhrnne metriky a casova rada' } },
},
},
'/api/dashboard/widgets': {
get: {
tags: ['Dashboard'],
summary: 'Katalog widgetu prehledu',
description: 'Co jde polozit na dashboard vcetne povolenych sirek.',
security: [{ bearerAuth: [] }],
responses: { '200': { description: 'Widgety' } },
},
},
'/api/dashboard/layout': {
get: {
tags: ['Dashboard'],
summary: 'Rozlozeni dashboardu',
description:
'Uklada se pro dvojici uzivatel a firma. `custom: false` znamena, ' +
'ze uzivatel kouka na vychozi rozlozeni.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'tenantId',
in: 'query',
schema: { type: 'string' },
description: 'Firma. Bez ni se pouzije prvni, do ktere uzivatel patri.',
},
],
responses: {
'200': { description: 'Rozlozeni' },
'403': { description: 'Ucet nepatri do zadne firmy' },
'404': { description: 'Firma neexistuje, nebo do ni uzivatel nepatri' },
},
},
put: {
tags: ['Dashboard'],
summary: 'Ulozit rozlozeni',
description: 'Overuje se proti katalogu. Neznamy widget nebo sirka vraci 400.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'tenantId', in: 'query', schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['items'],
properties: {
items: {
type: 'array',
items: {
type: 'object',
required: ['id', 'widgetId', 'size'],
properties: {
id: { type: 'string', example: 'w1' },
widgetId: { type: 'string', example: 'stat.openTickets' },
size: { type: 'string', enum: ['third', 'half', 'full'] },
},
},
},
},
},
},
},
},
responses: {
'200': { description: 'Ulozeno' },
'400': { description: 'Neplatne rozlozeni' },
},
},
delete: {
tags: ['Dashboard'],
summary: 'Vratit na vychozi rozlozeni',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'tenantId', in: 'query', schema: { type: 'string' } }],
responses: { '200': { description: 'Vychozi rozlozeni' } },
},
},
'/api/dashboard/access': {
get: {
tags: ['Dashboard'],
summary: 'Co uzivatel smi videt',
description:
'Povolene pohledy, firmy k prepinani, prava a nazvy roli za vybranou firmu. ' +
'Klient si to nesmi dovozovat sam.',
security: [{ bearerAuth: [] }],
parameters: [tenantParam],
responses: {
'200': {
description: 'Opravneni',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Access' } },
},
},
},
},
},
'/api/dashboard/widget-data': {
post: {
tags: ['Dashboard'],
summary: 'Data vlastnich widgetu',
description: 'Jeden request na cely prehled. Deset dlazdic nesmi znamenat deset dotazu.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['widgetIds'],
properties: { widgetIds: { type: 'array', items: { type: 'string' } } },
},
},
},
},
responses: {
'200': {
description: 'Data po widgetech',
content: { 'application/json': { schema: { type: 'object' } } },
},
},
},
},
'/api/dashboard/widget-data/options': {
get: {
tags: ['Dashboard'],
summary: 'Co jde ve vlastnim widgetu nastavit',
description: 'Zdroje dat, mozna seskupeni a sirky. Aby to klient nemel v kodu.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Moznosti',
content: { 'application/json': { schema: { type: 'object' } } },
},
},
},
},
'/api/dashboard/incidents': {
get: {
tags: ['Dashboard'],
summary: 'Seznam incidentu',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Incidenty',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/Incident' } },
},
},
},
},
},
},
},
},
'/api/dashboard/incidents/{id}': {
get: {
tags: ['Dashboard'],
summary: 'Detail incidentu',
description:
'Incident firmy nebo platformni. `detail` (diagnostika) dostane jen spravce platformy, ' +
'ostatni maji null. Cizi incident je 404.',
security: [{ bearerAuth: [] }],
parameters: [idParam, tenantParam],
responses: {
'200': jsonResponse('Incident', { $ref: '#/components/schemas/Incident' }),
'404': { description: 'Incident neexistuje nebo patri jine firme' },
},
},
},
'/api/dashboard/incidents/{id}/status': {
patch: {
tags: ['Dashboard'],
summary: 'Posunout incident do dalsiho stavu',
description:
'Pravo incident.manage za firmu incidentu; platformni incident (bez firmy) meni jen ' +
'spravce platformy. Stav resolved nastavi resolvedAt.',
security: [{ bearerAuth: [] }],
parameters: [idParam, tenantParam],
requestBody: jsonBody({
type: 'object',
required: ['status'],
properties: {
status: {
type: 'string',
enum: ['investigating', 'identified', 'monitoring', 'resolved'],
},
},
}),
responses: {
'200': jsonResponse('Incident', { $ref: '#/components/schemas/Incident' }),
'400': { description: 'Neznamy stav' },
'403': { description: 'Chybi pravo incident.manage' },
'404': { description: 'Incident neexistuje nebo patri jine firme' },
},
},
},
'/api/dashboard/stream': {
get: {
tags: ['Dashboard'],
summary: 'Zivy stream zmen (SSE)',
description:
'Server-Sent Events. Drzi otevrene spojeni a posila udalosti, jakmile nastanou. ' +
'Swagger UI streamovanou odpoved nezobrazi rozumne, testujte prohlizecem nebo curl.',
security: [{ bearerAuth: [] }],
responses: { '200': { description: 'Proud udalosti text/event-stream' } },
},
},
'/api/dashboard/storage': {
get: {
tags: ['Dashboard'],
summary: 'Kam se uklada',
description:
'mode postgres nebo memory. `ephemeral: true` znamena, ze restart procesu ' +
'data smaze. Portal to musi umet rict nahlas.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Rezim uloziste',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
mode: { type: 'string', enum: ['postgres', 'memory'] },
reason: { type: 'string', nullable: true },
ephemeral: { type: 'boolean' },
},
},
},
},
},
},
},
},
'/api/dashboard/client-crash': {
post: {
tags: ['Portal'],
summary: 'Nahlasit pad vykreslovani',
parameters: [tenantParam],
description:
'Zaklada incident z padu portalu v prohlizeci. Bez toho je jedina stopa v konzoli ' +
'uzivatele, kam se nikdo nedostane, takze bychom o padu vedeli jen tehdy, kdyby ho ' +
'nekdo nahlasil. Incident nese title a impact pro zakaznika a detail pro spravce ' +
'platformy: hlaska, misto v kodu, strom komponent, adresa stranky a verze buildu. ' +
'Tentyz pad na tomtez miste zalozi incident nejvys jednou za deset minut - pad pri ' +
'vykreslovani se opakuje pri kazdem prekresleni a jinak by z jedne chyby vzniklo ' +
'padesat incidentu. Volá to pojistka v klientovi, ne clovek.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['message'],
properties: {
message: { type: 'string' },
stack: { type: 'string' },
componentStack: { type: 'string' },
path: { type: 'string', description: 'Kde v portalu se to stalo.' },
build: { type: 'string', description: 'Verze nasazeneho klienta.' },
},
},
},
},
},
responses: {
'201': {
description: 'Incident zalozen',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
created: { type: 'boolean' },
incidentId: { type: 'string' },
},
},
},
},
},
'202': { description: 'Stejny pad uz je hlaseny, incident se nezaklada' },
'400': { description: 'Neplatny vstup' },
'404': { description: 'Firma neexistuje, nebo do ni volajici nepatri' },
},
},
},
'/api/dashboard/notifications': {
get: {
tags: ['Dashboard'],
summary: 'Upozorneni prihlaseneho',
description:
'Cislo u zalozky Tickety a hlasky o pridelene praci. Upozorneni jsou ulozena, ' +
'takze je najde i ten, kdo mel portal zavreny.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Upozorneni',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: { type: 'array', items: { type: 'object' } },
unread: { type: 'integer' },
mine: { type: 'integer', description: 'Kolik ticketu ma volajici u sebe.' },
},
},
},
},
},
},
},
},
'/api/dashboard/notifications/read': {
post: {
tags: ['Dashboard'],
summary: 'Oznacit upozorneni jako prectena',
security: [{ bearerAuth: [] }],
requestBody: {
required: false,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ids: {
type: 'array',
items: { type: 'string' },
description: 'Bez seznamu se oznaci vsechna.',
},
},
},
},
},
},
responses: { '200': { description: 'Oznaceno' } },
},
},
};
+71
View File
@@ -0,0 +1,71 @@
/** Helpdesk: pozadavky firmy na jejiho dodavatele. */
import { bearer, idParam, tenantParam, jsonBody, jsonResponse } from '../helpers.js';
export const helpdeskPaths: Record<string, unknown> = {
'/api/dashboard/helpdesk': {
get: {
tags: ['Helpdesk'],
summary: 'Pozadavky, ktere firma poslala svemu dodavateli',
description:
'Pohled zadavatele, ne resitele. Kdo nevidi celou firmu, vidi jen to, co sam poslal.',
security: bearer,
parameters: [tenantParam],
responses: {
'200': jsonResponse('Pozadavky, dodavatel a jestli lze zakladat'),
'403': { description: 'Chybi pravo helpdesk.view' },
},
},
post: {
tags: ['Helpdesk'],
summary: 'Poslat pozadavek dodavateli',
description: 'Vlastnikem ticketu je dodavatel firmy, zadavatel ho vidi pres helpdesk.',
security: bearer,
parameters: [tenantParam],
requestBody: jsonBody({
type: 'object',
required: ['subject'],
properties: {
subject: { type: 'string' },
body: { type: 'string' },
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
},
}),
responses: {
'201': jsonResponse('Zalozeno', { $ref: '#/components/schemas/Ticket' }),
'400': { description: 'Neplatny vstup, nebo firma nema dodavatele helpdesku' },
'403': { description: 'Chybi pravo helpdesk.create' },
},
},
},
'/api/dashboard/helpdesk/{id}': {
get: {
tags: ['Helpdesk'],
summary: 'Detail vlastniho pozadavku',
security: bearer,
parameters: [idParam, tenantParam],
responses: {
'200': jsonResponse('Pozadavek', { $ref: '#/components/schemas/TicketDetail' }),
'404': { description: 'Pozadavek neexistuje nebo ho neposlala tato firma' },
},
},
},
'/api/dashboard/helpdesk/{id}/comment': {
post: {
tags: ['Helpdesk'],
summary: 'Komentar zadavatele',
description: 'Jedina zmena, kterou zadavatel nad pozadavkem smi.',
security: bearer,
parameters: [idParam, tenantParam],
requestBody: jsonBody({
type: 'object',
required: ['text'],
properties: { text: { type: 'string', minLength: 1 } },
}),
responses: {
'200': jsonResponse('Zapsano', { $ref: '#/components/schemas/Ticket' }),
'404': { description: 'Pozadavek neexistuje' },
},
},
},
};
+108
View File
@@ -0,0 +1,108 @@
/** Pozvanky do firmy a jejich prijeti. */
import { bearer, idParam, tenantParam, jsonBody, jsonResponse, tooMany } from '../helpers.js';
export const invitesPaths: Record<string, unknown> = {
'/api/dashboard/invites': {
get: {
tags: ['Pozvanky'],
summary: 'Pozvanky firmy',
description: 'Vcetne cele adresy k odeslani a roli, ktere jde pridelit. Chce user.manage.',
security: bearer,
parameters: [tenantParam],
responses: {
'200': jsonResponse('Pozvanky a role'),
'403': { description: 'Chybi pravo user.manage' },
},
},
post: {
tags: ['Pozvanky'],
summary: 'Vytvorit pozvanku',
description:
'Odkaz s neodhadnutelnym kodem, plati tyden. Role musi byt teto firmy nebo systemove. ' +
'Pozvanka nikdy nedela spravce platformy.',
security: bearer,
parameters: [tenantParam],
requestBody: jsonBody({
type: 'object',
required: ['roleIds'],
properties: {
email: { type: 'string', description: 'Prazdne = komukoliv s odkazem.' },
note: { type: 'string' },
roleIds: { type: 'array', items: { type: 'string' } },
asPerson: {
type: 'boolean',
description: 'Stary priznak, ignoruje se: resitel je kazdy clen firmy.',
},
},
}),
responses: {
'201': jsonResponse('Pozvanka vcetne url'),
'400': { description: 'Neplatny vstup nebo neznama role' },
'403': { description: 'Chybi pravo user.manage' },
},
},
},
'/api/dashboard/invites/{id}': {
delete: {
tags: ['Pozvanky'],
summary: 'Zrusit pozvanku',
security: bearer,
parameters: [idParam, tenantParam],
responses: {
'204': { description: 'Zruseno' },
'403': { description: 'Chybi pravo user.manage' },
'404': { description: 'Pozvanka neexistuje' },
},
},
},
'/api/invites/{code}': {
get: {
tags: ['Pozvanky'],
summary: 'Co je za odkazem pozvanky',
description: 'VEREJNE. Vraci jen nazev firmy, pripadny e-mail a jestli jde prijmout.',
parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': jsonResponse('Firma a platnost', {
type: 'object',
properties: {
tenant: { type: 'string' },
email: { type: 'string', nullable: true },
knownUser: { type: 'boolean' },
valid: { type: 'boolean' },
problem: { type: 'string', nullable: true },
},
}),
'404': { description: 'Pozvanka neexistuje' },
},
},
},
'/api/invites/{code}/accept': {
post: {
tags: ['Pozvanky'],
summary: 'Prijmout pozvanku',
description:
'VEREJNE. Bez uctu ho zalozi, s uctem ho po overeni hesla pripoji k firme. ' +
'Pet pokusu za ctvrt hodiny z jedne adresy.',
parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: jsonBody({
type: 'object',
required: ['name', 'email', 'password'],
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
password: { type: 'string', format: 'password', minLength: 8 },
},
}),
responses: {
'201': jsonResponse('Prijato'),
'400': { description: 'Neplatne udaje' },
'401': { description: 'Ucet existuje a heslo nesedi' },
'403': { description: 'Pozvanka je pro jinou adresu' },
'404': { description: 'Pozvanka neexistuje' },
'409': { description: 'Pozvanka uz byla pouzita nebo vyprsela' },
...tooMany,
},
},
},
};
+68
View File
@@ -0,0 +1,68 @@
/** Provoz: health, readiness a echo adresy volajiciho. */
export const opsPaths: Record<string, unknown> = {
'/health': {
get: {
tags: ['Provoz'],
summary: 'Health check',
description: 'Vraci 200, pokud je aplikace schopna prijimat provoz.',
responses: {
'200': {
description: 'Aplikace bezi',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
status: { type: 'string', example: 'ok' },
uptimeSec: { type: 'integer', example: 42 },
},
},
},
},
},
},
},
},
'/health/ready': {
get: {
tags: ['Provoz'],
summary: 'Readiness vcetne databaze',
description:
'Vraci 503, kdyz je databaze nastavena a nedostupna. `/health` na databazi ' +
'zamerne nezavisi - kratky vypadek DB by jinak vedl k restartovani containeru.',
responses: {
'200': { description: 'Aplikace je pripravena' },
'503': { description: 'Databaze je nastavena, ale nedostupna' },
},
},
},
'/whoami': {
get: {
tags: ['Provoz'],
summary: 'Jak nas vidi ten, kdo nam vola',
description:
'Vraci volajicimu jeho vlastni adresu tak, jak dorazila k serveru. Zni to ' +
'zbytecne, ale je to jediny zpusob, jak zmerit, s jakou zdrojovou adresou ' +
'doruci reverse proxy volani, ktere vyslo z naseho containeru. Bez prihlaseni ' +
'zamerne - volajici dostane svoji vlastni adresu, nic navic.',
responses: {
'200': {
description: 'Adresa volajiciho',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ip: { type: 'string', nullable: true },
forwardedFor: { type: 'string', nullable: true },
remoteAddress: { type: 'string', nullable: true },
},
},
},
},
},
},
},
},
};
+249
View File
@@ -0,0 +1,249 @@
/** Skripty konektoru a skripty firmy. */
import { bearer, idParam, tenantParam, jsonBody, jsonResponse } from '../helpers.js';
export const scriptsPaths: Record<string, unknown> = {
'/api/dashboard/tenant-scripts': {
get: {
tags: ['Skripty'],
summary: 'Skripty firmy',
description: 'Prevod dat v JS uvnitr firmy. Nevolaji ven. Vraci i vzor pro novy skript.',
security: bearer,
parameters: [tenantParam],
responses: { '200': jsonResponse('Skripty, vzor a limit delky') },
},
post: {
tags: ['Skripty'],
summary: 'Zalozit skript firmy',
security: bearer,
parameters: [tenantParam],
requestBody: jsonBody({
type: 'object',
required: ['name', 'code'],
properties: {
name: { type: 'string' },
description: { type: 'string' },
code: { type: 'string' },
enabled: { type: 'boolean' },
},
}),
responses: {
'201': jsonResponse('Zalozeno'),
'400': { description: 'Neplatny vstup' },
'403': { description: 'Chybi pravo action.manage' },
},
},
},
'/api/dashboard/tenant-scripts/{id}': {
put: {
tags: ['Skripty'],
summary: 'Upravit skript firmy',
security: bearer,
parameters: [idParam, tenantParam],
requestBody: jsonBody({ type: 'object' }),
responses: {
'200': jsonResponse('Ulozeno'),
'403': { description: 'Chybi pravo action.manage' },
'404': { description: 'Skript neexistuje' },
},
},
delete: {
tags: ['Skripty'],
summary: 'Smazat skript firmy',
security: bearer,
parameters: [idParam, tenantParam],
responses: {
'204': { description: 'Smazano' },
'403': { description: 'Chybi pravo action.manage' },
'404': { description: 'Skript neexistuje' },
},
},
},
'/api/dashboard/tenant-scripts/test': {
post: {
tags: ['Skripty'],
summary: 'Zkusit skript firmy bez ulozeni',
description: 'Vraci 200 i kdyz skript spadl. Chyba ve skriptu neni chyba API.',
security: bearer,
parameters: [tenantParam],
requestBody: jsonBody({
type: 'object',
required: ['code'],
properties: { code: { type: 'string' }, input: {} },
}),
responses: {
'200': jsonResponse('Vysledek behu'),
'403': { description: 'Chybi pravo action.manage' },
},
},
},
'/api/dashboard/scripts': {
get: {
tags: ['Skripty'],
summary: 'Seznam skriptu konektoru',
description:
'Manifesty vsech nactenych skriptu, rozbite skripty v `problems` ' +
'a stav napojeni v `connections`. Pristupove udaje se nikdy nevraci, ' +
'jen jmena chybejicich environment variables.',
security: [{ bearerAuth: [] }],
parameters: [tenantParam],
responses: {
'200': {
description: 'Skripty, problemy a stav napojeni',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: {
type: 'array',
items: { $ref: '#/components/schemas/ScriptManifest' },
},
problems: {
type: 'array',
items: { $ref: '#/components/schemas/ScriptProblem' },
},
connections: {
type: 'array',
items: { $ref: '#/components/schemas/ConnectionStatus' },
},
directory: {
type: 'string',
nullable: true,
example: '/app/scripts',
description: 'Jen pro spravce platformy, ostatnim null.',
},
},
},
},
},
},
},
},
},
'/api/dashboard/scripts/reload': {
post: {
tags: ['Skripty'],
summary: 'Znovu nacist skripty ze slozky',
description:
'Skripty se nacitaji samy podle casu zmeny souboru. Tenhle endpoint ' +
'to jen vynuti hned, bez cekani.',
security: [{ bearerAuth: [] }],
responses: {
'200': { description: 'Skripty po nacteni' },
'403': { description: 'Jen spravce platformy' },
},
},
},
'/api/dashboard/scripts/{id}': {
get: {
tags: ['Skripty'],
summary: 'Manifest a kod skriptu',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
example: 'idoklad.get-issued-invoice',
},
],
responses: {
'200': {
description: 'Kod se vraci vzdy. `manifest` je null, kdyz je skript rozbity.',
},
'400': { description: 'Neplatne ID skriptu' },
'404': { description: 'Skript neexistuje' },
},
},
put: {
tags: ['Skripty'],
summary: 'Ulozit kod skriptu',
description:
'Nejdriv se kod nacte a overi, az pak prepise soubor. Rozbita uprava ' +
'se neulozi a puvodni skript dal funguje.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
example: 'idoklad.get-issued-invoice',
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['code'],
properties: {
code: {
type: 'string',
description: 'Cely obsah souboru vcetne exportu manifest a run.',
},
},
},
},
},
},
responses: {
'200': { description: 'Ulozeno, vraci se overeny manifest' },
'400': {
description: 'Kod nebo manifest neprosel, v `issues` je co opravit',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
'403': { description: 'Jen spravce platformy' },
},
},
},
'/api/dashboard/scripts/{id}/test': {
post: {
tags: ['Skripty'],
summary: 'Zkusebni spusteni skriptu',
description:
'POZOR: vola opravdovou sluzbu. Vystavena faktura opravdu vznikne. ' +
'Chyba skriptu neni chyba API, vraci se 200 s popisem v `error`.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
example: 'idoklad.get-issued-invoice',
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
inputs: {
type: 'object',
additionalProperties: true,
example: { invoiceId: 12345 },
},
},
},
},
},
},
responses: {
'200': {
description: 'Vysledek behu',
content: {
'application/json': { schema: { $ref: '#/components/schemas/ScriptRunResult' } },
},
},
'400': { description: 'Neplatne ID nebo vstupy' },
'403': { description: 'Jen spravce platformy' },
},
},
},
};
+372
View File
@@ -0,0 +1,372 @@
/** Nastaveni: entity z fabriky crudRouter plus rucne psane endpointy. */
import { bearer, crudPaths, idParam, jsonBody, jsonResponse, tenantParam } from '../helpers.js';
/** Entity, ktere se spravuji v Nastaveni. Jeden radek na entitu. */
const settingsEntities = [
{ path: 'tenants', label: 'firmy', permission: 'tenant.manage' },
{ path: 'users', label: 'uzivatele', permission: 'user.manage' },
{ path: 'roles', label: 'role a prava', permission: 'role.manage' },
// Resitele maji vlastni popis nize: pod endpointy jsou ucty.
{ path: 'people', label: 'resitele', permission: 'people.manage' },
{ path: 'groups', label: 'skupiny resitelu', permission: 'group.manage' },
{ path: 'ticket-types', label: 'typy ticketu', permission: 'ticketType.manage' },
{ path: 'actions', label: 'akce na ticketu', permission: 'action.manage' },
{ path: 'widgets', label: 'vlastni widgety', permission: 'widget.manage' },
// `features` tu neni: zalozky firmy maji jen GET a PUT, viz nize.
];
export const settingsPaths: Record<string, unknown> = {
// Petice endpointu za kazdou entitu v Nastaveni, viz `crudPaths`.
...settingsEntities.reduce(
(all, entity) => ({ ...all, ...crudPaths(entity) }),
{} as Record<string, unknown>,
),
/*
* Resitel je clenstvi uctu ve firme, ID resitele je ID uctu. Endpointy
* a pravo zustavaji, ale zalozeni zaklada ucet (nebo prida clenstvi uz
* existujicimu) a smazani odebira clenstvi. Prepisuje obecny popis z
* `settingsEntities`, protoze telo je jine nez u ostatnich entit.
*/
'/api/dashboard/settings/people': {
get: {
tags: ['Nastaveni'],
summary: 'Seznam - resitele',
description: 'Clenove vybrane firmy vcetne vypnutych uctu, jeden pohled na clenstvi.',
security: bearer,
parameters: [tenantParam],
responses: {
'200': jsonResponse('Resitele', {
type: 'object',
properties: { items: { type: 'array', items: { $ref: '#/components/schemas/Person' } } },
}),
},
},
post: {
tags: ['Nastaveni'],
summary: 'Vytvorit - resitele',
description:
'Zalozi ucet s clenstvim ve vybrane firme. Kdyz ucet s tim e-mailem uz existuje ' +
'a ve firme neni, prida se mu jen clenstvi (jmeno, heslo a zapnuti se neprepisuji). ' +
'Bez hesla dostane nahodne. Role musi byt teto firmy nebo systemove, vychozi role_agent.',
security: bearer,
parameters: [tenantParam],
requestBody: jsonBody({
type: 'object',
required: ['name', 'email'],
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
password: {
type: 'string',
format: 'password',
description: 'Nepovinne, jinak nahodne.',
},
roleIds: { type: 'array', items: { type: 'string' }, default: ['role_agent'] },
role: { type: 'string', description: 'Popisek, cim se v tymu zabyva.' },
capacity: { type: 'integer', default: 8 },
externalIds: { type: 'array', items: { type: 'string' } },
enabled: { type: 'boolean', default: true },
},
}),
responses: {
'201': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }),
'400': { description: 'Neplatny vstup, neznama role, nebo uz je clenem firmy' },
'403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' },
},
},
},
'/api/dashboard/settings/people/{id}': {
get: {
tags: ['Nastaveni'],
summary: 'Detail - resitele',
security: bearer,
parameters: [idParam, tenantParam],
responses: {
'200': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }),
'404': { description: 'Neni clenem firmy' },
},
},
patch: {
tags: ['Nastaveni'],
summary: 'Upravit - resitele',
description:
'Jmeno, e-mail a zapnuti meni ucet (plati ve vsech firmach), role, popisek, kapacita ' +
'a externi ID meni clenstvi v teto firme. E-mail musi zustat unikatni.',
security: bearer,
parameters: [idParam, tenantParam],
requestBody: jsonBody({
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
enabled: { type: 'boolean' },
roleIds: { type: 'array', items: { type: 'string' } },
role: { type: 'string' },
capacity: { type: 'integer' },
externalIds: { type: 'array', items: { type: 'string' } },
},
}),
responses: {
'200': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }),
'400': { description: 'Neplatny vstup, neznama role, nebo obsazeny e-mail' },
'403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' },
'404': { description: 'Neni clenem firmy' },
},
},
delete: {
tags: ['Nastaveni'],
summary: 'Smazat - resitele',
description:
'Odebere clenstvi ve vybrane firme. Ucet zustava; bez jedineho clenstvi se vypne ' +
'(spravce platformy ne).',
security: bearer,
parameters: [idParam, tenantParam],
responses: {
'204': { description: 'Clenstvi odebrano' },
'403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' },
'404': { description: 'Neni clenem firmy' },
},
},
},
'/api/dashboard/settings/features': {
get: {
tags: ['Nastaveni'],
summary: 'Zalozky a limity firmy',
description: 'Vraci i vychozi, kdyz firma vlastni nastaveni jeste nema.',
security: bearer,
parameters: [tenantParam],
responses: { '200': jsonResponse('Nastaveni firmy') },
},
put: {
tags: ['Nastaveni'],
summary: 'Nastavit zalozky a limity firmy',
description: 'Jen spravce platformy. Povinne moduly se doplni vzdy.',
security: bearer,
parameters: [tenantParam],
requestBody: jsonBody({
type: 'object',
properties: {
modules: { type: 'array', items: { type: 'string' } },
limits: { type: 'object' },
serviceIds: { type: 'array', items: { type: 'string' } },
},
}),
responses: {
'200': jsonResponse('Ulozeno'),
'400': { description: 'Neznamy modul nebo neplatny vstup' },
'403': { description: 'Jen spravce platformy' },
},
},
},
'/api/dashboard/settings/ares/companies': {
get: {
tags: ['Nastaveni'],
summary: 'Firmy z registru ARES',
description:
'Jen spravce platformy. Query je bud IC (1 az 8 cislic, presna shoda), nebo cast nazvu. ' +
'U kazde firmy je `existingTenantId`, kdyz uz v portalu je.',
security: bearer,
parameters: [{ name: 'query', in: 'query', required: true, schema: { type: 'string' } }],
responses: {
'200': jsonResponse('Firmy', {
type: 'object',
properties: {
companies: { type: 'array', items: { $ref: '#/components/schemas/AresCompany' } },
},
}),
'400': { description: 'Prazdny dotaz nebo neplatne IC' },
'403': { description: 'Jen spravce platformy' },
'502': { description: 'ARES neodpovedel' },
},
},
},
'/api/dashboard/settings/ares/companies/{ico}/persons': {
get: {
tags: ['Nastaveni'],
summary: 'Osoby, ktere za firmu jednaji',
description:
'Soucasni clenove statutarnich organu a prokura z verejneho rejstriku. ' +
'Kazda osoba ma navrzeny nahradni e-mail IC-poradi@placeholder.cz, ARES e-maily nevede.',
security: bearer,
parameters: [{ name: 'ico', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': jsonResponse('Osoby', {
type: 'object',
properties: {
persons: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', example: 'Jan Novák' },
firstName: { type: 'string' },
lastName: { type: 'string' },
roles: { type: 'array', items: { type: 'string' }, example: ['jednatel'] },
email: { type: 'string', example: '12345678-1@placeholder.cz' },
},
},
},
},
}),
'403': { description: 'Jen spravce platformy' },
'502': { description: 'ARES neodpovedel' },
},
},
},
'/api/dashboard/settings/ares/tenants': {
post: {
tags: ['Nastaveni'],
summary: 'Zalozit firmu z ARES vcetne uctu',
description:
'Udaje firmy se berou znovu z ARES podle IC. Vybrane osoby dostanou ucet s roli spravce firmy ' +
'a nahodnym heslem; funkce z rejstriku jde do popisku clenstvi. Bez e-mailu dostanou ' +
'nahradni IC-poradi@placeholder.cz.',
security: bearer,
requestBody: jsonBody({
type: 'object',
required: ['ico'],
properties: {
ico: { type: 'string', example: '27074358' },
name: { type: 'string', description: 'Prepis nazvu z ARES.' },
note: { type: 'string' },
persons: {
type: 'array',
items: {
type: 'object',
required: ['name'],
properties: {
name: { type: 'string' },
email: { type: 'string', description: 'Prazdne = nahradni e-mail.' },
roles: {
type: 'array',
items: { type: 'string' },
description: 'Funkce z rejstriku, jde do popisku clenstvi.',
},
},
},
},
},
}),
responses: {
'201': jsonResponse('Firma a ucty', {
type: 'object',
properties: {
tenant: { $ref: '#/components/schemas/Tenant' },
users: { type: 'array', items: { type: 'object' } },
},
}),
'400': { description: 'Neplatny vstup' },
'403': { description: 'Jen spravce platformy' },
'404': { description: 'ARES firmu nezna' },
'409': { description: 'Firma nebo e-mail uz existuje' },
'502': { description: 'ARES neodpovedel' },
},
},
},
'/api/dashboard/settings/users-overview': {
get: {
tags: ['Nastaveni'],
summary: 'Uzivatele vcetne vypnutych',
description:
'Spravce platformy vidi vsechny, spravce firmy (user.manage) jen lidi sve firmy.',
security: bearer,
parameters: [tenantParam],
responses: {
'200': jsonResponse('Uzivatele'),
'403': { description: 'Chybi pravo user.manage' },
},
},
},
'/api/dashboard/settings/roles-available': {
get: {
tags: ['Nastaveni'],
summary: 'Role dostupne firme',
description: 'Vlastni role firmy plus systemove. Pro nabidku u clenstvi.',
security: bearer,
parameters: [tenantParam],
responses: { '200': jsonResponse('Role') },
},
},
'/api/dashboard/settings/people-overview': {
get: {
tags: ['Nastaveni'],
summary: 'Resitele vcetne vypnutych (totez co seznam)',
security: bearer,
parameters: [tenantParam],
responses: { '200': jsonResponse('Resitele') },
},
},
'/api/dashboard/settings/actions-overview': {
get: {
tags: ['Nastaveni'],
summary: 'Akce firmy vcetne vypnutych',
security: bearer,
parameters: [tenantParam],
responses: { '200': jsonResponse('Akce') },
},
},
'/api/dashboard/settings/actions/{id}/scope': {
get: {
tags: ['Nastaveni'],
summary: 'Na co se da ve stromu akce odkazovat',
description: 'Udaje ticketu plus vlastni pole jeho typu a doptavaci pole akce.',
security: bearer,
parameters: [idParam, tenantParam],
responses: {
'200': jsonResponse('Parametry'),
'404': { description: 'Akce neexistuje' },
},
},
},
'/api/dashboard/settings/widgets-overview': {
get: {
tags: ['Nastaveni'],
summary: 'Widgety firmy plus osobni prihlaseneho',
security: bearer,
parameters: [tenantParam],
responses: { '200': jsonResponse('Widgety') },
},
},
'/api/dashboard/settings/catalog': {
get: {
tags: ['Nastaveni'],
summary: 'Katalog prav a modulu',
description:
'Seznam vsech prav a zalozek. Formular role tak nema seznam prav v kodu klienta.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Katalog',
content: { 'application/json': { schema: { type: 'object' } } },
},
},
},
},
'/api/dashboard/settings/users/{id}/password': {
patch: {
tags: ['Nastaveni'],
summary: 'Zmenit heslo',
description: 'Svoje heslo si zmeni kazdy, cizi jen spravce platformy. Hash se nikdy nevraci.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['password'],
properties: { password: { type: 'string', minLength: 8 } },
},
},
},
},
responses: {
'204': { description: 'Zmeneno' },
'400': { description: 'Kratke heslo' },
'403': { description: 'Cizi heslo bez prava' },
},
},
},
};
+564
View File
@@ -0,0 +1,564 @@
/** Tickety: seznam, detail, resitele a vestavene akce. */
import {
bearer,
idParam,
tenantParam,
pagingParams,
totalCountHeader,
jsonBody,
jsonResponse,
} from '../helpers.js';
export const ticketsPaths: Record<string, unknown> = {
'/api/dashboard/people/{id}': {
get: {
tags: ['Tickety'],
summary: 'Detail resitele',
description:
'Kdo to je, statistika za 30 dni, skupiny, co ma u sebe a co naposledy vyresil. ' +
'Jednim requestem, protoze se to vsechno pocita z tehoz seznamu. Cizi resitel je 404.',
security: bearer,
parameters: [idParam, tenantParam],
responses: {
'200': jsonResponse('Detail resitele', {
type: 'object',
properties: {
person: { $ref: '#/components/schemas/Person' },
stats: { type: 'object', nullable: true },
groups: { type: 'array', items: { type: 'object' } },
open: { type: 'array', items: { $ref: '#/components/schemas/Ticket' } },
resolved: { type: 'array', items: { $ref: '#/components/schemas/Ticket' } },
},
}),
'404': { description: 'Resitel neexistuje' },
},
},
},
'/api/dashboard/tickets/statuses': {
get: {
tags: ['Tickety'],
summary: 'Stavy, ktere firma opravdu pouziva',
description:
'Stav je volny retezec, ne ciselnik. Tohle je jen naseptavac z toho, co v datech je.',
security: bearer,
parameters: [tenantParam],
responses: {
'200': jsonResponse('Stavy', {
type: 'object',
properties: { items: { type: 'array', items: { type: 'string' } } },
}),
},
},
},
'/api/dashboard/tickets/{id}/claim': {
post: {
tags: ['Tickety'],
summary: 'Prevzit ticket',
description:
'Clovek si vezme praci sam. Jde to u ticketu bez resitele nebo ze sve skupiny; ' +
'vzit cizi rozdelanou praci chce ticket.assign.others. Bez tela.',
security: bearer,
parameters: [idParam, tenantParam],
responses: {
'200': jsonResponse('Prevzato', { $ref: '#/components/schemas/Ticket' }),
'400': { description: 'Volajici neni clenem firmy' },
'403': {
description: 'Chybi pravo ticket.assign.self, nebo ticket neni ve skupine volajiciho',
},
'404': { description: 'Ticket neexistuje nebo na nej volajici nevidi' },
'409': { description: 'Ticket uz nekdo resi' },
},
},
},
'/api/dashboard/people': {
get: {
tags: ['Tickety'],
summary: 'Seznam resitelu',
description:
'Clenove firmy, na ktere jde ticket priradit. `meId` je ID prihlaseneho uctu, ' +
'nebo null, kdyz ve firme neni clenem.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Resitele',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/Person' } },
meId: { type: 'string', nullable: true },
},
},
},
},
},
},
},
},
'/api/dashboard/tickets': {
get: {
tags: ['Tickety'],
summary: 'Seznam ticketu',
description: 'Neznama hodnota filtru se ignoruje a zaloguje, seznam se nezuzi.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'scope',
in: 'query',
schema: { type: 'string', enum: ['all', 'tenant', 'mine'] },
description:
'`all` napric firmami (jen platformni admin), `tenant` cela firma, ' +
'`mine` jen moje tickety. Nepovoleny pohled vraci 403, nikdy se tise nezuzi.',
},
{
name: 'tenantId',
in: 'query',
schema: { type: 'string' },
description: 'Firma u pohledu `tenant` a `mine`. Bez clenstvi vraci 404.',
example: 'tnt_automia',
},
{
name: 'assignee',
in: 'query',
schema: { type: 'string' },
description: 'ID resitele nebo `unassigned` pro frontu. U pohledu `mine` se ignoruje.',
},
{
name: 'status',
in: 'query',
schema: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] },
},
{
name: 'channel',
in: 'query',
schema: {
type: 'string',
enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'],
},
},
{
name: 'typeId',
in: 'query',
schema: { type: 'string' },
description: '`none` = bez typu.',
},
{ name: 'tag', in: 'query', schema: { type: 'string' }, description: '`none` = bez tagu.' },
{
name: 'groupId',
in: 'query',
schema: { type: 'string' },
description: '`none` = bez skupiny.',
},
...pagingParams,
],
responses: {
'200': {
description: 'Tickety',
headers: totalCountHeader,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/Ticket' } },
total: { type: 'integer', description: 'Pocet pred strankovanim.' },
statuses: {
type: 'array',
items: { type: 'string' },
description: 'Stavy, ktere firma pouziva. Z celeho rozsahu, ne z filtru.',
},
meId: { type: 'string', nullable: true },
scope: { type: 'string', enum: ['all', 'tenant', 'mine'] },
tenantId: { type: 'string', nullable: true },
},
},
},
},
},
},
},
post: {
tags: ['Tickety'],
summary: 'Zalozit ticket rucne',
description:
'Zaklada se do prave prepnute firmy. Zakaznik je nepovinny - rucne zalozeny ' +
'ticket je casto ukol, ne pozadavek zvenku. Chce pravo ticket.create.',
security: [{ bearerAuth: [] }],
parameters: [tenantParam],
requestBody: jsonBody({
type: 'object',
required: ['subject'],
properties: {
subject: { type: 'string' },
body: { type: 'string' },
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
typeId: { type: 'string', nullable: true },
assigneeId: { type: 'string', nullable: true },
assigneeGroupId: { type: 'string', nullable: true },
tags: { type: 'array', items: { type: 'string' }, maxItems: 20 },
customer: { $ref: '#/components/schemas/TicketCustomer' },
},
}),
responses: {
'201': jsonResponse('Zalozeno', { $ref: '#/components/schemas/Ticket' }),
'400': { description: 'Neplatny vstup nebo neni vybrana firma' },
'403': { description: 'Chybi pravo ticket.create' },
},
},
},
'/api/dashboard/tickets/workload': {
get: {
tags: ['Tickety'],
summary: 'Kdo co ma u sebe',
description: 'Prehled zateze pres cely tym vcetne poctu ticketu ve fronte.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Vytizeni resitelu',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Workload' } },
},
},
},
},
},
'/api/dashboard/tickets/{id}': {
get: {
tags: ['Tickety'],
summary: 'Detail ticketu vcetne logu',
description: 'Log obsahuje i to, co ktera volana sluzba vratila.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
example: 'TK-4821',
},
],
responses: {
'200': {
description: 'Detail',
content: {
'application/json': { schema: { $ref: '#/components/schemas/TicketDetail' } },
},
},
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/assign': {
post: {
tags: ['Tickety'],
summary: 'Priradit resitele',
description:
'Poslete null pro vraceni ticketu do fronty. Vzit si ticket na sebe chce ' +
'ticket.assign.self, cokoliv jineho ticket.assign.others.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['assigneeId'],
properties: {
assigneeId: {
type: 'string',
nullable: true,
example: 'usr_2',
description: 'ID uctu clena firmy.',
},
},
},
},
},
},
responses: {
'200': {
description: 'Prirazeno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'400': { description: 'Chybi assigneeId' },
'403': { description: 'Chybi pravo ticket.assign.self nebo ticket.assign.others' },
'404': { description: 'Ticket nebo resitel neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/status': {
post: {
tags: ['Tickety'],
summary: 'Zmenit stav ticketu',
description:
'Stav je volny retezec, ne ciselnik - tickety chodi z cizich aplikaci. ' +
'`closed` rika, jestli je vyrizeny; bez nej priznak zustava. Chce ticket.status.change.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['status'],
properties: {
status: { type: 'string', maxLength: 60, example: 'open' },
closed: { type: 'boolean' },
},
},
},
},
},
responses: {
'200': {
description: 'Zmeneno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'400': { description: 'Neplatny stav' },
'403': { description: 'Chybi pravo ticket.status.change' },
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/comment': {
post: {
tags: ['Tickety'],
summary: 'Pridat komentar',
description: 'Komentar je dalsi radek logu, aby bylo vse na jedne casove ose.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['text'],
properties: { text: { type: 'string', minLength: 2 } },
},
},
},
},
responses: {
'200': {
description: 'Zapsano',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'400': { description: 'Prazdny komentar' },
'403': { description: 'Chybi pravo ticket.comment' },
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/type': {
post: {
tags: ['Tickety'],
summary: 'Nastavit typ ticketu',
description:
'Typ rozhoduje, ktera vlastni pole ticket ma a ktere akce se na nem ukazou. ' +
'Pri zmene typu se hodnoty poli **nemazou**, jen prestanou byt videt.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['typeId'],
properties: {
typeId: { type: 'string', nullable: true, example: 'tt_order' },
fields: {
type: 'object',
description: 'Hodnoty vlastnich poli. Klic je klic pole z typu ticketu.',
additionalProperties: true,
},
},
},
},
},
},
responses: {
'200': {
description: 'Ulozeno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'403': { description: 'Chybi pravo ticket.type.change' },
'404': { description: 'Ticket nebo typ neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/tags': {
post: {
tags: ['Tickety'],
summary: 'Nastavit tagy',
description: 'Tagy se prepisuji cele. Prirustkova zmena by u vic lidi naraz kolidovala.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['tags'],
properties: {
tags: { type: 'array', maxItems: 20, items: { type: 'string', maxLength: 40 } },
},
},
},
},
},
responses: {
'200': {
description: 'Ulozeno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'403': { description: 'Chybi pravo ticket.tag' },
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/group': {
post: {
tags: ['Tickety'],
summary: 'Prehodit na skupinu resitelu',
description:
'Prirazeni konkretnimu cloveku se **zrusi**. Kdyby zustalo, ticket by byl ' +
've fronte skupiny i u cloveka a nikdo by nevedel, kdo to resi.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['groupId'],
properties: { groupId: { type: 'string', nullable: true } },
},
},
},
},
responses: {
'200': {
description: 'Ulozeno',
content: {
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
},
},
'403': { description: 'Chybi pravo ticket.assign.group' },
'404': { description: 'Neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/actions': {
get: {
tags: ['Tickety'],
summary: 'Akce dostupne k ticketu',
description:
'Vraci **jen akce, ktere v teto situaci opravdu jdou spustit**: sedi typ nebo ' +
'tag, projdou podminky a volajici na ne ma pravo. Klient nefiltruje nic.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Akce',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
label: { type: 'string', example: 'Odeslat do iDokladu' },
icon: { type: 'string' },
style: { type: 'string', enum: ['primary', 'default', 'danger'] },
confirm: { type: 'string', nullable: true },
form: { type: 'array', items: { type: 'object' } },
},
},
},
},
},
},
},
},
'404': { description: 'Ticket neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/actions/{actionId}': {
post: {
tags: ['Tickety'],
summary: 'Spustit akci',
description:
'Vraci 200 **i kdyz akce selhala** - selhani akce neni chyba API. Cely prubeh ' +
'vcetne toho, co sluzba vratila, se zapise do logu ticketu.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'actionId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: false,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
form: {
type: 'object',
description: 'Hodnoty poli, ktera si akce vyzada.',
additionalProperties: { type: 'string' },
},
},
},
},
},
},
responses: {
'200': {
description: 'Akce probehla nebo selhala, viz ok',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean' },
summary: { type: 'string' },
detail: {
type: 'string',
nullable: true,
description: 'Cele chybove hlaseni. Nikdy se nezkracuje.',
},
durationMs: { type: 'integer' },
},
},
},
},
},
'403': { description: 'Chybi pravo na tuto akci' },
'404': { description: 'Ticket nebo akce neexistuje' },
},
},
},
};
+132
View File
@@ -0,0 +1,132 @@
/** Webhook: verejny prijem dat a adresa pro prijem udalosti do ticketu. */
import { bearer, tenantParam, jsonResponse } from '../helpers.js';
export const webhookPaths: Record<string, unknown> = {
'/api/dashboard/intake': {
get: {
tags: ['Webhook'],
summary: 'Adresa pro prijem udalosti do ticketu',
description:
'Token je pristupovy udaj, proto ho vidi jen kdo ma connector.manage. ' +
'Vraci i typy ticketu firmy, aby odesilatel vedel, jaka pole muze poslat.',
security: bearer,
parameters: [tenantParam],
responses: {
'200': jsonResponse('Adresa a typy ticketu'),
'403': { description: 'Chybi pravo connector.manage' },
},
},
},
'/api/dashboard/intake/regenerate': {
post: {
tags: ['Webhook'],
summary: 'Nova adresa prijmu',
description: 'Stara okamzite prestane fungovat.',
security: bearer,
parameters: [tenantParam],
responses: {
'200': jsonResponse('Nova adresa', {
type: 'object',
properties: { url: { type: 'string' } },
}),
'403': { description: 'Chybi pravo connector.manage' },
},
},
},
'/webhook/ticket/{token}': {
post: {
tags: ['Webhook'],
summary: 'Prijem udalosti do ticketu',
description:
'VEREJNY endpoint, autorizuje token firmy v adrese. Se stejnym externalId se ' +
'udalost navesi na existujici ticket, jinak vznikne novy. externalId je ' +
'unikatni v ramci firmy.',
parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
externalId: { oneOf: [{ type: 'string' }, { type: 'number' }] },
source: { type: 'string', example: 'eshop' },
event: { type: 'string', example: 'order.created' },
subject: { type: 'string' },
typeId: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } },
fields: { type: 'object', additionalProperties: true },
},
},
},
},
},
responses: {
'201': { description: 'Ticket vznikl' },
'200': { description: 'Udalost se navesila na existujici ticket' },
'400': { description: 'Neplatna data' },
'404': { description: 'Neznamy token' },
},
},
get: {
tags: ['Webhook'],
summary: 'Napoveda k prijmu udalosti',
description: 'Jak se ma volat a jake typy ticketu firma ma. Nic nemeni.',
parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': { description: 'Navod a typy ticketu' },
'404': { description: 'Neznamy token' },
},
},
},
'/webhook/{token}': {
post: {
tags: ['Webhook'],
summary: 'Prijem dat do automatizace',
description:
'VEREJNY endpoint. **Odpovi hned** (202) a strom vykona worker na pozadi - ' +
'cizi sluzba muze odpovidat pomalu a odesilateli by vyprsel timeout. ' +
'Telo se kontroluje proti kontraktu spoustece, vcetne vnorenych cest.',
parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { type: 'object', additionalProperties: true },
},
},
},
responses: {
'202': {
description: 'Prijato, zpracuje se na pozadi',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
accepted: { type: 'boolean' },
automationId: { type: 'string' },
runId: { type: 'string', nullable: true },
},
},
},
},
},
'400': { description: 'Telo neodpovida kontraktu spoustece' },
'404': { description: 'Neznamy token' },
'409': { description: 'Automatizace je pozastavena' },
},
},
get: {
tags: ['Webhook'],
summary: 'Napoveda: co se v tele ceka',
description: 'Vraci metodu, seznam parametru vcetne cest a ukazku tela.',
parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': { description: 'Kontrakt' },
'404': { description: 'Neznamy token' },
},
},
},
};
+86 -71
View File
@@ -57,60 +57,65 @@ const startSchema = z.object({
reason: z.string().trim().max(300).optional(),
});
adminRouter.post('/impersonate', requirePlatformAdmin, requirePlatformPermission('impersonate'), (req, res) => {
const parsed = startSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error, 'Vyberte uživatele.');
adminRouter.post(
'/impersonate',
requirePlatformAdmin,
requirePlatformPermission('impersonate'),
(req, res) => {
const parsed = startSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error, 'Vyberte uživatele.');
const target = findUserById(parsed.data.userId);
if (!target) {
return res.status(404).json({ error: 'not_found', message: 'Uživatel neexistuje.' });
}
if (target.id === req.user!.id) {
return res.status(400).json({ error: 'validation_error', message: 'Tohle jste vy.' });
}
// Prepnuti na jineho spravce platformy by obeslo cely tenhle mechanismus.
if (target.platformAdmin) {
return res.status(403).json({
error: 'forbidden',
message: 'Na jiného správce platformy se přepnout nelze.',
const target = findUserById(parsed.data.userId);
if (!target) {
return res.status(404).json({ error: 'not_found', message: 'Uživatel neexistuje.' });
}
if (target.id === req.user!.id) {
return res.status(400).json({ error: 'validation_error', message: 'Tohle jste vy.' });
}
// Prepnuti na jineho spravce platformy by obeslo cely tenhle mechanismus.
if (target.platformAdmin) {
return res.status(403).json({
error: 'forbidden',
message: 'Na jiného správce platformy se přepnout nelze.',
});
}
const token = jwt.sign(
{
sub: target.id,
email: target.email,
// `act` nese skutecneho cloveka. Podle nej audit pozna, kdo za tim byl.
act: req.user!.id,
actEmail: req.user!.email,
writes: parsed.data.allowWrites,
},
config.jwtSecret,
{ expiresIn: `${IMPERSONATION_MINUTES}m` },
);
recordAudit({
userId: target.id,
userEmail: target.email,
actedBy: req.user!.id,
tenantId: null,
action: 'impersonate.start',
target: target.id,
detail: { allowWrites: parsed.data.allowWrites, reason: parsed.data.reason ?? null },
});
}
const token = jwt.sign(
{
sub: target.id,
email: target.email,
// `act` nese skutecneho cloveka. Podle nej audit pozna, kdo za tim byl.
act: req.user!.id,
actEmail: req.user!.email,
writes: parsed.data.allowWrites,
},
config.jwtSecret,
{ expiresIn: `${IMPERSONATION_MINUTES}m` },
);
console.warn(
`[admin] ${req.user!.email} se prepina na ${target.email}` +
`${parsed.data.allowWrites ? ' VCETNE ZAPISU' : ' jen pro cteni'}`,
);
recordAudit({
userId: target.id,
userEmail: target.email,
actedBy: req.user!.id,
tenantId: null,
action: 'impersonate.start',
target: target.id,
detail: { allowWrites: parsed.data.allowWrites, reason: parsed.data.reason ?? null },
});
console.warn(
`[admin] ${req.user!.email} se prepina na ${target.email}` +
`${parsed.data.allowWrites ? ' VCETNE ZAPISU' : ' jen pro cteni'}`,
);
return res.json({
token,
expiresInMinutes: IMPERSONATION_MINUTES,
user: { id: target.id, name: target.name, email: target.email },
allowWrites: parsed.data.allowWrites,
});
});
return res.json({
token,
expiresInMinutes: IMPERSONATION_MINUTES,
user: { id: target.id, name: target.name, email: target.email },
allowWrites: parsed.data.allowWrites,
});
},
);
/**
* Konec impersonace.
@@ -134,28 +139,38 @@ adminRouter.post('/impersonate/stop', (req, res) => {
});
/** Koho lze prepnout. Spravci platformy se nenabizeji. */
adminRouter.get('/impersonate/candidates', requirePlatformAdmin, requirePlatformPermission('impersonate'), (_req, res) => {
res.json({
items: listAllUsers()
.filter((user) => !user.platformAdmin && user.enabled)
.map((user) => ({ id: user.id, name: user.name, email: user.email })),
});
});
adminRouter.get(
'/impersonate/candidates',
requirePlatformAdmin,
requirePlatformPermission('impersonate'),
(_req, res) => {
res.json({
items: listAllUsers()
.filter((user) => !user.platformAdmin && user.enabled)
.map((user) => ({ id: user.id, name: user.name, email: user.email })),
});
},
);
// ------------------------------------------------------------------- audit
// 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),
action: typeof req.query.action === 'string' ? req.query.action : undefined,
result: req.query.result === 'denied' ? 'denied' : undefined,
limit: Number.isFinite(limit) ? Math.min(limit, 500) : 200,
})
.then((items) => res.json({ items }))
.catch((err: unknown) => {
console.error('[admin] audit se nepodarilo precist:', err);
res.status(500).json({ error: 'internal_error', message: 'Audit se nepodařilo přečíst.' });
});
});
adminRouter.get(
'/audit',
requirePlatformAdmin,
requirePlatformPermission('audit.view'),
(req, res) => {
const limit = Number(req.query.limit ?? 200);
return void listAudit({
...readScope(req),
action: typeof req.query.action === 'string' ? req.query.action : undefined,
result: req.query.result === 'denied' ? 'denied' : undefined,
limit: Number.isFinite(limit) ? Math.min(limit, 500) : 200,
})
.then((items) => res.json({ items }))
.catch((err: unknown) => {
console.error('[admin] audit se nepodarilo precist:', err);
res.status(500).json({ error: 'internal_error', message: 'Audit se nepodařilo přečíst.' });
});
},
);
+48 -15
View File
@@ -23,7 +23,13 @@ import {
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, refreshUsers, userStore, type StoredUser } from '../data/users.js';
import {
hashPassword,
listAllUsers,
refreshUsers,
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';
@@ -37,6 +43,9 @@ function aresStatus(err: AresError): number {
return 502;
}
/** Kolik shod podle nazvu se nabidne. Vic by v dialogu nikdo neprochazel. */
const SEARCH_LIMIT = 10;
/** Firmy podle IC (presne) nebo casti nazvu. */
aresRouter.get('/companies', async (req, res) => {
const query = String(req.query.query ?? '').trim();
@@ -45,14 +54,22 @@ aresRouter.get('/companies', async (req, res) => {
}
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]));
const companies =
ico !== null
? [await lookupCompany(ico)].filter((c) => c !== null)
: await searchCompanies(query, SEARCH_LIMIT);
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 });
if (err instanceof AresError)
return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message });
throw err;
}
});
@@ -60,20 +77,28 @@ aresRouter.get('/companies', async (req, res) => {
/** 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.' });
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) })),
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 });
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.'),
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(),
@@ -112,7 +137,8 @@ aresRouter.post('/tenants', async (req, res) => {
try {
company = await lookupCompany(ico);
} catch (err) {
if (err instanceof AresError) return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message });
if (err instanceof AresError)
return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message });
throw err;
}
if (company === null) {
@@ -131,9 +157,14 @@ aresRouter.post('/tenants', async (req, res) => {
// 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(),
);
const persons = parsed.data.persons.map((person, index) => ({
...person,
email: (person.email && person.email !== ''
? person.email
: placeholderEmail(ico, index + 1)
).toLowerCase(),
}));
const emails = persons.map((person) => person.email);
for (const email of emails) {
if (taken.has(email)) {
return res.status(409).json({ error: 'conflict', message: `E-mail ${email} už někdo má.` });
@@ -141,7 +172,9 @@ aresRouter.post('/tenants', async (req, res) => {
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.' });
return res
.status(400)
.json({ error: 'validation_error', message: 'Dva lidé mají stejný e-mail.' });
}
const timestamp = nowIso();
@@ -163,11 +196,11 @@ aresRouter.post('/tenants', async (req, res) => {
const created = await tenantStore.create(tenant);
const users: StoredUser[] = [];
for (const [index, person] of parsed.data.persons.entries()) {
for (const person of persons) {
const user: StoredUser = {
id: `usr_${randomUUID().slice(0, 8)}`,
tenantId: null,
email: emails[index],
email: person.email,
name: person.name,
passwordHash: await hashPassword(randomBytes(18).toString('base64url')),
platformAdmin: false,
+7 -1
View File
@@ -19,7 +19,13 @@ const loginSchema = z.object({
* 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 });
const LOGIN_WINDOW_MS = 15 * 60_000;
const LOGIN_MAX_ATTEMPTS = 20;
const loginLimiter = rateLimit({
name: 'login',
windowMs: LOGIN_WINDOW_MS,
max: LOGIN_MAX_ATTEMPTS,
});
authRouter.post('/login', loginLimiter, async (req, res) => {
const parsed = loginSchema.safeParse(req.body);
+33 -18
View File
@@ -47,10 +47,10 @@ import { smtpSettings, smtpTargetUrl, verifySmtp } from '../mail/smtp.js';
import { listTools } from '../mcp/client.js';
import { forgetMcpTools, rememberMcpTools } from '../data/mcpTools.js';
import { isMcpService } from '../mcp/dialect.js';
import { resolveTarget, serviceBaseUrl, targetSecrets } from '../scripts/connections.js';
import { createHttp } from '../scripts/http.js';
import { ScriptError } from '../scripts/types.js';
import { createRedactor, describe, truncate } from '../scripts/util.js';
import { resolveTarget, serviceBaseUrl, targetSecrets } from '../runtime/scripts/connections.js';
import { createHttp } from '../runtime/scripts/http.js';
import { ScriptError } from '../runtime/scripts/types.js';
import { createRedactor, describe, truncate } from '../runtime/scripts/util.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { optionalTenantOrDeny, tenantOrDeny } from '../middleware/tenant.js';
import { validationError } from '../middleware/validation.js';
@@ -68,8 +68,12 @@ 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.' });
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;
@@ -111,9 +115,7 @@ connectorsRouter.get('/services', async (req, res) => {
const withScripts = new Map(
serviceCatalog(tenantId ?? null).map((service) => [service.id, service]),
);
const counts = tenantId
? await connectorCountsByService([tenantId])
: new Map<string, number>();
const counts = tenantId ? await connectorCountsByService([tenantId]) : new Map<string, number>();
const items = visible.map((service) => {
const merged = withScripts.get(service.id) ?? service;
@@ -202,8 +204,9 @@ connectorsRouter.post('/', async (req, res) => {
}
const issues = validateConnectorValues(service, parsed.data.values ?? {});
if (issues.length > 0) {
return res.status(400).json({ error: 'validation_error', message: issues[0].message, issues });
const firstIssue = issues[0];
if (firstIssue !== undefined) {
return res.status(400).json({ error: 'validation_error', message: firstIssue.message, issues });
}
const connector = await createConnector({
@@ -250,8 +253,11 @@ connectorsRouter.patch('/:id', async (req, res) => {
if (parsed.data.values) {
const issues = validateConnectorValues(service, parsed.data.values);
if (issues.length > 0) {
return res.status(400).json({ error: 'validation_error', message: issues[0].message, issues });
const firstIssue = issues[0];
if (firstIssue !== undefined) {
return res
.status(400)
.json({ error: 'validation_error', message: firstIssue.message, issues });
}
}
@@ -403,6 +409,9 @@ async function loadMcpTools(connectorId: string, tenantId: string) {
* prepsat, takze "vratilo 403" bez serveru nerika, jestli se to vubec trefilo
* na spravny stroj. Hadat to podle toho, kde je nasazeny portal, nejde.
*/
/** Strop testu napojeni. Sluzba, ktera neodpovi do deseti sekund, je pro test nedostupna. */
const TEST_TIMEOUT_MS = 10_000;
connectorsRouter.post('/:id/test', async (req, res) => {
const tenantId = managedTenantOrDeny(req, res);
if (!tenantId) return;
@@ -498,11 +507,12 @@ connectorsRouter.post('/:id/test', async (req, res) => {
const checked = service.verifyPath ? 'přístupové údaje' : 'jen dostupnost služby';
// Zaloha pro pripad, ze se k volani vubec nedoslo a chyba tedy `request` nema.
// Query se odrizne stejne jako v `ScriptRequestInfo` - muze v ni byt tajemstvi.
const verifyUrl = `${target.baseUrl.replace(/\/+$/, '')}${path.split('?')[0]}`;
const pathWithoutQuery = path.split('?')[0] ?? path;
const verifyUrl = `${target.baseUrl.replace(/\/+$/, '')}${pathWithoutQuery}`;
const redact = createRedactor(targetSecrets(target));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
const timer = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
try {
const http = createHttp({
@@ -518,7 +528,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
const message = service.verifyPath
? 'Napojení funguje, přístupové údaje jsou platné.'
: 'Služba odpovídá. Přístupové údaje se tímhle neověřily, služba na to nemá čtecí volání.';
const request = { method: 'GET', path: path.split('?')[0], url: verifyUrl };
const request = { method: 'GET', path: pathWithoutQuery, url: verifyUrl };
// Do historie patri i uspech. Bez nej se neda poznat, jestli konektor
// nesel nikdy, nebo prestal jit ve chvili, kdy se sahlo na udaje.
@@ -557,12 +567,17 @@ connectorsRouter.post('/:id/test', async (req, res) => {
? err.detail
? redact(err.detail)
: undefined
: redact(truncate(String(err instanceof Error ? err.stack ?? err.message : err), config.errorDetailBytes));
: redact(
truncate(
String(err instanceof Error ? (err.stack ?? err.message) : err),
config.errorDetailBytes,
),
);
const request =
isScriptError && err.request
? err.request
: { method: 'GET', path: path.split('?')[0], url: verifyUrl };
: { method: 'GET', path: pathWithoutQuery, url: verifyUrl };
// Hlavicky rikaji, kdo odpoved vydal. U 403 bez tela je to vsechno,
// co zbyde: `Server: Kestrel` je aplikace, `Via: 1.1 Caddy` proxy.
const responseHeaders = isScriptError ? (err.responseHeaders ?? null) : null;
+7 -1
View File
@@ -6,7 +6,13 @@ 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 CONTACT_WINDOW_MS = 60 * 60_000;
const CONTACT_MAX_PER_WINDOW = 5;
const contactLimiter = rateLimit({
name: 'contact',
windowMs: CONTACT_WINDOW_MS,
max: CONTACT_MAX_PER_WINDOW,
});
const contactSchema = z.object({
name: z.string().min(2, 'Zadejte jméno.'),
+15 -8
View File
@@ -25,7 +25,12 @@ import { type Request, type Response, type Router } from 'express';
import type { z } from 'zod';
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';
import {
nowIso,
type EntityStore,
type ListOptions,
type TenantEntity,
} from '../data/store/index.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { requestTenant, tenantOrDeny } from '../middleware/tenant.js';
import { validationError } from '../middleware/validation.js';
@@ -60,10 +65,7 @@ export interface CrudOptions<T extends TenantEntity, C, U> {
/** Pravo potrebne k zapisu. Cteni staci prihlaseni. */
writePermission: string;
/** Sestavi novy zaznam z overeneho vstupu. Smi byt asynchronni (hash hesla). */
build: (
input: C,
tenantId: string,
) => BuiltEntity<T> | Promise<BuiltEntity<T>>;
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. */
@@ -111,7 +113,8 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<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) };
const payload =
verb === 'deleted' ? { id: entity.id } : { id: entity.id, [options.event]: publish(entity) };
publishEvent(`${options.event}.${verb}`, message, payload, entity.tenantId);
};
@@ -141,7 +144,9 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
}
const outcome = options.scopeBy.prepare(req, tenantId, input, existing);
if (!outcome.ok) {
console.warn(`[crud] ${req.user!.email}: ${options.store.kind} odmitnuto - ${outcome.message}`);
console.warn(
`[crud] ${req.user!.email}: ${options.store.kind} odmitnuto - ${outcome.message}`,
);
res.status(outcome.status).json({ error: 'forbidden', message: outcome.message });
return null;
}
@@ -167,7 +172,9 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
* Prava se ptaji za firmu **toho zaznamu**, ne za tu, kterou ma clovek
* prave prepnutou. Kdo edituje zaznam jine firmy, musi mit pravo tam.
*/
if (!hasPermission(req.user!, options.writePermission, entity?.tenantId ?? requestTenant(req))) {
if (
!hasPermission(req.user!, options.writePermission, entity?.tenantId ?? requestTenant(req))
) {
console.warn(
`[crud] ${req.user!.email}: chybi pravo ${options.writePermission} u ${options.store.kind}`,
);
File diff suppressed because it is too large Load Diff
+468
View File
@@ -0,0 +1,468 @@
/**
* Automatizace: strom akci, jeho validace, webhook a fronta behu.
*/
import type { Request, Response } from 'express';
import { z } from 'zod';
import {
createAutomation,
deleteAutomation,
getAutomation,
listAutomations,
regenerateWebhookToken,
rulesOf,
updateAutomation,
type FlowStep,
} from '../../data/automationStore.js';
import { operatorAllowedForType } from '../../data/conditions.js';
import { defaultConnectorFor, getConnector } from '../../data/connectorStore.js';
import { findOperation, findService, providedFieldsFor } from '../../data/services.js';
import { collectScopes } from '../../data/flowScope.js';
import { hasPermission } from '../../data/permissions.js';
import { queueStats, recentRuns } from '../../runtime/queue.js';
import { accessOf, scopeOrDeny } from '../../middleware/tenant.js';
import { validationError } from '../../middleware/validation.js';
import { validateRules } from '../../runtime/scripts/mapping.js';
import { pageFrom } from './shared.js';
import { safeRouter } from '../../middleware/asyncHandler.js';
export const automationsRouter = safeRouter();
/** Strop ukazky tela spoustece. Uklada se s automatizaci a cte pri kazdem nacteni. */
const SAMPLE_MAX_CHARS = 100_000;
/** Kolik poslednich behu se vraci bez `limit`. */
const DEFAULT_RUNS_LIMIT = 50;
/** Firmy, na ktere ma uzivatel dosah pri zapisu. */
function writableTenants(req: Request): string[] {
return accessOf(req).tenants.map((tenant) => tenant.id);
}
/** Operator podminky. Vyctem, ne volnym retezcem - neznamy by tise nevetvil. */
const conditionOperatorSchema = z.enum([
'eq',
'neq',
'gt',
'gte',
'lt',
'lte',
'contains',
'startsWith',
'isEmpty',
'isNotEmpty',
'isTrue',
'isFalse',
]);
/** Jedna otazka podminky. Podminka jich muze mit vic, spojene pres `match`. */
const conditionRuleSchema = z.object({
fieldId: z.string().min(1, 'Podmínka musí mít vybraný parametr.'),
operator: conditionOperatorSchema,
value: z.string().optional(),
});
/**
* Rekurzivni schema kroku. z.lazy je nutne, protoze podminka obsahuje
* dalsi kroky - bez toho by se typ odkazoval sam na sebe drive, nez existuje.
*/
const stepSchema: z.ZodType<FlowStep> = z.lazy(() =>
z.discriminatedUnion('kind', [
z.object({
id: z.string().min(1),
kind: z.literal('action'),
serviceId: z.string().min(1),
operationId: z.string().min(1),
// null = vychozi konektor firmy. Prislusnost k firme se overuje nize.
connectorId: z.string().min(1).nullable().optional(),
// Klic je ID pole z katalogu, hodnota sablona. Overuje se nize.
inputs: z.record(z.string()).optional(),
}),
z.object({
id: z.string().min(1),
kind: z.literal('foreach'),
/** Cesta k seznamu v datech, napr. `data.order.items`. */
path: z.string().trim().min(1, 'Smyčka musí mít cestu k seznamu.').max(200),
steps: z.array(stepSchema),
}),
z.object({
id: z.string().min(1),
kind: z.literal('condition'),
/*
* Otazky podminky. `rules` je dnesni podoba, `fieldId` a spol. stara -
* strom ulozeny driv musi jit ulozit znovu, aniz by se prepisoval.
*/
rules: z.array(conditionRuleSchema).max(10).optional(),
match: z.enum(['all', 'any']).optional(),
fieldId: z.string().min(1).optional(),
operator: conditionOperatorSchema.optional(),
value: z.string().optional(),
yes: z.array(stepSchema),
no: z.array(stepSchema),
}),
]),
);
const fieldSchema = z.object({
id: z.string().min(1),
name: z
.string()
.trim()
.min(1, 'Parametr musí mít název.')
// Zamerne jen bezpecne znaky - nazev je klic v prichozim JSONu.
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Název parametru: písmena, číslice a _ (nezačíná číslicí).'),
// `object` a `list` sem chodi z katalogu (providedFields), viz normalizeTriggerFields.
// Builder nabizi uzivateli jen skalarni typy, protoze strukturu do sablony
// dosadit nejde - predava se jen jako celek dalsimu kroku.
type: z.enum(['string', 'number', 'boolean', 'date', 'object', 'list']),
required: z.boolean(),
/**
* Kde ta hodnota v prichozim tele je, kdyz to neni primo `name`.
* Bez toho by slo napojit jen ploche telo, viz `TriggerField.path`.
*/
path: z.string().trim().max(200).optional(),
});
const flowSchema = z.object({
trigger: z
.object({
serviceId: z.string().min(1),
operationId: z.string().min(1),
fields: z.array(fieldSchema),
/**
* Ukazka skutecneho tela. Odvozuje se z ni model, viz data/model.ts.
*
* Strop je kvuli tomu, ze se uklada s automatizaci a cte pri kazdem
* nacteni - cele katalogy sem nepatri.
*/
sample: z
.unknown()
.optional()
.refine(
(value) => value === undefined || JSON.stringify(value).length <= SAMPLE_MAX_CHARS,
'Ukázka těla je moc velká, vejde se 100 kB.',
),
/** Jak casto se ma sluzba obvolavat. Plati jen u spoustecu, co se ptaji. */
intervalSec: z.number().int().min(10).max(86_400).optional(),
// Token generuje server. Cokoliv od klienta se ignoruje.
webhookToken: z.string().optional(),
})
.nullable(),
steps: z.array(stepSchema),
});
const createSchema = z.object({
name: z.string().trim().min(3, 'Název musí mít alespoň 3 znaky.'),
});
const updateSchema = z.object({
name: z.string().trim().min(3, 'Název musí mít alespoň 3 znaky.').optional(),
enabled: z.boolean().optional(),
flow: flowSchema.optional(),
});
/**
* Spoustec, ktery si data urcuje sam (e-mail, WhatsApp, ticket), nesmi mit
* parametry od klienta. Dosadime katalogovou verzi, a to jeste PRED validaci -
* jinak by podminky odkazujici na katalogova ID vypadaly jako rozbite.
*/
function normalizeTriggerFields(flow: z.infer<typeof flowSchema>): z.infer<typeof flowSchema> {
if (!flow.trigger) return flow;
const provided = providedFieldsFor(flow.trigger.serviceId, flow.trigger.operationId);
if (!provided) return flow;
return { ...flow, trigger: { ...flow.trigger, fields: provided.map((field) => ({ ...field })) } };
}
/**
* Overi, ze kazdy krok odkazuje na existujici sluzbu, operaci a konektor.
* Vraci seznam problemu - prazdny znamena, ze je strom v poradku.
*
* `tenantIds` je potreba kvuli konektorum: cizi konektor se musi chovat jako
* neexistujici, jinak by strom mohl volat cizim jmenem.
*/
async function validateFlowReferences(
flow: z.infer<typeof flowSchema>,
tenantIds: string[],
): Promise<{ problems: string[]; issues: string[] }> {
const problems: string[] = [];
/** Nedodelky: strom se ulozi, jen automatizace nepujde zapnout. */
const issues: string[] = [];
if (!flow.trigger) return { problems, issues };
const trigger = findOperation(flow.trigger.serviceId, flow.trigger.operationId, 'trigger');
if (!trigger) {
problems.push(
`Spouštěč ${flow.trigger.serviceId}/${flow.trigger.operationId} neexistuje v katalogu.`,
);
}
// Nazvy parametru musi byt unikatni - jsou to klice v prichozich datech.
const names = new Set<string>();
for (const field of flow.trigger.fields) {
if (names.has(field.name)) {
problems.push(`Parametr "${field.name}" je uvedený dvakrát.`);
}
names.add(field.name);
}
// Scope rika, co je v kterem miste stromu videt. Podminka se smi ptat
// jen na parametry, ktere pred ni uz vznikly.
const scopes = collectScopes(flow);
const walk = async (steps: FlowStep[]): Promise<void> => {
for (const step of steps) {
if (step.kind === 'condition') {
// Kazda otazka zvlast, jinak by prvni spatna schovala ostatni.
for (const rule of rulesOf(step)) {
const field = scopes.all.get(rule.fieldId);
if (!field) {
problems.push(`Podmínka odkazuje na neexistující parametr (${rule.fieldId}).`);
} else if (!operatorAllowedForType(rule.operator, field.type)) {
problems.push(
`Operátor "${rule.operator}" nelze použít na parametr "${field.name}" typu ${field.type}.`,
);
}
}
await walk(step.yes);
await walk(step.no);
continue;
}
if (step.kind === 'foreach') {
if (step.path.trim().length === 0) {
problems.push('Smyčka musí mít cestu k seznamu, například data.order.items.');
}
await walk(step.steps);
continue;
}
const action = findOperation(step.serviceId, step.operationId, 'action');
if (!action) {
problems.push(`Akce ${step.serviceId}/${step.operationId} neexistuje v katalogu.`);
continue;
}
// Nastaveni musi sedet na katalog. Neznamy klic je rozbity strom,
// ne nedodelek - ulozit ho by znamenalo drzet data, ktera nikdo neprecte.
const allowed = new Set((action.inputs ?? []).map((input) => input.id));
for (const key of Object.keys(step.inputs ?? {})) {
if (!allowed.has(key)) {
problems.push(
`Akce ${step.serviceId}/${step.operationId} nemá nastavitelné pole "${key}".`,
);
}
}
// Pole typu json a mapping nesou strukturu zapsanou jako text. Preklep
// v zavorce je nedodelek, ne chyba: rozdelana prace se nezahazuje, jen
// automatizace nepujde zapnout. Bez teto kontroly by se to poznalo
// az z padleho behu.
for (const input of action.inputs ?? []) {
if (input.kind !== 'json' && input.kind !== 'mapping') continue;
const raw = (step.inputs ?? {})[input.id];
if (raw === undefined || raw.trim() === '') continue;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
const detail = err instanceof Error ? err.message : 'neplatný JSON';
issues.push(`Pole "${input.label}" není platný JSON: ${detail}`);
continue;
}
if (input.kind === 'mapping') {
issues.push(...validateRules(parsed, `pole "${input.label}"`));
}
}
// Vybrany konektor musi patrit te same firme a te same sluzbe.
// Cizi konektor je rozbity strom, ne nedodelek.
if (step.connectorId) {
const connector = await getConnector(step.connectorId, tenantIds);
if (!connector) {
problems.push(`Krok odkazuje na konektor, který neexistuje (${step.connectorId}).`);
} else if (connector.serviceId !== step.serviceId) {
problems.push(`Konektor ${connector.name} patří jiné službě než krok ${step.serviceId}.`);
}
} else {
const service = findService(step.serviceId);
// Chybejici napojeni je nedodelek, ne chyba - rozdelana prace se ulozi.
const onlyTenant = tenantIds.length === 1 ? tenantIds[0] : undefined;
if (service && !service.general && onlyTenant !== undefined) {
const fallback = await defaultConnectorFor(onlyTenant, step.serviceId);
if (!fallback) {
issues.push(
`Služba ${service.name} nemá v této firmě konektor. Vytvořte ho v Konektorech.`,
);
}
}
}
}
};
await walk(flow.steps);
return { problems, issues };
}
automationsRouter.get('/automations', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
return res.json({ items: listAutomations(scope.tenantIds) });
});
automationsRouter.get('/automations/:id', (req, res) => {
const automation = getAutomation(req.params.id, writableTenants(req));
if (!automation) {
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
}
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) {
// Bez parametru cesty se hleda prazdne ID, tedy nic - stejne jako drive.
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;
}
automationsRouter.post('/automations', (req, res) => {
const parsed = createSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error);
// Zakladat se musi do konkretni firmy, pohled "vse" na to nestaci.
const scope = scopeOrDeny(req, res);
if (!scope) return;
if (!scope.tenantId) {
return res.status(400).json({
error: 'tenant_required',
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);
});
automationsRouter.put('/automations/:id', async (req, res) => {
const parsed = updateSchema.safeParse(req.body);
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) {
// 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) => ({ field: 'flow', message })),
});
}
connectorIssues = issues;
}
const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, [existing.tenantId]);
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
}
// Chybejici konektor je nedodelek: strom se ulozil, jen to nepobezi.
return res.json({ ...updated, issues: [...updated.issues, ...connectorIssues] });
});
/** Nova adresa webhooku. Stara okamzite prestane fungovat - zamer, ne chyba. */
automationsRouter.post('/automations/:id/webhook/regenerate', (req, res) => {
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',
message: 'Automatizace neexistuje, nebo jejím spouštěčem není webhook.',
});
}
return res.json(updated);
});
automationsRouter.delete('/automations/:id', (req, res) => {
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();
});
/**
* Stav fronty behu.
*
* Kdyz neco nefunguje, tohle je prvni misto, kam se clovek podiva: ceka
* fronta, nebo uz to nekolikrat selhalo a vzdalo se?
*/
automationsRouter.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 ?? DEFAULT_RUNS_LIMIT)).slice(
page.offset,
);
res.setHeader('X-Total-Count', String(stats.pending + stats.running + stats.failed + stats.done));
return res.json({
stats,
items: runs.map((run) => ({
id: run.id,
automationId: run.automationId,
trigger: run.trigger,
status: run.status,
attempts: run.attempts,
ticketId: run.ticketId,
// Cele hlaseni. Zkratit ho tady znamena, ze se pricina uz nedozvime.
lastError: run.lastError,
nextAttemptAt: run.nextAttemptAt,
createdAt: run.createdAt,
finishedAt: run.finishedAt,
})),
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Hlaseni padu portalu z prohlizece. Z hlaseni vznika incident.
*/
import { createHash } from 'node:crypto';
import { z } from 'zod';
import { createIncident } from '../../data/incidentStore.js';
import { tenantOrDeny } from '../../middleware/tenant.js';
import { validationError } from '../../middleware/validation.js';
import { safeRouter } from '../../middleware/asyncHandler.js';
export const clientCrashRouter = safeRouter();
/**
* Hlaseni padu portalu.
*
* Kdyz v prohlizeci spadne vykreslovani, uzivatel to vidi, ale my ne. Bez
* tohohle endpointu je jedina stopa v jeho konzoli, kam se nikdo nedostane,
* takze o padu vime jen tehdy, kdyz ho nekdo nahlasi. To znamena, ze o vetsine
* padu nevime vubec.
*
* Incident nese dve casti a je to zamerne:
* - `title` a `impact` cte zakaznik, takze zadne stack trace,
* - `detail` cte spravce platformy a je v nem vsechno: hlaska, misto v kodu,
* strom komponent, adresa stranky a verze buildu.
*
* Cely `detail` je to, **jak se to stalo**. Bez adresy a stromu komponent je
* hlaska "Cannot create property" k nepouziti.
*/
/**
* Kolik nejvys znaku se z jednoho pole prevezme.
*
* Strom komponent umi byt velmi dlouhy a do incidentu patri jeho zacatek,
* protoze prave nahore je komponenta, ktera spadla.
*/
const FIELD_LIMIT = 4_000;
/**
* Jak dlouho se tentyz pad povazuje za jeden.
*
* Pad pri vykreslovani se opakuje pri kazdem stisku klavesy. Bez tohohle by
* z jedne chyby vzniklo padesat incidentu a ten pravy by v nich zapadl.
*/
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(),
componentStack: z.string().max(FIELD_LIMIT).optional(),
/** Kde v portalu se to stalo. Bez toho se to nema kde hledat. */
path: z.string().max(500).optional(),
/** Verze nasazeneho klienta. Rika, jestli uz je v tom oprava. */
build: z.string().max(200).optional(),
});
clientCrashRouter.post('/client-crash', (req, res) => {
// 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 validationError(res, parsed.error);
const crash = parsed.data;
const where = crash.path ?? 'neznámá stránka';
/*
* 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 = 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 = [
`Stránka: ${where}`,
`Uživatel: ${req.user!.email}`,
`Verze klienta: ${crash.build ?? 'neznámá'}`,
`Prohlížeč: ${String(req.headers['user-agent'] ?? 'neznámý')}`,
'',
`Hláška: ${crash.message}`,
...(crash.componentStack ? ['', 'Strom komponent:', crash.componentStack] : []),
...(crash.stack ? ['', 'Zásobník volání:', crash.stack] : []),
].join('\n');
const incident = createIncident({
tenantId,
title: 'Část portálu se nepodařilo vykreslit',
service: 'Portál',
// Pad vykreslovani neni vypadek sluzby, ale uzivatel u toho nemuze
// pokracovat v praci. Prostredni zavaznost, ne nejvyssi.
severity: 'sev2',
impact: `Stránka ${where} se části uživatelů nezobrazila správně. Ostatní části portálu fungují.`,
detail,
source: 'portál',
});
console.error(`[ui] pad portalu na ${where}: ${crash.message} (incident ${incident.id})`);
return res.status(201).json({ created: true, incidentId: incident.id });
});
+98
View File
@@ -0,0 +1,98 @@
/**
* Incidenty: seznam, detail a posun stavu.
*/
import type { Request } from 'express';
import { z } from 'zod';
import {
findIncident,
listIncidents,
updateIncidentStatus,
type Incident,
} from '../../data/incidentStore.js';
import { hasPermission } from '../../data/permissions.js';
import { recordAudit } from '../../data/audit.js';
import { scopeOrDeny } from '../../middleware/tenant.js';
import { validationError } from '../../middleware/validation.js';
import { safeRouter } from '../../middleware/asyncHandler.js';
export const incidentsRouter = safeRouter();
/**
* Incidenty firmy plus platformni.
*
* Klient vidi `title` a `impact`, tedy co to pro nej znamena. `detail` s celym
* hlasenim, ID behu a daty na vstupu vidi **jen spravce platformy** - je to
* nase diagnostika, ne informace pro zakaznika.
*/
incidentsRouter.get('/incidents', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const forAdmin = req.user!.platformAdmin;
return res.json({
items: listIncidents(scope.tenantIds).map((incident) => ({
...incident,
detail: forAdmin ? incident.detail : null,
})),
});
});
/** Stejne pravidlo jako u seznamu: diagnostiku vidi jen spravce platformy. */
function publicIncident(req: Request, incident: Incident): Incident {
return { ...incident, detail: req.user!.platformAdmin ? incident.detail : null };
}
incidentsRouter.get('/incidents/:id', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const incident = findIncident(req.params.id, scope.tenantIds);
if (!incident)
return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' });
return res.json(publicIncident(req, incident));
});
const incidentStatusSchema = z.object({
status: z.enum(['investigating', 'identified', 'monitoring', 'resolved']),
});
/**
* Posun incidentu do dalsiho stavu.
*
* Incident firmy meni, kdo ma v te firme `incident.manage`. Platformni incident
* (bez firmy) je nas a meni ho jen spravce platformy - klient by jinak mohl
* "vyresit" vypadek, ktery se tyka vsech.
*/
incidentsRouter.patch('/incidents/:id/status', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const incident = findIncident(req.params.id, scope.tenantIds);
if (!incident)
return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' });
const allowed =
req.user!.platformAdmin ||
(incident.tenantId !== null && hasPermission(req.user!, 'incident.manage', incident.tenantId));
if (!allowed) {
return res.status(403).json({ error: 'forbidden', message: 'Stav incidentu nemůžete měnit.' });
}
const parsed = incidentStatusSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error);
const updated = updateIncidentStatus(incident.id, parsed.data.status);
if (!updated)
return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' });
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId: incident.tenantId,
action: 'incident.status',
target: incident.id,
detail: { status: parsed.data.status },
});
return res.json(publicIncident(req, updated));
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Dashboard: vsechno za prihlasenim.
*
* Tady se jen skladaji routery podle domeny. Prava se spocitaji jednou za
* request (`attachAccess`) a kazdy router z nich cte. Poradi mountu zustava
* jako driv: konkretni cesty pred obecnymi, aby je nic neprebilo.
*/
import { safeRouter } from '../../middleware/asyncHandler.js';
import { requireAuth } from '../../middleware/auth.js';
import { attachAccess } from '../../middleware/tenant.js';
import { connectorsRouter } from '../connectors.js';
import { helpdeskRouter } from '../helpdesk.js';
import { inviteRouter } from '../invites.js';
import { scriptsRouter } from '../scripts.js';
import { settingsRouter } from '../settings/index.js';
import { streamRouter } from '../stream.js';
import { tenantScriptRouter } from '../tenantScripts.js';
import { ticketActionsRouter } from '../ticketActions.js';
import { widgetDataRouter } from '../widgetData.js';
import { automationsRouter } from './automations.js';
import { clientCrashRouter } from './clientCrash.js';
import { incidentsRouter } from './incidents.js';
import { intakeRouter } from './intake.js';
import { layoutRouter } from './layout.js';
import { miscRouter } from './misc.js';
import { notificationsRouter } from './notifications.js';
import { peopleRouter } from './people.js';
import { ticketsRouter } from './tickets.js';
export const dashboardRouter = safeRouter();
// Cely dashboard je jen pro prihlasene. Prava se spocitaji jednou za request.
dashboardRouter.use(requireAuth, attachAccess);
dashboardRouter.use(miscRouter);
dashboardRouter.use(incidentsRouter);
dashboardRouter.use(clientCrashRouter);
dashboardRouter.use(layoutRouter);
dashboardRouter.use(peopleRouter);
dashboardRouter.use(intakeRouter);
dashboardRouter.use(notificationsRouter);
// Seznam, stavy a detail ticketu. `/tickets/:id` je tady, akce nize.
dashboardRouter.use(ticketsRouter);
// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo.
dashboardRouter.use('/stream', streamRouter);
// Skripty konektoru. Taky pred obecnymi cestami.
dashboardRouter.use('/scripts', scriptsRouter);
// Skripty firmy. Prevod dat v JS, na rozdil od skriptu sluzeb nevolaji ven.
dashboardRouter.use('/tenant-scripts', tenantScriptRouter);
// Sluzby a konektory. `/connectors/services` je uvnitr toho routeru.
dashboardRouter.use('/connectors', connectorsRouter);
// Nastaveni vsech entit. Cele stoji na fabrice crudRouter.
dashboardRouter.use('/settings', settingsRouter);
// Pozvanky do firmy.
dashboardRouter.use('/invites', inviteRouter);
// Data pro vlastni widgety. Jeden request na cely prehled.
dashboardRouter.use('/widget-data', widgetDataRouter);
// Akce na ticketu: prevzeti, prirazeni, stav, komentar. Kazda ma sve pravo.
dashboardRouter.use('/tickets', ticketActionsRouter);
// Pohled zadavatele na jeho vlastni pozadavky. Vlastni router, protoze se
// scopuje podle `helpdeskSourceId`, ne podle vlastnika ticketu.
dashboardRouter.use('/helpdesk', helpdeskRouter);
// Automatizace a fronta behu.
dashboardRouter.use(automationsRouter);
+104
View File
@@ -0,0 +1,104 @@
/**
* Adresa pro prijem udalosti do ticketu a jeji obnova.
*/
import { publicBaseUrl } from '../../config.js';
import { hasPermission } from '../../data/permissions.js';
import { recordAudit } from '../../data/audit.js';
import {
findTenant,
generateIntakeToken,
refreshTenants,
tenantStore,
} from '../../data/tenants.js';
import { listTicketTypes } from '../../data/ticketTypes.js';
import { scopeOrDeny } from '../../middleware/tenant.js';
import { safeRouter } from '../../middleware/asyncHandler.js';
export const intakeRouter = safeRouter();
/**
* Adresa pro prijem udalosti do ticketu.
*
* Token je pristupovy udaj, proto ho vidi jen ten, kdo spravuje napojeni.
* Kdo umi zalozit konektor, umi zaridit i prijem - je to tatáz prace.
*/
intakeRouter.get('/intake', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
if (!hasPermission(req.user!, 'connector.manage', scope.tenantId)) {
return res.status(403).json({
error: 'forbidden',
message: 'Adresu pro příjem vidí ten, kdo spravuje napojení.',
});
}
const tenantId = scope.tenantIds[0];
const tenant = tenantId ? findTenant(tenantId) : undefined;
if (!tenant) {
return res.status(404).json({ error: 'not_found', message: 'Firma neexistuje.' });
}
return res.json({
tenantId: tenant.id,
tenantName: tenant.name,
url: `${publicBaseUrl()}/webhook/ticket/${tenant.intakeToken}`,
ticketTypes: listTicketTypes([tenant.id]).map((type) => ({
id: type.id,
name: type.name,
fields: type.fields.map((field) => ({
key: field.key,
label: field.label,
type: field.type,
required: field.required,
})),
})),
});
});
/** Nova adresa. Stara okamzite prestane fungovat. */
intakeRouter.post('/intake/regenerate', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
if (!hasPermission(req.user!, 'connector.manage', scope.tenantId)) {
return res.status(403).json({
error: 'forbidden',
message: 'Adresu pro příjem mění ten, kdo spravuje napojení.',
});
}
const tenantId = scope.tenantIds[0];
if (!tenantId) {
return res.status(400).json({ error: 'no_tenant', message: 'Vyberte firmu.' });
}
return void tenantStore
.update(
tenantId,
{ intakeToken: generateIntakeToken() },
{ tenantIds: [], includeGlobal: true },
)
.then(async (updated) => {
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Firma neexistuje.' });
}
await refreshTenants();
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId,
action: 'intake.regenerate',
target: tenantId,
});
console.info(`[intake] ${tenantId}: adresa pregenerovana, stara neplati`);
return res.json({ url: `${publicBaseUrl()}/webhook/ticket/${updated.intakeToken}` });
})
.catch((err: unknown) => {
console.error('[intake] regenerace selhala:', err);
return res
.status(500)
.json({ error: 'internal_error', message: 'Adresu se nepodařilo změnit.' });
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* Rozlozeni dashboardu: katalog widgetu a ulozene rozlozeni za firmu.
*/
import { z } from 'zod';
import {
getLayout,
hasCustomLayout,
resetLayout,
saveLayout,
validateLayout,
} from '../../data/dashboardLayouts.js';
import { widgetCatalog } from '../../data/widgets.js';
import { accessOf, tenantOrDeny } from '../../middleware/tenant.js';
import { validationError } from '../../middleware/validation.js';
import { safeRouter } from '../../middleware/asyncHandler.js';
export const layoutRouter = safeRouter();
/**
* Katalog widgetu: pevne z kodu plus vlastni firmy.
*
* Vlastni widget je pro klienta tentyz tvar jako pevny, jen `custom: true`.
* Diky tomu se rozlozeni dashboardu nemuselo menit.
*/
layoutRouter.get('/widgets', (req, res) => {
/*
* Katalog je za konkretni firmu, stejne jako rozlozeni. Kdyby vracel widgety
* vsech firem uzivatele, sla by polozit dlazdice Automie na dashboard
* Nordisu - v nabidce by byla, ale data by k ni nikdy neprisla.
*/
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({
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.
*/
layoutRouter.get('/layout', (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({
tenantId,
items: getLayout(req.user!.id, tenantId, accessOf(req).personId !== null),
custom: hasCustomLayout(req.user!.id, tenantId),
});
});
const layoutSchema = z.object({
items: z.array(
z.object({
id: z.string().min(1),
widgetId: z.string().min(1),
size: z.enum(['third', 'half', 'full']),
}),
),
});
layoutRouter.put('/layout', (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const parsed = layoutSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error, 'Neplatné rozložení.');
const problems = validateLayout(
parsed.data.items,
widgetCatalog([tenantId], req.user!.id, accessOf(req).personId !== null),
);
if (problems.length > 0) {
console.warn(`[layout] ${req.user!.email}: neplatne rozlozeni - ${problems.join(' ')}`);
return res.status(400).json({ error: 'validation_error', message: problems[0] });
}
const items = saveLayout(req.user!.id, tenantId, parsed.data.items);
return res.json({ tenantId, items, custom: true });
});
/** Vraceni na vychozi. Zamerne DELETE - je to smazani ulozeneho nastaveni. */
layoutRouter.delete('/layout', (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
return res.json({
tenantId,
items: resetLayout(req.user!.id, tenantId, accessOf(req).personId !== null),
custom: false,
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* Drobne endpointy dashboardu: prava, prehled, uloziste a katalog sluzeb.
*/
import { publicBaseUrl } from '../../config.js';
import { operatorsByType } from '../../data/conditions.js';
import { listTenantScripts } from '../../data/tenantScripts.js';
import { connectorCountsByService, storageStatus } from '../../data/connectorStore.js';
import {
serviceCatalog,
serviceCategories,
visibleServices,
withRuntimeOptions,
} from '../../data/services.js';
import { getSummary } from '../../data/mock.js';
import { listGroups, listPeople } from '../../data/people.js';
import { listTicketTypes } from '../../data/ticketTypes.js';
import { accessOf, optionalTenantOrDeny, scopeOrDeny } from '../../middleware/tenant.js';
import { safeRouter } from '../../middleware/asyncHandler.js';
export const miscRouter = safeRouter();
/** Co uzivatel smi, aby klient nemusel hadat, ktere prepinace kreslit. */
miscRouter.get('/access', (req, res) => {
res.json(accessOf(req));
});
miscRouter.get('/summary', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
return res.json(getSummary(scope.tenantIds));
});
/**
* Kam se uklada a jestli to prezije restart.
*
* 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.
*/
miscRouter.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 });
});
/**
* Katalog sluzeb pro builder.
*
* Operace, ktere obsluhuje skript, se domeruji z jeho manifestu, takze builder
* vidi skutecne vstupy a vystupy. Podrobnosti v `src/scripts/registry.ts`.
*
* Vraci se **jen sluzby, ktere uzivatel vidi**. Neviditelna sluzba v odpovedi
* neni vubec, ne se stavem "nemate pravo".
*/
miscRouter.get('/services', async (req, res) => {
// 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] : [];
/*
* Ke ktere sluzbe ma firma napojeni.
*
* Katalog se **nefiltruje**, jen se oznaci: log ticketu a detail akce
* podle nej prekladaji ID operaci na jmena, a kdyby sluzba z odpovedi
* zmizela, zustalo by v uz zapsanem radku holé ID. Vybirat z ni ale nema
* smysl, kdyz ji neni cim zavolat - o to se stara builder.
*/
const counts = await connectorCountsByService(tenantIds);
res.json({
categories: serviceCategories,
items: withRuntimeOptions(
// Firma se predava kvuli MCP: nastroje jsou vlastnost jejiho napojeni,
// ne katalogu. Bez ni se nevrati zadne.
serviceCatalog(tenantId ?? null)
.filter((service) => visible.has(service.id))
.map((service) => ({ ...service, connected: (counts.get(service.id) ?? 0) > 0 })),
{
people: listPeople(tenantIds).map((person) => ({ id: person.id, name: person.name })),
groups: listGroups(tenantIds).map((group) => ({ id: group.id, name: group.name })),
scripts: listTenantScripts(tenantIds).map((script) => ({
id: script.id,
name: script.name,
})),
// Typy ticketu jsou vlastnost firmy. Bez teto nabidky by uzivatel musel
// ID typu nekde vyhledat a prepsat, coz je presne to, co nikdo nedela.
ticketTypes: listTicketTypes(tenantIds).map((type) => ({
id: type.id,
name: type.name,
})),
},
),
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
// a jakou zakladni adresu ukazat u webhooku.
operatorsByType,
webhookBaseUrl: `${publicBaseUrl()}/webhook`,
});
});
+56
View File
@@ -0,0 +1,56 @@
/**
* Upozorneni prihlaseneho a pocet jeho otevrenych ticketu.
*/
import type { Request } from 'express';
import { listNotifications, markRead, unreadCount } from '../../data/notifications.js';
import { listTickets } from '../../data/ticketStore.js';
import { accessOf } from '../../middleware/tenant.js';
import { safeRouter } from '../../middleware/asyncHandler.js';
export const notificationsRouter = safeRouter();
/**
* Upozorneni prihlaseneho.
*
* Cislo u zalozky "Moje tickety" a hlaska pri prirazeni. Nechodi to pres
* stream jako jedina cesta - kdo mel portal zavreny, musi to najit i po
* prihlaseni, proto jsou upozorneni ulozena.
*/
notificationsRouter.get('/notifications', (req, res) => {
const items = listNotifications(req.user!.id);
return res.json({
items,
unread: unreadCount(req.user!.id),
/** Kolik ticketu ma prihlaseny u sebe. To je to cislo u zalozky. */
mine: myOpenTickets(req),
});
});
/** Oznaci prectene. Bez seznamu vsechny. */
notificationsRouter.post('/notifications/read', (req, res) => {
const ids = Array.isArray(req.body?.ids)
? (req.body.ids as unknown[]).filter((id): id is string => typeof id === 'string')
: undefined;
return void markRead(req.user!.id, ids)
.then((count) => res.json({ marked: count, unread: unreadCount(req.user!.id) }))
.catch((err: unknown) => {
console.error('[upozorneni] oznaceni selhalo:', err);
return res.status(500).json({ error: 'internal_error', message: 'Nepodařilo se uložit.' });
});
});
/** Kolik nevyrizenych ma prihlaseny u sebe. */
function myOpenTickets(req: Request): number {
const access = accessOf(req);
if (!access.personId) return 0;
// Vlastni tickety jsou ve stropu vzdycky, ale posila se vyslovne - filtr
// na prava nesmi byt nepovinny.
return listTickets({
tenantIds: access.tenants.map((tenant) => tenant.id),
visibility: { kind: 'scoped', personIds: [access.personId], groupIds: [] },
assignee: access.personId,
}).filter((ticket) => !ticket.closed).length;
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Resitele: seznam pro nabidky a detail jednoho cloveka.
*/
import { isMember, listGroups, listPeople } from '../../data/people.js';
import { getAgentStats, listTickets } from '../../data/ticketStore.js';
import { accessOf, scopeOrDeny } from '../../middleware/tenant.js';
import { safeRouter } from '../../middleware/asyncHandler.js';
export const peopleRouter = safeRouter();
/** Obdobi statistiky resitele. Stejne jako u widgetu vykonu, aby cisla sedela. */
const STATS_WINDOW_MS = 30 * 86_400_000;
/** Kolik posledne vyresenych ticketu se ukaze v detailu. */
const RESOLVED_LIMIT = 20;
/**
* Resitele vybrane firmy. Klient je potrebuje do nabidky prirazeni i do prehledu.
*
* Skupiny jdou stejnym endpointem zamerne: kdo smi prirazovat ticket, smi ho
* prirazit i skupine, a druhy request na dve polozky nema smysl. Sprava skupin
* je jina vec a ma vlastni pravo v nastaveni.
*/
peopleRouter.get('/people', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
return res.json({
items: listPeople(scope.tenantIds),
groups: listGroups(scope.tenantIds).map((group) => ({ id: group.id, name: group.name })),
meId: accessOf(req).personId,
});
});
/**
* Detail resitele: kdo to je, jak mu to jde a co ma u sebe.
*
* Statistika i tickety chodi jednim requestem. Stranka o jednom cloveku by
* jinak delala tri dotazy na tri veci, ktere se pocitaji z tehoz seznamu.
*/
peopleRouter.get('/people/:id', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const person = listPeople(scope.tenantIds).find((item) => item.id === req.params.id);
if (!person) {
// Cizi resitel se chova jako neexistujici, ne jako odepreni prava.
return res.status(404).json({ error: 'not_found', message: 'Řešitel neexistuje.' });
}
// 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() - STATS_WINDOW_MS;
const stats = getAgentStats([person], scope.tenantIds, since, scope.visibility)[0] ?? null;
return res.json({
person,
stats,
groups: listGroups(scope.tenantIds)
.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 && ticket.assignee?.id === person.id),
/** Co za posledni dobu vyresil, nejnovejsi nahore. */
resolved: tickets
.filter((ticket) => ticket.closed && ticket.resolvedById === person.id)
.sort((a, b) => (b.resolvedAt ?? '').localeCompare(a.resolvedAt ?? ''))
.slice(0, RESOLVED_LIMIT),
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Strankovani sdilene routami dashboardu (seznam ticketu, fronta behu).
*/
/** Nejvetsi povolena stranka. Vic polozek najednou uz klient stejne nevykresli. */
const MAX_PAGE_LIMIT = 500;
/**
* 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.
*/
export function pageFrom(
query: Record<string, unknown>,
maxLimit = MAX_PAGE_LIMIT,
): { 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,
};
}
export 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);
}
+268
View File
@@ -0,0 +1,268 @@
/**
* Tickety: seznam s filtrem, rucni zalozeni, stavy, vytizeni a detail.
*
* Vestavene akce (prevzeti, prirazeni, stav, komentar) jsou v
* `routes/ticketActions.ts`, mountuje je `dashboard/index.ts`.
*/
import { z } from 'zod';
import type { ResolvedScope } from '../../data/access.js';
import { listPeople } from '../../data/people.js';
import { hasPermission } from '../../data/permissions.js';
import { recordAudit } from '../../data/audit.js';
import {
createTicket,
getWorkload,
listTickets,
type TicketChannel,
type TicketFilter,
} from '../../data/ticketStore.js';
import { accessOf, scopeOrDeny } from '../../middleware/tenant.js';
import { validationError } from '../../middleware/validation.js';
import { visibleTicketOrDeny } from '../ticketActions.js';
import { pageFrom, paginate } from './shared.js';
import { safeRouter } from '../../middleware/asyncHandler.js';
export const ticketsRouter = safeRouter();
/*
* Stav uz neni ciselnik. Filtr proto bere, co prijde - kdyz to na nic nesedi,
* vrati se prazdny seznam, coz je spravna odpoved na "ukaz mi stav, ktery
* nikdo nema".
*/
const ticketChannels: TicketChannel[] = [
'whatsapp',
'facebook',
'instagram',
'email',
'voice',
'form',
'portal',
];
/**
* Filtr ze query parametru. Nesmyslnou hodnotu zahodime a zalogujeme -
* je lepsi ukazat vic ticketu nez prazdny seznam bez vysvetleni.
*/
function ticketFilterFrom(query: Record<string, unknown>, scope: ResolvedScope): TicketFilter {
const filter: TicketFilter = { tenantIds: scope.tenantIds, visibility: scope.visibility };
// Filtry, na ktere se odkazuje z widgetu. `none` znamena "bez toho".
const typeId = query.typeId;
if (typeof typeId === 'string' && typeId !== '') filter.typeId = typeId;
const tag = query.tag;
if (typeof tag === 'string' && tag !== '') filter.tag = tag;
const groupId = query.groupId;
if (typeof groupId === 'string' && groupId !== '') filter.groupId = groupId;
// Pohled "moje" je silnejsi nez rucni filtr na resitele.
if (scope.scope === 'mine') {
filter.assignee = scope.personId ?? '__nikdo__';
return applyRest(query, filter);
}
const assignee = typeof query.assignee === 'string' ? query.assignee : undefined;
if (assignee) filter.assignee = assignee;
return applyRest(query, filter);
}
function applyRest(query: Record<string, unknown>, filter: TicketFilter): TicketFilter {
/*
* Stav uz neni ciselnik, takze se nekontroluje proti seznamu. Kdyz hodnota
* na nic nesedi, vrati se prazdny seznam - to je spravna odpoved na dotaz
* po stavu, ktery nikdo nema.
*/
const status = typeof query.status === 'string' ? query.status : undefined;
if (status) filter.status = status;
const channel = typeof query.channel === 'string' ? query.channel : undefined;
if (channel) {
if (ticketChannels.includes(channel as TicketChannel))
filter.channel = channel as TicketChannel;
else console.warn(`[tickets] neznamy kanal ve filtru: ${channel}`);
}
return filter;
}
ticketsRouter.get('/tickets', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const filter = ticketFilterFrom(req.query as Record<string, unknown>, scope);
/*
* 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. Kdyz zadny
* filtr neni, je cely rozsah zaroven vysledek a seznam se cte jen jednou.
*/
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: paginate(filtered, page),
total: filtered.length,
statuses,
meId: accessOf(req).personId,
scope: scope.scope,
tenantId: scope.tenantId,
});
});
/**
* Rucne zalozeny ticket.
*
* Dosud ticket vznikal jen z automatizace nebo z prichozi udalosti. Jenze
* pozadavek casto prijde telefonem nebo pri kafi a nekdo ho musi zapsat -
* bez toho konci na papirku a v systemu neni.
*
* **Zakaznik je nepovinny.** Ticket zalozeny rucne je casto ukol, ne pozadavek
* od nekoho zvenku, a nutit k nemu firmu a kontakt by znamenalo vymyslet si je.
*/
const createTicketSchema = z.object({
subject: z.string().trim().min(1, 'Předmět nesmí být prázdný.'),
body: z.string().default(''),
priority: z.enum(['low', 'normal', 'high', 'critical']).default('normal'),
typeId: z.string().trim().min(1).nullable().optional(),
assigneeId: z.string().trim().min(1).nullable().optional(),
assigneeGroupId: z.string().trim().min(1).nullable().optional(),
tags: z.array(z.string().trim().min(1)).max(20).default([]),
/** Nepovinny. Prazdna pole se neukladaji jako prazdne retezce nasilim. */
customer: z
.object({
company: z.string().trim().default(''),
contact: z.string().trim().default(''),
reply: z.string().trim().default(''),
})
.optional(),
});
ticketsRouter.post('/tickets', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
if (!hasPermission(req.user!, 'ticket.create', scope.tenantId)) {
return res.status(403).json({
error: 'forbidden',
message: 'Nemáte právo zakládat tickety.',
});
}
const parsed = createTicketSchema.safeParse(req.body);
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.
const tenantId = scope.tenantId;
if (!tenantId) {
return res.status(400).json({
error: 'no_tenant',
message: 'Vyberte firmu, do které ticket patří.',
});
}
const input = parsed.data;
const customer = input.customer;
const hasCustomer =
customer !== undefined &&
(customer.company !== '' || customer.contact !== '' || customer.reply !== '');
const ticket = createTicket({
tenantId,
subject: input.subject,
body: input.body,
// Rucne zalozeny ticket prisel z portalu, ne z WhatsAppu ani z e-mailu.
channel: 'portal',
priority: input.priority,
typeId: input.typeId ?? null,
assigneeId: input.assigneeId ?? null,
assigneeGroupId: input.assigneeGroupId ?? null,
tags: input.tags,
...(hasCustomer
? {
customer: {
id: null,
company: customer.company,
contact: customer.contact,
reply: customer.reply,
},
}
: {}),
trace: [
{
kind: 'note',
label: 'Založeno ručně',
status: 'info',
response: `Ticket založil ${req.user!.email} v portálu.`,
},
],
});
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId,
action: 'ticket.create',
target: ticket.id,
detail: { subject: ticket.subject },
});
return res.status(201).json(ticket);
});
/*
* Prevzeti, prirazeni, stav a komentar jsou vestavene akce na ticketu,
* viz `routes/ticketActions.ts` - kazda ma sve pravo a projde auditem.
*/
/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */
/**
* Stavy, ktere firma opravdu pouziva.
*
* Stav je **volny retezec**, ne ciselnik: ticket muze prijit z cizi aplikace
* s jejim vlastnim stavem. Pevny seznam v rozhrani by na nej nikdy nesedel.
* Tohle je proto jen naseptavac - vraci to, co uz v datech je, a nova hodnota
* projde stejne dobre.
*
* Musi byt registrovane pred `/tickets/:id`, jinak by se `statuses` chytilo
* jako ID ticketu.
*/
ticketsRouter.get('/tickets/statuses', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const items = [
...new Set(
listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }).map(
(ticket) => ticket.status,
),
),
].sort((a, b) => a.localeCompare(b, 'cs'));
return res.json({ items });
});
ticketsRouter.get('/tickets/workload', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
return res.json(getWorkload(listPeople(scope.tenantIds), scope.tenantIds, scope.visibility));
});
ticketsRouter.get('/tickets/:id', (req, res) => {
// 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);
});

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