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
+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;
}
}