Nahrazeni sablony kompletnim webem a klientskym portalem
Web a portal Automia v jednom containeru. Express obsluhuje API i zbuildovanou React aplikaci z dist/public. Obsah: - verejny web: homepage, sluzby, o nas, kontakt, 404 - prihlaseni pres JWT, demo ucty - portal: prehled s grafem, tickety, incidenty, automatizace, konektory - builder automatizaci: strom akci, vetveni podminkou - katalog 25 konektoru v 8 kategoriich - webhook s registrovanou adresou, token generuje server - zivy dashboard pres SSE vcetne simulace provozu - Swagger UI na /docs a OpenAPI na /openapi.json Soulad s AGENTS.md: - ROOT_PATH z prostredi, prefix proxy nikde nehardcodovan - mount na koren i na prefix, funguje s handle_path i bez nej - base tag a window.__BASE_PATH__ vkladane do index.html za behu - OpenAPI servers obsahuje prefix, Try it out vola spravnou adresu - povinne /health a /docs, port 3000, naslouchani na 0.0.0.0 - secrets jen z environment variables, nikdy v logu Dokumentace ve slozce documentation/.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Konfigurace z environment variables.
|
||||
* AppFactory je predava containeru, viz AGENTS.md, sekce Variables a secrets.
|
||||
* Zadna hodnota se nehardcoduje a zadny secret se neloguje.
|
||||
*/
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
function requiredInProduction(name: string, fallback: string): string {
|
||||
const value = process.env[name];
|
||||
if (value && value.trim().length > 0) return value;
|
||||
if (isProduction) {
|
||||
// Zamerne padame pri startu - tichy fallback na dev secret by byl bezpecnostni dira.
|
||||
throw new Error(`Chybi povinna promenna prostredi ${name} (NODE_ENV=production).`);
|
||||
}
|
||||
console.warn(`[config] ${name} neni nastavena, pouzivam DEV fallback. Nepouzivat v produkci.`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix verejne adresy, napr. "/apps/csbot-prototype".
|
||||
* Caddy ho pred predanim do containeru odstranuje (handle_path), ale prohlizec
|
||||
* ho vidi - proto se z nej sklada base pro SPA, odkazy, Swagger i webhooky.
|
||||
*/
|
||||
function normalizeRootPath(value: string | undefined): string {
|
||||
const trimmed = (value ?? '').trim();
|
||||
if (trimmed.length === 0 || trimmed === '/') return '';
|
||||
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
return withSlash.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export const config = {
|
||||
isProduction,
|
||||
/** Port urcuje AppFactory sablona, vychozi 3000. Nemenit bez upravy metadat. */
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
rootPath: normalizeRootPath(process.env.ROOT_PATH),
|
||||
jwtSecret: requiredInProduction('JWT_SECRET', 'dev-only-secret-nepouzivat-v-produkci'),
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '8h',
|
||||
/**
|
||||
* Povolene originy pro CORS. V nasazeni bezi web i API na stejne domene,
|
||||
* takze se CORS neuplatni. Je tu kvuli lokalnimu vyvoji s Vite dev serverem.
|
||||
*/
|
||||
corsOrigins: (process.env.CORS_ORIGIN ?? 'http://localhost:5173,http://localhost:4173')
|
||||
.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean),
|
||||
/**
|
||||
* Verejna adresa bez prefixu, napr. "https://services.csbot.cz".
|
||||
* Sklada se z ni absolutni URL webhooku. Kdyz neni vyplnena, pouzije se
|
||||
* relativni tvar - nikdy se nehardcoduje produkcni domena.
|
||||
*/
|
||||
publicOrigin: (process.env.PUBLIC_ORIGIN ?? '').trim().replace(/\/+$/, ''),
|
||||
};
|
||||
|
||||
/** Zaklad verejne adresy aplikace vcetne prefixu proxy. */
|
||||
export function publicBaseUrl(): string {
|
||||
return `${config.publicOrigin}${config.rootPath}`;
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* Uloziste automatizaci vcetne jejich stromu akci (flow).
|
||||
*
|
||||
* POZOR: data jsou v pameti procesu - restart API je vrati na vychozi sadu.
|
||||
* To je vedome zjednoduseni prototypu, nahrada za databazi je popsana
|
||||
* v docs/04-backend-api.md, sekce "Kam dal".
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { publish } from '../events/bus.js';
|
||||
import { isUnary, type ConditionOperator, type FieldType } from './conditions.js';
|
||||
import { findConnector } from './connectors.js';
|
||||
|
||||
export type AutomationKind = 'workflow' | 'voicebot' | 'integrace' | 'report';
|
||||
|
||||
/** Jeden vstupni parametr, ktery spoustec preda dal do stromu. */
|
||||
export interface TriggerField {
|
||||
id: string;
|
||||
/** Klic v prichozich datech - napr. "orderTotal". Musi byt unikatni. */
|
||||
name: string;
|
||||
type: FieldType;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface FlowTrigger {
|
||||
connectorId: string;
|
||||
operationId: string;
|
||||
/** Deklarovane vstupni parametry. Podminky se odkazuji na jejich `id`. */
|
||||
fields: TriggerField[];
|
||||
/**
|
||||
* Neodhadnutelny token v adrese webhooku. Generuje VZDY server,
|
||||
* klient ho nesmi urcovat ani menit.
|
||||
*/
|
||||
webhookToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Krok stromu. `action` je jeden ukon nad konektorem, `condition` rozdeluje
|
||||
* beh na dve vetve podle hodnoty vstupniho parametru - proto je to strom.
|
||||
*/
|
||||
export type FlowStep =
|
||||
| {
|
||||
id: string;
|
||||
kind: 'action';
|
||||
connectorId: string;
|
||||
operationId: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: 'condition';
|
||||
/** id parametru z trigger.fields */
|
||||
fieldId: string;
|
||||
operator: ConditionOperator;
|
||||
/** Chybi u operatoru, ktere hodnotu nepotrebuji (isEmpty, isTrue…). */
|
||||
value?: string;
|
||||
yes: FlowStep[];
|
||||
no: FlowStep[];
|
||||
};
|
||||
|
||||
export interface AutomationFlow {
|
||||
trigger: FlowTrigger | null;
|
||||
steps: FlowStep[];
|
||||
}
|
||||
|
||||
/** Neodhadnutelny token do adresy webhooku (32 znaku, base64url). */
|
||||
export function generateWebhookToken(): string {
|
||||
return randomBytes(24).toString('base64url');
|
||||
}
|
||||
|
||||
/** Polozka v seznamu automatizaci - bez celeho stromu. */
|
||||
export interface Automation {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: AutomationKind;
|
||||
enabled: boolean;
|
||||
runsToday: number;
|
||||
successRate: number;
|
||||
avgDurationMs: number;
|
||||
lastRunAt: string;
|
||||
/** Pocet vsech kroku vcetne vnorenych vetvi. */
|
||||
stepCount: number;
|
||||
/** false = automatizace jeste nema spoustec, je to koncept. */
|
||||
configured: boolean;
|
||||
/**
|
||||
* Co chybi k tomu, aby se dala zapnout. Prazdne = je hotova.
|
||||
* Zobrazuje se uzivateli, nesmi zmizet tise.
|
||||
*/
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
export interface AutomationDetail extends Automation {
|
||||
flow: AutomationFlow;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface StoredAutomation {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: AutomationKind;
|
||||
enabled: boolean;
|
||||
runsToday: number;
|
||||
successRate: number;
|
||||
avgDurationMs: number;
|
||||
lastRunAt: string;
|
||||
flow: AutomationFlow;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function minutesAgo(minutes: number): string {
|
||||
return new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
}
|
||||
|
||||
/** Rekurzivne secte kroky vcetne obou vetvi podminek. */
|
||||
export function countSteps(steps: FlowStep[]): number {
|
||||
return steps.reduce((sum, step) => {
|
||||
if (step.kind === 'condition') {
|
||||
return sum + 1 + countSteps(step.yes) + countSteps(step.no);
|
||||
}
|
||||
return sum + 1;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.connectorId === 'webhook';
|
||||
if (isWebhook && !flow.trigger.webhookToken) {
|
||||
issues.push('Webhook nemá vygenerovanou adresu.');
|
||||
}
|
||||
|
||||
const fieldById = new Map(flow.trigger.fields.map((field) => [field.id, field]));
|
||||
|
||||
const walk = (steps: FlowStep[]) => {
|
||||
for (const step of steps) {
|
||||
if (step.kind !== 'condition') continue;
|
||||
|
||||
const field = fieldById.get(step.fieldId);
|
||||
if (!field) {
|
||||
issues.push('Podmínka se odkazuje na parametr, který už neexistuje.');
|
||||
} else if (!isUnary(step.operator) && (step.value ?? '').trim().length === 0) {
|
||||
issues.push(`Podmínka nad parametrem „${field.name}" nemá vyplněnou hodnotu.`);
|
||||
} else if (field.type === 'number' && !isUnary(step.operator)) {
|
||||
if (Number.isNaN(Number(step.value))) {
|
||||
issues.push(`Podmínka nad parametrem „${field.name}" má nečíselnou hodnotu.`);
|
||||
}
|
||||
}
|
||||
|
||||
walk(step.yes);
|
||||
walk(step.no);
|
||||
}
|
||||
};
|
||||
walk(flow.steps);
|
||||
|
||||
return 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);
|
||||
}
|
||||
return findConnector(step.connectorId)?.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.
|
||||
*/
|
||||
function deriveKind(flow: AutomationFlow): AutomationKind {
|
||||
if (!flow.trigger) return 'workflow';
|
||||
|
||||
const connector = findConnector(flow.trigger.connectorId);
|
||||
if (!connector) {
|
||||
console.warn(`[automations] spoustec odkazuje na neznamy konektor: ${flow.trigger.connectorId}`);
|
||||
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';
|
||||
}
|
||||
|
||||
const store = new Map<string, StoredAutomation>();
|
||||
let idCounter = 0;
|
||||
|
||||
function nextId(): string {
|
||||
idCounter += 1;
|
||||
return `AUT-${String(idCounter).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function seed(automation: Omit<StoredAutomation, 'id' | 'createdAt' | 'updatedAt' | 'kind'>) {
|
||||
const id = nextId();
|
||||
store.set(id, {
|
||||
...automation,
|
||||
id,
|
||||
kind: deriveKind(automation.flow),
|
||||
createdAt: minutesAgo(60 * 24 * 90),
|
||||
updatedAt: minutesAgo(60 * 12),
|
||||
});
|
||||
}
|
||||
|
||||
seed({
|
||||
name: 'Objednávka → sklad → fakturace',
|
||||
enabled: true,
|
||||
runsToday: 428,
|
||||
successRate: 99.3,
|
||||
avgDurationMs: 1_240,
|
||||
lastRunAt: minutesAgo(3),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: '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', connectorId: 'transform', operationId: 'map-fields' },
|
||||
{ id: 'st_2', kind: 'action', connectorId: 'eshop', operationId: 'update-stock' },
|
||||
{
|
||||
id: 'st_3',
|
||||
kind: 'condition',
|
||||
fieldId: 'f_2',
|
||||
operator: 'gte',
|
||||
value: '5000',
|
||||
yes: [
|
||||
{ id: 'st_4', kind: 'action', connectorId: 'idoklad', operationId: 'create-proforma' },
|
||||
{ id: 'st_5', kind: 'action', connectorId: 'email', operationId: 'send' },
|
||||
],
|
||||
no: [{ id: 'st_6', kind: 'action', connectorId: 'idoklad', operationId: 'create-invoice' }],
|
||||
},
|
||||
{ id: 'st_7', kind: 'action', connectorId: '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: {
|
||||
connectorId: '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', connectorId: 'voicebot', operationId: 'play-scenario' },
|
||||
{ id: 'st_12', kind: 'action', connectorId: 'transcription', operationId: 'transcribe' },
|
||||
{ id: 'st_13', kind: 'action', connectorId: 'ai-text', operationId: 'classify' },
|
||||
{
|
||||
id: 'st_14',
|
||||
kind: 'condition',
|
||||
fieldId: 'f_12',
|
||||
operator: 'isTrue',
|
||||
yes: [{ id: 'st_15', kind: 'action', connectorId: 'voicebot', operationId: 'transfer' }],
|
||||
no: [
|
||||
{ id: 'st_16', kind: 'action', connectorId: 'raynet', operationId: 'create-lead' },
|
||||
{ id: 'st_17', kind: 'action', connectorId: 'email', operationId: 'send' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
seed({
|
||||
name: 'Synchronizace CRM ↔ účetnictví',
|
||||
enabled: true,
|
||||
runsToday: 96,
|
||||
successRate: 98.9,
|
||||
avgDurationMs: 2_050,
|
||||
lastRunAt: minutesAgo(26),
|
||||
flow: {
|
||||
trigger: {
|
||||
connectorId: 'raynet',
|
||||
operationId: 'company-changed',
|
||||
fields: [{ id: 'f_21', name: 'companyId', type: 'string', required: true }],
|
||||
},
|
||||
steps: [
|
||||
{ id: 'st_21', kind: 'action', connectorId: 'transform', operationId: 'deduplicate' },
|
||||
{ id: 'st_22', kind: 'action', connectorId: 'idoklad', operationId: 'create-invoice' },
|
||||
{ id: 'st_23', kind: 'action', connectorId: '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: { connectorId: 'scheduler', operationId: 'interval', fields: [] },
|
||||
steps: [
|
||||
{ id: 'st_31', kind: 'action', connectorId: 'ga4', operationId: 'run-report' },
|
||||
{ id: 'st_32', kind: 'action', connectorId: 'google-ads', operationId: 'campaign-report' },
|
||||
{ id: 'st_33', kind: 'action', connectorId: 'sklik', operationId: 'campaign-report' },
|
||||
{ id: 'st_34', kind: 'action', connectorId: 'ai-text', operationId: 'generate' },
|
||||
{ id: 'st_35', kind: 'action', connectorId: '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: {
|
||||
connectorId: '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', connectorId: 'raynet', operationId: 'add-activity' }],
|
||||
no: [
|
||||
{ id: 'st_43', kind: 'action', connectorId: 'ticket', operationId: 'create' },
|
||||
{ id: 'st_44', kind: 'action', connectorId: 'microsoft365', operationId: 'post-teams' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
function toSummary(stored: StoredAutomation): Automation {
|
||||
const { flow: _flow, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = stored;
|
||||
return {
|
||||
...rest,
|
||||
stepCount: countSteps(stored.flow.steps),
|
||||
configured: stored.flow.trigger !== null,
|
||||
issues: collectFlowIssues(stored.flow),
|
||||
};
|
||||
}
|
||||
|
||||
function toDetail(stored: StoredAutomation): AutomationDetail {
|
||||
return {
|
||||
...toSummary(stored),
|
||||
flow: stored.flow,
|
||||
createdAt: stored.createdAt,
|
||||
updatedAt: stored.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function listAutomations(): Automation[] {
|
||||
// Nejnovejsi nahoru, aby prave vytvorena automatizace byla hned videt.
|
||||
return [...store.values()]
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.map(toSummary);
|
||||
}
|
||||
|
||||
export function getAutomation(id: string): AutomationDetail | undefined {
|
||||
const stored = store.get(id);
|
||||
return stored ? toDetail(stored) : undefined;
|
||||
}
|
||||
|
||||
export function createAutomation(name: string): AutomationDetail {
|
||||
const id = nextId();
|
||||
const now = new Date().toISOString();
|
||||
const stored: StoredAutomation = {
|
||||
id,
|
||||
name,
|
||||
kind: 'workflow',
|
||||
enabled: false,
|
||||
runsToday: 0,
|
||||
successRate: 100,
|
||||
avgDurationMs: 0,
|
||||
lastRunAt: now,
|
||||
flow: { trigger: null, steps: [] },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
store.set(id, stored);
|
||||
console.info(`[automations] vytvorena automatizace ${id} "${name}"`);
|
||||
publish('automation.created', `Vytvořena automatizace ${id}: ${name}`, { automationId: id });
|
||||
return toDetail(stored);
|
||||
}
|
||||
|
||||
export function updateAutomation(
|
||||
id: string,
|
||||
patch: { name?: string; enabled?: boolean; flow?: AutomationFlow },
|
||||
): AutomationDetail | undefined {
|
||||
const stored = store.get(id);
|
||||
if (!stored) {
|
||||
console.warn(`[automations] pokus o upravu neexistujici automatizace: ${id}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const flow = withWebhookToken(patch.flow ?? stored.flow, stored.flow, id);
|
||||
const updated: StoredAutomation = {
|
||||
...stored,
|
||||
name: patch.name ?? stored.name,
|
||||
enabled: patch.enabled ?? stored.enabled,
|
||||
flow,
|
||||
kind: deriveKind(flow),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Nedokoncenou automatizaci nepustime do provozu - "aktivni" by nic nedelala
|
||||
// nebo by delala neco jineho, nez uzivatel ceka.
|
||||
const issues = collectFlowIssues(updated.flow);
|
||||
if (updated.enabled && issues.length > 0) {
|
||||
console.warn(`[automations] ${id}: zapnuti odmitnuto - ${issues.join(' ')}`);
|
||||
updated.enabled = false;
|
||||
}
|
||||
|
||||
store.set(id, updated);
|
||||
console.info(
|
||||
`[automations] ulozena automatizace ${id} (kroku: ${countSteps(updated.flow.steps)}, aktivni: ${updated.enabled}, nedodelku: ${issues.length})`,
|
||||
);
|
||||
publish('automation.updated', `Automatizace ${id} uložena: ${updated.name}`, {
|
||||
automationId: id,
|
||||
enabled: updated.enabled,
|
||||
});
|
||||
return toDetail(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function withWebhookToken(
|
||||
next: AutomationFlow,
|
||||
previous: AutomationFlow,
|
||||
id: string,
|
||||
): AutomationFlow {
|
||||
if (!next.trigger) return next;
|
||||
|
||||
if (next.trigger.connectorId !== '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?.connectorId === '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 } };
|
||||
}
|
||||
|
||||
/** Vygeneruje novy token - stara adresa okamzite prestane fungovat. */
|
||||
export function regenerateWebhookToken(id: string): AutomationDetail | undefined {
|
||||
const stored = store.get(id);
|
||||
if (!stored) {
|
||||
console.warn(`[automations] regenerace tokenu pro neexistujici automatizaci: ${id}`);
|
||||
return undefined;
|
||||
}
|
||||
if (stored.flow.trigger?.connectorId !== 'webhook') {
|
||||
console.warn(`[automations] ${id}: regenerace tokenu, ale spoustec neni webhook`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const updated: StoredAutomation = {
|
||||
...stored,
|
||||
flow: {
|
||||
...stored.flow,
|
||||
trigger: { ...stored.flow.trigger, webhookToken: generateWebhookToken() },
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.set(id, 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 === token) return toDetail(stored);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Zapise beh automatizace - drzi metriky i graf zive. */
|
||||
export function recordRun(id: string, ok = true): AutomationDetail | undefined {
|
||||
const stored = store.get(id);
|
||||
if (!stored) {
|
||||
console.warn(`[automations] recordRun pro neexistujici automatizaci: ${id}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const runs = stored.runsToday + 1;
|
||||
// Klouzavy prumer uspesnosti pres dnesni behy, ne prepisovani na 0 nebo 100.
|
||||
const previousOk = (stored.successRate / 100) * stored.runsToday;
|
||||
const successRate = runs === 0 ? 100 : ((previousOk + (ok ? 1 : 0)) / runs) * 100;
|
||||
|
||||
const updated: StoredAutomation = {
|
||||
...stored,
|
||||
runsToday: runs,
|
||||
successRate: Math.round(successRate * 10) / 10,
|
||||
lastRunAt: new Date().toISOString(),
|
||||
};
|
||||
store.set(id, updated);
|
||||
|
||||
publish(
|
||||
'automation.run',
|
||||
ok
|
||||
? `Automatizace ${id} proběhla: ${updated.name}`
|
||||
: `Automatizace ${id} skončila chybou: ${updated.name}`,
|
||||
{ automationId: id, ok },
|
||||
);
|
||||
return toDetail(updated);
|
||||
}
|
||||
|
||||
export function deleteAutomation(id: string): boolean {
|
||||
const name = store.get(id)?.name;
|
||||
const existed = store.delete(id);
|
||||
if (!existed) {
|
||||
console.warn(`[automations] pokus o smazani neexistujici automatizace: ${id}`);
|
||||
} else {
|
||||
console.info(`[automations] smazana automatizace ${id}`);
|
||||
publish('automation.deleted', `Automatizace ${id} smazána: ${name ?? ''}`, {
|
||||
automationId: id,
|
||||
});
|
||||
}
|
||||
return existed;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Typy vstupnich parametru a operatory podminek.
|
||||
*
|
||||
* POZOR: stejne tabulky ma i frontend v apps/web/src/lib/flow.ts.
|
||||
* Pri zmene je nutne upravit obe strany (viz docs/08-automatizace-builder.md).
|
||||
*/
|
||||
|
||||
export type FieldType = 'string' | 'number' | 'boolean' | 'date';
|
||||
|
||||
export type ConditionOperator =
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'contains'
|
||||
| 'startsWith'
|
||||
| 'isEmpty'
|
||||
| 'isNotEmpty'
|
||||
| 'isTrue'
|
||||
| 'isFalse';
|
||||
|
||||
export const fieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
|
||||
|
||||
/** Ktere operatory maji smysl pro ktery typ. */
|
||||
export const operatorsByType: Record<FieldType, ConditionOperator[]> = {
|
||||
string: ['eq', 'neq', 'contains', 'startsWith', 'isEmpty', 'isNotEmpty'],
|
||||
number: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'],
|
||||
boolean: ['isTrue', 'isFalse'],
|
||||
date: ['eq', 'gt', 'lt'],
|
||||
};
|
||||
|
||||
/** Operatory, ktere nepotrebuji hodnotu k porovnani. */
|
||||
export const unaryOperators: ConditionOperator[] = [
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'isTrue',
|
||||
'isFalse',
|
||||
];
|
||||
|
||||
export function isUnary(operator: ConditionOperator): boolean {
|
||||
return unaryOperators.includes(operator);
|
||||
}
|
||||
|
||||
export function operatorAllowedForType(operator: ConditionOperator, type: FieldType): boolean {
|
||||
return operatorsByType[type].includes(operator);
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
/**
|
||||
* Katalog konektoru = zdroj pravdy o tom, co lze v automatizaci pouzit.
|
||||
*
|
||||
* Kazdy konektor ma:
|
||||
* - triggers: udalosti, kterymi muze automatizace ZACIT (spoustec)
|
||||
* - actions: co se s nim da UDELAT uprostred behu
|
||||
*
|
||||
* Konektor muze mit jen triggery (webhook), jen akce (odeslani e-mailu), nebo obojí.
|
||||
* Jak pridat novy konektor: docs/08-automatizace-builder.md
|
||||
*/
|
||||
|
||||
export type ConnectorCategory =
|
||||
| 'spoustece'
|
||||
| 'crm'
|
||||
| 'ekonomika'
|
||||
| 'logistika'
|
||||
| 'komunikace'
|
||||
| 'analytika'
|
||||
| 'ai'
|
||||
| 'nastroje';
|
||||
|
||||
/** connected = klient ho ma napojeny, available = umime napojit, planned = na roadmape */
|
||||
export type ConnectorStatus = 'connected' | 'available' | 'planned';
|
||||
|
||||
export interface ConnectorOperation {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Popis toho, co bude potreba nastavit. Zatim jen informativni. */
|
||||
fields?: string[];
|
||||
/**
|
||||
* Jen u triggeru: true = vstupni parametry si definuje uzivatel
|
||||
* (webhook, formular). false/chybi = data urcuje sluzba.
|
||||
*/
|
||||
customPayload?: boolean;
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
id: string;
|
||||
name: string;
|
||||
category: ConnectorCategory;
|
||||
description: string;
|
||||
/** Klic ikony - frontend si ho mapuje na komponentu (lib/connectorIcons.ts). */
|
||||
icon: string;
|
||||
status: ConnectorStatus;
|
||||
triggers: ConnectorOperation[];
|
||||
actions: ConnectorOperation[];
|
||||
}
|
||||
|
||||
export const connectorCategories: Array<{ id: ConnectorCategory; label: string }> = [
|
||||
{ id: 'spoustece', label: 'Spouštěče' },
|
||||
{ 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: 'nastroje', label: 'Nástroje' },
|
||||
];
|
||||
|
||||
export const connectors: Connector[] = [
|
||||
// ---------------------------------------------------------------- spoustece
|
||||
{
|
||||
id: 'webhook',
|
||||
name: 'Webhook',
|
||||
category: 'spoustece',
|
||||
description: 'Spustí automatizaci příchozím HTTP požadavkem z libovolného systému.',
|
||||
icon: 'Webhook',
|
||||
status: 'connected',
|
||||
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: 'spoustece',
|
||||
description: 'Spouštění podle času — každou hodinu, denně, nebo podle cron výrazu.',
|
||||
icon: 'Clock',
|
||||
status: 'connected',
|
||||
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: 'spoustece',
|
||||
description: 'Automatizaci spustí člověk tlačítkem v portálu. Vhodné pro testování.',
|
||||
icon: 'MousePointerClick',
|
||||
status: 'connected',
|
||||
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: 'spoustece',
|
||||
description: 'Odeslání formuláře z webu — poptávka, registrace, reklamace.',
|
||||
icon: 'FileInput',
|
||||
status: 'connected',
|
||||
triggers: [
|
||||
{
|
||||
id: 'submitted',
|
||||
name: 'Formulář odeslán',
|
||||
description: 'Spustí se po odeslání formuláře. Pole formuláře si definujete sami.',
|
||||
customPayload: true,
|
||||
},
|
||||
],
|
||||
actions: [],
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------------- crm
|
||||
{
|
||||
id: 'raynet',
|
||||
name: 'RAYNET CRM',
|
||||
category: 'crm',
|
||||
description: 'Firmy, kontakty, obchodní případy a aktivity v RAYNET CRM.',
|
||||
icon: 'Users',
|
||||
status: 'connected',
|
||||
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'],
|
||||
},
|
||||
{
|
||||
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'],
|
||||
},
|
||||
{
|
||||
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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------- ekonomika
|
||||
{
|
||||
id: 'idoklad',
|
||||
name: 'iDoklad',
|
||||
category: 'ekonomika',
|
||||
description: 'Fakturace — vydané i přijaté doklady, kontakty, úhrady.',
|
||||
icon: 'Receipt',
|
||||
status: 'connected',
|
||||
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: 'connected',
|
||||
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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------- logistika
|
||||
{
|
||||
id: 'ppl',
|
||||
name: 'PPL CPL',
|
||||
category: 'logistika',
|
||||
description: 'Zásilky, štítky a svozy v systému PPL.',
|
||||
icon: 'Truck',
|
||||
status: 'connected',
|
||||
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',
|
||||
triggers: [
|
||||
{
|
||||
id: 'order-created',
|
||||
name: 'Nová objednávka',
|
||||
description: 'Spustí se při vytvoření objednávky v e-shopu.',
|
||||
},
|
||||
{
|
||||
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í'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------- komunikace
|
||||
{
|
||||
id: 'email',
|
||||
name: 'E-mail',
|
||||
category: 'komunikace',
|
||||
description: 'Příjem i odesílání e-mailů včetně příloh.',
|
||||
icon: 'Mail',
|
||||
status: 'connected',
|
||||
triggers: [
|
||||
{
|
||||
id: 'received',
|
||||
name: 'Přijat e-mail',
|
||||
description: 'Spustí se při doručení e-mailu do sledované schránky.',
|
||||
fields: ['Schránka', 'Filtr odesílatele nebo předmětu'],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 'send',
|
||||
name: 'Odeslat e-mail',
|
||||
description: 'Odešle zprávu podle šablony s daty z předchozích kroků.',
|
||||
fields: ['Příjemce', 'Předmět', 'Šablona', 'Přílohy'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'microsoft365',
|
||||
name: 'Microsoft 365',
|
||||
category: 'komunikace',
|
||||
description: 'Outlook, kalendář, Teams, SharePoint a OneDrive.',
|
||||
icon: 'Building2',
|
||||
status: 'connected',
|
||||
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: 'sms',
|
||||
name: 'SMS',
|
||||
category: 'komunikace',
|
||||
description: 'Odesílání SMS zpráv zákazníkům nebo obsluze.',
|
||||
icon: 'MessageSquare',
|
||||
status: 'available',
|
||||
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',
|
||||
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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------- analytika
|
||||
{
|
||||
id: 'ga4',
|
||||
name: 'Google Analytics 4',
|
||||
category: 'analytika',
|
||||
description: 'Návštěvnost, konverze a chování uživatelů.',
|
||||
icon: 'BarChart3',
|
||||
status: 'connected',
|
||||
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: 'connected',
|
||||
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: 'connected',
|
||||
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: 'connected',
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
id: 'campaign-report',
|
||||
name: 'Načíst výkon kampaní',
|
||||
description: 'Stáhne statistiky kampaní za období.',
|
||||
fields: ['Účet', 'Období'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------- ai
|
||||
{
|
||||
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: 'connected',
|
||||
triggers: [
|
||||
{
|
||||
id: 'call-received',
|
||||
name: 'Příchozí hovor',
|
||||
description: 'Spustí se při přijetí hovoru na hlasovou linku.',
|
||||
fields: ['Linka', 'Jazyk'],
|
||||
},
|
||||
{
|
||||
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: 'connected',
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
id: 'transcribe',
|
||||
name: 'Přepsat nahrávku',
|
||||
description: 'Vrátí přepis, jazyk a časové značky.',
|
||||
fields: ['Zdroj nahrávky', 'Jazyk'],
|
||||
},
|
||||
{
|
||||
id: 'summarize',
|
||||
name: 'Vytvořit souhrn',
|
||||
description: 'Z přepisu udělá krátký souhrn a seznam dalších kroků.',
|
||||
fields: ['Délka souhrnu'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ai-text',
|
||||
name: 'AI zpracování textu',
|
||||
category: 'ai',
|
||||
description: 'Klasifikace, extrakce dat a generování textu jazykovým modelem.',
|
||||
icon: 'Sparkles',
|
||||
status: 'connected',
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
id: 'classify',
|
||||
name: 'Zařadit do kategorie',
|
||||
description: 'Rozhodne, do které kategorie text patří (např. téma ticketu).',
|
||||
fields: ['Seznam kategorií', 'Text'],
|
||||
},
|
||||
{
|
||||
id: 'extract',
|
||||
name: 'Vytáhnout údaje',
|
||||
description: 'Z volného textu získá strukturovaná data podle schématu.',
|
||||
fields: ['Schéma polí', 'Text'],
|
||||
},
|
||||
{
|
||||
id: 'generate',
|
||||
name: 'Vygenerovat text',
|
||||
description: 'Napíše odpověď nebo shrnutí podle instrukce.',
|
||||
fields: ['Instrukce', 'Vstupní data'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------- nastroje
|
||||
{
|
||||
id: 'http',
|
||||
name: 'HTTP požadavek',
|
||||
category: 'nastroje',
|
||||
description: 'Zavolá libovolné API, které nemá vlastní konektor.',
|
||||
icon: 'Globe',
|
||||
status: 'connected',
|
||||
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: 'nastroje',
|
||||
description: 'Přemapování polí, formátování a čištění dat mezi kroky.',
|
||||
icon: 'Shuffle',
|
||||
status: 'connected',
|
||||
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: 'delay',
|
||||
name: 'Pauza',
|
||||
category: 'nastroje',
|
||||
description: 'Pozdrží běh o daný čas nebo do konkrétního okamžiku.',
|
||||
icon: 'Timer',
|
||||
status: 'connected',
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
id: 'wait',
|
||||
name: 'Počkat',
|
||||
description: 'Pozastaví běh na zadanou dobu.',
|
||||
fields: ['Doba čekání'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ticket',
|
||||
name: 'Tickety',
|
||||
category: 'nastroje',
|
||||
description: 'Servicedesk — zakládání a aktualizace požadavků.',
|
||||
icon: 'LifeBuoy',
|
||||
status: 'connected',
|
||||
triggers: [
|
||||
{
|
||||
id: 'created',
|
||||
name: 'Nový ticket',
|
||||
description: 'Spustí se při založení ticketu.',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 'create',
|
||||
name: 'Založit ticket',
|
||||
description: 'Vytvoří požadavek včetně priority a přiřazení.',
|
||||
fields: ['Předmět', 'Popis', 'Priorita', 'Řešitel'],
|
||||
},
|
||||
{
|
||||
id: 'comment',
|
||||
name: 'Přidat komentář',
|
||||
description: 'Zapíše komentář k existujícímu ticketu.',
|
||||
fields: ['ID ticketu', 'Text'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'log',
|
||||
name: 'Zápis do logu',
|
||||
category: 'nastroje',
|
||||
description: 'Uloží zprávu do provozního logu automatizace.',
|
||||
icon: 'ScrollText',
|
||||
status: 'connected',
|
||||
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: ['Úroveň', 'Zpráva'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function findConnector(connectorId: string): Connector | undefined {
|
||||
return connectors.find((c) => c.id === connectorId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overi, ze konektor existuje a ma danou operaci pozadovaneho druhu.
|
||||
* Pouziva se pri ukladani stromu, aby se do nej nedostaly neexistujici kroky.
|
||||
*/
|
||||
export function findOperation(
|
||||
connectorId: string,
|
||||
operationId: string,
|
||||
type: 'trigger' | 'action',
|
||||
): ConnectorOperation | undefined {
|
||||
const connector = findConnector(connectorId);
|
||||
if (!connector) return undefined;
|
||||
const pool = type === 'trigger' ? connector.triggers : connector.actions;
|
||||
return pool.find((op) => op.id === operationId);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Uloziste incidentu. Stejny princip jako ticketStore - kazda zmena
|
||||
* posle udalost na sbernici a projevi se v dashboardu okamzite.
|
||||
*/
|
||||
|
||||
import { publish } from '../events/bus.js';
|
||||
|
||||
export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3';
|
||||
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
|
||||
|
||||
export interface Incident {
|
||||
id: string;
|
||||
title: string;
|
||||
service: string;
|
||||
severity: IncidentSeverity;
|
||||
status: IncidentStatus;
|
||||
startedAt: string;
|
||||
resolvedAt: string | null;
|
||||
}
|
||||
|
||||
function minutesAgo(minutes: number): string {
|
||||
return new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
}
|
||||
|
||||
const incidents: Incident[] = [
|
||||
{
|
||||
id: 'INC-231',
|
||||
title: 'Zvýšená latence hlasové brány (region EU-West)',
|
||||
service: 'Voicebot Gateway',
|
||||
severity: 'sev2',
|
||||
status: 'monitoring',
|
||||
startedAt: minutesAgo(88),
|
||||
resolvedAt: null,
|
||||
},
|
||||
{
|
||||
id: 'INC-230',
|
||||
title: 'Timeouty při zápisu do fakturačního API',
|
||||
service: 'Integrace / iDoklad',
|
||||
severity: 'sev3',
|
||||
status: 'identified',
|
||||
startedAt: minutesAgo(240),
|
||||
resolvedAt: null,
|
||||
},
|
||||
{
|
||||
id: 'INC-228',
|
||||
title: 'Neúspěšné doručení webhooků z e-shopu',
|
||||
service: 'Webhook Router',
|
||||
severity: 'sev3',
|
||||
status: 'resolved',
|
||||
startedAt: minutesAgo(2_900),
|
||||
resolvedAt: minutesAgo(2_700),
|
||||
},
|
||||
];
|
||||
|
||||
let counter = 231;
|
||||
|
||||
export function listIncidents(): Incident[] {
|
||||
return [...incidents].sort((a, b) => {
|
||||
if (a.status === 'resolved' && b.status !== 'resolved') return 1;
|
||||
if (b.status === 'resolved' && a.status !== 'resolved') return -1;
|
||||
return b.startedAt.localeCompare(a.startedAt);
|
||||
});
|
||||
}
|
||||
|
||||
export function createIncident(input: {
|
||||
title: string;
|
||||
service: string;
|
||||
severity: IncidentSeverity;
|
||||
}): Incident {
|
||||
counter += 1;
|
||||
const incident: Incident = {
|
||||
id: `INC-${counter}`,
|
||||
title: input.title,
|
||||
service: input.service,
|
||||
severity: input.severity,
|
||||
status: 'investigating',
|
||||
startedAt: new Date().toISOString(),
|
||||
resolvedAt: null,
|
||||
};
|
||||
incidents.unshift(incident);
|
||||
|
||||
publish('incident.started', `Nový incident ${incident.id}: ${incident.title}`, {
|
||||
incidentId: incident.id,
|
||||
severity: incident.severity,
|
||||
});
|
||||
return incident;
|
||||
}
|
||||
|
||||
export function updateIncidentStatus(id: string, status: IncidentStatus): Incident | undefined {
|
||||
const incident = incidents.find((i) => i.id === id);
|
||||
if (!incident) {
|
||||
console.warn(`[incidents] zmena stavu neexistujiciho incidentu: ${id}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
incident.status = status;
|
||||
incident.resolvedAt = status === 'resolved' ? new Date().toISOString() : null;
|
||||
|
||||
if (status === 'resolved') {
|
||||
publish('incident.resolved', `Incident ${incident.id} vyřešen: ${incident.title}`, {
|
||||
incidentId: incident.id,
|
||||
});
|
||||
} else {
|
||||
publish('incident.updated', `Incident ${incident.id}: ${status}`, {
|
||||
incidentId: incident.id,
|
||||
status,
|
||||
});
|
||||
}
|
||||
return incident;
|
||||
}
|
||||
|
||||
/** Prvni bezici incident - pouziva simulace, kdyz uzivatel neurci ktery. */
|
||||
export function firstActiveIncident(): Incident | undefined {
|
||||
return incidents.find((i) => i.status !== 'resolved');
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Souhrn pro dashboard a casova rada do grafu.
|
||||
*
|
||||
* Tickety, incidenty ani automatizace tu uz nejsou - maji vlastni uloziste
|
||||
* (ticketStore, incidentStore, automationStore), protoze se daji menit
|
||||
* a kazda zmena posila udalost na sbernici.
|
||||
*/
|
||||
|
||||
import { listAutomations } from './automationStore.js';
|
||||
import { listIncidents } from './incidentStore.js';
|
||||
import { listTickets } from './ticketStore.js';
|
||||
|
||||
/** Casova rada pro graf na dashboardu - poslednich 14 dni. */
|
||||
export function getRunsSeries(days = 14) {
|
||||
const series: Array<{ date: string; runs: number; failures: number }> = [];
|
||||
for (let i = days - 1; i >= 0; i -= 1) {
|
||||
const date = new Date(Date.now() - i * 86_400_000);
|
||||
// Deterministicky "sum" podle dne, aby graf nepreskakoval pri kazdem refreshi.
|
||||
const seed = date.getUTCDate();
|
||||
const runs = 380 + ((seed * 37) % 260);
|
||||
const failures = 2 + ((seed * 7) % 11);
|
||||
series.push({ date: date.toISOString().slice(0, 10), runs, failures });
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
export function getSummary() {
|
||||
const tickets = listTickets();
|
||||
const incidents = listIncidents();
|
||||
const automations = listAutomations();
|
||||
const series = getRunsSeries();
|
||||
|
||||
// Dnesni sloupec grafu doplnujeme o skutecne behy, aby se simulace projevila.
|
||||
const runsToday = automations.reduce((sum, a) => sum + a.runsToday, 0);
|
||||
if (series.length > 0) {
|
||||
series[series.length - 1] = { ...series[series.length - 1], runs: runsToday };
|
||||
}
|
||||
|
||||
return {
|
||||
openTickets: tickets.filter((t) => t.status !== 'resolved').length,
|
||||
activeIncidents: incidents.filter((i) => i.status !== 'resolved').length,
|
||||
activeAutomations: automations.filter((a) => a.enabled).length,
|
||||
runsToday,
|
||||
savedHoursMonth: 312,
|
||||
uptime: 99.98,
|
||||
series,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Uloziste ticketu. Zmeny posilaji udalost na sbernici, takze se projevi
|
||||
* v dashboardu okamzite bez obnoveni stranky.
|
||||
*
|
||||
* POZOR: data jsou v pameti procesu, restart API je vrati na vychozi sadu.
|
||||
*/
|
||||
|
||||
import { publish } from '../events/bus.js';
|
||||
|
||||
export type TicketStatus = 'new' | 'open' | 'waiting' | 'resolved';
|
||||
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
subject: string;
|
||||
requester: string;
|
||||
status: TicketStatus;
|
||||
priority: TicketPriority;
|
||||
assignee: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function minutesAgo(minutes: number): string {
|
||||
return new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
}
|
||||
|
||||
const tickets: Ticket[] = [
|
||||
{
|
||||
id: 'TK-4821',
|
||||
subject: 'Voicebot neodpovídá na volání po 18:00',
|
||||
requester: 'Firma s.r.o.',
|
||||
status: 'open',
|
||||
priority: 'high',
|
||||
assignee: 'Jiří U.',
|
||||
createdAt: minutesAgo(310),
|
||||
updatedAt: minutesAgo(42),
|
||||
},
|
||||
{
|
||||
id: 'TK-4820',
|
||||
subject: 'Přidat pole IČO do synchronizace CRM a fakturace',
|
||||
requester: 'Nordis a.s.',
|
||||
status: 'waiting',
|
||||
priority: 'normal',
|
||||
assignee: 'Martin K.',
|
||||
createdAt: minutesAgo(1_180),
|
||||
updatedAt: minutesAgo(190),
|
||||
},
|
||||
{
|
||||
id: 'TK-4819',
|
||||
subject: 'Chybí denní report objednávek v e-mailu',
|
||||
requester: 'Bistro Kolektiv',
|
||||
status: 'new',
|
||||
priority: 'normal',
|
||||
assignee: null,
|
||||
createdAt: minutesAgo(95),
|
||||
updatedAt: minutesAgo(95),
|
||||
},
|
||||
{
|
||||
id: 'TK-4817',
|
||||
subject: 'Rozšíření hlasového scénáře o objednávku svozu',
|
||||
requester: 'LogiTrans',
|
||||
status: 'open',
|
||||
priority: 'low',
|
||||
assignee: 'Eva N.',
|
||||
createdAt: minutesAgo(2_600),
|
||||
updatedAt: minutesAgo(420),
|
||||
},
|
||||
{
|
||||
id: 'TK-4812',
|
||||
subject: 'Duplicitní zápis kontaktů z webového formuláře',
|
||||
requester: 'Firma s.r.o.',
|
||||
status: 'resolved',
|
||||
priority: 'critical',
|
||||
assignee: 'Jiří U.',
|
||||
createdAt: minutesAgo(5_100),
|
||||
updatedAt: minutesAgo(1_500),
|
||||
},
|
||||
];
|
||||
|
||||
let counter = 4_821;
|
||||
|
||||
export function listTickets(): Ticket[] {
|
||||
// Nejdriv nevyrizene, uvnitr od nejnovejsi upravy.
|
||||
return [...tickets].sort((a, b) => {
|
||||
if (a.status === 'resolved' && b.status !== 'resolved') return 1;
|
||||
if (b.status === 'resolved' && a.status !== 'resolved') return -1;
|
||||
return b.updatedAt.localeCompare(a.updatedAt);
|
||||
});
|
||||
}
|
||||
|
||||
export function getTicket(id: string): Ticket | undefined {
|
||||
return tickets.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
export function createTicket(input: {
|
||||
subject: string;
|
||||
requester: string;
|
||||
priority: TicketPriority;
|
||||
}): Ticket {
|
||||
counter += 1;
|
||||
const now = new Date().toISOString();
|
||||
const ticket: Ticket = {
|
||||
id: `TK-${counter}`,
|
||||
subject: input.subject,
|
||||
requester: input.requester,
|
||||
status: 'new',
|
||||
priority: input.priority,
|
||||
assignee: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
tickets.unshift(ticket);
|
||||
|
||||
publish('ticket.created', `Nový ticket ${ticket.id}: ${ticket.subject}`, {
|
||||
ticketId: ticket.id,
|
||||
priority: ticket.priority,
|
||||
});
|
||||
return ticket;
|
||||
}
|
||||
|
||||
export function updateTicketStatus(id: string, status: TicketStatus): Ticket | undefined {
|
||||
const ticket = tickets.find((t) => t.id === id);
|
||||
if (!ticket) {
|
||||
console.warn(`[tickets] zmena stavu neexistujiciho ticketu: ${id}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
ticket.status = status;
|
||||
ticket.updatedAt = new Date().toISOString();
|
||||
|
||||
if (status === 'resolved') {
|
||||
publish('ticket.resolved', `Ticket ${ticket.id} vyřešen: ${ticket.subject}`, {
|
||||
ticketId: ticket.id,
|
||||
});
|
||||
} else {
|
||||
publish('ticket.updated', `Ticket ${ticket.id} má nový stav`, {
|
||||
ticketId: ticket.id,
|
||||
status,
|
||||
});
|
||||
}
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/** Prvni nevyrizeny ticket - pouziva simulace, kdyz uzivatel neurci ktery. */
|
||||
export function firstOpenTicket(): Ticket | undefined {
|
||||
return tickets.find((t) => t.status !== 'resolved');
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import type { User } from '../types.js';
|
||||
|
||||
/**
|
||||
* PROTOTYP: uzivatele jsou v pameti. Az prijde realny dashboard, tohle nahradi
|
||||
* databaze (viz docs/04-backend-api.md, sekce "Kam dal").
|
||||
* Hesla jsou hashovana pri startu, aby v kodu nebyl plaintext v uloziste-podobne strukture.
|
||||
*/
|
||||
const DEMO_PASSWORD = 'demo1234';
|
||||
|
||||
export const users: User[] = [
|
||||
{
|
||||
id: 'usr_1',
|
||||
email: 'admin@automia.cz',
|
||||
passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10),
|
||||
name: 'Jiří Uhlíř',
|
||||
role: 'admin',
|
||||
company: 'Automia',
|
||||
},
|
||||
{
|
||||
id: 'usr_2',
|
||||
email: 'klient@firma.cz',
|
||||
passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10),
|
||||
name: 'Petra Klientová',
|
||||
role: 'client',
|
||||
company: 'Firma s.r.o.',
|
||||
},
|
||||
];
|
||||
|
||||
export function findUserByEmail(email: string): User | undefined {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
return users.find((u) => u.email.toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
export function findUserById(id: string): User | undefined {
|
||||
return users.find((u) => u.id === id);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Sbernice udalosti dashboardu. Vse, co zmeni data, sem posle udalost
|
||||
* a pripojeni klienti ji dostanou pres SSE stream okamzite.
|
||||
*
|
||||
* Zamerne v pameti procesu - pri vice instancich API by tohle musel
|
||||
* nahradit Redis pub/sub nebo jina sdilena fronta (viz dokumentace).
|
||||
*/
|
||||
|
||||
export type DashboardEventType =
|
||||
| 'ticket.created'
|
||||
| 'ticket.updated'
|
||||
| 'ticket.resolved'
|
||||
| 'incident.started'
|
||||
| 'incident.updated'
|
||||
| 'incident.resolved'
|
||||
| 'automation.created'
|
||||
| 'automation.updated'
|
||||
| 'automation.deleted'
|
||||
| 'automation.run'
|
||||
| 'webhook.received';
|
||||
|
||||
export interface DashboardEvent {
|
||||
id: string;
|
||||
type: DashboardEventType;
|
||||
at: string;
|
||||
/** Kratka veta pro uzivatele, zobrazuje se v notifikaci. */
|
||||
message: string;
|
||||
/** Doplnkova data, napr. id ticketu. */
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const CHANNEL = 'dashboard';
|
||||
const MAX_RECENT = 50;
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
// Kazdy pripojeny klient je jeden listener, vychozich 10 je malo.
|
||||
emitter.setMaxListeners(200);
|
||||
|
||||
const recent: DashboardEvent[] = [];
|
||||
|
||||
export function publish(
|
||||
type: DashboardEventType,
|
||||
message: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): DashboardEvent {
|
||||
const event: DashboardEvent = {
|
||||
id: randomUUID(),
|
||||
type,
|
||||
at: new Date().toISOString(),
|
||||
message,
|
||||
payload,
|
||||
};
|
||||
|
||||
recent.unshift(event);
|
||||
if (recent.length > MAX_RECENT) recent.length = MAX_RECENT;
|
||||
|
||||
emitter.emit(CHANNEL, event);
|
||||
console.info(`[event] ${type}: ${message} (posluchacu: ${emitter.listenerCount(CHANNEL)})`);
|
||||
return event;
|
||||
}
|
||||
|
||||
/** Vraci funkci pro odhlaseni. Volajici ji MUSI zavolat pri odpojeni. */
|
||||
export function subscribe(listener: (event: DashboardEvent) => void): () => void {
|
||||
emitter.on(CHANNEL, listener);
|
||||
return () => {
|
||||
emitter.off(CHANNEL, listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Poslednich par udalosti - klient je dostane hned po pripojeni. */
|
||||
export function recentEvents(limit = 10): DashboardEvent[] {
|
||||
return recent.slice(0, limit);
|
||||
}
|
||||
|
||||
export function listenerCount(): number {
|
||||
return emitter.listenerCount(CHANNEL);
|
||||
}
|
||||
+173
-23
@@ -1,35 +1,185 @@
|
||||
import express from "express";
|
||||
import cors from 'cors';
|
||||
import express, {
|
||||
Router,
|
||||
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 { buildOpenApiDocument } from './openapi.js';
|
||||
import { authRouter } from './routes/auth.js';
|
||||
import { contactRouter } from './routes/contact.js';
|
||||
import { dashboardRouter } from './routes/dashboard.js';
|
||||
import { simulateRouter } from './routes/simulate.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');
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const rootPath = process.env.ROOT_PATH || "";
|
||||
// Aplikace bezi za reverse proxy, jinak by req.ip a protokol byly containeru.
|
||||
app.set('trust proxy', true);
|
||||
|
||||
app.get("/", (_req, res) => {
|
||||
res.json({
|
||||
name: "csbot-prototype",
|
||||
service: "csbot-prototype",
|
||||
status: "ok"
|
||||
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' }));
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
// Loguje se jen metoda a cesta, nikdy hlavicky ani telo - obsahuji secrets.
|
||||
console.info(`[req] ${req.method} ${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.
|
||||
*/
|
||||
const api = Router({ strict: true });
|
||||
|
||||
api.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', uptimeSec: Math.round(process.uptime()) });
|
||||
});
|
||||
|
||||
/**
|
||||
* 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: 'Automia 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);
|
||||
api.use('/api/simulate', simulateRouter);
|
||||
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.
|
||||
app.use((req, res, next) => {
|
||||
if (/^(\/apps\/[^/]+)?\/(api|webhook)\//.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.get("/health", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
app.use(serveStatic);
|
||||
if (config.rootPath) app.use(config.rootPath, serveStatic);
|
||||
|
||||
if (rootPath) {
|
||||
app.get(rootPath, (_req, res) => {
|
||||
// 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",
|
||||
service: "csbot-prototype",
|
||||
status: "ok"
|
||||
name: 'csbot-prototype',
|
||||
status: 'ok',
|
||||
note: 'Build webu chybi, dostupne je jen API a /docs.',
|
||||
});
|
||||
});
|
||||
|
||||
app.get(rootPath + "/health", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(port, "0.0.0.0", () => {
|
||||
console.log("csbot-prototype listening on port " + port);
|
||||
// Centralni error handler - nic nesmi propadnout bez logu.
|
||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
// Poslouchat na vsech rozhranich containeru, ne jen na localhost (AGENTS.md).
|
||||
const server = app.listen(config.port, '0.0.0.0', () => {
|
||||
console.info(`[start] csbot-prototype bezi na portu ${config.port}`);
|
||||
console.info(`[start] ROOT_PATH: ${config.rootPath || '(neni nastaven)'}`);
|
||||
console.info(`[start] health: ${config.rootPath}/health, docs: ${config.rootPath}/docs`);
|
||||
});
|
||||
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`[start] Port ${config.port} je obsazeny.`);
|
||||
} else {
|
||||
console.error('[start] Aplikaci se nepodarilo spustit:', err);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config.js';
|
||||
import { findUserById } from '../data/users.js';
|
||||
import type { JwtPayload, User } from '../types.js';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: User;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Overi Bearer token a doplni req.user. Bez tokenu / s neplatnym tokenem vraci 401. */
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const header = req.header('authorization') ?? '';
|
||||
const [scheme, token] = header.split(' ');
|
||||
|
||||
if (scheme?.toLowerCase() !== 'bearer' || !token) {
|
||||
return res.status(401).json({ error: 'unauthorized', message: 'Chybí přihlašovací token.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwtSecret) as JwtPayload;
|
||||
const user = findUserById(payload.sub);
|
||||
if (!user) {
|
||||
console.warn(`[auth] token s platnym podpisem, ale neexistujici uzivatel: ${payload.sub}`);
|
||||
return res.status(401).json({ error: 'unauthorized', message: 'Uživatel neexistuje.' });
|
||||
}
|
||||
req.user = user;
|
||||
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.' });
|
||||
}
|
||||
}
|
||||
|
||||
/** Omezi pristup na konkretni role. Pouzivat vzdy AZ za requireAuth. */
|
||||
export function requireRole(...roles: User['role'][]) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
console.error('[auth] requireRole pouzito bez requireAuth');
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
if (!roles.includes(req.user.role)) {
|
||||
return res.status(403).json({ error: 'forbidden', message: 'Nedostatečná oprávnění.' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
+600
@@ -0,0 +1,600 @@
|
||||
import { config } from './config.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: 'Automia - 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: 'Automatizace', description: 'Sprava automatizaci a stromu akci' },
|
||||
{ name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' },
|
||||
{ name: 'Webhook', description: 'Verejny prijem dat do automatizace' },
|
||||
{ name: 'Kontakt', description: 'Poptavkovy formular z webu' },
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'Token z POST /api/auth/login. Vlozte samotny token bez slova Bearer.',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: { type: 'string', example: 'validation_error' },
|
||||
message: { type: 'string', example: 'Zadejte platny e-mail.' },
|
||||
},
|
||||
},
|
||||
User: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'usr_1' },
|
||||
email: { type: 'string', example: 'admin@automia.cz' },
|
||||
name: { type: 'string', example: 'Jiri Uhlir' },
|
||||
role: { type: 'string', enum: ['admin', 'client'] },
|
||||
company: { type: 'string', example: 'Automia' },
|
||||
},
|
||||
},
|
||||
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' },
|
||||
},
|
||||
},
|
||||
Ticket: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'TK-4821' },
|
||||
subject: { type: 'string' },
|
||||
requester: { type: 'string' },
|
||||
status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] },
|
||||
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
|
||||
assignee: { type: 'string', nullable: true },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
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' },
|
||||
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' },
|
||||
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' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
paths: {
|
||||
'/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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/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' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/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' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/summary': {
|
||||
get: {
|
||||
tags: ['Dashboard'],
|
||||
summary: 'Souhrn pro prehled',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Souhrnne metriky a casova rada' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/tickets': {
|
||||
get: {
|
||||
tags: ['Dashboard'],
|
||||
summary: 'Seznam ticketu',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Tickety',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: { type: 'array', items: { $ref: '#/components/schemas/Ticket' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/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/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/connectors': {
|
||||
get: {
|
||||
tags: ['Automatizace'],
|
||||
summary: 'Katalog konektoru',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Konektory, kategorie a operatory podminek' } },
|
||||
},
|
||||
},
|
||||
'/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',
|
||||
security: [{ bearerAuth: [] }],
|
||||
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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/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' },
|
||||
'404': { description: 'Neexistuje' },
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ['Automatizace'],
|
||||
summary: 'Smazat automatizaci',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '204': { description: 'Smazano' }, '404': { description: 'Neexistuje' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/automations/{id}/webhook/regenerate': {
|
||||
post: {
|
||||
tags: ['Automatizace'],
|
||||
summary: 'Nova adresa webhooku',
|
||||
description: 'Stara adresa okamzite prestane fungovat.',
|
||||
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' } },
|
||||
},
|
||||
},
|
||||
'404': { description: 'Neexistuje nebo spoustecem neni webhook' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/simulate': {
|
||||
post: {
|
||||
tags: ['Simulace'],
|
||||
summary: 'Vyvolat provozni udalost',
|
||||
description:
|
||||
'Meni skutecna data, aby bylo videt, jak dashboard reaguje zive. ' +
|
||||
'Nevyplnena pole server doplni ukazkovou hodnotou.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['action'],
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'ticket.created',
|
||||
'ticket.resolved',
|
||||
'incident.started',
|
||||
'incident.resolved',
|
||||
'automation.run',
|
||||
],
|
||||
},
|
||||
subject: { type: 'string' },
|
||||
requester: { type: 'string' },
|
||||
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
|
||||
ticketId: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
service: { type: 'string' },
|
||||
severity: { type: 'string', enum: ['sev1', 'sev2', 'sev3'] },
|
||||
incidentId: { type: 'string' },
|
||||
automationId: { type: 'string' },
|
||||
ok: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': { description: 'Udalost provedena' },
|
||||
'201': { description: 'Zaznam vytvoren' },
|
||||
'409': { description: 'Neni co provest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/webhook/{token}': {
|
||||
post: {
|
||||
tags: ['Webhook'],
|
||||
summary: 'Prijem dat do automatizace',
|
||||
description:
|
||||
'Verejny endpoint bez prihlaseni. Autorizuje neuhodnutelny token v adrese. ' +
|
||||
'Telo se overuje proti parametrum deklarovanym u spoustece.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'token',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
description: '32znakovy token vygenerovany serverem.',
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'object', additionalProperties: true },
|
||||
example: { customer: 'Nordis', score: 18, comment: 'vse ok' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'202': { description: 'Prijato' },
|
||||
'400': { description: 'Chybi parametr nebo nesedi typ' },
|
||||
'404': { description: 'Neznamy token' },
|
||||
'409': { description: 'Automatizace je pozastavena' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Router } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../config.js';
|
||||
import { findUserByEmail } from '../data/users.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { toPublicUser, type JwtPayload } from '../types.js';
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Zadejte platný e-mail.'),
|
||||
password: z.string().min(1, 'Zadejte heslo.'),
|
||||
});
|
||||
|
||||
authRouter.post('/login', async (req, res) => {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
|
||||
const { email, password } = parsed.data;
|
||||
const user = findUserByEmail(email);
|
||||
|
||||
// Stejna odpoved pro neexistujiciho uzivatele i spatne heslo (neprozrazujeme, ktery ucet existuje).
|
||||
const invalid = () =>
|
||||
res.status(401).json({ error: 'invalid_credentials', message: 'Nesprávný e-mail nebo heslo.' });
|
||||
|
||||
if (!user) {
|
||||
console.info(`[auth] neuspesne prihlaseni - neznamy e-mail: ${email}`);
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const passwordOk = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!passwordOk) {
|
||||
console.info(`[auth] neuspesne prihlaseni - spatne heslo: ${user.email}`);
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
|
||||
const token = jwt.sign(payload, config.jwtSecret, {
|
||||
expiresIn: config.jwtExpiresIn as jwt.SignOptions['expiresIn'],
|
||||
});
|
||||
|
||||
console.info(`[auth] prihlasen: ${user.email} (${user.role})`);
|
||||
return res.json({ token, user: toPublicUser(user) });
|
||||
});
|
||||
|
||||
authRouter.get('/me', requireAuth, (req, res) => {
|
||||
// requireAuth garantuje req.user
|
||||
return res.json({ user: toPublicUser(req.user!) });
|
||||
});
|
||||
|
||||
authRouter.post('/logout', requireAuth, (req, res) => {
|
||||
// Stateless JWT - odhlaseni resi klient zahozenim tokenu.
|
||||
// Endpoint existuje kvuli auditu a budoucimu blacklistu / refresh tokenum.
|
||||
console.info(`[auth] odhlasen: ${req.user!.email}`);
|
||||
return res.status(204).end();
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const contactRouter = Router();
|
||||
|
||||
const contactSchema = z.object({
|
||||
name: z.string().min(2, 'Zadejte jméno.'),
|
||||
email: z.string().email('Zadejte platný e-mail.'),
|
||||
company: z.string().optional().default(''),
|
||||
phone: z.string().optional().default(''),
|
||||
topic: z.enum(['automatizace', 'voicebot', 'integrace', 'dashboard', 'podpora', 'jine']),
|
||||
message: z.string().min(10, 'Napište prosím alespoň pár slov (min. 10 znaků).'),
|
||||
});
|
||||
|
||||
/**
|
||||
* PROTOTYP: zpravu jen zalogujeme. Realne odeslani (SMTP / ticket system)
|
||||
* pribude pozdeji - viz docs/04-backend-api.md.
|
||||
*/
|
||||
contactRouter.post('/', (req, res) => {
|
||||
const parsed = contactSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
|
||||
});
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
console.info(
|
||||
`[contact] nova poptavka: ${data.name} <${data.email}> tema=${data.topic} firma=${data.company || '-'}`,
|
||||
);
|
||||
|
||||
return res.status(202).json({
|
||||
ok: true,
|
||||
message: 'Děkujeme, ozveme se do jednoho pracovního dne.',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { publicBaseUrl } from '../config.js';
|
||||
import {
|
||||
createAutomation,
|
||||
deleteAutomation,
|
||||
getAutomation,
|
||||
listAutomations,
|
||||
regenerateWebhookToken,
|
||||
updateAutomation,
|
||||
type FlowStep,
|
||||
} from '../data/automationStore.js';
|
||||
import { operatorAllowedForType, operatorsByType } from '../data/conditions.js';
|
||||
import { connectorCategories, connectors, findOperation } from '../data/connectors.js';
|
||||
import { listIncidents } from '../data/incidentStore.js';
|
||||
import { getSummary } from '../data/mock.js';
|
||||
import { listTickets } from '../data/ticketStore.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { streamRouter } from './stream.js';
|
||||
|
||||
export const dashboardRouter = Router();
|
||||
|
||||
// Cely dashboard je jen pro prihlasene.
|
||||
dashboardRouter.use(requireAuth);
|
||||
|
||||
dashboardRouter.get('/summary', (_req, res) => {
|
||||
res.json(getSummary());
|
||||
});
|
||||
|
||||
dashboardRouter.get('/tickets', (_req, res) => {
|
||||
res.json({ items: listTickets() });
|
||||
});
|
||||
|
||||
dashboardRouter.get('/incidents', (_req, res) => {
|
||||
res.json({ items: listIncidents() });
|
||||
});
|
||||
|
||||
// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo.
|
||||
dashboardRouter.use('/stream', streamRouter);
|
||||
|
||||
// ---------------------------------------------------------------- konektory
|
||||
|
||||
dashboardRouter.get('/connectors', (_req, res) => {
|
||||
res.json({
|
||||
categories: connectorCategories,
|
||||
items: connectors,
|
||||
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
|
||||
// a jakou zakladni adresu ukazat u webhooku.
|
||||
operatorsByType,
|
||||
webhookBaseUrl: `${publicBaseUrl()}/webhook`,
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- automatizace
|
||||
|
||||
/**
|
||||
* 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'),
|
||||
connectorId: z.string().min(1),
|
||||
operationId: z.string().min(1),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
kind: z.literal('condition'),
|
||||
fieldId: z.string().min(1, 'Podmínka musí mít vybraný parametr.'),
|
||||
operator: z.enum([
|
||||
'eq',
|
||||
'neq',
|
||||
'gt',
|
||||
'gte',
|
||||
'lt',
|
||||
'lte',
|
||||
'contains',
|
||||
'startsWith',
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'isTrue',
|
||||
'isFalse',
|
||||
]),
|
||||
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í).'),
|
||||
type: z.enum(['string', 'number', 'boolean', 'date']),
|
||||
required: z.boolean(),
|
||||
});
|
||||
|
||||
const flowSchema = z.object({
|
||||
trigger: z
|
||||
.object({
|
||||
connectorId: z.string().min(1),
|
||||
operationId: z.string().min(1),
|
||||
fields: z.array(fieldSchema),
|
||||
// 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(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Overi, ze kazdy krok odkazuje na existujici konektor a operaci.
|
||||
* Vraci seznam problemu - prazdny znamena, ze je strom v poradku.
|
||||
*/
|
||||
function validateFlowReferences(flow: z.infer<typeof flowSchema>): string[] {
|
||||
const problems: string[] = [];
|
||||
|
||||
if (!flow.trigger) return problems;
|
||||
|
||||
const trigger = findOperation(flow.trigger.connectorId, flow.trigger.operationId, 'trigger');
|
||||
if (!trigger) {
|
||||
problems.push(
|
||||
`Spouštěč ${flow.trigger.connectorId}/${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);
|
||||
}
|
||||
|
||||
const fieldById = new Map(flow.trigger.fields.map((field) => [field.id, field]));
|
||||
|
||||
const walk = (steps: FlowStep[]) => {
|
||||
for (const step of steps) {
|
||||
if (step.kind === 'condition') {
|
||||
const field = fieldById.get(step.fieldId);
|
||||
if (!field) {
|
||||
problems.push(`Podmínka odkazuje na neexistující parametr (${step.fieldId}).`);
|
||||
} else if (!operatorAllowedForType(step.operator, field.type)) {
|
||||
problems.push(
|
||||
`Operátor "${step.operator}" nelze použít na parametr „${field.name}" typu ${field.type}.`,
|
||||
);
|
||||
}
|
||||
walk(step.yes);
|
||||
walk(step.no);
|
||||
continue;
|
||||
}
|
||||
if (!findOperation(step.connectorId, step.operationId, 'action')) {
|
||||
problems.push(`Akce ${step.connectorId}/${step.operationId} neexistuje v katalogu.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(flow.steps);
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
dashboardRouter.get('/automations', (_req, res) => {
|
||||
res.json({ items: listAutomations() });
|
||||
});
|
||||
|
||||
dashboardRouter.get('/automations/:id', (req, res) => {
|
||||
const automation = getAutomation(req.params.id);
|
||||
if (!automation) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
return res.json(automation);
|
||||
});
|
||||
|
||||
dashboardRouter.post('/automations', (req, res) => {
|
||||
const parsed = createSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
|
||||
const automation = createAutomation(parsed.data.name);
|
||||
return res.status(201).json(automation);
|
||||
});
|
||||
|
||||
dashboardRouter.put('/automations/:id', (req, res) => {
|
||||
const parsed = updateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.flow) {
|
||||
const problems = validateFlowReferences(parsed.data.flow);
|
||||
if (problems.length > 0) {
|
||||
console.warn(`[automations] ${req.params.id}: neplatny strom - ${problems.join(' ')}`);
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: problems[0],
|
||||
issues: problems.map((message) => ({ path: 'flow', message })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = updateAutomation(req.params.id, parsed.data);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
return res.json(updated);
|
||||
});
|
||||
|
||||
/** Nova adresa webhooku. Stara okamzite prestane fungovat - zamer, ne chyba. */
|
||||
dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => {
|
||||
const updated = regenerateWebhookToken(req.params.id);
|
||||
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);
|
||||
});
|
||||
|
||||
dashboardRouter.delete('/automations/:id', (req, res) => {
|
||||
if (!deleteAutomation(req.params.id)) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
return res.status(204).end();
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { listAutomations, recordRun } from '../data/automationStore.js';
|
||||
import {
|
||||
createIncident,
|
||||
firstActiveIncident,
|
||||
updateIncidentStatus,
|
||||
} from '../data/incidentStore.js';
|
||||
import { createTicket, firstOpenTicket, updateTicketStatus } from '../data/ticketStore.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const simulateRouter = Router();
|
||||
|
||||
simulateRouter.use(requireAuth);
|
||||
|
||||
/**
|
||||
* Simulace provoznich udalosti pro nahled zivého dashboardu.
|
||||
*
|
||||
* Zamerne MENI skutecna data v ulozisti, ne jen posila falesnou notifikaci.
|
||||
* Diky tomu se zmena projevi i v seznamech a v souhrnu, ne jen v bublinach.
|
||||
*/
|
||||
const schema = z.discriminatedUnion('action', [
|
||||
z.object({
|
||||
action: z.literal('ticket.created'),
|
||||
subject: z.string().trim().min(3).optional(),
|
||||
requester: z.string().trim().min(2).optional(),
|
||||
priority: z.enum(['low', 'normal', 'high', 'critical']).optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('ticket.resolved'),
|
||||
ticketId: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('incident.started'),
|
||||
title: z.string().trim().min(3).optional(),
|
||||
service: z.string().trim().min(2).optional(),
|
||||
severity: z.enum(['sev1', 'sev2', 'sev3']).optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('incident.resolved'),
|
||||
incidentId: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('automation.run'),
|
||||
automationId: z.string().optional(),
|
||||
ok: z.boolean().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const defaultSubjects = [
|
||||
'Nefunguje export objednávek do skladu',
|
||||
'Voicebot nerozumí názvu ulice',
|
||||
'Faktura se nespárovala s platbou',
|
||||
'Chybí notifikace o nové poptávce',
|
||||
'Zákazník žádá změnu fakturačních údajů',
|
||||
];
|
||||
|
||||
const defaultIncidents = [
|
||||
{ title: 'Výpadek spojení s fakturačním API', service: 'Integrace / iDoklad' },
|
||||
{ title: 'Zpoždění doručování webhooků', service: 'Webhook Router' },
|
||||
{ title: 'Hlasová brána odmítá hovory', service: 'Voicebot Gateway' },
|
||||
];
|
||||
|
||||
/** Nahodny prvek - jen pro rozmanitost ukazkovych dat. */
|
||||
function pick<T>(items: T[]): T {
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
simulateRouter.post('/', (req, res) => {
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup simulace.',
|
||||
});
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
console.info(`[simulace] ${input.action} spustil ${req.user?.email}`);
|
||||
|
||||
switch (input.action) {
|
||||
case 'ticket.created': {
|
||||
const ticket = createTicket({
|
||||
subject: input.subject ?? pick(defaultSubjects),
|
||||
requester: input.requester ?? 'Firma s.r.o.',
|
||||
priority: input.priority ?? 'normal',
|
||||
});
|
||||
return res.status(201).json({ ok: true, ticket });
|
||||
}
|
||||
|
||||
case 'ticket.resolved': {
|
||||
const target = input.ticketId ? { id: input.ticketId } : firstOpenTicket();
|
||||
if (!target) {
|
||||
return res.status(409).json({
|
||||
error: 'nothing_to_resolve',
|
||||
message: 'Není co vyřešit, všechny tickety jsou hotové.',
|
||||
});
|
||||
}
|
||||
const ticket = updateTicketStatus(target.id, 'resolved');
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
return res.json({ ok: true, ticket });
|
||||
}
|
||||
|
||||
case 'incident.started': {
|
||||
const preset = pick(defaultIncidents);
|
||||
const incident = createIncident({
|
||||
title: input.title ?? preset.title,
|
||||
service: input.service ?? preset.service,
|
||||
severity: input.severity ?? 'sev2',
|
||||
});
|
||||
return res.status(201).json({ ok: true, incident });
|
||||
}
|
||||
|
||||
case 'incident.resolved': {
|
||||
const target = input.incidentId ? { id: input.incidentId } : firstActiveIncident();
|
||||
if (!target) {
|
||||
return res.status(409).json({
|
||||
error: 'nothing_to_resolve',
|
||||
message: 'Není co vyřešit, žádný incident neběží.',
|
||||
});
|
||||
}
|
||||
const incident = updateIncidentStatus(target.id, 'resolved');
|
||||
if (!incident) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' });
|
||||
}
|
||||
return res.json({ ok: true, incident });
|
||||
}
|
||||
|
||||
case 'automation.run': {
|
||||
const automations = listAutomations().filter((a) => a.enabled);
|
||||
const targetId = input.automationId ?? automations[0]?.id;
|
||||
if (!targetId) {
|
||||
return res.status(409).json({
|
||||
error: 'no_automation',
|
||||
message: 'Není co spustit, žádná automatizace není aktivní.',
|
||||
});
|
||||
}
|
||||
const automation = recordRun(targetId, input.ok ?? true);
|
||||
if (!automation) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
return res.json({ ok: true, automation });
|
||||
}
|
||||
|
||||
default: {
|
||||
// Vetve jsou vycerpane, tohle je jen pojistka pri rozsireni schematu.
|
||||
console.error('[simulace] neosetrena akce:', input);
|
||||
return res.status(400).json({ error: 'unknown_action', message: 'Neznámá akce.' });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Router } from 'express';
|
||||
import { listenerCount, recentEvents, subscribe, type DashboardEvent } from '../events/bus.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const streamRouter = Router();
|
||||
|
||||
/** Jak casto poslat komentar, aby spojeni neuspalo proxy nebo prohlizec. */
|
||||
const HEARTBEAT_MS = 25_000;
|
||||
|
||||
/**
|
||||
* Server-Sent Events stream. Klient se pripoji jednou a dostava zmeny,
|
||||
* misto aby se kazdych par sekund ptal.
|
||||
*
|
||||
* Pouziva se SSE, ne WebSocket, protoze tok dat je jednosmerny
|
||||
* (server -> klient). Klient posila zmeny beznym REST volanim.
|
||||
*
|
||||
* Autorizace jde pres bezny Authorization header - klient se pripojuje
|
||||
* pres fetch, ne pres EventSource, ktery hlavicky neumi. Diky tomu
|
||||
* nekonci token v adrese a tedy ani v access logu.
|
||||
*/
|
||||
streamRouter.get('/', requireAuth, (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
// Vypne bufferovani na pripadne reverzni proxy (nginx), jinak se nic nedoruci.
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
const email = req.user?.email ?? 'neznamy';
|
||||
console.info(`[stream] pripojen ${email} (celkem posluchacu: ${listenerCount() + 1})`);
|
||||
|
||||
const send = (event: DashboardEvent) => {
|
||||
res.write(`event: ${event.type}\n`);
|
||||
res.write(`id: ${event.id}\n`);
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
};
|
||||
|
||||
// Potvrzeni pripojeni, aby klient hned vedel, ze stream bezi.
|
||||
res.write(`event: connected\ndata: ${JSON.stringify({ at: new Date().toISOString() })}\n\n`);
|
||||
|
||||
// Kratka historie, aby klient nepresel o to, co se stalo tesne pred pripojenim.
|
||||
for (const event of recentEvents(5).reverse()) send(event);
|
||||
|
||||
const unsubscribe = subscribe(send);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
// Komentarovy radek - klient ho ignoruje, spojeni zustane zive.
|
||||
res.write(': ping\n\n');
|
||||
}, HEARTBEAT_MS);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
console.info(`[stream] odpojen ${email} (zbyva posluchacu: ${listenerCount()})`);
|
||||
};
|
||||
|
||||
req.on('close', cleanup);
|
||||
res.on('error', (err) => {
|
||||
console.warn('[stream] chyba spojeni:', err);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Router } from 'express';
|
||||
import { findByWebhookToken, recordRun, type TriggerField } from '../data/automationStore.js';
|
||||
import type { FieldType } from '../data/conditions.js';
|
||||
import { publish } from '../events/bus.js';
|
||||
|
||||
export const webhookRouter = Router();
|
||||
|
||||
/**
|
||||
* VEREJNY endpoint - zamerne BEZ prihlaseni. Autorizaci resi neodhadnutelny
|
||||
* token v adrese (32 znaku, base64url), presne tak, jak to dela vetsina
|
||||
* webhookovych sluzeb.
|
||||
*
|
||||
* Neznamy token vraci 404 a nikdy neprozradi, ze nejaka automatizace existuje.
|
||||
*/
|
||||
webhookRouter.post('/:token', (req, res) => {
|
||||
const { token } = req.params;
|
||||
const automation = findByWebhookToken(token);
|
||||
|
||||
if (!automation || !automation.flow.trigger) {
|
||||
console.warn(`[webhook] volani na neznamy token (${token.slice(0, 6)}…)`);
|
||||
return res.status(404).json({ error: 'not_found', message: 'Webhook neexistuje.' });
|
||||
}
|
||||
|
||||
if (!automation.enabled) {
|
||||
console.info(`[webhook] ${automation.id}: volani na pozastavenou automatizaci`);
|
||||
return res.status(409).json({
|
||||
error: 'automation_disabled',
|
||||
message: 'Automatizace je pozastavená, požadavek nebyl zpracován.',
|
||||
});
|
||||
}
|
||||
|
||||
const payload = (req.body ?? {}) as Record<string, unknown>;
|
||||
const problems = validatePayload(automation.flow.trigger.fields, payload);
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.warn(`[webhook] ${automation.id}: neplatna data - ${problems.join(' ')}`);
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: problems[0],
|
||||
issues: problems,
|
||||
});
|
||||
}
|
||||
|
||||
publish('webhook.received', `Webhook přijal data pro ${automation.id}`, {
|
||||
automationId: automation.id,
|
||||
fields: Object.keys(payload),
|
||||
});
|
||||
recordRun(automation.id);
|
||||
console.info(
|
||||
`[webhook] ${automation.id}: prijato (${Object.keys(payload).join(', ') || 'bez dat'})`,
|
||||
);
|
||||
|
||||
// PROTOTYP: strom se nevykonava, jen potvrdime prijem.
|
||||
// Runtime je popsany v docs/08-automatizace-builder.md.
|
||||
return res.status(202).json({
|
||||
accepted: true,
|
||||
automationId: automation.id,
|
||||
message: 'Požadavek přijat. Prototyp strom akcí nevykonává.',
|
||||
});
|
||||
});
|
||||
|
||||
/** Overi prichozi data proti deklarovanym parametrum spoustece. */
|
||||
function validatePayload(
|
||||
fields: TriggerField[],
|
||||
payload: Record<string, unknown>,
|
||||
): string[] {
|
||||
const problems: string[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
const value = payload[field.name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
if (field.required) problems.push(`Chybí povinný parametr „${field.name}".`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!matchesType(value, field.type)) {
|
||||
problems.push(`Parametr „${field.name}" má mít typ ${field.type}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Neznama pole jen zalogujeme - odmitat je by rozbilo odesilatele,
|
||||
// kteri posilaji navic i sva vlastni data.
|
||||
const declared = new Set(fields.map((f) => f.name));
|
||||
const extra = Object.keys(payload).filter((key) => !declared.has(key));
|
||||
if (extra.length > 0) {
|
||||
console.info(`[webhook] nedeklarovane parametry navic: ${extra.join(', ')}`);
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
function matchesType(value: unknown, type: FieldType): boolean {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return typeof value === 'string';
|
||||
case 'number':
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
case 'boolean':
|
||||
return typeof value === 'boolean';
|
||||
case 'date':
|
||||
return typeof value === 'string' && !Number.isNaN(new Date(value).getTime());
|
||||
default:
|
||||
console.warn(`[webhook] neznamy typ parametru: ${type}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export type UserRole = 'admin' | 'client';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
/** bcrypt hash - nikdy neposilat na klienta */
|
||||
passwordHash: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
company: string;
|
||||
}
|
||||
|
||||
/** Verze uzivatele bezpecna pro odeslani na klienta. */
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
company: string;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export function toPublicUser(user: User): PublicUser {
|
||||
const { passwordHash: _passwordHash, ...rest } = user;
|
||||
return rest;
|
||||
}
|
||||
Reference in New Issue
Block a user