Nahrazeni sablony kompletnim webem a klientskym portalem
Web a portal Automia v jednom containeru. Express obsluhuje API i zbuildovanou React aplikaci z dist/public. Obsah: - verejny web: homepage, sluzby, o nas, kontakt, 404 - prihlaseni pres JWT, demo ucty - portal: prehled s grafem, tickety, incidenty, automatizace, konektory - builder automatizaci: strom akci, vetveni podminkou - katalog 25 konektoru v 8 kategoriich - webhook s registrovanou adresou, token generuje server - zivy dashboard pres SSE vcetne simulace provozu - Swagger UI na /docs a OpenAPI na /openapi.json Soulad s AGENTS.md: - ROOT_PATH z prostredi, prefix proxy nikde nehardcodovan - mount na koren i na prefix, funguje s handle_path i bez nej - base tag a window.__BASE_PATH__ vkladane do index.html za behu - OpenAPI servers obsahuje prefix, Try it out vola spravnou adresu - povinne /health a /docs, port 3000, naslouchani na 0.0.0.0 - secrets jen z environment variables, nikdy v logu Dokumentace ve slozce documentation/.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Router } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../config.js';
|
||||
import { findUserByEmail } from '../data/users.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { toPublicUser, type JwtPayload } from '../types.js';
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Zadejte platný e-mail.'),
|
||||
password: z.string().min(1, 'Zadejte heslo.'),
|
||||
});
|
||||
|
||||
authRouter.post('/login', async (req, res) => {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
|
||||
const { email, password } = parsed.data;
|
||||
const user = findUserByEmail(email);
|
||||
|
||||
// Stejna odpoved pro neexistujiciho uzivatele i spatne heslo (neprozrazujeme, ktery ucet existuje).
|
||||
const invalid = () =>
|
||||
res.status(401).json({ error: 'invalid_credentials', message: 'Nesprávný e-mail nebo heslo.' });
|
||||
|
||||
if (!user) {
|
||||
console.info(`[auth] neuspesne prihlaseni - neznamy e-mail: ${email}`);
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const passwordOk = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!passwordOk) {
|
||||
console.info(`[auth] neuspesne prihlaseni - spatne heslo: ${user.email}`);
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
|
||||
const token = jwt.sign(payload, config.jwtSecret, {
|
||||
expiresIn: config.jwtExpiresIn as jwt.SignOptions['expiresIn'],
|
||||
});
|
||||
|
||||
console.info(`[auth] prihlasen: ${user.email} (${user.role})`);
|
||||
return res.json({ token, user: toPublicUser(user) });
|
||||
});
|
||||
|
||||
authRouter.get('/me', requireAuth, (req, res) => {
|
||||
// requireAuth garantuje req.user
|
||||
return res.json({ user: toPublicUser(req.user!) });
|
||||
});
|
||||
|
||||
authRouter.post('/logout', requireAuth, (req, res) => {
|
||||
// Stateless JWT - odhlaseni resi klient zahozenim tokenu.
|
||||
// Endpoint existuje kvuli auditu a budoucimu blacklistu / refresh tokenum.
|
||||
console.info(`[auth] odhlasen: ${req.user!.email}`);
|
||||
return res.status(204).end();
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const contactRouter = Router();
|
||||
|
||||
const contactSchema = z.object({
|
||||
name: z.string().min(2, 'Zadejte jméno.'),
|
||||
email: z.string().email('Zadejte platný e-mail.'),
|
||||
company: z.string().optional().default(''),
|
||||
phone: z.string().optional().default(''),
|
||||
topic: z.enum(['automatizace', 'voicebot', 'integrace', 'dashboard', 'podpora', 'jine']),
|
||||
message: z.string().min(10, 'Napište prosím alespoň pár slov (min. 10 znaků).'),
|
||||
});
|
||||
|
||||
/**
|
||||
* PROTOTYP: zpravu jen zalogujeme. Realne odeslani (SMTP / ticket system)
|
||||
* pribude pozdeji - viz docs/04-backend-api.md.
|
||||
*/
|
||||
contactRouter.post('/', (req, res) => {
|
||||
const parsed = contactSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
|
||||
});
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
console.info(
|
||||
`[contact] nova poptavka: ${data.name} <${data.email}> tema=${data.topic} firma=${data.company || '-'}`,
|
||||
);
|
||||
|
||||
return res.status(202).json({
|
||||
ok: true,
|
||||
message: 'Děkujeme, ozveme se do jednoho pracovního dne.',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { publicBaseUrl } from '../config.js';
|
||||
import {
|
||||
createAutomation,
|
||||
deleteAutomation,
|
||||
getAutomation,
|
||||
listAutomations,
|
||||
regenerateWebhookToken,
|
||||
updateAutomation,
|
||||
type FlowStep,
|
||||
} from '../data/automationStore.js';
|
||||
import { operatorAllowedForType, operatorsByType } from '../data/conditions.js';
|
||||
import { connectorCategories, connectors, findOperation } from '../data/connectors.js';
|
||||
import { listIncidents } from '../data/incidentStore.js';
|
||||
import { getSummary } from '../data/mock.js';
|
||||
import { listTickets } from '../data/ticketStore.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { streamRouter } from './stream.js';
|
||||
|
||||
export const dashboardRouter = Router();
|
||||
|
||||
// Cely dashboard je jen pro prihlasene.
|
||||
dashboardRouter.use(requireAuth);
|
||||
|
||||
dashboardRouter.get('/summary', (_req, res) => {
|
||||
res.json(getSummary());
|
||||
});
|
||||
|
||||
dashboardRouter.get('/tickets', (_req, res) => {
|
||||
res.json({ items: listTickets() });
|
||||
});
|
||||
|
||||
dashboardRouter.get('/incidents', (_req, res) => {
|
||||
res.json({ items: listIncidents() });
|
||||
});
|
||||
|
||||
// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo.
|
||||
dashboardRouter.use('/stream', streamRouter);
|
||||
|
||||
// ---------------------------------------------------------------- konektory
|
||||
|
||||
dashboardRouter.get('/connectors', (_req, res) => {
|
||||
res.json({
|
||||
categories: connectorCategories,
|
||||
items: connectors,
|
||||
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
|
||||
// a jakou zakladni adresu ukazat u webhooku.
|
||||
operatorsByType,
|
||||
webhookBaseUrl: `${publicBaseUrl()}/webhook`,
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- automatizace
|
||||
|
||||
/**
|
||||
* Rekurzivni schema kroku. z.lazy je nutne, protoze podminka obsahuje
|
||||
* dalsi kroky - bez toho by se typ odkazoval sam na sebe drive, nez existuje.
|
||||
*/
|
||||
const stepSchema: z.ZodType<FlowStep> = z.lazy(() =>
|
||||
z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
kind: z.literal('action'),
|
||||
connectorId: z.string().min(1),
|
||||
operationId: z.string().min(1),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
kind: z.literal('condition'),
|
||||
fieldId: z.string().min(1, 'Podmínka musí mít vybraný parametr.'),
|
||||
operator: z.enum([
|
||||
'eq',
|
||||
'neq',
|
||||
'gt',
|
||||
'gte',
|
||||
'lt',
|
||||
'lte',
|
||||
'contains',
|
||||
'startsWith',
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'isTrue',
|
||||
'isFalse',
|
||||
]),
|
||||
value: z.string().optional(),
|
||||
yes: z.array(stepSchema),
|
||||
no: z.array(stepSchema),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const fieldSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Parametr musí mít název.')
|
||||
// Zamerne jen bezpecne znaky - nazev je klic v prichozim JSONu.
|
||||
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Název parametru: písmena, číslice a _ (nezačíná číslicí).'),
|
||||
type: z.enum(['string', 'number', 'boolean', 'date']),
|
||||
required: z.boolean(),
|
||||
});
|
||||
|
||||
const flowSchema = z.object({
|
||||
trigger: z
|
||||
.object({
|
||||
connectorId: z.string().min(1),
|
||||
operationId: z.string().min(1),
|
||||
fields: z.array(fieldSchema),
|
||||
// Token generuje server. Cokoliv od klienta se ignoruje.
|
||||
webhookToken: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
steps: z.array(stepSchema),
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().trim().min(3, 'Název musí mít alespoň 3 znaky.'),
|
||||
});
|
||||
|
||||
const updateSchema = z.object({
|
||||
name: z.string().trim().min(3, 'Název musí mít alespoň 3 znaky.').optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
flow: flowSchema.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Overi, ze kazdy krok odkazuje na existujici konektor a operaci.
|
||||
* Vraci seznam problemu - prazdny znamena, ze je strom v poradku.
|
||||
*/
|
||||
function validateFlowReferences(flow: z.infer<typeof flowSchema>): string[] {
|
||||
const problems: string[] = [];
|
||||
|
||||
if (!flow.trigger) return problems;
|
||||
|
||||
const trigger = findOperation(flow.trigger.connectorId, flow.trigger.operationId, 'trigger');
|
||||
if (!trigger) {
|
||||
problems.push(
|
||||
`Spouštěč ${flow.trigger.connectorId}/${flow.trigger.operationId} neexistuje v katalogu.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Nazvy parametru musi byt unikatni - jsou to klice v prichozich datech.
|
||||
const names = new Set<string>();
|
||||
for (const field of flow.trigger.fields) {
|
||||
if (names.has(field.name)) {
|
||||
problems.push(`Parametr „${field.name}" je uvedený dvakrát.`);
|
||||
}
|
||||
names.add(field.name);
|
||||
}
|
||||
|
||||
const fieldById = new Map(flow.trigger.fields.map((field) => [field.id, field]));
|
||||
|
||||
const walk = (steps: FlowStep[]) => {
|
||||
for (const step of steps) {
|
||||
if (step.kind === 'condition') {
|
||||
const field = fieldById.get(step.fieldId);
|
||||
if (!field) {
|
||||
problems.push(`Podmínka odkazuje na neexistující parametr (${step.fieldId}).`);
|
||||
} else if (!operatorAllowedForType(step.operator, field.type)) {
|
||||
problems.push(
|
||||
`Operátor "${step.operator}" nelze použít na parametr „${field.name}" typu ${field.type}.`,
|
||||
);
|
||||
}
|
||||
walk(step.yes);
|
||||
walk(step.no);
|
||||
continue;
|
||||
}
|
||||
if (!findOperation(step.connectorId, step.operationId, 'action')) {
|
||||
problems.push(`Akce ${step.connectorId}/${step.operationId} neexistuje v katalogu.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(flow.steps);
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
dashboardRouter.get('/automations', (_req, res) => {
|
||||
res.json({ items: listAutomations() });
|
||||
});
|
||||
|
||||
dashboardRouter.get('/automations/:id', (req, res) => {
|
||||
const automation = getAutomation(req.params.id);
|
||||
if (!automation) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
return res.json(automation);
|
||||
});
|
||||
|
||||
dashboardRouter.post('/automations', (req, res) => {
|
||||
const parsed = createSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
|
||||
const automation = createAutomation(parsed.data.name);
|
||||
return res.status(201).json(automation);
|
||||
});
|
||||
|
||||
dashboardRouter.put('/automations/:id', (req, res) => {
|
||||
const parsed = updateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.flow) {
|
||||
const problems = validateFlowReferences(parsed.data.flow);
|
||||
if (problems.length > 0) {
|
||||
console.warn(`[automations] ${req.params.id}: neplatny strom - ${problems.join(' ')}`);
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: problems[0],
|
||||
issues: problems.map((message) => ({ path: 'flow', message })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = updateAutomation(req.params.id, parsed.data);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
return res.json(updated);
|
||||
});
|
||||
|
||||
/** Nova adresa webhooku. Stara okamzite prestane fungovat - zamer, ne chyba. */
|
||||
dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => {
|
||||
const updated = regenerateWebhookToken(req.params.id);
|
||||
if (!updated) {
|
||||
return res.status(404).json({
|
||||
error: 'not_found',
|
||||
message: 'Automatizace neexistuje, nebo jejím spouštěčem není webhook.',
|
||||
});
|
||||
}
|
||||
return res.json(updated);
|
||||
});
|
||||
|
||||
dashboardRouter.delete('/automations/:id', (req, res) => {
|
||||
if (!deleteAutomation(req.params.id)) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
return res.status(204).end();
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { listAutomations, recordRun } from '../data/automationStore.js';
|
||||
import {
|
||||
createIncident,
|
||||
firstActiveIncident,
|
||||
updateIncidentStatus,
|
||||
} from '../data/incidentStore.js';
|
||||
import { createTicket, firstOpenTicket, updateTicketStatus } from '../data/ticketStore.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const simulateRouter = Router();
|
||||
|
||||
simulateRouter.use(requireAuth);
|
||||
|
||||
/**
|
||||
* Simulace provoznich udalosti pro nahled zivého dashboardu.
|
||||
*
|
||||
* Zamerne MENI skutecna data v ulozisti, ne jen posila falesnou notifikaci.
|
||||
* Diky tomu se zmena projevi i v seznamech a v souhrnu, ne jen v bublinach.
|
||||
*/
|
||||
const schema = z.discriminatedUnion('action', [
|
||||
z.object({
|
||||
action: z.literal('ticket.created'),
|
||||
subject: z.string().trim().min(3).optional(),
|
||||
requester: z.string().trim().min(2).optional(),
|
||||
priority: z.enum(['low', 'normal', 'high', 'critical']).optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('ticket.resolved'),
|
||||
ticketId: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('incident.started'),
|
||||
title: z.string().trim().min(3).optional(),
|
||||
service: z.string().trim().min(2).optional(),
|
||||
severity: z.enum(['sev1', 'sev2', 'sev3']).optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('incident.resolved'),
|
||||
incidentId: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('automation.run'),
|
||||
automationId: z.string().optional(),
|
||||
ok: z.boolean().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const defaultSubjects = [
|
||||
'Nefunguje export objednávek do skladu',
|
||||
'Voicebot nerozumí názvu ulice',
|
||||
'Faktura se nespárovala s platbou',
|
||||
'Chybí notifikace o nové poptávce',
|
||||
'Zákazník žádá změnu fakturačních údajů',
|
||||
];
|
||||
|
||||
const defaultIncidents = [
|
||||
{ title: 'Výpadek spojení s fakturačním API', service: 'Integrace / iDoklad' },
|
||||
{ title: 'Zpoždění doručování webhooků', service: 'Webhook Router' },
|
||||
{ title: 'Hlasová brána odmítá hovory', service: 'Voicebot Gateway' },
|
||||
];
|
||||
|
||||
/** Nahodny prvek - jen pro rozmanitost ukazkovych dat. */
|
||||
function pick<T>(items: T[]): T {
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
simulateRouter.post('/', (req, res) => {
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup simulace.',
|
||||
});
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
console.info(`[simulace] ${input.action} spustil ${req.user?.email}`);
|
||||
|
||||
switch (input.action) {
|
||||
case 'ticket.created': {
|
||||
const ticket = createTicket({
|
||||
subject: input.subject ?? pick(defaultSubjects),
|
||||
requester: input.requester ?? 'Firma s.r.o.',
|
||||
priority: input.priority ?? 'normal',
|
||||
});
|
||||
return res.status(201).json({ ok: true, ticket });
|
||||
}
|
||||
|
||||
case 'ticket.resolved': {
|
||||
const target = input.ticketId ? { id: input.ticketId } : firstOpenTicket();
|
||||
if (!target) {
|
||||
return res.status(409).json({
|
||||
error: 'nothing_to_resolve',
|
||||
message: 'Není co vyřešit, všechny tickety jsou hotové.',
|
||||
});
|
||||
}
|
||||
const ticket = updateTicketStatus(target.id, 'resolved');
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
return res.json({ ok: true, ticket });
|
||||
}
|
||||
|
||||
case 'incident.started': {
|
||||
const preset = pick(defaultIncidents);
|
||||
const incident = createIncident({
|
||||
title: input.title ?? preset.title,
|
||||
service: input.service ?? preset.service,
|
||||
severity: input.severity ?? 'sev2',
|
||||
});
|
||||
return res.status(201).json({ ok: true, incident });
|
||||
}
|
||||
|
||||
case 'incident.resolved': {
|
||||
const target = input.incidentId ? { id: input.incidentId } : firstActiveIncident();
|
||||
if (!target) {
|
||||
return res.status(409).json({
|
||||
error: 'nothing_to_resolve',
|
||||
message: 'Není co vyřešit, žádný incident neběží.',
|
||||
});
|
||||
}
|
||||
const incident = updateIncidentStatus(target.id, 'resolved');
|
||||
if (!incident) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' });
|
||||
}
|
||||
return res.json({ ok: true, incident });
|
||||
}
|
||||
|
||||
case 'automation.run': {
|
||||
const automations = listAutomations().filter((a) => a.enabled);
|
||||
const targetId = input.automationId ?? automations[0]?.id;
|
||||
if (!targetId) {
|
||||
return res.status(409).json({
|
||||
error: 'no_automation',
|
||||
message: 'Není co spustit, žádná automatizace není aktivní.',
|
||||
});
|
||||
}
|
||||
const automation = recordRun(targetId, input.ok ?? true);
|
||||
if (!automation) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
return res.json({ ok: true, automation });
|
||||
}
|
||||
|
||||
default: {
|
||||
// Vetve jsou vycerpane, tohle je jen pojistka pri rozsireni schematu.
|
||||
console.error('[simulace] neosetrena akce:', input);
|
||||
return res.status(400).json({ error: 'unknown_action', message: 'Neznámá akce.' });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Router } from 'express';
|
||||
import { listenerCount, recentEvents, subscribe, type DashboardEvent } from '../events/bus.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const streamRouter = Router();
|
||||
|
||||
/** Jak casto poslat komentar, aby spojeni neuspalo proxy nebo prohlizec. */
|
||||
const HEARTBEAT_MS = 25_000;
|
||||
|
||||
/**
|
||||
* Server-Sent Events stream. Klient se pripoji jednou a dostava zmeny,
|
||||
* misto aby se kazdych par sekund ptal.
|
||||
*
|
||||
* Pouziva se SSE, ne WebSocket, protoze tok dat je jednosmerny
|
||||
* (server -> klient). Klient posila zmeny beznym REST volanim.
|
||||
*
|
||||
* Autorizace jde pres bezny Authorization header - klient se pripojuje
|
||||
* pres fetch, ne pres EventSource, ktery hlavicky neumi. Diky tomu
|
||||
* nekonci token v adrese a tedy ani v access logu.
|
||||
*/
|
||||
streamRouter.get('/', requireAuth, (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
// Vypne bufferovani na pripadne reverzni proxy (nginx), jinak se nic nedoruci.
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
const email = req.user?.email ?? 'neznamy';
|
||||
console.info(`[stream] pripojen ${email} (celkem posluchacu: ${listenerCount() + 1})`);
|
||||
|
||||
const send = (event: DashboardEvent) => {
|
||||
res.write(`event: ${event.type}\n`);
|
||||
res.write(`id: ${event.id}\n`);
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
};
|
||||
|
||||
// Potvrzeni pripojeni, aby klient hned vedel, ze stream bezi.
|
||||
res.write(`event: connected\ndata: ${JSON.stringify({ at: new Date().toISOString() })}\n\n`);
|
||||
|
||||
// Kratka historie, aby klient nepresel o to, co se stalo tesne pred pripojenim.
|
||||
for (const event of recentEvents(5).reverse()) send(event);
|
||||
|
||||
const unsubscribe = subscribe(send);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
// Komentarovy radek - klient ho ignoruje, spojeni zustane zive.
|
||||
res.write(': ping\n\n');
|
||||
}, HEARTBEAT_MS);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
console.info(`[stream] odpojen ${email} (zbyva posluchacu: ${listenerCount()})`);
|
||||
};
|
||||
|
||||
req.on('close', cleanup);
|
||||
res.on('error', (err) => {
|
||||
console.warn('[stream] chyba spojeni:', err);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Router } from 'express';
|
||||
import { findByWebhookToken, recordRun, type TriggerField } from '../data/automationStore.js';
|
||||
import type { FieldType } from '../data/conditions.js';
|
||||
import { publish } from '../events/bus.js';
|
||||
|
||||
export const webhookRouter = Router();
|
||||
|
||||
/**
|
||||
* VEREJNY endpoint - zamerne BEZ prihlaseni. Autorizaci resi neodhadnutelny
|
||||
* token v adrese (32 znaku, base64url), presne tak, jak to dela vetsina
|
||||
* webhookovych sluzeb.
|
||||
*
|
||||
* Neznamy token vraci 404 a nikdy neprozradi, ze nejaka automatizace existuje.
|
||||
*/
|
||||
webhookRouter.post('/:token', (req, res) => {
|
||||
const { token } = req.params;
|
||||
const automation = findByWebhookToken(token);
|
||||
|
||||
if (!automation || !automation.flow.trigger) {
|
||||
console.warn(`[webhook] volani na neznamy token (${token.slice(0, 6)}…)`);
|
||||
return res.status(404).json({ error: 'not_found', message: 'Webhook neexistuje.' });
|
||||
}
|
||||
|
||||
if (!automation.enabled) {
|
||||
console.info(`[webhook] ${automation.id}: volani na pozastavenou automatizaci`);
|
||||
return res.status(409).json({
|
||||
error: 'automation_disabled',
|
||||
message: 'Automatizace je pozastavená, požadavek nebyl zpracován.',
|
||||
});
|
||||
}
|
||||
|
||||
const payload = (req.body ?? {}) as Record<string, unknown>;
|
||||
const problems = validatePayload(automation.flow.trigger.fields, payload);
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.warn(`[webhook] ${automation.id}: neplatna data - ${problems.join(' ')}`);
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: problems[0],
|
||||
issues: problems,
|
||||
});
|
||||
}
|
||||
|
||||
publish('webhook.received', `Webhook přijal data pro ${automation.id}`, {
|
||||
automationId: automation.id,
|
||||
fields: Object.keys(payload),
|
||||
});
|
||||
recordRun(automation.id);
|
||||
console.info(
|
||||
`[webhook] ${automation.id}: prijato (${Object.keys(payload).join(', ') || 'bez dat'})`,
|
||||
);
|
||||
|
||||
// PROTOTYP: strom se nevykonava, jen potvrdime prijem.
|
||||
// Runtime je popsany v docs/08-automatizace-builder.md.
|
||||
return res.status(202).json({
|
||||
accepted: true,
|
||||
automationId: automation.id,
|
||||
message: 'Požadavek přijat. Prototyp strom akcí nevykonává.',
|
||||
});
|
||||
});
|
||||
|
||||
/** Overi prichozi data proti deklarovanym parametrum spoustece. */
|
||||
function validatePayload(
|
||||
fields: TriggerField[],
|
||||
payload: Record<string, unknown>,
|
||||
): string[] {
|
||||
const problems: string[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
const value = payload[field.name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
if (field.required) problems.push(`Chybí povinný parametr „${field.name}".`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!matchesType(value, field.type)) {
|
||||
problems.push(`Parametr „${field.name}" má mít typ ${field.type}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Neznama pole jen zalogujeme - odmitat je by rozbilo odesilatele,
|
||||
// kteri posilaji navic i sva vlastni data.
|
||||
const declared = new Set(fields.map((f) => f.name));
|
||||
const extra = Object.keys(payload).filter((key) => !declared.has(key));
|
||||
if (extra.length > 0) {
|
||||
console.info(`[webhook] nedeklarovane parametry navic: ${extra.join(', ')}`);
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
function matchesType(value: unknown, type: FieldType): boolean {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return typeof value === 'string';
|
||||
case 'number':
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
case 'boolean':
|
||||
return typeof value === 'boolean';
|
||||
case 'date':
|
||||
return typeof value === 'string' && !Number.isNaN(new Date(value).getTime());
|
||||
default:
|
||||
console.warn(`[webhook] neznamy typ parametru: ${type}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user