Poptavka z webu je ticket, prilohy, udaje provozovatele, kolacovy graf

Provozovatel portalu: firma s priznakem portalOperator (jen jedna, zapnuti
odebere ostatnim) a novymi poli contactEmail, contactPhone, website vedle
ico, dic, adresy a pravni formy. Verejny GET /api/public/brand vraci jeji
udaje a web je bere pres useBrand() na kontaktu, v paticce, O nas,
prihlaseni i v titulku; brand.ts je jen zaloha.

Poptavka z webu zaklada u provozovatele ticket kanalu form: predmet
"Poptavka: tema", telo JSON s poli formulare, tag Poptavka plus tema,
zakaznik z formulare, poznamka v logu. Bez provozovatele se jen zaloguje.

Prilohy ticketu: formular az 3 soubory po 5 MB, ticket az 10; nahrani,
seznam, stazeni a smazani (pravo ticket.comment, strop viditelnosti,
poznamky v logu, audit). Soubor jde v JSON jako Base64 a lezi v beznem
ulozisti, bez nove zavislosti; strop tela jen na techto cestach.

Vlastni widget s kreslenim Graf umi i pocet ticketu se seskupenim jako
kolac (PieChart.tsx, ciste SVG, osm barev z tokenu, zbytek jako ostatni).

