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:
+25
-1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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ů.');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export {
|
||||
assignTicketGroup,
|
||||
claimTicket,
|
||||
createTicket,
|
||||
noteAttachment,
|
||||
setTicketTags,
|
||||
setTicketType,
|
||||
ticketAssignee,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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.',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user