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:
JiriUhlir
2026-07-31 17:00:37 +02:00
parent 46f2f0b07e
commit 7b045a9f20
100 changed files with 15409 additions and 35 deletions
+58
View File
@@ -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}`;
}
+582
View File
@@ -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;
}
+48
View File
@@ -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);
}
+699
View File
@@ -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);
}
+115
View File
@@ -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');
}
+48
View File
@@ -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,
};
}
+148
View File
@@ -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');
}
+37
View File
@@ -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);
}
+80
View File
@@ -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
View File
@@ -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);
});
+52
View File
@@ -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
View File
@@ -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' },
},
},
},
},
};
}
+63
View File
@@ -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();
});
+38
View File
@@ -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.',
});
});
+251
View File
@@ -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();
});
+153
View File
@@ -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.' });
}
}
});
+62
View File
@@ -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();
});
});
+107
View File
@@ -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;
}
}
+31
View File
@@ -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;
}