OpenAPI rozdelene na mensi soubory (102 cest, 28 schemat overeno shodnych),
28 novych testu (135 celkem), dokumentace aktualizovana.
This commit is contained in:
JiriUhlir
2026-09-09 19:35:00 +02:00
parent 22dda2d139
commit c25e826766
72 changed files with 3978 additions and 1118 deletions
+25 -1
View File
@@ -22,7 +22,9 @@ import { authRouter } from './routes/auth.js';
import { contactRouter } from './routes/contact.js';
import { dashboardRouter } from './routes/dashboard/index.js';
import { publicInviteRouter } from './routes/invites.js';
import { publicRouter } from './routes/public.js';
import { webhookRouter } from './routes/webhook.js';
import { hasOwnBodyLimit } from './routes/bodyLimit.js';
const here = path.dirname(fileURLToPath(import.meta.url));
/** Zbuildovana SPA. Vite ji zapisuje do dist/public, viz vite.config.ts. */
@@ -34,6 +36,14 @@ const JSON_BODY_LIMIT = '256kb';
/** Rok. Soubory buildu maji hash v nazvu, takze se muzou cachovat na maximum. */
const STATIC_MAX_AGE_SEC = 31_536_000;
/** Cesta bez query a bez prefixu proxy, aby se dala porovnat se vzorem routy. */
function stripRootPath(url: string): string {
const path = url.split('?')[0] ?? '';
return config.rootPath && path.startsWith(config.rootPath)
? path.slice(config.rootPath.length)
: path;
}
/**
* Token v adrese je pristupovy udaj. Do logu jde jen jeho zacatek, aby slo
* volani dohledat, ale ne zopakovat.
@@ -89,7 +99,19 @@ function applyBaseMiddleware(app: express.Express): void {
credentials: true,
}),
);
app.use(express.json({ limit: JSON_BODY_LIMIT }));
/*
* Routy s prilohami (kontakt, prilohy ticketu) maji vlastni `express.json`
* s vetsim stropem. Globalni parser je musi preskocit, jinak telo odmitne
* driv, nez se k nemu router dostane. `type` rozhoduje, jestli se telo cte.
*/
app.use(
express.json({
limit: JSON_BODY_LIMIT,
type: (req) =>
!hasOwnBodyLimit(stripRootPath(req.url ?? '')) &&
(req.headers['content-type'] ?? '').startsWith('application/json'),
}),
);
/*
* Bezpecnostni hlavicky. Rucne a stridme: zadne CSP, ktere by rozbilo SPA
@@ -195,6 +217,8 @@ function buildApiRouter(): express.Router {
api.use('/api/invites', publicInviteRouter);
api.use('/api/admin', adminRouter);
api.use('/api/contact', contactRouter);
// Verejne udaje provozovatele pro web. Jen cteni, bez prihlaseni.
api.use('/api/public', publicRouter);
api.use('/webhook', webhookRouter);
return api;
+220
View File
@@ -0,0 +1,220 @@
/**
* Prilohy ticketu.
*
* Obsah se uklada jako base64 primo v zaznamu obecneho uloziste. V rezimu
* `file` to znamena, ze kazda zmena prepise cely `attachment.json` - pro
* jednotky MB je to prijatelne a dalsi krok je presun obsahu do blob uloziste
* (S3 nebo tabulka s bytea), az to zacne byt znat. Rozhrani tohoto modulu se
* tim nezmeni, meni se jen to, odkud `getAttachment` cte `content`.
*
* Mazani ticketu dnes neexistuje, proto tu neni kaskada. Az pribude, patri
* sem `removeAttachmentsOfTicket(ticketId)`.
*/
import { randomUUID } from 'node:crypto';
import type { Attachment, AttachmentUpload } from '../shared/attachments.js';
import type { Ticket } from '../shared/tickets.js';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { noteAttachment } from './ticketStore.js';
export type { Attachment, AttachmentUpload };
/** Nejvetsi soubor po dekodovani. 5 MB staci na PDF i fotku, vic uz je archiv. */
export const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
/** Kolik priloh smi mit jeden ticket. Vic uz nikdo neprojde, patri to do archivu. */
export const MAX_ATTACHMENTS_PER_TICKET = 10;
/** Delka nazvu souboru. Delsi nazvy rozbiji hlavicku Content-Disposition. */
export const ALLOWED_NAME_LENGTH = 200;
/** Vychozi typ obsahu, kdyz ho klient neposle nebo posle nesmysl. */
const DEFAULT_MIME = 'application/octet-stream';
/** Nazev, kdyz po ocisteni nic nezbyde. */
const FALLBACK_NAME = 'priloha';
/** Typ obsahu jde do hlavicky odpovedi, proto jen `typ/podtyp` bez parametru. */
const MIME_PATTERN = /^[\w.+-]+\/[\w.+-]+$/;
/** Base64 bez bilych znaku, delka nasobek ctyr, nejvys dve rovnitka na konci. */
const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/;
/** Zaznam v ulozisti: priloha plus obsah, ktery se v seznamu nevraci. */
interface StoredAttachment extends TenantEntity, Attachment {
tenantId: string;
/** Obsah v base64. */
content: string;
}
export const attachmentStore = defineStore<StoredAttachment>('attachment');
/** Verejna podoba: bez obsahu a bez `updatedAt`, ktere priloha nemeni. */
function toPublic(stored: StoredAttachment): Attachment {
return {
id: stored.id,
tenantId: stored.tenantId,
ticketId: stored.ticketId,
name: stored.name,
mime: stored.mime,
size: stored.size,
uploadedBy: stored.uploadedBy,
createdAt: stored.createdAt,
};
}
/** Ridici znaky ASCII (0-31) a DEL (127). Do nazvu souboru ani do hlavicky nepatri. */
const LAST_CONTROL_CHAR = 31;
const DEL_CHAR = 127;
function isControlChar(char: string): boolean {
const code = char.charCodeAt(0);
return code <= LAST_CONTROL_CHAR || code === DEL_CHAR;
}
/**
* Nazev souboru bez cesty a ridicich znaku.
*
* Klient posila, co chce: `../../etc/passwd` nebo nazev s novym radkem, ktery
* by rozbil hlavicku. Bere se jen posledni cast za lomitkem.
*/
export function sanitizeName(name: string): string {
const base = name.split(/[\\/]/).pop() ?? '';
const cleaned = [...base].filter((char) => !isControlChar(char)).join('').trim();
const safe = cleaned === '' || cleaned === '.' || cleaned === '..' ? FALLBACK_NAME : cleaned;
return safe.slice(0, ALLOWED_NAME_LENGTH);
}
/** Typ obsahu do hlavicky. Co nevypada jako `typ/podtyp`, se nahradi vychozim. */
export function normalizeMime(mime: string | undefined): string {
const trimmed = (mime ?? '').trim().toLowerCase();
return MIME_PATTERN.test(trimmed) ? trimmed : DEFAULT_MIME;
}
/**
* Dekoduje base64. Vraci null, kdyz to base64 neni nebo je soubor prazdny.
* `Buffer.from` je shovivavy a neplatne znaky tise zahazuje, proto regex
* a kontrola delky pred nim.
*/
export function decodeBase64(content: string): Buffer | null {
if (content === '' || content.length % 4 !== 0 || !BASE64_PATTERN.test(content)) return null;
const buffer = Buffer.from(content, 'base64');
return buffer.length === 0 ? null : buffer;
}
/** Velikost pro cloveka do logu ticketu. */
export function formatBytes(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} kB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
/** Soubor po kontrole, pripraveny k ulozeni. */
interface CheckedUpload {
name: string;
mime: string;
content: string;
size: number;
}
export type UploadCheck =
| { ok: true; files: CheckedUpload[] }
| { ok: false; message: string };
/**
* Overi davku souboru proti limitum. Cista funkce, aby sla otestovat bez
* uloziste a aby ji kontaktni formular mohl zavolat driv, nez zalozi ticket.
*/
export function checkUploads(uploads: AttachmentUpload[], existingCount: number): UploadCheck {
if (existingCount + uploads.length > MAX_ATTACHMENTS_PER_TICKET) {
return {
ok: false,
message: `Ticket může mít nejvýš ${MAX_ATTACHMENTS_PER_TICKET} příloh.`,
};
}
const files: CheckedUpload[] = [];
for (const upload of uploads) {
const name = sanitizeName(upload.name);
const buffer = decodeBase64(upload.content);
if (!buffer) {
return { ok: false, message: `Soubor ${name} není platný base64 nebo je prázdný.` };
}
if (buffer.length > MAX_ATTACHMENT_BYTES) {
return {
ok: false,
message: `Soubor ${name} je větší než ${formatBytes(MAX_ATTACHMENT_BYTES)}.`,
};
}
files.push({ name, mime: normalizeMime(upload.mime), content: upload.content, size: buffer.length });
}
return { ok: true, files };
}
/** Prilohy ticketu bez obsahu. Cizi firma dostane prazdny seznam. */
export async function listAttachments(ticketId: string, tenantIds: string[]): Promise<Attachment[]> {
const rows = await attachmentStore.list({ tenantIds });
return rows
.filter((row) => row.ticketId === ticketId)
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
.map(toPublic);
}
export type AddAttachmentsResult =
| { ok: true; items: Attachment[] }
| { ok: false; message: string };
/**
* Ulozi soubory k ticketu a kazdy zapise do logu ticketu.
*
* Kontrola limitu bezi nad celou davkou pred prvnim zapisem: kdyz neprojde
* treti soubor, neulozi se ani prvni dva, jinak by klient nevedel, co uz tam je.
*/
export async function addAttachments(
ticket: Ticket,
uploads: AttachmentUpload[],
uploadedBy: string | null,
): Promise<AddAttachmentsResult> {
const existing = await listAttachments(ticket.id, [ticket.tenantId]);
const checked = checkUploads(uploads, existing.length);
if (!checked.ok) return checked;
const items: Attachment[] = [];
for (const file of checked.files) {
const timestamp = nowIso();
const stored = await attachmentStore.create({
id: `att_${randomUUID().slice(0, 8)}`,
tenantId: ticket.tenantId,
ticketId: ticket.id,
name: file.name,
mime: file.mime,
size: file.size,
uploadedBy,
content: file.content,
createdAt: timestamp,
updatedAt: timestamp,
});
items.push(toPublic(stored));
noteAttachment(ticket.id, [ticket.tenantId], `Příloha: ${file.name} (${formatBytes(file.size)})`);
}
return { ok: true, items };
}
/** Priloha vcetne obsahu. Cizi nebo k jinemu ticketu se chova jako neexistujici. */
export async function getAttachment(
id: string,
ticketId: string,
tenantIds: string[],
): Promise<(Attachment & { content: string }) | undefined> {
const stored = await attachmentStore.get(id, { tenantIds });
if (!stored || stored.ticketId !== ticketId) return undefined;
return { ...toPublic(stored), content: stored.content };
}
/** Smaze prilohu a zapise to do logu ticketu. Vraci false, kdyz neexistuje. */
export async function removeAttachment(
id: string,
ticketId: string,
tenantIds: string[],
): Promise<boolean> {
const stored = await attachmentStore.get(id, { tenantIds });
if (!stored || stored.ticketId !== ticketId) return false;
const removed = await attachmentStore.remove(id, { tenantIds });
if (removed) noteAttachment(ticketId, [stored.tenantId], `Příloha odebrána: ${stored.name}`);
return removed;
}
+3
View File
@@ -10,6 +10,7 @@
*/
import { actionPermissions, actionStore, seedActions } from './ticketActions.js';
import { attachmentStore } from './attachments.js';
import { customWidgetStore, seedCustomWidgets } from './customWidgets.js';
import { auditStore } from './audit.js';
import { notificationStore } from './notifications.js';
@@ -61,6 +62,8 @@ const entities: Array<{
{ store: notificationStore as EntityStore<TenantEntity> },
{ store: inviteStore as EntityStore<TenantEntity> },
{ store: tenantScriptStore as EntityStore<TenantEntity> },
// Prilohy ticketu. Zadna kopie v pameti, ctou se az u detailu ticketu.
{ store: attachmentStore as EntityStore<TenantEntity> },
];
/**
+7 -2
View File
@@ -166,8 +166,13 @@ export function validateWidget(widget: CustomWidget): string[] {
if (widget.render === 'stat' && !['ticketCount', 'connector'].includes(widget.source.kind)) {
problems.push('Jedno číslo umí jen zdroj Počet ticketů nebo Konektor.');
}
if (widget.render === 'chart' && widget.source.kind !== 'ticketSeries') {
problems.push('Graf umí jen zdroj Časová řada.');
// Graf je bud casova rada (krivka), nebo seskupeny pocet (kolac).
// Pocet bez seskupeni je jedno cislo a kolac z jedne vysece nic nerika.
const groupedCount = widget.source.kind === 'ticketCount' && widget.source.groupBy !== undefined;
if (widget.render === 'chart' && widget.source.kind !== 'ticketSeries' && !groupedCount) {
problems.push(
'Graf umí zdroj Časová řada (křivka), nebo Počet ticketů se seskupením (koláč).',
);
}
if (widget.render === 'table' && widget.source.kind === 'ticketList') {
problems.push('Tabulka potřebuje seskupení nebo výkon řešitelů, ne seznam ticketů.');
+36
View File
@@ -14,6 +14,7 @@
*/
import { randomBytes } from 'node:crypto';
import { publish } from '../events/bus.js';
import { timingSafeEqualString } from '../lib/secure.js';
import { defineStore, nowIso } from './store/index.js';
import { withCache } from './store/cached.js';
@@ -93,3 +94,38 @@ export function listActiveTenants(): Tenant[] {
export function findTenant(id: string): Tenant | undefined {
return cache.byId(id);
}
/**
* Provozovatel portalu: firma, ktere chodi poptavky z webu a ze ktere web
* bere kontaktni udaje. Vypnuta firma provozovatelem neni, i kdyz priznak ma.
*/
export function operatorTenant(): Tenant | undefined {
return cache.all().find((tenant) => tenant.enabled && tenant.portalOperator === true);
}
/**
* Provozovatel je jen jeden. Kdyz priznak dostane dalsi firma, ostatnim se
* sunda - dve firmy s poptavkami by znamenaly, ze se ticket zalozi jen jedne
* a nikdo nevi ktere. Zmenene firmy se ohlasi do streamu stejne jako zapis
* z nastaveni, aby si portal opravil sklad ciselniku.
*/
export async function clearOtherOperators(keepId: string): Promise<void> {
const others = cache.all().filter((tenant) => tenant.portalOperator === true && tenant.id !== keepId);
if (others.length === 0) return;
const changed: Tenant[] = [];
for (const tenant of others) {
// Firma je platformni zaznam (tenantId null), proto includeGlobal.
const updated = await tenantStore.update(
tenant.id,
{ portalOperator: false },
{ tenantIds: [], includeGlobal: true },
);
if (updated) changed.push(updated);
}
await cache.refresh();
for (const tenant of changed) {
publish('tenant.updated', `tenant updated: ${tenant.name}`, { id: tenant.id, tenant }, null);
}
}
+1
View File
@@ -60,6 +60,7 @@ export {
assignTicketGroup,
claimTicket,
createTicket,
noteAttachment,
setTicketTags,
setTicketType,
ticketAssignee,
+17
View File
@@ -403,3 +403,20 @@ export function addComment(
publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
return toTicket(ticket);
}
/**
* Poznamka o priloze. Priloha lezi ve vlastnim ulozisti (`data/attachments.ts`),
* ale ticket se o ni musi dozvedet: radek do logu, `updatedAt` a udalost,
* aby se detail v portalu prekreslil stejne jako po komentari.
*/
export function noteAttachment(id: string, tenantIds: string[], note: string): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
if (!ticket) {
console.warn(`[tickets] poznamka o priloze k nedostupnemu ticketu: ${id}`);
return undefined;
}
touch(ticket);
appendTrace(id, [{ kind: 'note', label: note, status: 'info' }]);
publish('ticket.updated', `Ticket ${ticket.id} má změnu příloh`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
return toTicket(ticket);
}
-612
View File
@@ -1,612 +0,0 @@
/** Schemata a zabezpeceni. Jen popis tvaru dat, zadna logika. */
export const components = {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Token z POST /api/auth/login. Vlozte samotny token bez slova Bearer.',
},
},
schemas: {
AresCompany: {
type: 'object',
properties: {
ico: { type: 'string', example: '27074358' },
name: { type: 'string', example: 'Asseco Central Europe, a.s.' },
dic: { type: 'string', nullable: true, example: 'CZ27074358' },
address: { type: 'string', example: 'Budejovicka 778/3a, Michle, 14000 Praha 4' },
legalFormCode: { type: 'string', example: '121' },
legalForm: { type: 'string', example: 'Akciova spolecnost' },
existingTenantId: {
type: 'string',
nullable: true,
description: 'ID firmy v portalu, kdyz uz je zalozena.',
},
},
},
Tenant: {
type: 'object',
properties: {
id: { type: 'string', example: 'tnt_automia' },
name: { type: 'string' },
note: { type: 'string' },
enabled: { type: 'boolean' },
helpdeskProviderId: { type: 'string', nullable: true },
ico: { type: 'string', nullable: true },
dic: { type: 'string', nullable: true },
address: { type: 'string', nullable: true },
legalForm: { type: 'string', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
Error: {
type: 'object',
properties: {
error: { type: 'string', example: 'validation_error' },
message: { type: 'string', example: 'Zadejte platny e-mail.' },
issues: {
type: 'array',
description:
'Jen u validation_error: vsechny problemy vstupu. `field` je cesta ' +
'k poli spojena teckou, prazdna u chyby celeho tela.',
items: {
type: 'object',
properties: {
field: { type: 'string', example: 'memberships.0.roleIds' },
message: { type: 'string' },
},
},
},
},
},
User: {
type: 'object',
description:
'Uzivatel muze patrit do vic firem. Role je vzdy az uvnitr firmy, ' +
'pristup napric firmami je zvlast jako platformAdmin.',
properties: {
id: { type: 'string', example: 'usr_1' },
email: { type: 'string', example: 'admin@automia.cz' },
name: { type: 'string', example: 'Jiri Uhlir' },
platformAdmin: {
type: 'boolean',
description: 'Vidi napric vsemi firmami a muze mezi nimi prepinat.',
},
memberships: {
type: 'array',
items: {
type: 'object',
properties: {
tenantId: { type: 'string', example: 'tnt_automia' },
role: { type: 'string', enum: ['admin', 'agent'] },
},
},
},
},
},
Access: {
type: 'object',
description: 'Co uzivatel smi. Klient podle toho kresli prepinac pohledu.',
properties: {
scopes: {
type: 'array',
items: { type: 'string', enum: ['all', 'tenant', 'mine'] },
},
tenants: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'tnt_automia' },
name: { type: 'string', example: 'Automia' },
},
},
},
defaultTenantId: { type: 'string', nullable: true },
canAssignOthers: {
type: 'boolean',
description: 'Smi prehazovat tickety mezi lidmi, ne jen brat na sebe.',
},
personId: { type: 'string', nullable: true },
permissions: {
type: 'array',
items: { type: 'string' },
description: 'Efektivni prava ve vybrane firme. Klient podle nich kresli tlacitka.',
},
roleNames: {
type: 'array',
items: { type: 'string' },
example: ['Spravce firmy'],
description:
'Nazvy roli uzivatele ve vybrane firme. Tohle se ukazuje jako popis uctu, ' +
'ne odhad z poctu prav.',
},
nav: { type: 'array', items: { type: 'object' }, description: 'Zalozky, ktere ma videt.' },
platformAdmin: { type: 'boolean' },
seesOthers: { type: 'boolean', description: 'Vidi i cizi tickety, ne jen svoje.' },
visibleGroups: {
type: 'array',
items: {
type: 'object',
properties: { id: { type: 'string' }, name: { type: 'string' } },
},
},
},
},
Connector: {
type: 'object',
description:
'Napojeni firmy na jednu sluzbu. Hodnoty pristupovych udaju tady zamerne ' +
'nejsou a nikdy nebudou - secrets se z beznych endpointu nevraci.',
properties: {
id: { type: 'string', example: 'con_1a2b3c4d' },
tenantId: { type: 'string', example: 'tnt_automia' },
serviceId: { type: 'string', example: 'idoklad' },
name: { type: 'string', example: 'iDoklad Automia' },
baseUrl: { type: 'string', nullable: true },
enabled: { type: 'boolean' },
status: { type: 'string', enum: ['untested', 'ok', 'error'] },
lastCheckAt: { type: 'string', format: 'date-time', nullable: true },
lastError: { type: 'string', nullable: true },
checkCount: {
type: 'integer',
description:
'Kolik zaznamu o overeni je v historii. Samotna historie se cte pres ' +
'/api/dashboard/connectors/{id}/checks - v seznamu by to byla tela odpovedi navic.',
},
isDefault: {
type: 'boolean',
description: 'Krok stromu bez vybraneho konektoru pouzije tenhle.',
},
filled: {
type: 'array',
items: { type: 'string' },
description: 'ID poli, ktera jsou vyplnena. Hodnoty se nevraci.',
},
missing: {
type: 'array',
items: { type: 'string' },
description: 'ID povinnych poli, ktera chybi.',
},
config: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Necitliva nastaveni. Tajna pole tu nejsou vubec.',
},
ready: { type: 'boolean' },
},
},
ScriptField: {
type: 'object',
description:
'Parametr skriptu. Stejny tvar pro vstup i vystup - kontrola je pak ' +
'jedna funkce, ne dve skoro stejne.',
required: ['id', 'label', 'type', 'required'],
properties: {
id: {
type: 'string',
example: 'invoiceId',
description: 'Pouziva se v sablonach jako {{invoiceId}}.',
},
label: { type: 'string', example: 'ID faktury v iDokladu' },
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
required: { type: 'boolean' },
hint: { type: 'string' },
options: {
type: 'array',
description: 'Vyber z hodnot. Jina hodnota neprojde kontrolou.',
items: {
type: 'object',
properties: { value: { type: 'string' }, label: { type: 'string' } },
},
},
pattern: { type: 'string', description: 'Jen u typu string.' },
multiline: { type: 'boolean', description: 'Jen u typu string.' },
default: { description: 'Dosadi se, kdyz hodnota chybi a parametr neni povinny.' },
},
},
ScriptManifest: {
type: 'object',
description: 'Co skript umi. Podle nej s nim umi pracovat strom automatizace.',
properties: {
id: {
type: 'string',
example: 'idoklad.get-issued-invoice',
description: 'Tvar sluzba.operace. Nazev souboru musi byt <id>.js.',
},
serviceId: { type: 'string', example: 'idoklad' },
serviceName: { type: 'string', example: 'iDoklad' },
operationId: { type: 'string', example: 'get-issued-invoice' },
name: { type: 'string', example: 'Získat vydanou fakturu' },
description: { type: 'string' },
inputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
outputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
timeoutMs: { type: 'integer', example: 15000 },
},
},
ScriptProblem: {
type: 'object',
description: 'Rozbity skript. Nesmi shodit ostatni ani tise zmizet, proto se vraci sem.',
properties: {
file: { type: 'string', example: 'idoklad.get-issued-invoice.js' },
scriptId: { type: 'string', nullable: true },
message: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
ConnectionStatus: {
type: 'object',
description:
'Stav napojeni konektoru. Hodnoty pristupovych udaju se nevraci nikdy, ' +
'jen jmena promennych, ktere chybi.',
properties: {
connectorId: { type: 'string', example: 'idoklad' },
baseUrl: { type: 'string', example: 'https://services.csbot.cz/apps/idoklad' },
ready: { type: 'boolean' },
missing: {
type: 'array',
items: { type: 'string' },
example: ['IDOKLAD_CLIENT_SECRET'],
},
headers: { type: 'array', items: { type: 'string' }, example: ['X-ClientId'] },
},
},
ScriptRunResult: {
type: 'object',
description:
'Vysledek behu skriptu. `retryable` rika, jestli ma smysl zkusit to znovu - ' +
'timeout ano, spatny vstup ne.',
properties: {
ok: { type: 'boolean' },
scriptId: { type: 'string' },
outputs: {
type: 'object',
additionalProperties: true,
description: 'Prazdne, kdyz beh selhal.',
},
logs: {
type: 'array',
items: {
type: 'object',
properties: {
at: { type: 'string', format: 'date-time' },
message: { type: 'string' },
detail: { type: 'string' },
},
},
},
durationMs: { type: 'integer' },
httpCalls: { type: 'integer' },
error: {
type: 'object',
nullable: true,
properties: {
kind: {
type: 'string',
enum: [
'not_found',
'config',
'validation',
'output',
'retryable',
'terminal',
'timeout',
'internal',
],
},
message: { type: 'string' },
retryable: { type: 'boolean' },
status: { type: 'integer' },
detail: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
},
},
LoginRequest: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email', example: 'admin@automia.cz' },
password: { type: 'string', format: 'password', example: 'demo1234' },
},
},
LoginResponse: {
type: 'object',
properties: {
token: { type: 'string' },
user: { $ref: '#/components/schemas/User' },
},
},
Person: {
type: 'object',
description:
'Resitel ticketu = clen firmy. Neni to vlastni zaznam: `id` je ID uctu, `tenantId` ' +
'firma clenstvi. Jmeno a e-mail jsou z uctu, role, kapacita a externi ID z clenstvi.',
properties: {
id: { type: 'string', example: 'usr_2', description: 'ID uctu.' },
tenantId: { type: 'string', example: 'tnt_automia' },
name: { type: 'string', example: 'Karel Vomacka' },
email: { type: 'string', format: 'email' },
role: { type: 'string', example: 'Servicedesk', description: 'Popisek, nic nerozhoduje.' },
capacity: {
type: 'integer',
description: 'Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez.',
},
enabled: {
type: 'boolean',
description:
'Zapnute clenstvi v teto firme. Vypnuty se nenabizi k prirazeni, ucet jinde bezi dal.',
},
externalIds: { type: 'array', items: { type: 'string' } },
roleIds: {
type: 'array',
items: { type: 'string' },
description: 'Role clenstvi v teto firme.',
},
},
},
TicketCustomer: {
type: 'object',
properties: {
id: {
type: 'string',
nullable: true,
description: 'ID firmy v CRM. null = zakaznika se nepodarilo dohledat.',
example: 'crm_1042',
},
company: { type: 'string', example: 'Firma s.r.o.' },
contact: { type: 'string', example: 'Petra Klientova' },
reply: {
type: 'string',
description: 'Adresa nebo cislo, odkud pozadavek prisel a kam se odpovida.',
},
},
},
Ticket: {
type: 'object',
properties: {
id: { type: 'string', example: 'TK-4821' },
subject: { type: 'string' },
body: {
type: 'string',
description:
'Cely text pozadavku. Prazdny retezec = krok "Zalozit ticket" obsah nenaplnil.',
},
sourceRef: {
type: 'string',
nullable: true,
description: 'Odkaz na zdrojovou zpravu u poskytovatele.',
example: 'wamid.HBgLNDIwNzc0OTAyMzMx',
},
channel: {
type: 'string',
enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'],
description: 'Odkud pozadavek prisel.',
},
customer: { $ref: '#/components/schemas/TicketCustomer' },
status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] },
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
assignee: {
type: 'object',
nullable: true,
description: 'Kdo ma ticket u sebe. null = ceka ve fronte.',
properties: {
id: { type: 'string', example: 'usr_2', description: 'ID uctu resitele.' },
name: { type: 'string', example: 'Karel Vomacka' },
},
},
automationId: {
type: 'string',
nullable: true,
description: 'Automatizace, ktera ticket zalozila. null = zalozeno rucne.',
},
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
TicketTraceEntry: {
type: 'object',
description:
'Jeden radek logu ticketu. Strom se sklada pres parentId - vetev podminky ' +
'visi na zaznamu te podminky.',
properties: {
id: { type: 'string', example: 'tr_12' },
parentId: {
type: 'string',
nullable: true,
description: 'null = zaznam v hlavni sekvenci.',
},
kind: { type: 'string', enum: ['trigger', 'action', 'condition', 'note'] },
connectorId: { type: 'string', nullable: true, example: 'raynet' },
operationId: { type: 'string', nullable: true, example: 'upsert-contact' },
label: { type: 'string' },
status: { type: 'string', enum: ['ok', 'error', 'skipped', 'info'] },
response: {
type: 'string',
nullable: true,
description: 'Co sluzba vratila. Kvuli tomuhle log existuje.',
},
durationMs: { type: 'integer', nullable: true },
at: { type: 'string', format: 'date-time' },
},
},
TicketDetail: {
allOf: [
{ $ref: '#/components/schemas/Ticket' },
{
type: 'object',
properties: {
trace: {
type: 'array',
items: { $ref: '#/components/schemas/TicketTraceEntry' },
},
},
},
],
},
Workload: {
type: 'object',
description: 'Prehled nad firmou - kdo ma kolik ticketu u sebe.',
properties: {
rows: {
type: 'array',
items: {
type: 'object',
properties: {
person: { $ref: '#/components/schemas/Person' },
open: { type: 'integer', description: 'Nevyresene tickety.' },
total: { type: 'integer' },
critical: { type: 'integer' },
oldestOpenAt: { type: 'string', format: 'date-time', nullable: true },
overloaded: { type: 'boolean' },
},
},
},
unassigned: { type: 'integer', description: 'Nevyresene tickety bez resitele.' },
openTotal: { type: 'integer' },
},
},
Incident: {
type: 'object',
properties: {
id: { type: 'string', example: 'INC-231' },
title: { type: 'string' },
service: { type: 'string' },
severity: { type: 'string', enum: ['sev1', 'sev2', 'sev3'] },
status: {
type: 'string',
enum: ['investigating', 'identified', 'monitoring', 'resolved'],
},
startedAt: { type: 'string', format: 'date-time' },
resolvedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
TriggerField: {
type: 'object',
required: ['id', 'name', 'type', 'required'],
properties: {
id: { type: 'string', example: 'f_42' },
name: {
type: 'string',
example: 'score',
description: 'Klic v prichozich datech, pismena, cislice a podtrzitko.',
},
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
required: { type: 'boolean' },
},
},
FlowStep: {
type: 'object',
description: 'Krok stromu. Bud akce nad konektorem, nebo podminka se dvema vetvemi.',
properties: {
id: { type: 'string' },
kind: { type: 'string', enum: ['action', 'condition'] },
connectorId: { type: 'string', example: 'email' },
operationId: { type: 'string', example: 'send' },
inputs: {
type: 'object',
additionalProperties: { type: 'string' },
description:
'Nastaveni akce. Klic je ID pole z katalogu, hodnota je sablona - ' +
'{{nazev}} se nahradi parametrem spoustece. Neznamy klic vraci 400.',
example: { subject: 'Reklamace od {{profileName}}', body: '{{text}}' },
},
fieldId: { type: 'string', example: 'f_42' },
operator: {
type: 'string',
enum: [
'eq',
'neq',
'gt',
'gte',
'lt',
'lte',
'contains',
'startsWith',
'isEmpty',
'isNotEmpty',
'isTrue',
'isFalse',
],
},
value: { type: 'string', example: '15' },
yes: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
no: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
},
},
AutomationFlow: {
type: 'object',
properties: {
trigger: {
type: 'object',
nullable: true,
properties: {
connectorId: { type: 'string', example: 'webhook' },
operationId: { type: 'string', example: 'received' },
fields: {
type: 'array',
items: { $ref: '#/components/schemas/TriggerField' },
},
webhookToken: {
type: 'string',
readOnly: true,
description: 'Generuje vyhradne server, hodnota od klienta se ignoruje.',
},
},
},
steps: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
},
},
Automation: {
type: 'object',
properties: {
id: { type: 'string', example: 'AUT-01' },
name: { type: 'string' },
kind: { type: 'string', enum: ['workflow', 'voicebot', 'integrace', 'report'] },
enabled: { type: 'boolean' },
runsToday: { type: 'integer' },
runsYesterday: { type: 'integer' },
runsTotal: { type: 'integer' },
successRate: { type: 'number' },
avgDurationMs: { type: 'integer' },
lastRunAt: { type: 'string', format: 'date-time' },
stepCount: { type: 'integer' },
configured: { type: 'boolean' },
issues: {
type: 'array',
items: { type: 'string' },
description: 'Co chybi k zapnuti. Prazdne pole znamena hotovo.',
},
},
},
AutomationDetail: {
allOf: [
{ $ref: '#/components/schemas/Automation' },
{
type: 'object',
properties: {
flow: { $ref: '#/components/schemas/AutomationFlow' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
],
},
},
};
+37
View File
@@ -0,0 +1,37 @@
/** Prilohy ticketu: zaznam bez obsahu a tvar nahravaneho souboru. */
export const attachmentSchemas = {
Attachment: {
type: 'object',
description: 'Priloha ticketu bez obsahu. Obsah se stahuje zvlast pres .../content.',
properties: {
id: { type: 'string', example: 'att_1a2b3c4d' },
tenantId: { type: 'string', example: 'tnt_automia' },
ticketId: { type: 'string', example: 'TK-4822' },
name: { type: 'string', example: 'zadani.pdf', description: 'Bez cesty, ocisteny.' },
mime: { type: 'string', example: 'application/pdf' },
size: { type: 'integer', description: 'Bajty po dekodovani.' },
uploadedBy: {
type: 'string',
nullable: true,
description: 'ID uctu. null = prislo z verejneho formulare.',
},
createdAt: { type: 'string', format: 'date-time' },
},
},
AttachmentUpload: {
type: 'object',
required: ['name', 'content'],
properties: {
name: { type: 'string', maxLength: 200 },
mime: {
type: 'string',
description: 'Nepovinny. Chybejici nebo neplatny = application/octet-stream.',
},
content: {
type: 'string',
description: 'Obsah v base64 bez prefixu data:. Po dekodovani nejvys 5 MB.',
},
},
},
};
+96
View File
@@ -0,0 +1,96 @@
/** Ucty a prihlaseni. Dve skupiny, protoze v seznamu schemat lezi Login* az za skripty. */
export const userSchemas = {
User: {
type: 'object',
description:
'Uzivatel muze patrit do vic firem. Role je vzdy az uvnitr firmy, ' +
'pristup napric firmami je zvlast jako platformAdmin.',
properties: {
id: { type: 'string', example: 'usr_1' },
email: { type: 'string', example: 'admin@automia.cz' },
name: { type: 'string', example: 'Jiri Uhlir' },
platformAdmin: {
type: 'boolean',
description: 'Vidi napric vsemi firmami a muze mezi nimi prepinat.',
},
memberships: {
type: 'array',
items: {
type: 'object',
properties: {
tenantId: { type: 'string', example: 'tnt_automia' },
role: { type: 'string', enum: ['admin', 'agent'] },
},
},
},
},
},
Access: {
type: 'object',
description: 'Co uzivatel smi. Klient podle toho kresli prepinac pohledu.',
properties: {
scopes: {
type: 'array',
items: { type: 'string', enum: ['all', 'tenant', 'mine'] },
},
tenants: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'tnt_automia' },
name: { type: 'string', example: 'Automia' },
},
},
},
defaultTenantId: { type: 'string', nullable: true },
canAssignOthers: {
type: 'boolean',
description: 'Smi prehazovat tickety mezi lidmi, ne jen brat na sebe.',
},
personId: { type: 'string', nullable: true },
permissions: {
type: 'array',
items: { type: 'string' },
description: 'Efektivni prava ve vybrane firme. Klient podle nich kresli tlacitka.',
},
roleNames: {
type: 'array',
items: { type: 'string' },
example: ['Spravce firmy'],
description:
'Nazvy roli uzivatele ve vybrane firme. Tohle se ukazuje jako popis uctu, ' +
'ne odhad z poctu prav.',
},
nav: { type: 'array', items: { type: 'object' }, description: 'Zalozky, ktere ma videt.' },
platformAdmin: { type: 'boolean' },
seesOthers: { type: 'boolean', description: 'Vidi i cizi tickety, ne jen svoje.' },
visibleGroups: {
type: 'array',
items: {
type: 'object',
properties: { id: { type: 'string' }, name: { type: 'string' } },
},
},
},
},
};
export const loginSchemas = {
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' },
},
},
};
+115
View File
@@ -0,0 +1,115 @@
/** Automatizace: spoustec, strom kroku a souhrn behu. */
export const automationSchemas = {
TriggerField: {
type: 'object',
required: ['id', 'name', 'type', 'required'],
properties: {
id: { type: 'string', example: 'f_42' },
name: {
type: 'string',
example: 'score',
description: 'Klic v prichozich datech, pismena, cislice a podtrzitko.',
},
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
required: { type: 'boolean' },
},
},
FlowStep: {
type: 'object',
description: 'Krok stromu. Bud akce nad konektorem, nebo podminka se dvema vetvemi.',
properties: {
id: { type: 'string' },
kind: { type: 'string', enum: ['action', 'condition'] },
connectorId: { type: 'string', example: 'email' },
operationId: { type: 'string', example: 'send' },
inputs: {
type: 'object',
additionalProperties: { type: 'string' },
description:
'Nastaveni akce. Klic je ID pole z katalogu, hodnota je sablona - ' +
'{{nazev}} se nahradi parametrem spoustece. Neznamy klic vraci 400.',
example: { subject: 'Reklamace od {{profileName}}', body: '{{text}}' },
},
fieldId: { type: 'string', example: 'f_42' },
operator: {
type: 'string',
enum: [
'eq',
'neq',
'gt',
'gte',
'lt',
'lte',
'contains',
'startsWith',
'isEmpty',
'isNotEmpty',
'isTrue',
'isFalse',
],
},
value: { type: 'string', example: '15' },
yes: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
no: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
},
},
AutomationFlow: {
type: 'object',
properties: {
trigger: {
type: 'object',
nullable: true,
properties: {
connectorId: { type: 'string', example: 'webhook' },
operationId: { type: 'string', example: 'received' },
fields: {
type: 'array',
items: { $ref: '#/components/schemas/TriggerField' },
},
webhookToken: {
type: 'string',
readOnly: true,
description: 'Generuje vyhradne server, hodnota od klienta se ignoruje.',
},
},
},
steps: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
},
},
Automation: {
type: 'object',
properties: {
id: { type: 'string', example: 'AUT-01' },
name: { type: 'string' },
kind: { type: 'string', enum: ['workflow', 'voicebot', 'integrace', 'report'] },
enabled: { type: 'boolean' },
runsToday: { type: 'integer' },
runsYesterday: { type: 'integer' },
runsTotal: { type: 'integer' },
successRate: { type: 'number' },
avgDurationMs: { type: 'integer' },
lastRunAt: { type: 'string', format: 'date-time' },
stepCount: { type: 'integer' },
configured: { type: 'boolean' },
issues: {
type: 'array',
items: { type: 'string' },
description: 'Co chybi k zapnuti. Prazdne pole znamena hotovo.',
},
},
},
AutomationDetail: {
allOf: [
{ $ref: '#/components/schemas/Automation' },
{
type: 'object',
properties: {
flow: { $ref: '#/components/schemas/AutomationFlow' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
],
},
};
+24
View File
@@ -0,0 +1,24 @@
/** Spolecne tvary: chybova odpoved. */
export const commonSchemas = {
Error: {
type: 'object',
properties: {
error: { type: 'string', example: 'validation_error' },
message: { type: 'string', example: 'Zadejte platny e-mail.' },
issues: {
type: 'array',
description:
'Jen u validation_error: vsechny problemy vstupu. `field` je cesta ' +
'k poli spojena teckou, prazdna u chyby celeho tela.',
items: {
type: 'object',
properties: {
field: { type: 'string', example: 'memberships.0.roleIds' },
message: { type: 'string' },
},
},
},
},
},
};
+47
View File
@@ -0,0 +1,47 @@
/** Konektory: napojeni firmy na sluzbu bez hodnot pristupovych udaju. */
export const connectorSchemas = {
Connector: {
type: 'object',
description:
'Napojeni firmy na jednu sluzbu. Hodnoty pristupovych udaju tady zamerne ' +
'nejsou a nikdy nebudou - secrets se z beznych endpointu nevraci.',
properties: {
id: { type: 'string', example: 'con_1a2b3c4d' },
tenantId: { type: 'string', example: 'tnt_automia' },
serviceId: { type: 'string', example: 'idoklad' },
name: { type: 'string', example: 'iDoklad Automia' },
baseUrl: { type: 'string', nullable: true },
enabled: { type: 'boolean' },
status: { type: 'string', enum: ['untested', 'ok', 'error'] },
lastCheckAt: { type: 'string', format: 'date-time', nullable: true },
lastError: { type: 'string', nullable: true },
checkCount: {
type: 'integer',
description:
'Kolik zaznamu o overeni je v historii. Samotna historie se cte pres ' +
'/api/dashboard/connectors/{id}/checks - v seznamu by to byla tela odpovedi navic.',
},
isDefault: {
type: 'boolean',
description: 'Krok stromu bez vybraneho konektoru pouzije tenhle.',
},
filled: {
type: 'array',
items: { type: 'string' },
description: 'ID poli, ktera jsou vyplnena. Hodnoty se nevraci.',
},
missing: {
type: 'array',
items: { type: 'string' },
description: 'ID povinnych poli, ktera chybi.',
},
config: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Necitliva nastaveni. Tajna pole tu nejsou vubec.',
},
ready: { type: 'boolean' },
},
},
};
+35
View File
@@ -0,0 +1,35 @@
/**
* Schemata a zabezpeceni. Jen popis tvaru dat, zadna logika.
* Poradi spreadu je poradi schemat ve Swagger UI, drzi se puvodni seznam.
*/
import { tenantSchemas } from './settings.js';
import { attachmentSchemas } from './attachments.js';
import { commonSchemas } from './common.js';
import { userSchemas, loginSchemas } from './auth.js';
import { connectorSchemas } from './connectors.js';
import { scriptSchemas } from './scripts.js';
import { ticketSchemas } from './tickets.js';
import { automationSchemas } from './automations.js';
export const components = {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Token z POST /api/auth/login. Vlozte samotny token bez slova Bearer.',
},
},
schemas: {
...tenantSchemas,
...attachmentSchemas,
...commonSchemas,
...userSchemas,
...connectorSchemas,
...scriptSchemas,
...loginSchemas,
...ticketSchemas,
...automationSchemas,
},
};
+143
View File
@@ -0,0 +1,143 @@
/** Skripty: manifest, parametry, stav napojeni a vysledek behu. */
export const scriptSchemas = {
ScriptField: {
type: 'object',
description:
'Parametr skriptu. Stejny tvar pro vstup i vystup - kontrola je pak ' +
'jedna funkce, ne dve skoro stejne.',
required: ['id', 'label', 'type', 'required'],
properties: {
id: {
type: 'string',
example: 'invoiceId',
description: 'Pouziva se v sablonach jako {{invoiceId}}.',
},
label: { type: 'string', example: 'ID faktury v iDokladu' },
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
required: { type: 'boolean' },
hint: { type: 'string' },
options: {
type: 'array',
description: 'Vyber z hodnot. Jina hodnota neprojde kontrolou.',
items: {
type: 'object',
properties: { value: { type: 'string' }, label: { type: 'string' } },
},
},
pattern: { type: 'string', description: 'Jen u typu string.' },
multiline: { type: 'boolean', description: 'Jen u typu string.' },
default: { description: 'Dosadi se, kdyz hodnota chybi a parametr neni povinny.' },
},
},
ScriptManifest: {
type: 'object',
description: 'Co skript umi. Podle nej s nim umi pracovat strom automatizace.',
properties: {
id: {
type: 'string',
example: 'idoklad.get-issued-invoice',
description: 'Tvar sluzba.operace. Nazev souboru musi byt <id>.js.',
},
serviceId: { type: 'string', example: 'idoklad' },
serviceName: { type: 'string', example: 'iDoklad' },
operationId: { type: 'string', example: 'get-issued-invoice' },
name: { type: 'string', example: 'Získat vydanou fakturu' },
description: { type: 'string' },
inputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
outputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
timeoutMs: { type: 'integer', example: 15000 },
},
},
ScriptProblem: {
type: 'object',
description: 'Rozbity skript. Nesmi shodit ostatni ani tise zmizet, proto se vraci sem.',
properties: {
file: { type: 'string', example: 'idoklad.get-issued-invoice.js' },
scriptId: { type: 'string', nullable: true },
message: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
ConnectionStatus: {
type: 'object',
description:
'Stav napojeni konektoru. Hodnoty pristupovych udaju se nevraci nikdy, ' +
'jen jmena promennych, ktere chybi.',
properties: {
connectorId: { type: 'string', example: 'idoklad' },
baseUrl: { type: 'string', example: 'https://services.csbot.cz/apps/idoklad' },
ready: { type: 'boolean' },
missing: {
type: 'array',
items: { type: 'string' },
example: ['IDOKLAD_CLIENT_SECRET'],
},
headers: { type: 'array', items: { type: 'string' }, example: ['X-ClientId'] },
},
},
ScriptRunResult: {
type: 'object',
description:
'Vysledek behu skriptu. `retryable` rika, jestli ma smysl zkusit to znovu - ' +
'timeout ano, spatny vstup ne.',
properties: {
ok: { type: 'boolean' },
scriptId: { type: 'string' },
outputs: {
type: 'object',
additionalProperties: true,
description: 'Prazdne, kdyz beh selhal.',
},
logs: {
type: 'array',
items: {
type: 'object',
properties: {
at: { type: 'string', format: 'date-time' },
message: { type: 'string' },
detail: { type: 'string' },
},
},
},
durationMs: { type: 'integer' },
httpCalls: { type: 'integer' },
error: {
type: 'object',
nullable: true,
properties: {
kind: {
type: 'string',
enum: [
'not_found',
'config',
'validation',
'output',
'retryable',
'terminal',
'timeout',
'internal',
],
},
message: { type: 'string' },
retryable: { type: 'boolean' },
status: { type: 'integer' },
detail: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
},
},
};
+61
View File
@@ -0,0 +1,61 @@
/** Firmy: zaznam v ARES, firma v portalu a verejne udaje provozovatele. */
export const tenantSchemas = {
AresCompany: {
type: 'object',
properties: {
ico: { type: 'string', example: '27074358' },
name: { type: 'string', example: 'Asseco Central Europe, a.s.' },
dic: { type: 'string', nullable: true, example: 'CZ27074358' },
address: { type: 'string', example: 'Budejovicka 778/3a, Michle, 14000 Praha 4' },
legalFormCode: { type: 'string', example: '121' },
legalForm: { type: 'string', example: 'Akciova spolecnost' },
existingTenantId: {
type: 'string',
nullable: true,
description: 'ID firmy v portalu, kdyz uz je zalozena.',
},
},
},
Tenant: {
type: 'object',
properties: {
id: { type: 'string', example: 'tnt_automia' },
name: { type: 'string' },
note: { type: 'string' },
enabled: { type: 'boolean' },
helpdeskProviderId: { type: 'string', nullable: true },
ico: { type: 'string', nullable: true },
dic: { type: 'string', nullable: true },
address: { type: 'string', nullable: true },
legalForm: { type: 'string', nullable: true },
portalOperator: {
type: 'boolean',
description:
'Provozovatel portalu. Prave jedna firma: nastaveni na true sunda priznak ' +
'vsem ostatnim (kazda zmenena firma posle tenant.updated). Chodi ji poptavky ' +
'z webu a web z ni bere kontaktni udaje. Chybejici = false.',
},
contactEmail: { type: 'string', nullable: true, description: 'Kontakt pro verejny web.' },
contactPhone: { type: 'string', nullable: true, maxLength: 30 },
website: { type: 'string', nullable: true, description: 'Adresa webu vcetne https://.' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
PublicBrand: {
type: 'object',
description: 'Udaje provozovatele portalu pro verejny web. Bez provozovatele je vsechno null.',
properties: {
name: { type: 'string', nullable: true, example: 'Automia' },
legalName: { type: 'string', nullable: true, description: 'Dnes totez co name.' },
ico: { type: 'string', nullable: true },
dic: { type: 'string', nullable: true },
address: { type: 'string', nullable: true },
legalForm: { type: 'string', nullable: true },
email: { type: 'string', nullable: true },
phone: { type: 'string', nullable: true },
website: { type: 'string', nullable: true },
},
},
};
+168
View File
@@ -0,0 +1,168 @@
/** Tickety: resitel, zakaznik, ticket, log prubehu, vytizeni a incident. */
export const ticketSchemas = {
Person: {
type: 'object',
description:
'Resitel ticketu = clen firmy. Neni to vlastni zaznam: `id` je ID uctu, `tenantId` ' +
'firma clenstvi. Jmeno a e-mail jsou z uctu, role, kapacita a externi ID z clenstvi.',
properties: {
id: { type: 'string', example: 'usr_2', description: 'ID uctu.' },
tenantId: { type: 'string', example: 'tnt_automia' },
name: { type: 'string', example: 'Karel Vomacka' },
email: { type: 'string', format: 'email' },
role: { type: 'string', example: 'Servicedesk', description: 'Popisek, nic nerozhoduje.' },
capacity: {
type: 'integer',
description: 'Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez.',
},
enabled: {
type: 'boolean',
description:
'Zapnute clenstvi v teto firme. Vypnuty se nenabizi k prirazeni, ucet jinde bezi dal.',
},
externalIds: { type: 'array', items: { type: 'string' } },
roleIds: {
type: 'array',
items: { type: 'string' },
description: 'Role clenstvi v teto firme.',
},
},
},
TicketCustomer: {
type: 'object',
properties: {
id: {
type: 'string',
nullable: true,
description: 'ID firmy v CRM. null = zakaznika se nepodarilo dohledat.',
example: 'crm_1042',
},
company: { type: 'string', example: 'Firma s.r.o.' },
contact: { type: 'string', example: 'Petra Klientova' },
reply: {
type: 'string',
description: 'Adresa nebo cislo, odkud pozadavek prisel a kam se odpovida.',
},
},
},
Ticket: {
type: 'object',
properties: {
id: { type: 'string', example: 'TK-4821' },
subject: { type: 'string' },
body: {
type: 'string',
description:
'Cely text pozadavku. Prazdny retezec = krok "Zalozit ticket" obsah nenaplnil.',
},
sourceRef: {
type: 'string',
nullable: true,
description: 'Odkaz na zdrojovou zpravu u poskytovatele.',
example: 'wamid.HBgLNDIwNzc0OTAyMzMx',
},
channel: {
type: 'string',
enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'],
description: 'Odkud pozadavek prisel.',
},
customer: { $ref: '#/components/schemas/TicketCustomer' },
status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] },
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
assignee: {
type: 'object',
nullable: true,
description: 'Kdo ma ticket u sebe. null = ceka ve fronte.',
properties: {
id: { type: 'string', example: 'usr_2', description: 'ID uctu resitele.' },
name: { type: 'string', example: 'Karel Vomacka' },
},
},
automationId: {
type: 'string',
nullable: true,
description: 'Automatizace, ktera ticket zalozila. null = zalozeno rucne.',
},
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
TicketTraceEntry: {
type: 'object',
description:
'Jeden radek logu ticketu. Strom se sklada pres parentId - vetev podminky ' +
'visi na zaznamu te podminky.',
properties: {
id: { type: 'string', example: 'tr_12' },
parentId: {
type: 'string',
nullable: true,
description: 'null = zaznam v hlavni sekvenci.',
},
kind: { type: 'string', enum: ['trigger', 'action', 'condition', 'note'] },
connectorId: { type: 'string', nullable: true, example: 'raynet' },
operationId: { type: 'string', nullable: true, example: 'upsert-contact' },
label: { type: 'string' },
status: { type: 'string', enum: ['ok', 'error', 'skipped', 'info'] },
response: {
type: 'string',
nullable: true,
description: 'Co sluzba vratila. Kvuli tomuhle log existuje.',
},
durationMs: { type: 'integer', nullable: true },
at: { type: 'string', format: 'date-time' },
},
},
TicketDetail: {
allOf: [
{ $ref: '#/components/schemas/Ticket' },
{
type: 'object',
properties: {
trace: {
type: 'array',
items: { $ref: '#/components/schemas/TicketTraceEntry' },
},
},
},
],
},
Workload: {
type: 'object',
description: 'Prehled nad firmou - kdo ma kolik ticketu u sebe.',
properties: {
rows: {
type: 'array',
items: {
type: 'object',
properties: {
person: { $ref: '#/components/schemas/Person' },
open: { type: 'integer', description: 'Nevyresene tickety.' },
total: { type: 'integer' },
critical: { type: 'integer' },
oldestOpenAt: { type: 'string', format: 'date-time', nullable: true },
overloaded: { type: 'boolean' },
},
},
},
unassigned: { type: 'integer', description: 'Nevyresene tickety bez resitele.' },
openTotal: { type: 'integer' },
},
},
Incident: {
type: 'object',
properties: {
id: { type: 'string', example: 'INC-231' },
title: { type: 'string' },
service: { type: 'string' },
severity: { type: 'string', enum: ['sev1', 'sev2', 'sev3'] },
status: {
type: 'string',
enum: ['investigating', 'identified', 'monitoring', 'resolved'],
},
startedAt: { type: 'string', format: 'date-time' },
resolvedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
};
+8 -1
View File
@@ -1,9 +1,11 @@
import { config } from '../config.js';
import { components } from './components.js';
import { components } from './components/index.js';
import { opsPaths } from './paths/ops.js';
import { authPaths } from './paths/auth.js';
import { dashboardPaths } from './paths/dashboard.js';
import { ticketsPaths } from './paths/tickets.js';
import { attachmentsPaths } from './paths/attachments.js';
import { ticketActionsPaths } from './paths/ticketActions.js';
import { automationsPaths } from './paths/automations.js';
import { settingsPaths } from './paths/settings.js';
import { adminPaths } from './paths/admin.js';
@@ -13,6 +15,7 @@ import { webhookPaths } from './paths/webhook.js';
import { helpdeskPaths } from './paths/helpdesk.js';
import { invitesPaths } from './paths/invites.js';
import { contactPaths } from './paths/contact.js';
import { publicPaths } from './paths/public.js';
/**
* OpenAPI popis API.
@@ -50,6 +53,7 @@ export function buildOpenApiDocument() {
{ name: 'Pozvanky', description: 'Pozvanky do firmy a jejich prijeti' },
{ name: 'Portal', description: 'Pomocne endpointy klienta' },
{ name: 'Kontakt', description: 'Poptavkovy formular z webu' },
{ name: 'Verejne', description: 'Udaje provozovatele pro web, bez prihlaseni' },
],
components,
paths: {
@@ -58,6 +62,8 @@ export function buildOpenApiDocument() {
...authPaths,
...dashboardPaths,
...ticketsPaths,
...attachmentsPaths,
...ticketActionsPaths,
...automationsPaths,
...settingsPaths,
...adminPaths,
@@ -67,6 +73,7 @@ export function buildOpenApiDocument() {
...helpdeskPaths,
...invitesPaths,
...contactPaths,
...publicPaths,
},
};
}
+98
View File
@@ -0,0 +1,98 @@
/** Prilohy ticketu: seznam, nahrani, stazeni a odebrani. */
import { bearer, idParam, jsonBody, jsonResponse } from '../helpers.js';
export const attachmentsPaths: Record<string, unknown> = {
'/api/dashboard/tickets/{id}/attachments': {
get: {
tags: ['Tickety'],
summary: 'Prilohy ticketu',
description:
'Seznam bez obsahu. Ticket se hleda stejne jako detail: cizi nebo nad strop ' +
'viditelnosti je 404.',
security: bearer,
parameters: [idParam],
responses: {
'200': jsonResponse('Prilohy', {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/Attachment' } },
},
}),
'404': { description: 'Ticket neexistuje nebo na nej volajici nevidi' },
},
},
post: {
tags: ['Tickety'],
summary: 'Pridat prilohy',
description:
'Soubory v base64. Kazdy nejvys 5 MB po dekodovani, ticket nejvys 10 priloh; ' +
'davka se uklada cela nebo vubec. Kazdy soubor zapise radek do logu ticketu ' +
'a posle ticket.updated. Chce pravo ticket.comment za firmu ticketu.',
security: bearer,
parameters: [idParam],
requestBody: jsonBody({
type: 'object',
required: ['files'],
properties: {
files: {
type: 'array',
minItems: 1,
maxItems: 10,
items: { $ref: '#/components/schemas/AttachmentUpload' },
},
},
}),
responses: {
'201': jsonResponse('Ulozene prilohy (jen nove pridane)', {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/Attachment' } },
},
}),
'400': { description: 'Neplatny base64, prilis velky soubor nebo prekrocen pocet' },
'403': { description: 'Chybi pravo ticket.comment' },
'404': { description: 'Ticket neexistuje nebo na nej volajici nevidi' },
'413': { description: 'Telo presahlo strop pro davku souboru' },
},
},
},
'/api/dashboard/tickets/{id}/attachments/{attachmentId}/content': {
get: {
tags: ['Tickety'],
summary: 'Stahnout prilohu',
description:
'Binarni telo s Content-Type podle prilohy, Content-Length a ' +
"Content-Disposition: attachment; filename*=UTF-8''<nazev>.",
security: bearer,
parameters: [
idParam,
{ name: 'attachmentId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
'200': {
description: 'Obsah souboru',
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
},
'404': { description: 'Ticket nebo priloha neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/attachments/{attachmentId}': {
delete: {
tags: ['Tickety'],
summary: 'Odebrat prilohu',
description: 'Zapise radek do logu ticketu a posle ticket.updated. Chce ticket.comment.',
security: bearer,
parameters: [
idParam,
{ name: 'attachmentId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
'204': { description: 'Odebrano' },
'403': { description: 'Chybi pravo ticket.comment' },
'404': { description: 'Ticket nebo priloha neexistuje' },
},
},
},
};
+11 -1
View File
@@ -7,6 +7,10 @@ export const contactPaths: Record<string, unknown> = {
post: {
tags: ['Kontakt'],
summary: 'Odeslat poptavku z webu',
description:
'Poptavka se zaklada jako ticket provozovateli portalu (kanal form, tag Poptavka, ' +
'telo je JSON s poli formulare). Bez provozovatele se jen zaloguje. Odpoved je ' +
'v obou pripadech 202. Nejvys 3 prilohy po 5 MB, telo ma vlastni strop.',
requestBody: {
required: true,
content: {
@@ -24,6 +28,11 @@ export const contactPaths: Record<string, unknown> = {
enum: ['automatizace', 'voicebot', 'integrace', 'dashboard', 'podpora', 'jine'],
},
message: { type: 'string', minLength: 10 },
attachments: {
type: 'array',
maxItems: 3,
items: { $ref: '#/components/schemas/AttachmentUpload' },
},
},
},
},
@@ -31,7 +40,8 @@ export const contactPaths: Record<string, unknown> = {
},
responses: {
'202': { description: 'Prijato' },
'400': { description: 'Neplatny vstup' },
'400': { description: 'Neplatny vstup nebo neplatna priloha' },
'413': { description: 'Telo presahlo strop pro prilohy' },
...tooMany,
},
},
+19
View File
@@ -0,0 +1,19 @@
/** Verejne udaje bez prihlaseni. */
import { jsonResponse } from '../helpers.js';
export const publicPaths: Record<string, unknown> = {
'/api/public/brand': {
get: {
tags: ['Verejne'],
summary: 'Udaje provozovatele portalu',
description:
'Kontaktni a fakturacni udaje firmy s priznakem portalOperator. Web z nich ' +
'sklada paticku a kontakty. Bez provozovatele jsou vsechna pole null, odpoved ' +
'je i tak 200. Cache-Control: public, max-age=60.',
responses: {
'200': jsonResponse('Udaje provozovatele', { $ref: '#/components/schemas/PublicBrand' }),
},
},
},
};
+99
View File
@@ -0,0 +1,99 @@
/** Akce nad ticketem: co jde v dane situaci spustit a spusteni akce. */
export const ticketActionsPaths: Record<string, unknown> = {
'/api/dashboard/tickets/{id}/actions': {
get: {
tags: ['Tickety'],
summary: 'Akce dostupne k ticketu',
description:
'Vraci **jen akce, ktere v teto situaci opravdu jdou spustit**: sedi typ nebo ' +
'tag, projdou podminky a volajici na ne ma pravo. Klient nefiltruje nic.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Akce',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
label: { type: 'string', example: 'Odeslat do iDokladu' },
icon: { type: 'string' },
style: { type: 'string', enum: ['primary', 'default', 'danger'] },
confirm: { type: 'string', nullable: true },
form: { type: 'array', items: { type: 'object' } },
},
},
},
},
},
},
},
},
'404': { description: 'Ticket neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/actions/{actionId}': {
post: {
tags: ['Tickety'],
summary: 'Spustit akci',
description:
'Vraci 200 **i kdyz akce selhala** - selhani akce neni chyba API. Cely prubeh ' +
'vcetne toho, co sluzba vratila, se zapise do logu ticketu.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'actionId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: false,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
form: {
type: 'object',
description: 'Hodnoty poli, ktera si akce vyzada.',
additionalProperties: { type: 'string' },
},
},
},
},
},
},
responses: {
'200': {
description: 'Akce probehla nebo selhala, viz ok',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean' },
summary: { type: 'string' },
detail: {
type: 'string',
nullable: true,
description: 'Cele chybove hlaseni. Nikdy se nezkracuje.',
},
durationMs: { type: 'integer' },
},
},
},
},
},
'403': { description: 'Chybi pravo na tuto akci' },
'404': { description: 'Ticket nebo akce neexistuje' },
},
},
},
};
+1 -96
View File
@@ -1,4 +1,4 @@
/** Tickety: seznam, detail, resitele a vestavene akce. */
/** Tickety: seznam, detail, resitele a zmeny stavu. Prilohy a akce maji vlastni soubor. */
import {
bearer,
@@ -466,99 +466,4 @@ export const ticketsPaths: Record<string, unknown> = {
},
},
},
'/api/dashboard/tickets/{id}/actions': {
get: {
tags: ['Tickety'],
summary: 'Akce dostupne k ticketu',
description:
'Vraci **jen akce, ktere v teto situaci opravdu jdou spustit**: sedi typ nebo ' +
'tag, projdou podminky a volajici na ne ma pravo. Klient nefiltruje nic.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Akce',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
label: { type: 'string', example: 'Odeslat do iDokladu' },
icon: { type: 'string' },
style: { type: 'string', enum: ['primary', 'default', 'danger'] },
confirm: { type: 'string', nullable: true },
form: { type: 'array', items: { type: 'object' } },
},
},
},
},
},
},
},
},
'404': { description: 'Ticket neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/actions/{actionId}': {
post: {
tags: ['Tickety'],
summary: 'Spustit akci',
description:
'Vraci 200 **i kdyz akce selhala** - selhani akce neni chyba API. Cely prubeh ' +
'vcetne toho, co sluzba vratila, se zapise do logu ticketu.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'actionId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: false,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
form: {
type: 'object',
description: 'Hodnoty poli, ktera si akce vyzada.',
additionalProperties: { type: 'string' },
},
},
},
},
},
},
responses: {
'200': {
description: 'Akce probehla nebo selhala, viz ok',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean' },
summary: { type: 'string' },
detail: {
type: 'string',
nullable: true,
description: 'Cele chybove hlaseni. Nikdy se nezkracuje.',
},
durationMs: { type: 'integer' },
},
},
},
},
},
'403': { description: 'Chybi pravo na tuto akci' },
'404': { description: 'Ticket nebo akce neexistuje' },
},
},
},
};
+32
View File
@@ -0,0 +1,32 @@
/**
* Strop tela pro routy, ktere prijimaji soubory v base64.
*
* Globalni `express.json` ma 256 kB, coz na prilohy nestaci. Tyhle routy si
* nasazuji vlastni `express.json` a globalni je preskakuje (viz `app.ts`).
* Vypocet je na jednom miste, aby kontaktni formular a prilohy ticketu
* pocitaly stejne.
*/
/** Base64 zvetsi data na 4/3. */
const BASE64_OVERHEAD = 4 / 3;
/** Rezerva na zbytek JSONu: nazvy, typy, textova pole formulare. */
const JSON_SLACK_BYTES = 64 * 1024;
/** Strop tela v bajtech pro `count` souboru po `maxBytes` kazdy. */
export function jsonLimitFor(count: number, maxBytes: number): number {
return Math.ceil(count * maxBytes * BASE64_OVERHEAD) + JSON_SLACK_BYTES;
}
/**
* Cesty s vlastnim stropem tela. Globalni parser je preskoci, jinak by telo
* odmitl driv, nez se k nemu router dostane. Kontroluje se `req.path` bez
* prefixu proxy - aplikace je mountovana na koren i na prefix.
*/
const OWN_LIMIT_PATHS = [
/^\/api\/contact\/?$/,
/^\/api\/dashboard\/tickets\/[^/]+\/attachments\/?$/,
];
export function hasOwnBodyLimit(path: string): boolean {
return OWN_LIMIT_PATHS.some((pattern) => pattern.test(path));
}
+130 -18
View File
@@ -1,9 +1,28 @@
import { Router } from 'express';
/**
* Kontaktni formular z webu.
*
* Poptavka se zaklada jako ticket provozovateli portalu (`operatorTenant`).
* Kdyz zadna firma provozovatelem neni, poptavka se jen zaloguje - odpoved
* je v obou pripadech 202, zvenku nema byt poznat, jak je portal nastaveny.
*/
import express from 'express';
import { z } from 'zod';
import {
addAttachments,
ALLOWED_NAME_LENGTH,
checkUploads,
MAX_ATTACHMENT_BYTES,
} from '../data/attachments.js';
import { operatorTenant } from '../data/tenants.js';
import { createTicket } from '../data/ticketStore.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { rateLimit } from '../middleware/rateLimit.js';
import { validationError } from '../middleware/validation.js';
import { jsonLimitFor } from './bodyLimit.js';
export const contactRouter = Router();
// safeRouter: handler je async a odmitnuty promise ma skoncit jako 500, ne viset.
export const contactRouter = safeRouter();
/** Verejny formular bez prihlaseni. Pet poptavek za hodinu z jedne adresy staci. */
const CONTACT_WINDOW_MS = 60 * 60_000;
@@ -14,30 +33,123 @@ const contactLimiter = rateLimit({
max: CONTACT_MAX_PER_WINDOW,
});
/** Kolik souboru jde poslat s poptavkou. Tri staci na zadani a dve ukazky. */
export const CONTACT_MAX_ATTACHMENTS = 3;
/** Vlastni strop tela, globalni `express.json` tuhle cestu preskakuje (viz app.ts). */
const CONTACT_BODY_LIMIT = jsonLimitFor(CONTACT_MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES);
const topics = ['automatizace', 'voicebot', 'integrace', 'dashboard', 'podpora', 'jine'] as const;
type Topic = (typeof topics)[number];
/** Popisek tematu do predmetu ticketu. */
const topicLabels: Record<Topic, string> = {
automatizace: 'automatizace',
voicebot: 'voicebot',
integrace: 'integrace',
dashboard: 'dashboard',
podpora: 'podpora',
jine: 'jiné',
};
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']),
topic: z.enum(topics),
message: z.string().min(10, 'Napište prosím alespoň pár slov (min. 10 znaků).'),
attachments: z
.array(
z.object({
name: z
.string()
.min(1)
.max(ALLOWED_NAME_LENGTH * 2),
mime: z.string().max(120).optional(),
content: z.string().min(1),
}),
)
.max(
CONTACT_MAX_ATTACHMENTS,
`K poptávce jdou přiložit nejvýš ${CONTACT_MAX_ATTACHMENTS} soubory.`,
)
.optional()
.default([]),
});
/**
* PROTOTYP: zpravu jen zalogujeme. Realne odeslani (SMTP / ticket system)
* pribude pozdeji - viz docs/04-backend-api.md.
/*
* Limit pokusu bezi pred parserem tela: kdo uz vycerpal pokusy, nema server
* nutit cist megabajty base64.
*/
contactRouter.post('/', contactLimiter, (req, res) => {
const parsed = contactSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error);
contactRouter.post(
'/',
contactLimiter,
express.json({ limit: CONTACT_BODY_LIMIT }),
async (req, res) => {
const parsed = contactSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error);
const data = parsed.data;
console.info(
`[contact] nova poptavka: ${data.name} <${data.email}> tema=${data.topic} firma=${data.company || '-'}`,
);
const data = parsed.data;
// Prilohy se kontroluji driv, nez vznikne ticket, at po chybe nezustane
// poptavka bez souboru, ktere k ni patrily.
const checked = checkUploads(data.attachments, 0);
if (!checked.ok) {
return res.status(400).json({ error: 'validation_error', message: checked.message });
}
return res.status(202).json({
ok: true,
message: 'Děkujeme, ozveme se do jednoho pracovního dne.',
});
});
console.info(
`[contact] nova poptavka: ${data.name} <${data.email}> tema=${data.topic} firma=${data.company || '-'}`,
);
const operator = operatorTenant();
if (!operator) {
console.warn('[contact] zadna firma neni provozovatel portalu, poptavka jen zalogovana');
} else {
const receivedAt = new Date().toISOString();
const ticket = createTicket({
tenantId: operator.id,
channel: 'form',
subject: `Poptávka: ${topicLabels[data.topic]}`,
body: JSON.stringify(
{
name: data.name,
email: data.email,
company: data.company,
phone: data.phone,
topic: data.topic,
message: data.message,
receivedAt,
},
null,
2,
),
tags: ['Poptávka', data.topic],
customer: { id: null, company: data.company, contact: data.name, reply: data.email },
priority: 'normal',
externalSource: 'web-form',
externalId: null,
createdById: null,
trace: [
{
kind: 'note',
label: 'Poptávka z webového formuláře',
status: 'info',
response: `Odesílatel ${data.name} <${data.email}>, téma ${topicLabels[data.topic]}.`,
},
],
});
if (data.attachments.length > 0) {
const stored = await addAttachments(ticket, data.attachments, null);
// Davka uz prosla kontrolou vyse, sem se dostane jen chyba uloziste.
if (!stored.ok)
console.error(`[contact] prilohy k ${ticket.id} se neulozily: ${stored.message}`);
}
}
return res.status(202).json({
ok: true,
message: 'Děkujeme, ozveme se do jednoho pracovního dne.',
});
},
);
+8
View File
@@ -78,6 +78,12 @@ export interface CrudOptions<T extends TenantEntity, C, U> {
event?: EntityEventKind;
/** true = zaznamy vidi jen spravce platformy. */
platformOnly?: boolean;
/**
* Co se ma stat po zapisu, kdyz zmena jednoho zaznamu sahne na jine
* (napr. jediny provozovatel portalu mezi firmami). Bezi az po ohlaseni
* zmeny, chyba z nej je 500 - zapis uz prosel a nema se tvarit, ze ne.
*/
afterWrite?: (verb: 'created' | 'updated', entity: T) => Promise<void> | void;
/**
* Zaznamy bez firmy (`tenantId: null`), ktere se presto spravuji po firmach.
*
@@ -233,6 +239,7 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
const created = await options.store.create(entity);
announce('created', created);
if (options.afterWrite) await options.afterWrite('created', created);
return res.status(201).json(publish(created));
});
@@ -266,6 +273,7 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
}
announce('updated', updated);
if (options.afterWrite) await options.afterWrite('updated', updated);
return res.json(publish(updated));
});
+142
View File
@@ -0,0 +1,142 @@
/**
* Prilohy ticketu: seznam, nahrani, stazeni, smazani.
*
* Ticket se hleda stejne jako u detailu (`visibleTicketOrDeny`): cizi nebo
* nad strop viditelnosti je 404. Zapis chce `ticket.comment` za firmu ticketu,
* stejne jako komentar - priloha je jen dalsi zprava k ticketu.
*/
import express from 'express';
import { z } from 'zod';
import {
addAttachments,
ALLOWED_NAME_LENGTH,
getAttachment,
listAttachments,
MAX_ATTACHMENT_BYTES,
MAX_ATTACHMENTS_PER_TICKET,
removeAttachment,
} from '../../data/attachments.js';
import { recordAudit } from '../../data/audit.js';
import { hasPermission } from '../../data/permissions.js';
import type { TicketDetail } from '../../data/ticketStore.js';
import { safeRouter } from '../../middleware/asyncHandler.js';
import { validationError } from '../../middleware/validation.js';
import { visibleTicketOrDeny } from '../ticketActions.js';
import { jsonLimitFor } from '../bodyLimit.js';
export const attachmentsRouter = safeRouter();
/**
* Vlastni strop tela: cela davka souboru v base64. Globalni `express.json`
* tuhle cestu preskakuje, viz `app.ts`.
*/
const ATTACHMENTS_BODY_LIMIT = jsonLimitFor(MAX_ATTACHMENTS_PER_TICKET, MAX_ATTACHMENT_BYTES);
const uploadSchema = z.object({
files: z
.array(
z.object({
name: z
.string()
.min(1, 'Soubor musí mít název.')
.max(ALLOWED_NAME_LENGTH * 2),
mime: z.string().max(120).optional(),
content: z.string().min(1, 'Soubor je prázdný.'),
}),
)
.min(1, 'Nebyl vybrán žádný soubor.')
.max(
MAX_ATTACHMENTS_PER_TICKET,
`Najednou jde nahrát nejvýš ${MAX_ATTACHMENTS_PER_TICKET} souborů.`,
),
});
const ATTACHMENTS_PATH = '/tickets/:id/attachments';
/** Pravo za firmu ticketu, ne za prepnutou firmu. */
function canWrite(req: express.Request, res: express.Response, ticket: TicketDetail): boolean {
if (hasPermission(req.user!, 'ticket.comment', ticket.tenantId)) return true;
console.warn(`[attachments] ${req.user!.email}: chybi pravo ticket.comment u ${ticket.id}`);
res.status(403).json({ error: 'forbidden', message: 'Nemáte právo přidávat přílohy.' });
return false;
}
attachmentsRouter.get(ATTACHMENTS_PATH, async (req, res) => {
const ticket = visibleTicketOrDeny(req, res);
if (!ticket) return;
return res.json({ items: await listAttachments(ticket.id, [ticket.tenantId]) });
});
attachmentsRouter.post(
ATTACHMENTS_PATH,
express.json({ limit: ATTACHMENTS_BODY_LIMIT }),
async (req, res) => {
const ticket = visibleTicketOrDeny(req, res);
if (!ticket) return;
if (!canWrite(req, res, ticket)) return;
const parsed = uploadSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error);
const result = await addAttachments(ticket, parsed.data.files, req.user!.id);
if (!result.ok) {
return res.status(400).json({ error: 'validation_error', message: result.message });
}
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId: ticket.tenantId,
action: 'ticket.attachment.add',
target: ticket.id,
detail: { files: result.items.map((item) => item.name) },
});
return res.status(201).json({ items: result.items });
},
);
attachmentsRouter.get(`${ATTACHMENTS_PATH}/:attachmentId/content`, async (req, res) => {
const ticket = visibleTicketOrDeny(req, res);
if (!ticket) return;
const attachment = await getAttachment(req.params.attachmentId ?? '', ticket.id, [
ticket.tenantId,
]);
if (!attachment) {
return res.status(404).json({ error: 'not_found', message: 'Příloha neexistuje.' });
}
const body = Buffer.from(attachment.content, 'base64');
res.setHeader('Content-Type', attachment.mime);
// RFC 5987: nazev s diakritikou v hlavicce musi byt zakodovany.
res.setHeader(
'Content-Disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
);
res.setHeader('Content-Length', String(body.length));
return res.end(body);
});
attachmentsRouter.delete(`${ATTACHMENTS_PATH}/:attachmentId`, async (req, res) => {
const ticket = visibleTicketOrDeny(req, res);
if (!ticket) return;
if (!canWrite(req, res, ticket)) return;
const removed = await removeAttachment(req.params.attachmentId ?? '', ticket.id, [
ticket.tenantId,
]);
if (!removed) {
return res.status(404).json({ error: 'not_found', message: 'Příloha neexistuje.' });
}
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId: ticket.tenantId,
action: 'ticket.attachment.remove',
target: ticket.id,
detail: { attachmentId: req.params.attachmentId },
});
return res.status(204).end();
});
+3
View File
@@ -18,6 +18,7 @@ import { streamRouter } from '../stream.js';
import { tenantScriptRouter } from '../tenantScripts.js';
import { ticketActionsRouter } from '../ticketActions.js';
import { widgetDataRouter } from '../widgetData.js';
import { attachmentsRouter } from './attachments.js';
import { automationsRouter } from './automations.js';
import { clientCrashRouter } from './clientCrash.js';
import { incidentsRouter } from './incidents.js';
@@ -42,6 +43,8 @@ dashboardRouter.use(intakeRouter);
dashboardRouter.use(notificationsRouter);
// Seznam, stavy a detail ticketu. `/tickets/:id` je tady, akce nize.
dashboardRouter.use(ticketsRouter);
// Prilohy ticketu. Vlastni strop tela, proto zvlast od akci.
dashboardRouter.use(attachmentsRouter);
// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo.
dashboardRouter.use('/stream', streamRouter);
+51
View File
@@ -0,0 +1,51 @@
/**
* Verejne udaje bez prihlaseni.
*
* Web bere kontaktni a fakturacni udaje z provozovatele portalu misto
* z natvrdo zapsaneho souboru. Cte se z kopie firem v pameti, takze bez
* limitu pokusu - je to levnejsi nez health.
*/
import { operatorTenant } from '../data/tenants.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import type { PublicBrand } from '../shared/tenants.js';
export const publicRouter = safeRouter();
/** Minuta. Udaje se meni jednou za rok, ale zmena se ma projevit bez restartu. */
const BRAND_MAX_AGE_SEC = 60;
/** Bez provozovatele same null, at web umi rict "neni nastaveno" misto pádu. */
const emptyBrand: PublicBrand = {
name: null,
legalName: null,
ico: null,
dic: null,
address: null,
legalForm: null,
email: null,
phone: null,
website: null,
};
export function brandOfOperator(): PublicBrand {
const tenant = operatorTenant();
if (!tenant) return emptyBrand;
return {
name: tenant.name,
// Nazev firmy je uz obchodni jmeno, zvlastni pole neni.
legalName: tenant.name,
ico: tenant.ico ?? null,
dic: tenant.dic ?? null,
address: tenant.address ?? null,
legalForm: tenant.legalForm ?? null,
email: tenant.contactEmail ?? null,
phone: tenant.contactPhone ?? null,
website: tenant.website ?? null,
};
}
publicRouter.get('/brand', (_req, res) => {
res.setHeader('Cache-Control', `public, max-age=${BRAND_MAX_AGE_SEC}`);
res.json(brandOfOperator());
});
+37 -1
View File
@@ -3,12 +3,29 @@
*/
import { z } from 'zod';
import { generateIntakeToken, tenantStore, type Tenant } from '../../data/tenants.js';
import {
clearOtherOperators,
generateIntakeToken,
tenantStore,
type Tenant,
} from '../../data/tenants.js';
import { crudRouter } from '../crud.js';
import { safeRouter } from '../../middleware/asyncHandler.js';
export const tenantsRouter = safeRouter();
/** Nejdelsi telefon vcetne predvolby a mezer. */
const PHONE_MAX_LENGTH = 30;
/** Prazdny retezec z formulare znamena "nic", uklada se jako null. */
function emptyToNull(schema: z.ZodString) {
return schema
.or(z.literal(''))
.nullable()
.optional()
.transform((value) => (value === '' ? null : value));
}
/** Udaje z ARES jsou nepovinne, rucne zalozena firma je mit nemusi. */
const tenantRegistryFields = {
ico: z
@@ -22,10 +39,20 @@ const tenantRegistryFields = {
legalForm: z.string().trim().max(120).nullable().optional(),
};
/** Kontakt pro verejny web. Bere se z provozovatele portalu. */
const tenantContactFields = {
contactEmail: emptyToNull(z.string().trim().email('Zadejte platný e-mail.')),
contactPhone: emptyToNull(z.string().trim().max(PHONE_MAX_LENGTH, 'Telefon je moc dlouhý.')),
website: emptyToNull(z.string().trim().url('Zadejte platnou adresu webu.')),
/** Provozovatel portalu. Prave jeden, ostatnim se priznak sunda. */
portalOperator: z.boolean().optional(),
};
const tenantCreate = z.object({
name: z.string().trim().min(2, 'Název firmy je moc krátký.').max(80),
note: z.string().trim().max(500).optional(),
...tenantRegistryFields,
...tenantContactFields,
});
/** Dve firmy se stejnym IC jsou jedna firma zalozena dvakrat. */
@@ -50,6 +77,7 @@ tenantsRouter.use(
/** Kdo teto firme resi helpdesk. null = nikdo, pozadavek nepujde poslat. */
helpdeskProviderId: z.string().trim().min(1).nullable().optional(),
...tenantRegistryFields,
...tenantContactFields,
}),
writePermission: 'tenant.manage',
platformOnly: true,
@@ -68,6 +96,10 @@ tenantsRouter.use(
dic: input.dic ?? null,
address: input.address ?? null,
legalForm: input.legalForm ?? null,
contactEmail: input.contactEmail ?? null,
contactPhone: input.contactPhone ?? null,
website: input.website ?? null,
portalOperator: input.portalOperator ?? false,
}),
validate: (tenant, all) => [
...(all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase())
@@ -75,5 +107,9 @@ tenantsRouter.use(
: []),
...duplicateIco(tenant, all),
],
// Provozovatel je jen jeden: kdo priznak dostal, ostatnim ho bere.
afterWrite: async (_verb, tenant) => {
if (tenant.portalOperator === true) await clearOtherOperators(tenant.id);
},
}),
);
+30
View File
@@ -0,0 +1,30 @@
/**
* Priloha ticketu: soubor, ktery prisel s poptavkou z webu nebo ho nekdo
* pripojil v portalu. Obsah se v seznamu nikdy nevraci, jen pres
* `/attachments/:id/content`.
*/
export interface Attachment {
id: string;
/** Firma ticketu. Hranice viditelnosti, stejne jako u ticketu. */
tenantId: string;
ticketId: string;
/** Nazev souboru bez cesty, uz ocisteny. */
name: string;
/** Typ obsahu, napr. `application/pdf`. Neznamy = `application/octet-stream`. */
mime: string;
/** Velikost v bajtech po dekodovani. */
size: number;
/** Ucet, ktery prilohu pripojil. null = prisla z verejneho formulare. */
uploadedBy: string | null;
createdAt: string;
}
/** Soubor tak, jak ho posila klient: obsah v base64. */
export interface AttachmentUpload {
name: string;
/** Nepovinny, chybejici se doplni na `application/octet-stream`. */
mime?: string;
/** Obsah souboru v base64 (bez prefixu `data:`). */
content: string;
}
+1
View File
@@ -7,6 +7,7 @@
*/
export type * from './access.js';
export type * from './attachments.js';
export type * from './automations.js';
export type * from './conditions.js';
export type * from './connectors.js';
+27
View File
@@ -37,4 +37,31 @@ export interface Tenant extends TenantEntity {
address?: string | null;
/** Nazev pravni formy, napr. "Spolecnost s rucenim omezenym". */
legalForm?: string | null;
/**
* Provozovatel portalu. Prave jedna firma: chodi ji poptavky z verejneho
* webu a web z ni bere kontaktni udaje. Nastaveni priznaku u jine firmy ho
* te puvodni sunda (viz `routes/settings/tenants.ts`). Chybejici = false.
*/
portalOperator?: boolean;
/** Kontaktni udaje pro verejny web. Vyplnuje se u provozovatele. */
contactEmail?: string | null;
contactPhone?: string | null;
website?: string | null;
}
/**
* Udaje provozovatele pro verejny web (`GET /api/public/brand`). Vsechno
* `null`, kdyz zadna firma provozovatelem neni - odpoved je i tak 200.
*/
export interface PublicBrand {
name: string | null;
/** Obchodni jmeno. Dnes totez co `name`. */
legalName: string | null;
ico: string | null;
dic: string | null;
address: string | null;
legalForm: string | null;
email: string | null;
phone: string | null;
website: string | null;
}