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:
+297
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
+4
-1345
File diff suppressed because it is too large
Load Diff
@@ -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.',
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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í.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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ň'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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í'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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í'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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: [],
|
||||
},
|
||||
];
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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í'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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}}.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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: [],
|
||||
},
|
||||
];
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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' })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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.' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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' } },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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' } },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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.' });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user