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

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

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

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

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

OpenAPI rozdelene na mensi soubory (102 cest, 28 schemat overeno shodnych),
28 novych testu (135 celkem), dokumentace aktualizovana.
This commit is contained in:
JiriUhlir
2026-09-09 19:35:00 +02:00
parent 22dda2d139
commit c25e826766
72 changed files with 3978 additions and 1118 deletions
+18 -7
View File
@@ -66,7 +66,9 @@ React aplikaci ze slozky `dist/public`.
| Monetizace a cena za krok | navrh | popis v 16-monetizace.md, neni naprogramovane |
| Helpdesk pro zadavatele | hotovo | pozadavek vidi zadavatel i resitel, kazdy ze sve strany |
| Odesilani e-mailu pres SMTP | hotovo | konektor se schrankou firmy, HTML telo s promennymi |
| Odesilani e-mailu z formulare | chybi | poptavka se zatim jen loguje |
| Poptavka z webu jako ticket | hotovo | `POST /api/contact` zaklada ticket provozovateli portalu |
| Prilohy ticketu | hotovo | 5 MB na soubor, 10 na ticket, z webu i z portalu |
| Udaje provozovatele na webu | hotovo | `GET /api/public/brand`, `brand.ts` je jen zaloha |
| Jeden rezim uloziste | hotovo | `initStores` rozhoduje pro vsechna uloziste naraz |
| Zapisy serazene za sebou | hotovo | `withMirror` radi zapisy tehoz zaznamu, audit se oreza |
| Worker jako pool | hotovo | ctyri behy naraz nezavisle, tlukot, opakovani jen kdyz ma smysl |
@@ -80,7 +82,7 @@ React aplikaci ze slozky `dist/public`.
| Struktura podle zasad | hotovo | `index.ts` a `app.ts`, routy a data po slozkach, `connectors/` |
| Lint a formatovani v repu | hotovo | eslint a prettier, `npm run lint` cisty bez vyjimek |
| Prisny TypeScript | hotovo | `noUncheckedIndexedAccess` v obou tsconfig, zadne `!` |
| Testy | castecne | vitest v `tests/`, 8 souboru a 105 testu: prava, tickety, executor, sit, health |
| Testy | castecne | vitest v `tests/`, 12 souboru a 130 testu: prava, tickety, prilohy, kontakt, executor, sit, health |
## Znama omezeni
@@ -107,9 +109,18 @@ v [19-kapacita-200-firem.md](19-kapacita-200-firem.md).
Limity requestu (prihlaseni, kontakt, pozvanky) jsou v pameti jedne instance,
stejne jako stream. Pri vice instancich by kazda pocitala zvlast.
Obsah verejneho webu je ukazkovy. Nazev firmy, reference, tym i cisla jsou
vymyslene a pred ostrym pouzitim se musi nahradit. Firemni udaje jsou na jednom
miste v `web/src/config/brand.ts`.
Obsah verejneho webu je ukazkovy. Reference, tym i cisla jsou vymyslene
a pred ostrym pouzitim se musi nahradit. Kontaktni a fakturacni udaje bere web
z firmy oznacene jako provozovatel portalu (`GET /api/public/brand`),
`web/src/config/brand.ts` je jen staticka zaloha pro pripad, ze provozovatel
neni nastaven. Viz [22-znacka-a-design.md](22-znacka-a-design.md).
Prilohy ticketu lezi v obecnem ulozisti jako base64 uvnitr zaznamu. V rezimu
`file` kazda zmena prepise cely `attachment.json`, pro jednotky MB to staci,
blob uloziste (S3 nebo `bytea`) je dalsi krok. Viz [14-databaze.md](14-databaze.md).
Poptavka z webu vznikne jako ticket provozovatele, ale nikdo se o ni nedozvi
e-mailem: upozorneni na novou poptavku zatim chybi, resitel ji vidi az v portalu.
Zivy stream drzi seznam posluchacu v pameti jedne instance. Pri vice instancich
by ho musel nahradit sdileny kanal, napriklad Redis pub/sub.
@@ -128,8 +139,8 @@ zamer nebo odlozena prace, ne opomenuti:
| jeden `package.json` pro server i web | mala aplikace v jednom containeru; workspaces az bude mit kazda strana vlastni build |
| logovani `console.*` s prefixem modulu | strukturovany logger (`pino`) zatim neni potreba, prefix `[modul]` staci k dohledani |
| zadny soubor CI | lint, typecheck a testy se spousti rucne se svolenim (pravidlo 2) |
| testy jen na cast logiky | pokryta prava, tickety, executor, cteni tela a health; routy nastaveni a runtime fronty cekaji |
| ctyri soubory nad 500 radku | `AutomationDetail`, `TicketDetail`, `MappingEditor`, `catalog/ticket.ts`; duvod v [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md) |
| testy jen na cast logiky | pokryta prava, tickety, prilohy, kontakt, executor, cteni tela a health; routy nastaveni a runtime fronty cekaji |
| deset souboru nad 500 radku | tri na webu z duvodu, sedm na serveru ceka na deleni pri nejblizsi praci v nich; seznam v [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md) |
## Dalsi krok
+27 -10
View File
@@ -64,8 +64,8 @@ viz [99-zmeny.md](99-zmeny.md).
| `src/config.ts` | **jedine misto, kde se cte `process.env`**; `serviceBaseUrlOverride(variable)` pro `<SLUZBA>_BASE_URL` |
| `src/openapi/index.ts` | `buildOpenApiDocument()`: sklada dokument, `servers` s prefixem proxy |
| `src/openapi/helpers.ts` | `crudPaths` a opakujici se parametry, tela a odpovedi |
| `src/openapi/components.ts` | schemata a zabezpeceni |
| `src/openapi/paths/*.ts` | cesty po routerech: `ops`, `auth`, `dashboard`, `tickets`, `automations`, `settings`, `connectors`, `scripts`, `helpdesk`, `invites`, `admin`, `contact`, `webhook` |
| `src/openapi/components/` | schemata a zabezpeceni po domenach (`common`, `auth`, `tickets`, `attachments`, `automations`, `connectors`, `scripts`, `settings`), `index.ts` je sklada |
| `src/openapi/paths/*.ts` | cesty po routerech: `ops`, `auth`, `dashboard`, `tickets`, `automations`, `settings`, `connectors`, `scripts`, `helpdesk`, `invites`, `admin`, `contact`, `attachments`, `public`, `webhook` |
| `src/types.ts` | typy uzivatele a JWT payloadu |
| `src/shared/` | ciste typove moduly API, jediny zdroj typu pro server i web |
| `src/middleware/auth.ts` | `requireAuth`, `requireRole`, `requirePlatformAdmin` |
@@ -89,6 +89,7 @@ viz [99-zmeny.md](99-zmeny.md).
| `src/routes/dashboard/notifications.ts` | upozorneni a pocet otevrenych ticketu |
| `src/routes/dashboard/clientCrash.ts` | hlaseni padu portalu, z nej incident |
| `src/routes/dashboard/shared.ts` | strankovani: `pageFrom`, `paginate` |
| `src/routes/dashboard/attachments.ts` | prilohy ticketu: seznam, nahrani, stazeni, smazani; pravo `ticket.comment` |
| `src/routes/ticketActions.ts` | akce nad ticketem vcetne vestavenych, pravo za firmu ticketu |
| `src/routes/settings/index.ts` | mount routeru nastaveni |
| `src/routes/settings/{tenants,users,roles,people,groups,features,ticketTypes,actions,widgets}.ts` | jedna entita = jeden soubor nad `crudRouter`; `people` a `users` maji vlastni handlery |
@@ -99,15 +100,18 @@ viz [99-zmeny.md](99-zmeny.md).
| `src/routes/scripts.ts`, `tenantScripts.ts` | skripty konektoru a skripty firmy |
| `src/routes/stream.ts` | SSE stream zmen, filtr podle firem uzivatele |
| `src/routes/webhook.ts` | verejny prijem dat do automatizace |
| `src/routes/contact.ts` | poptavkovy formular z webu |
| `src/routes/contact.ts` | poptavkovy formular z webu, zaklada ticket provozovateli portalu |
| `src/routes/public.ts` | verejne udaje bez prihlaseni: `GET /api/public/brand` z provozovatele |
| `src/routes/bodyLimit.ts` | `jsonLimitFor`, `hasOwnBodyLimit`: strop tela pro routy se soubory v base64 |
| `src/ares/client.ts` | klient verejneho API ARES |
| `src/data/store/` | tri rezimy uloziste, `withCache`, `withMirror`, `initStores` |
| `src/data/snapshot.ts` | atomicky zapis JSONu pro rezim `file` |
| `src/data/ticketStore.ts` | fasada nad `src/data/tickets/`, importy zustavaji |
| `src/data/tickets/` | `index` (verejne API), `model` (tvar, `toTicket`), `state` (pamet a indexy), `persist` (zapis, `initTickets`), `queries` (seznam, detail, strop viditelnosti), `store` (zapisy: zalozeni, stav, resitel, typ, tagy, skupina, komentar), `intake` (udalost zvenku), `trace` (log prubehu), `stats` (vytizeni a vykon), `seed`, `remap` |
| `src/data/tickets/` | `index` (verejne API), `model` (tvar, `toTicket`), `state` (pamet a indexy), `persist` (zapis, `initTickets`), `queries` (seznam, detail, strop viditelnosti), `store` (zapisy: zalozeni, stav, resitel, typ, tagy, skupina, komentar, poznamka o priloze), `intake` (udalost zvenku), `trace` (log prubehu), `stats` (vytizeni a vykon), `seed`, `remap` |
| `src/data/people.ts` | resitele jako pohled na clenstvi uctu (`personView`), skupiny |
| `src/data/migratePeople.ts` | jednorazovy prevod starych zaznamu resitelu `ppl_` na ucty |
| `src/data/tenants.ts` | firmy, ktere portal pouzivaji, vcetne udaju z ARES |
| `src/data/tenants.ts` | firmy, ktere portal pouzivaji, vcetne udaju z ARES; `operatorTenant`, `clearOtherOperators` |
| `src/data/attachments.ts` | prilohy ticketu: kontrola davky, zapis s obsahem v base64, cteni, mazani |
| `src/data/access.ts` | kdo co vidi - jedno misto pro cely portal |
| `src/data/widgets.ts` | katalog widgetu prehledu |
| `src/data/dashboardLayouts.ts` | rozlozeni dashboardu za dvojici uzivatel a firma |
@@ -135,7 +139,8 @@ viz [99-zmeny.md](99-zmeny.md).
| `web/src/main.tsx` | vstupni bod, `basename` routeru podle prefixu proxy |
| `web/src/App.tsx` | routovani, portal se nacita lazy |
| `web/src/index.css` | design tokeny a vlastni utility Tailwindu |
| `web/src/config/brand.ts` | vsechny firemni udaje na jednom miste |
| `web/src/config/brand.ts` | nazev, claim a staticka zaloha kontaktu; kontakty bere web z provozovatele pres `useBrand` |
| `web/src/lib/files.ts` | soubor na base64, kontrola poctu a velikosti, limity stejne jako na serveru |
| `web/src/lib/api.ts` | fetch wrapper, sprava tokenu, skladani adres, `auth:expired` na 401 |
| `web/src/lib/eventStream.ts` | cteni SSE streamu pres fetch |
| `web/src/lib/collections.tsx` | klientsky sklad ciselniku za firmu, opravovany z udalosti |
@@ -151,16 +156,18 @@ viz [99-zmeny.md](99-zmeny.md).
| `web/src/hooks/useSyncFromSource.ts` | prevzeti dat ze zdroje do rozepsaneho stavu pri vykresleni, ne v effectu |
| `web/src/hooks/useMediaQuery.ts` | sirka obrazovky pres `useSyncExternalStore` |
| `web/src/hooks/usePageMeta.ts` | titulek a popis stranky |
| `web/src/hooks/useBrand.ts` | udaje provozovatele z `/api/public/brand` nad zalohou `brand.ts`, jeden sdileny dotaz |
| `web/src/types/` | fasada nad `src/shared` (alias `@shared/*`), zadne vlastni typy API |
| `web/src/components/ui/` | zakladni prvky: `Badge`, `Button`, `Card`, `Chip`, `Modal`, `Section`, `Spinner`, ... |
| `web/src/components/ui/Table.tsx` | `Table`, `TableHead`, `Th`, `TableRow`, `Td`: jedna tabulka seznamu pro `EntityAdmin`, `InvitePanel`, `People`, `AuditView` |
| `web/src/components/ui/ServiceIcon.tsx` | ikona sluzby podle klice z katalogu, misto `const Icon = serviceIcon()` v JSX |
| `web/src/components/ui/form/` | `Field`, `Input`, `Select`, `Textarea`, `controlClass`: jedna sada trid |
| `web/src/components/ui/form/` | `Field`, `Input`, `Select`, `Textarea`, `FilePicker`, `controlClass`: jedna sada trid |
| `web/src/components/dashboard/` | shell portalu, dlazdice, graf, stream, `TicketCard` |
| `web/src/components/dashboard/EntityAdmin.tsx`, `EntityForm.tsx` | sprava jedne entity: tabulka a formular v modalu |
| `web/src/components/dashboard/flow/` | strom akci: `FlowCanvas` a karty `ActionCard`, `ConditionCard`, `ForeachCard`, `StepControls`; k tomu `TriggerConfig`, `SampleBody`, `ModelTree`, `WebhookCalls`, `MappingEditor` |
| `web/src/components/dashboard/scripts/` | `TestPanel` (zkusebni spusteni) a `CodeEditor` (uprava kodu) pro stranku Skripty |
| `web/src/components/dashboard/settings/` | `FeaturesAdmin` (zalozky a limity), `AuditView`, `types` |
| `web/src/components/dashboard/settings/` | `TenantsAdmin` (firmy, provozovatel, kontakt), `FeaturesAdmin` (zalozky a limity), `AuditView`, `types` |
| `web/src/components/dashboard/TicketAttachments.tsx`, `TicketAssignPanel.tsx` | sekce priloh a panel resitele v detailu ticketu |
| `web/src/components/dashboard/widgets/` | `WidgetCard`, `WidgetPicker`, `CustomWidget`, `EditBar` (lista uprav rozlozeni) |
| `web/src/components/dashboard/TicketTrace.tsx` | log ticketu jako strom |
| `web/src/components/dashboard/TicketWorkload.tsx` | prehled, kdo co ma u sebe |
@@ -171,15 +178,25 @@ viz [99-zmeny.md](99-zmeny.md).
### Soubory nad 500 radku
Zasada rika, ze soubor nad 500 radku je signal k rozdeleni. Po rozdeleni
zustavaji ctyri, kazdy z duvodu:
zustavaji tri, kazdy z duvodu (`TicketDetail` sel pod hranici vyclenenim
`TicketAssignPanel` a `TicketAttachments`, `Settings` vyclenenim
`TenantsAdmin`):
| Soubor | Proc zustava |
| ---------------------------------------------- | ---------------------------------------------------------------------- |
| `web/src/pages/dashboard/AutomationDetail.tsx` | stranka drzi stav stromu a ukladani; casti bez stavu uz jsou ve `flow/` |
| `web/src/pages/dashboard/TicketDetail.tsx` | detail sklada sest komponent, zbytek je stav a odeslani akci |
| `web/src/components/dashboard/flow/MappingEditor.tsx` | dva rezimy editoru nad jednim stavem, deleni by stav zdvojilo |
| `src/data/services/catalog/ticket.ts` | jedna sluzba s nejvic operacemi; deleni jedne sluzby do dvou souboru by rozbilo "jedna vec v jednom souboru" |
Na serveru zustava dalsich sedm, ktere strukturalni pruchod nedelil, protoze
nebyly v zadani a kazdy je jeden souvisly modul: `runtime/builtinSteps.ts`
(929, vestavene kroky; kandidat na slozku `runtime/steps/` po kroku),
`mcp/client.ts` (884, protokol MCP; kandidat na oddeleni handshake a volani
nastroju), `runtime/executor.ts` (779; kandidat na vycleneni vyhodnoceni
podminek), `routes/connectors.ts` (677; kandidat na `routes/connectors/`),
`routes/ticketActions.ts` (599), `routes/widgetData.ts` (587), `mcp/auth.ts`
(527). Deli se pri nejblizsi praci v nich, ne naraz.
Dalsi velke soubory (`builtinSteps.ts`, `mcp/client.ts`, `executor.ts`,
`routes/connectors.ts`) jsou kandidati na priste, az se do nich bude sahat.
+76 -1
View File
@@ -14,7 +14,8 @@ Verejne:
| GET | `/docs` | Swagger UI |
| GET | `/openapi.json` | OpenAPI definice |
| POST | `/api/auth/login` | prihlaseni, vraci JWT |
| POST | `/api/contact` | poptavka z webu |
| POST | `/api/contact` | poptavka z webu, vznikne ticket provozovatele |
| GET | `/api/public/brand` | udaje provozovatele portalu pro web |
| POST | `/webhook/:token` | prijem dat do automatizace |
| POST | `/webhook/ticket/:token` | prijem udalosti do ticketu |
| GET | `/webhook/ticket/:token` | napoveda k prijmu |
@@ -51,6 +52,10 @@ Vyzaduji `Authorization: Bearer <token>`:
| POST | `/api/dashboard/tickets/:id/claim` |
| GET | `/api/dashboard/tickets/:id/actions` |
| POST | `/api/dashboard/tickets/:id/actions/:actionId` |
| GET | `/api/dashboard/tickets/:id/attachments` |
| POST | `/api/dashboard/tickets/:id/attachments` |
| GET | `/api/dashboard/tickets/:id/attachments/:attachmentId/content` |
| DELETE | `/api/dashboard/tickets/:id/attachments/:attachmentId` |
| GET | `/api/dashboard/invites` |
| POST | `/api/dashboard/invites` |
| DELETE | `/api/dashboard/invites/:id` |
@@ -142,6 +147,75 @@ pozvanky 5 za 15 minut. Pocita se podle adresy klienta, proto ma Express
Kazdy asynchronni handler je obaleny (`safeRouter` v `src/middleware/asyncHandler.ts`).
Odmitnuta promise je 500 s logem, ne pad procesu.
## Strop tela requestu
Globalni `express.json` v `src/app.ts` ma 256 kB. To staci na formulare
a stromy automatizaci, ne na soubory. Dve cesty prijimaji soubory v base64
a maji **vlastni** `express.json` s vetsim stropem; globalni parser je
preskakuje (`hasOwnBodyLimit` v `src/routes/bodyLimit.ts`), jinak by telo
odmitl driv, nez se k nemu router dostane.
| Cesta | Strop |
| ---------------------------------------- | ------------------------------------------- |
| `POST /api/contact` | `jsonLimitFor(3, 5 MB)`, tj. 3 soubory |
| `POST /api/dashboard/tickets/:id/attachments` | `jsonLimitFor(10, 5 MB)`, tj. 10 souboru |
`jsonLimitFor(count, maxBytes)` pocita `count * maxBytes * 4/3` (base64) plus
64 kB rezervy na zbytek JSONu. Vypocet je na jednom miste, aby formular
a prilohy pocitaly stejne. U kontaktu bezi limit pokusu **pred** parserem
tela: kdo uz pokusy vycerpal, nema server nutit cist megabajty.
## Poptavka z webu
`POST /api/contact` je verejny, 5 poptavek za hodinu z jedne adresy. Telo:
`name`, `email`, `topic` (`automatizace`, `voicebot`, `integrace`,
`dashboard`, `podpora`, `jine`), `message`, nepovinne `company`, `phone`
a `attachments` (nejvys 3, kazda `{ name, mime?, content }` s obsahem
v base64).
Poptavka vznikne jako **ticket firmy, ktera je provozovatelem portalu**
(`Tenant.portalOperator`, viz [07-firmy-a-prava.md](07-firmy-a-prava.md)):
kanal `form`, predmet `Poptávka: <tema>`, telo je JSON formulare, tagy
`Poptávka` a tema, `externalSource` `web-form`. Kdyz zadna firma
provozovatelem neni, poptavka se jen zaloguje (warn). Odpoved je v obou
pripadech 202 - zvenku nema byt poznat, jak je portal nastaveny. Podrobnosti
v [06-tickety.md](06-tickety.md).
## Udaje provozovatele
`GET /api/public/brand` je verejny, bez limitu pokusu (cte z kopie firem
v pameti, je levnejsi nez health) a s `Cache-Control: public, max-age=60`.
Vraci `name`, `legalName`, `ico`, `dic`, `address`, `legalForm`, `email`,
`phone`, `website` provozovatele portalu; bez provozovatele same `null`
a porad 200, aby web umel rict "neni nastaveno" misto padu. Web ho cte
pres `useBrand`, viz [22-znacka-a-design.md](22-znacka-a-design.md).
## Prilohy ticketu
Model je v [06-tickety.md](06-tickety.md). Prilohy nejsou pole ticketu, maji
vlastni cesty pod `/api/dashboard/tickets/:id/attachments`:
| Volani | Co se stane |
| ---------------------------------------- | ---------------------------------------------------------------------------- |
| `GET attachments` | seznam bez obsahu (`id`, `name`, `mime`, `size`, `uploadedBy`, `createdAt`) |
| `POST attachments` | telo `{ files: [{ name, mime?, content }] }`, obsah base64; vraci 201 a `items` |
| `GET attachments/:attachmentId/content` | binarni obsah s `Content-Type` a `Content-Disposition` (nazev v RFC 5987) |
| `DELETE attachments/:attachmentId` | 204 |
Limity: 5 MB na soubor po dekodovani, 10 priloh na ticket, nazev bez cesty
a ridicich znaku, nejvys 200 znaku. Kontrola bezi nad **celou davkou** pred
prvnim zapisem: kdyz neprojde treti soubor, neulozi se ani prvni dva.
Ticket se hleda stejne jako u detailu (`visibleTicketOrDeny`): cizi nebo nad
strop viditelnosti je 404. Zapis a mazani chce `ticket.comment` za firmu
ticketu - priloha je jen dalsi zprava k ticketu. Kazda zmena zapise radek do
logu ticketu, posle `ticket.updated` a jde do auditu jako
`ticket.attachment.add` / `ticket.attachment.remove`.
Stazeni chce hlavicku `Authorization`, obycejny odkaz `<a href>` ji neposle.
Portal proto stahuje pres fetch (`apiBlob` v `web/src/lib/api.ts`) a docasny
odkaz na blob.
## Autentizace
Hesla se hashuji bcryptem, plaintext se nikde neuklada. Login vraci JWT
@@ -401,6 +475,7 @@ Pravo se vzdy pta **za firmu zaznamu**, ne za prepnutou firmu. Cizi firma je
| automatizace create, update, delete, regenerate | `automation.edit` |
| `/services`, `/connectors/services` | clenstvi ve firme |
| assign, status, comment, claim na ticketu | prava vestavene akce za firmu ticketu plus strop viditelnosti |
| prilohy ticketu (POST, DELETE) | `ticket.comment` za firmu ticketu plus strop viditelnosti |
| `/api/admin/impersonate*` | `impersonate` |
| `/api/admin/audit` | `audit.view` |
| `/storage`, `/scripts` s cestami na serveru | cesty jen spravci platformy, ostatni dostanou odpoved bez nich |
+91
View File
@@ -231,6 +231,96 @@ do korene a zaloguje - ztratit radek logu je horsi nez ho ukazat spatne zanoreny
Komentare jsou taky zaznamy logu (`kind: 'note'`). Diky tomu je vsechno na jedne
casove ose a nemusi se nikde skladat dohromady dva ruzne seznamy.
Prilohy do logu zapisuji taky `note`: `Příloha: nazev (velikost)` pri nahrani,
`Příloha odebrána: nazev` pri smazani (`noteAttachment`
v `src/data/tickets/store.ts`). Radek v logu je jedine, co ticket o priloze
vi - soubor sam lezi jinde, viz nize.
## Prilohy
`src/data/attachments.ts`, typ `Attachment` v `src/shared/attachments.ts`
Priloha je soubor, ktery prisel s poptavkou z webu nebo ho nekdo pripojil
v detailu ticketu. **Neni to pole ticketu.** `Ticket` o prilohach nic nenese,
lezi ve vlastni kolekci `attachment` obecneho uloziste a vazou se pres
`ticketId` a `tenantId`:
```ts
interface Attachment {
id: string; // att_xxxxxxxx
tenantId: string; // firma ticketu, hranice viditelnosti
ticketId: string;
name: string; // bez cesty a ridicich znaku, nejvys 200 znaku
mime: string; // neznamy = application/octet-stream
size: number; // bajty po dekodovani
uploadedBy: string | null; // ucet; null = prisla z verejneho formulare
createdAt: string;
}
```
Proc vlastni kolekce a ne pole na ticketu:
| Duvod | Co by se stalo s polem na ticketu |
| --------------------------- | -------------------------------------------------------------- |
| obsah je velky | kazde cteni seznamu ticketu by taha megabajty base64 |
| meni se nezavisle | nahrani souboru by prepisovalo cely ticket a soutezilo s komentari |
| seznam se vraci bez obsahu | obsah jde jen pres `/attachments/:id/content`, ne v detailu |
Obsah se uklada jako **base64 uvnitr zaznamu**. Zadna nova zavislost (multer,
S3 klient): prototyp ma ulozit jednotky MB a obecne uloziste to zvladne.
Cenou je, ze v rezimu `file` kazda zmena prepise cely `attachment.json`.
Az to zacne byt znat, presune se obsah do blob uloziste; rozhrani modulu
zustane, zmeni se jen odkud `getAttachment` cte `content`.
Viz [14-databaze.md](14-databaze.md).
Limity: 5 MB na soubor (`MAX_ATTACHMENT_BYTES`), 10 na ticket
(`MAX_ATTACHMENTS_PER_TICKET`). `checkUploads` je cista funkce nad celou
davkou: kdyz neprojde treti soubor, neulozi se ani prvni dva, jinak by klient
nevedel, co uz tam je. Kontaktni formular ji vola **pred** zalozenim ticketu,
aby po chybe nezustala poptavka bez souboru, ktere k ni patrily.
Nazev projde `sanitizeName`: bere se jen cast za poslednim lomitkem,
ridici znaky se vyhodi. Klient posila, co chce - `../../etc/passwd` nebo
nazev s novym radkem, ktery by rozbil hlavicku `Content-Disposition`.
Kazde nahrani a smazani zapise radek do logu ticketu, posune `updatedAt`
a posle `ticket.updated`, aby se detail v portalu prekreslil stejne jako po
komentari. Pravo je `ticket.comment` za firmu ticketu: priloha je jen dalsi
zprava k ticketu. API je v [04-api.md](04-api.md), portal ma sekci
`TicketAttachments` v detailu ticketu.
Mazani ticketu dnes neexistuje, proto tu neni kaskada. Az pribude, patri
do `attachments.ts` `removeAttachmentsOfTicket(ticketId)`.
## Poptavka z webu
`src/routes/contact.ts`
Kontaktni formular na verejnem webu je **kanal `form`** jako kazdy jiny:
`POST /api/contact` zalozi ticket firme, ktera je provozovatelem portalu
(`operatorTenant`, viz [07-firmy-a-prava.md](07-firmy-a-prava.md)). Driv se
poptavka jen zalogovala a nikdo ji nevidel.
| Pole ticketu | Hodnota |
| ---------------- | ------------------------------------------------------------- |
| `channel` | `form` |
| `subject` | `Poptávka: <tema>` (automatizace, voicebot, integrace, dashboard, podpora, jine) |
| `body` | JSON formulare: `name`, `email`, `company`, `phone`, `topic`, `message`, `receivedAt` |
| `tags` | `Poptávka` a tema |
| `customer` | `id` null, `company` a `contact` z formulare, `reply` = e-mail |
| `externalSource` | `web-form`, `externalId` null |
| `createdById` | null |
| `trace` | `note` "Poptávka z webového formuláře" s odesilatelem a tematem |
Telo je JSON, ne veta, zamerne: `TicketBody` ho ukaze jako tabulku klic
a hodnota (viz vyse "Obsah se na detailu cte") a zadne pole formulare se
neztrati v prose. Prilohy z formulare (nejvys 3) se ulozi pres
`addAttachments` s `uploadedBy` null.
Bez provozovatele se poptavka jen zaloguje (warn) a odpoved je porad 202:
zvenku nema byt poznat, jak je portal nastaveny. Upozorneni e-mailem na novou
poptavku zatim neni, resitel ji vidi az v seznamu ticketu.
## Opakovana udalost
Odesilatele umi poslat stejnou zpravu i osmdesatkrat za minutu. Kdyz prijde
@@ -509,5 +599,6 @@ to rozhodnout vedome, ne omylem. Varianty od nejlevnejsi:
| `inputs` u zbylych konektoru | zatim ticket, kanaly, CRM a AI, ostatni maji jen napovedu |
| Napojeni logu na beh | `automationId` je odkaz, historie behu ale neexistuje |
| Odpoved zakaznikovi z detailu | akce `send` u kanalu se z portalu nevola |
| Upozorneni na poptavku z webu | ticket vznikne, ale nikomu neprijde e-mail |
| SLA a eskalace | zadne lhuty, `capacity` je jen orientacni |
| Databaze | data v pameti, restart je vrati na vychozi sadu |
+27
View File
@@ -228,6 +228,8 @@ a drzi se vsude, kde se neco zaklada:
| automatizace | `automation.edit` za firmu automatizace | `src/routes/dashboard/automations.ts` |
| stav incidentu | `incident.manage` za firmu incidentu, platformni jen spravce platformy | `src/routes/dashboard/incidents.ts` |
| akce nad ticketem | pravo akce za firmu ticketu a strop viditelnosti | `src/routes/ticketActions.ts` |
| priloha ticketu | `ticket.comment` za firmu ticketu a strop viditelnosti | `src/routes/dashboard/attachments.ts` |
| provozovatel portalu | jen spravce platformy, prave jedna firma | `src/routes/settings/tenants.ts` |
Spravce firmy s `user.manage` ma **jen svou firmu**: nenastavi `platformAdmin`,
neprida cizi clenstvi, nesahne na spravce platformy a nesmaze cloveka, ktery
@@ -261,6 +263,31 @@ firmy vrati `existingTenantId` misto tlacitka Zalozit.
Od te chvile je to na spravci firmy: skupiny, vedouci, clenove, pozvanky.
Spravce platformy do firmy nesaha, pokud nemusi.
### Provozovatel portalu
Jedna z firem je **provozovatel portalu** (`Tenant.portalOperator`). Je to
ta, ktera portal provozuje pro ostatni: chodi ji poptavky z verejneho
kontaktniho formulare jako tickety (kanal `form`, viz
[06-tickety.md](06-tickety.md)) a verejny web z ni bere kontaktni
a fakturacni udaje (`GET /api/public/brand`, viz
[22-znacka-a-design.md](22-znacka-a-design.md)).
Nastavuje ho **jen spravce platformy** v Nastaveni, Firmy - je to totez
rozhodnuti jako zalozeni firmy, nase pravo, ne zakaznicke. Provozovatel je
**prave jeden**: kdyz priznak dostane dalsi firma, ostatnim se sunda
(`clearOtherOperators` v `src/data/tenants.ts`, volane z `afterWrite`
CRUD firem). Dve firmy s poptavkami by znamenaly, ze se ticket zalozi jen
jedne a nikdo nevi ktere. Zmenene firmy se ohlasi do streamu jako
`tenant.updated`, aby si portal opravil sklad ciselniku.
Vypnuta firma provozovatelem neni, i kdyz priznak ma (`operatorTenant`).
Bez provozovatele se poptavka jen zaloguje a web ukaze statickou zalohu.
K tomu firma nese kontaktni udaje pro web: `contactEmail`, `contactPhone`
a `website`. Vyplnuji se u provozovatele; u ostatnich firem nic neznamenaji,
ale schema je nezakazuje. Prazdny retezec z formulare se uklada jako `null`
(`emptyToNull` v `src/routes/settings/tenants.ts`).
## Co chybi
| Chybi | Poznamka |
+18
View File
@@ -126,6 +126,23 @@ Tickety navic slucuji vic zmen v jednom tiku do jednoho zapisu
nebo nejvys jednou za minutu. Prvni verze mazala jen radky platformy
(`tenantId: null`) a audit firem rostl donekonecna.
**Prilohy ticketu** (`kind: 'attachment'`, `src/data/attachments.ts`) jdou
primo pres `defineStore` bez obalky: nectou se pri kazdem requestu a nemeni
se v pameti, kazde volani jde do uloziste. Zaznam nese vedle metadat
(`ticketId`, `name`, `mime`, `size`, `uploadedBy`) i **obsah souboru
v base64**. V Postgresu je to radek v `records` jako u kazde jine entity,
v rezimu `file` soubor `DATA_DIR/attachment.json`.
Velikost je tu jina nez u ostatnich kolekci: jeden zaznam ma az 5 MB
(po base64 skoro 7), ticket jich muze mit deset. V rezimu `file` kazda
zmena prepise **cely** `attachment.json`, takze s kazdou prilohou roste
cena zapisu vsech ostatnich. Pro jednotky MB v prototypu to staci a zadna
nova zavislost nebyla potreba. Dalsi krok je presun obsahu do blob uloziste
(S3, nebo tabulka s `bytea`), az to zacne byt znat; rozhrani modulu
zustane, zmeni se jen odkud `getAttachment` cte `content`. Seznam priloh
obsah nikdy nevraci (`toPublic`), aby se megabajty netahaly pri kazdem
otevreni detailu.
### Klic mimo databazi
Bez `SECRETS_KEY` si aplikace v rezimu `file` vygeneruje klic do
@@ -282,3 +299,4 @@ Proti Postgresu 16 v kontejneru:
| Sbernice udalosti pres LISTEN/NOTIFY | dnes `EventEmitter` v pameti jedne instance |
| Vymena klice (rotace) | `v` je pripravene, prevod dat napsany neni |
| Retence a partitionovani | az u tabulek behu, viz dokument 10 |
| Blob uloziste pro prilohy | obsah je base64 v zaznamu, v rezimu `file` se prepisuje cely `attachment.json` |
+17 -1
View File
@@ -87,7 +87,7 @@ Puvodni soubory zustavaji jako fasady (`ticketStore.ts`, `automationStore.ts`,
| `tickets/state.ts` | pole ticketu, indexy podle ID a externiho ID, log, udalosti, citace ID |
| `tickets/persist.ts` | `persist`, `touch` (`updatedAt` a zapis v jednom), `initTickets` |
| `tickets/queries.ts` | `listTickets` s `TicketFilter`, `getTicket`, `findTicket`, `findByExternalId`, `ticketWithinVisibility` |
| `tickets/store.ts` | zapisy: `createTicket`, `updateTicketStatus`, `assignTicket`, `setTicketType`, `setTicketTags`, `assignTicketGroup`, `claimTicket`, `addComment` |
| `tickets/store.ts` | zapisy: `createTicket`, `updateTicketStatus`, `assignTicket`, `setTicketType`, `setTicketTags`, `assignTicketGroup`, `claimTicket`, `addComment`, `noteAttachment` (radek v logu a `ticket.updated` po zmene prilohy) |
| `tickets/intake.ts` | `intakeEvent`: udalost zvenku se stane ticketem nebo se navesi |
| `tickets/trace.ts` | `appendTrace`, `flattenTrace`, `lastTraceId`, `describePayload`: log prubehu |
| `tickets/stats.ts` | `getWorkload`, `getAgentStats` |
@@ -105,6 +105,15 @@ Puvodni soubory zustavaji jako fasady (`ticketStore.ts`, `automationStore.ts`,
| `services/catalog/index.ts` | `services` a `serviceCategories` slozene ze skupin; poradi tady je poradi v nabidce |
| `services/catalog/<skupina>.ts` | staticky zapis sluzeb jedne skupiny: `triggers`, `incident`, `ticket`, `crm`, `finance`, `logistics`, `email`, `messaging`, `social`, `office`, `analytics`, `ai`, `mcp`, `tools`, `polstryn` |
| `findByIntakeToken(token)` | `src/data/tenants.ts` | Firma podle tokenu příjmu. Určuje i to, v jakém rozsahu je externí ID unikátní. |
| `operatorTenant()` | `src/data/tenants.ts` | Zapnuta firma s priznakem provozovatele portalu. Komu chodi poptavky z webu a odkud web bere udaje. `undefined` = neni nastaven. |
| `clearOtherOperators(keepId)` | `src/data/tenants.ts` | Sunda priznak provozovatele vsem ostatnim firmam a ohlasi je do streamu. Vola `afterWrite` CRUD firem, provozovatel je vzdy jen jeden. |
| `checkUploads(uploads, existingCount)` | `src/data/attachments.ts` | Cista kontrola davky souboru: pocet, base64, velikost, nazev. Volat pred zapisem, kontakt ji vola pred zalozenim ticketu. |
| `addAttachments(ticket, uploads, uploadedBy)` | `src/data/attachments.ts` | Ulozi soubory k ticketu a kazdy zapise do logu. Cela davka projde, nebo nic. |
| `listAttachments`, `getAttachment`, `removeAttachment` | `src/data/attachments.ts` | Seznam bez obsahu, jedna priloha s obsahem, smazani se zapisem do logu. Vzdy s `ticketId` a `tenantIds`, cizi je jako neexistujici. |
| `sanitizeName`, `normalizeMime`, `decodeBase64`, `formatBytes` | `src/data/attachments.ts` | Nazev bez cesty a ridicich znaku, typ obsahu do hlavicky, prisne base64, velikost pro cloveka. |
| `jsonLimitFor(count, maxBytes)`, `hasOwnBodyLimit(path)` | `src/routes/bodyLimit.ts` | Strop tela pro routy se soubory v base64 a seznam cest, ktere globalni `express.json` preskakuje. |
| `visibleTicketOrDeny(req, res)` | `src/routes/ticketActions.ts` | Ticket z `:id` pres strop viditelnosti za firmu ticketu, jinak 404. Pouzivaji vestavene akce i prilohy. |
| `brandOfOperator()` | `src/routes/public.ts` | `PublicBrand` z provozovatele portalu, same `null` bez nej. |
| `refreshCaches()`, `refreshEntity(kind)` | `src/data/bootstrap.ts` | Obnoví všechny kopie v paměti, nebo jen jednu entitu. Route nastavení volá `bootstrapDataRefresh(route)` v `src/data/refresh.ts`, která vybere tu jednu. |
| `bootstrapData({databaseReady})` | `src/data/bootstrap.ts` | Seznam všech entit a provozních dat. **Nová entita se přidává tady**, ne rozesetě po modulech. |
@@ -145,6 +154,9 @@ Viz [11-skripty-konektoru.md](11-skripty-konektoru.md).
| `CustomWidgetCard` | `components/dashboard/widgets/CustomWidget.tsx` | Vykreslí widget, jehož data počítá server: číslo, pruhy, tabulka výkonu, časová řada, seznam, data z konektoru. |
| `TicketTable` | `components/dashboard/TicketTable.tsx` | Tabulka ticketů pro všechna místa. Na mobilu se místo posouvání do strany kreslí karty. |
| `TicketEvents` | `components/dashboard/TicketEvents.tsx` | Příchozí události ticketu včetně celého přijatého JSONu. |
| `TicketAttachments` | `components/dashboard/TicketAttachments.tsx` | Sekce priloh v detailu ticketu: seznam, stazeni pres fetch s tokenem, nahrani, smazani. Vlastni dotaz, obnovi se z `ticket.updated`. |
| `TicketAssignPanel` | `components/dashboard/TicketAssignPanel.tsx` | Panel resitele a skupiny v detailu ticketu, ciselniky si bere sam. Vyclenen z `TicketDetail`. |
| `TenantsAdmin` | `components/dashboard/settings/TenantsAdmin.tsx` | Sprava firem v Nastaveni vcetne priznaku provozovatele a kontaktnich udaju. Vyclenena ze `Settings.tsx`. |
| `ViewSwitch` | `components/dashboard/ViewSwitch.tsx` | Přepínač tabulka nebo dlaždice. Používají ho všechny seznamy. |
| `FlowCanvas` s `start` | `components/dashboard/flow/FlowCanvas.tsx` | Tentýž strom kroků i bez spouštěče - pro tělo akce, které spouští člověk. |
| `MappingEditor` | `components/dashboard/flow/MappingEditor.tsx` | Editor transformací v obou režimech (pole na pole, JSON). |
@@ -159,6 +171,10 @@ Viz [11-skripty-konektoru.md](11-skripty-konektoru.md).
| `priorities`, `priorityLabel` | `lib/options.ts` | Pevné číselníky. Stavy a kanály se berou ze serveru (`/widget-data/options`). |
| `plural(count, forms)` | `lib/format.ts` | Skloňování počtu (1 ticket, 2 tickety, 5 ticketů). |
| `Field`, `Input`, `Select`, `Textarea` | `components/ui/form/` | Formulářové prvky s jednou sadou tříd (`controlClass`). Vlastní `inputClass` ve stránce je chyba. |
| `FilePicker` | `components/ui/form/FilePicker.tsx` | Vyber priloh: tlacitko, skryty `<input type="file">`, seznam vybranych, kontrola poctu a velikosti pri vyberu. Soubory drzi rodic. |
| `readFileAsBase64`, `validateFiles`, `formatBytes`, `MAX_ATTACHMENT_BYTES` | `lib/files.ts` | Soubor na base64 bez prefixu `data:`, kontrola davky proti limitum, velikost pro cloveka. Limity stejne jako na serveru. |
| `apiBlob(path)` | `lib/api.ts` | Stazeni binarniho obsahu s hlavickou `Authorization`. Obycejny odkaz token neposle. |
| `useBrand()` | `hooks/useBrand.ts` | Udaje provozovatele z `/api/public/brand` nad statickou zalohou `config/brand.ts`. Jeden dotaz na nacteni stranky, sdileny. |
| `Chip` | `components/ui/Chip.tsx` | Štítek. |
| `Table`, `TableHead`, `Th`, `TableRow`, `Td` | `components/ui/Table.tsx` | Tabulka seznamu v portalu (hlavicka verzalkami, radky s linkou). Ctyri stranky ji kreslily kazda jinak. Huste tabulky ticketu a vykonu zustavaji zvlast, jsou to jine tabulky. |
| `ServiceIcon` | `components/ui/ServiceIcon.tsx` | Ikona sluzby podle klice z katalogu. Misto `const Icon = serviceIcon(key)` v JSX, ktere lint hlasi jako komponentu vytvorenou pri vykresleni. |
+25
View File
@@ -57,6 +57,8 @@ byla, ale `viewer` mohl zalozit konektor nebo smazat automatizaci. Ted:
| automatizace (zalozeni, uprava, smazani, novy token) | `automation.edit` |
| stav incidentu | `incident.manage` za firmu incidentu; platformni jen spravce platformy |
| vestavene akce na ticketu | pravo akce za firmu ticketu plus strop viditelnosti |
| prilohy ticketu (nahrani, smazani) | `ticket.comment` za firmu ticketu plus strop viditelnosti; seznam a stazeni jen strop |
| provozovatel portalu | jen spravce platformy, je to pole firmy |
| prepnuti na jiny ucet, audit | `impersonate`, `audit.view` |
Spravce firmy s `user.manage` nenastavi `platformAdmin`, neprida clenstvi
@@ -87,6 +89,23 @@ adresy se musi nahradit skutecnymi, jinak se ti lide neprihlasi. Firma pak
nese `ico`, `dic`, `address` a `legalForm`, IC je unikatni. Endpointy jsou
v [04-api.md](04-api.md).
### Provozovatel portalu a kontakt firmy
Zalozka Firmy (`components/dashboard/settings/TenantsAdmin.tsx`) ma
u firmy prepinac **Provozovatel portalu** (`portalOperator`) a tri kontaktni
pole: `contactEmail`, `contactPhone`, `website`. Provozovatel je firma,
ktere chodi poptavky z verejneho kontaktniho formulare jako tickety
a ze ktere web bere kontaktni a fakturacni udaje (`GET /api/public/brand`).
V seznamu firem ma odznak "provozovatel".
Provozovatel je **prave jeden**. Zaskrtnuti u jine firmy sunda priznak te
puvodni (`afterWrite` CRUD firem vola `clearOtherOperators`), obe zmeny
prijdou do portalu jako `tenant.updated`. Nastavuje ho jen spravce platformy,
stejne jako zaklada firmy - kdo portal provozuje, je nase rozhodnuti, ne
zakaznicke. Kontaktni pole jde vyplnit u kazde firmy, vyznam maji jen
u provozovatele; prazdne pole se uklada jako `null`. Duvody
v [07-firmy-a-prava.md](07-firmy-a-prava.md).
## Navigace chodí ze serveru
Co uživatel vidí za záložky, je průnik dvou věcí:
@@ -144,6 +163,12 @@ Widget je definice zdroje dat plus způsob zobrazení. Zdroj je ticket nebo
automatizace, k tomu filtr a případné seskupení (podle stavu, kanálu, řešitele,
typu, tagu).
Kresleni "Graf" ma dva tvary podle zdroje: casova rada je krivka, pocet
ticketu se seskupenim je kolac (`widgets/PieChart.tsx`, nejvys osm vysecu,
zbytek jako "ostatni"). Pocet bez seskupeni graf odmitne uz pri ulozeni,
kolac z jedne vysece nic nerika. Tataz seskupena data jako "Tabulka" jsou
sloupce: sloupce srovnavaji, kolac ukazuje podily.
Data pro celý přehled chodí **jedním requestem**. Deset dlaždic nesmí znamenat
deset dotazů.
+18 -1
View File
@@ -56,6 +56,16 @@ by `3` a `"3"` byly dva tickety a nikdo by nepoznal proč.
Bez `externalId` se vždycky zakládá nový ticket. Hádat podle předmětu by
slučovalo věci, které spolu nesouvisí.
### Poptavka z webu je taky ticket
Kontaktni formular na verejnem webu nechodi pres webhook ani token:
`POST /api/contact` zalozi ticket primo firme oznacene jako provozovatel
portalu (`Tenant.portalOperator`). Kanal je `form`, `externalSource` je
`web-form` a `externalId` je `null` - kazda poptavka je novy ticket, neni
podle ceho navesovat. Telo ticketu je JSON formulare, aby detail ukazal
tabulku poli. Prilohy z formulare se ulozi k ticketu. Podrobnosti
v [06-tickety.md](06-tickety.md).
### Událost není řádek logu
Dvě různé věci, které se snadno pletou:
@@ -70,6 +80,13 @@ přijatý JSON. Když se někdo ptá, proč ticket vypadá takhle, je to jediná
odpověď. Drží se jich nejvýš 200 na ticket, starší se odmazávají - nekonečně
rostoucí ticket by při každém zápisu přepisoval víc a víc dat.
Treti vec vedle udalosti a logu je **priloha**: soubor k ticketu. Neni
soucasti ticketu ani udalosti, lezi ve vlastni kolekci `attachment`
a ticket o ni vi jen radkem v logu (`Příloha: nazev (velikost)`). Detail
ma vlastni sekci Prilohy se seznamem, stazenim, nahranim a smazanim; zapis
chce `ticket.comment`. Model a limity jsou v [06-tickety.md](06-tickety.md),
API v [04-api.md](04-api.md).
## Co se u ticketu měří
Aby šlo říct, kdo kolik odbavil a komu to nejde, nestačí počítat vyřešené.
@@ -112,7 +129,7 @@ něco jiného.
| Stránka | Co ukazuje |
| -------------- | ----------------------------------------------------------- |
| Tickety | seznam s filtry, **tabulka nebo dlaždice** |
| Detail ticketu | obsah, události, log, akce, typ, tagy, řešitel, skupina |
| Detail ticketu | obsah, události, log, přílohy, akce, typ, tagy, řešitel, skupina |
| Lidé | řešitelé firmy a jejich vytížení, **tabulka nebo dlaždice** |
| Detail osoby | její výkon, co má u sebe, co naposledy vyřešila |
+40 -2
View File
@@ -11,7 +11,8 @@ pismo a znak, a co se pri prejmenovani menit nesmi.
| ----------------------- | ------------------------------------ |
| Barvy, pismo, utility | `web/src/index.css` |
| Znak a slovni znacka | `web/src/components/layout/Logo.tsx` |
| Nazev, kontakty, claim | `web/src/config/brand.ts` |
| Kontakty a fakturacni udaje | provozovatel portalu v Nastaveni, Firmy (`GET /api/public/brand`) |
| Nazev, claim, zaloha kontaktu | `web/src/config/brand.ts` |
| Nazev na serveru | `BRAND_NAME`, vychozi v `src/config.ts` |
| Titulek a fonty stranky | `web/index.html` |
@@ -34,6 +35,43 @@ Obe maji tutéž vychozi hodnotu, takze bez nastaveni cehokoliv sedi.
zve, a ukazkova firma se jmenuje Automia. Neni to zbytek po prejmenovani, je
to zaznam zakaznika - zadna promenna znacky ho nemeni.
### Kontakty bere web z provozovatele, `brand.ts` je jen zaloha
Kontaktni a fakturacni udaje uz nejsou natvrdo v kodu. Zdrojem je firma
oznacena jako **provozovatel portalu** (`Tenant.portalOperator`, nastavuje
spravce platformy v Nastaveni, Firmy - viz
[07-firmy-a-prava.md](07-firmy-a-prava.md)). Server je vraci bez prihlaseni
z `GET /api/public/brand` s `Cache-Control` na minutu, aby se zmena projevila
bez restartu a bez noveho buildu klienta.
| Pole z provozovatele | Odkud na firme |
| --------------------------- | ------------------------------------- |
| `name`, `legalName` | `name` (obchodni jmeno, zvlastni pole neni) |
| `ico`, `dic`, `address`, `legalForm` | udaje z ARES nebo rucne |
| `email`, `phone`, `website` | `contactEmail`, `contactPhone`, `website` |
Na klientovi to sklada `useBrand()` (`web/src/hooks/useBrand.ts`): vraci
hned statickou hodnotu z `brand.ts`, po odpovedi serveru ji **po polich**
vymeni - `null` ze serveru nechava zalohu, takze castecne vyplneny
provozovatel nerozbije paticku. Bez provozovatele nebo bez odpovedi zustane
`brand.ts` cely. Nacita se jednou za nacteni stranky a sdili pres
`useSyncExternalStore`, aby paticka, kontakt a vyzva k akci neposlaly tri
stejne dotazy.
Pouziva ho `Contact`, `Footer`, `CallToAction`, `About`, `Login`, `Logo`
a `usePageMeta`. Kdo pise novy kus webu s kontaktem, bere `useBrand()`,
ne `brand` primo.
Co **zustava staticke** v `brand.ts` a provozovatel to nema: `claim`,
`description`, `founded`, `social` a `support` (otevirajici doba, SLA).
Jsou to texty znacky, ne udaje firmy. Adresa ze serveru je jedna textova
radka, staticka zaloha ma tri (ulice, PSC a mesto, zeme); `website` zaloha
nema, bez provozovatele je `null`.
Kontaktni formular na webu (`pages/Contact.tsx`) ma vedle poli i vyber
priloh (`FilePicker`, nejvys 3 soubory po 5 MB) a poptavka konci jako ticket
provozovatele, viz [06-tickety.md](06-tickety.md).
## Barvy
| Token | Hodnota | K cemu |
@@ -142,7 +180,7 @@ zakaznika v ukazkovych datech a prejmenovat ho nema duvod.
| Chybi | Poznamka |
| --------------------- | --------------------------------------------------- |
| Materialy mimo web | prezentace, obrazky pro socialni site |
| Domena | `worknuke.cz` v `brand.ts`, ale nikde neni nasazena |
| Domena | `worknuke.cz` v `brand.ts` jako zaloha, web provozovatele az z Nastaveni |
| Prochazeni `accent-*` | 341 mist, ktera uz nemaji duvod pouzivat druhy klic |
## Rozmazani jen tam, kde neco prekryva
@@ -185,6 +185,7 @@ coz je cil, to sedi.
| kompaktni karta ticketu | `components/dashboard/TicketCard.tsx`, varianta `compact` |
| stitek | `components/ui/Chip.tsx` |
| sklonovani poctu | `plural()` v `lib/format.ts` |
| vyber priloh | `FilePicker` v `components/ui/form/FilePicker.tsx`, soubory v `lib/files.ts` |
Zmizelo 15 kopii trid vstupniho pole. Navrh zustava nize jako zduvodneni,
proc to vypada tak, jak vypada.
@@ -271,6 +272,16 @@ Sprava entit je rozdelena na `EntityAdmin` (tabulka, mazani) a `EntityForm`
(formular v modalu); nastaveni ma `settings/FeaturesAdmin` a `settings/AuditView`
jako vlastni komponenty, `Settings.tsx` je jen sklada.
Do vrstvy pribyl `FilePicker` (zari 2026, s prilohami ticketu): tlacitko,
skryty `<input type="file">` a seznam vybranych souboru, ve dvou velikostech
jako ostatni vstupy. Nativni vstup se nekresli, protoze se neda ostylovat
jednotne a jeho popisek "Soubor nevybran" se neda prelozit. Soubory drzi rodic
(`files` / `onChange`), komponenta jen pridava a odebira a pri vyberu
kontroluje pocet a velikost (`validateFiles` v `lib/files.ts`, limity stejne
jako na serveru). Pouziva ho kontaktni formular na webu (3 soubory) a sekce
priloh v detailu ticketu (10 na ticket). Vlastni `<input type="file">` ve
strance je od ted stejna chyba jako vlastni `inputClass`.
---
## 3 - Hledani jako modal s kriterii
+100
View File
@@ -2,6 +2,106 @@
Nejnovejsi nahore.
## 2026-09-09 - Kolacovy graf ve vlastnim widgetu
Vlastni widget s kreslenim "Graf" bral jen casovou radu; pokus o graf
ticketu podle stavu skoncil hlaskou "Graf umi jen zdroj Casova rada". Server
pritom seskupeny pocet uz umel (`ticketCount` s `groupBy`), jen ho klient
kreslil vzdy jako sloupce.
`validateWidget` pousti graf i nad `ticketCount` se seskupenim (bez
seskupeni dal ne, kolac z jedne vysece nic nerika) a klient seskupena data
u kreslení "Graf" vykresli jako kolac (`widgets/PieChart.tsx`, cisté SVG bez
knihovny, osm barev z tokenu, zbytek nad osm skupin jako "ostatni", legenda
s cislem a podilem, odkaz na filtrovany seznam). Napoveda ve Widgetech ma
priklad a uz nepouziva anglicke stavy `new/open/waiting`, ktere v datech
nejsou (filtr `closed: false`). Test `tests/data/customWidgets.test.ts`.
## 2026-09-09 - Poptavka z webu je ticket, prilohy, udaje provozovatele
Kontaktni formular na webu poptavku jen zalogoval: kdo nesledoval log
containeru, o ni nevedel, a odesilatel dostal "ozveme se", ktere nikdo
nemohl splnit. Zaroven mel web kontakty a IC natvrdo v `brand.ts`, takze
zmena telefonu znamenala novy build. Obe veci maji spolecny koren: portal
nevedel, **ktera firma ho provozuje**. Tahle zmena to zavadi a stavi na tom.
### Provozovatel portalu
Firma dostala priznak `portalOperator` a kontaktni pole `contactEmail`,
`contactPhone`, `website` (vedle `ico`, `dic`, `address`, `legalForm`).
Nastavuje ho spravce platformy v Nastaveni, Firmy
(`components/dashboard/settings/TenantsAdmin.tsx`, vyclenene ze `Settings.tsx`).
| Rozhodnuti | Proc |
| ----------------------------------- | -------------------------------------------------------------------------------------------- |
| priznak na firme, ne promenna | provozovatel je zaznam s IC a adresou, ktery uz v datech je; promenna by ho jen duplikovala |
| prave jeden | dve firmy s poptavkami by znamenaly, ze ticket vznikne jen jedne a nikdo nevi ktere |
| nastaveni u jine firmy sunda puvodni | `afterWrite` CRUD firem vola `clearOtherOperators`; zmenene firmy jdou do streamu jako `tenant.updated` |
| jen spravce platformy | kdo portal provozuje, je nase rozhodnuti, stejne jako zalozeni firmy |
| vypnuta firma provozovatelem neni | `operatorTenant` bere jen zapnutou, i kdyz priznak zustal |
### Poptavka z webu je ticket
`POST /api/contact` zaklada ticket provozovateli: kanal `form`, predmet
`Poptávka: <tema>`, tagy `Poptávka` a tema, zakaznik z formulare,
`externalSource` `web-form`, poznamka v logu ticketu. **Telo ticketu je JSON
formulare** (`name`, `email`, `company`, `phone`, `topic`, `message`,
`receivedAt`), ne slozena veta: `TicketBody` JSON ukaze jako tabulku
a zadne pole se neztrati v prose; rozhodnuti zadavatele. Bez provozovatele
se poptavka jen zaloguje (warn) a odpoved je porad 202, zvenku nema byt
poznat, jak je portal nastaveny. Limit 5 za hodinu z adresy se nemeni
a bezi **pred** parserem tela, aby vycerpane pokusy nenutily server cist
megabajty. Formular bere az 3 prilohy po 5 MB (`FilePicker` v `Contact.tsx`).
Upozorneni e-mailem na novou poptavku zatim neni, resitel ji vidi
v seznamu ticketu.
### Prilohy ticketu
Nova kolekce `attachment` (`src/data/attachments.ts`, typ
v `src/shared/attachments.ts`), **ne pole ticketu**: obsah je velky, meni
se nezavisle na ticketu a seznam se vraci bez nej. Obsah se uklada jako
base64 uvnitr zaznamu obecneho uloziste. Zadna nova zavislost (multer,
S3 klient): prototyp uklada jednotky MB a `defineStore` to zvladne; cenou
je, ze v rezimu `file` kazda zmena prepise cely `attachment.json`. Blob
uloziste (S3 nebo `bytea`) je dalsi krok, rozhrani modulu se tim nezmeni.
| Co | Hodnota |
| ------------------------- | ----------------------------------------------------------------------- |
| limity | 5 MB na soubor po dekodovani, 10 na ticket, nazev 200 znaku |
| kontrola | `checkUploads` nad celou davkou pred prvnim zapisem: bud vse, nebo nic |
| nazev | `sanitizeName`: bez cesty a ridicich znaku, jinak rozbije `Content-Disposition` |
| endpointy | `GET/POST /api/dashboard/tickets/:id/attachments`, `GET .../:attachmentId/content`, `DELETE .../:attachmentId` |
| pravo | `ticket.comment` za firmu ticketu, ticket pres `visibleTicketOrDeny` jako u detailu |
| stopa | radek v logu ticketu (`noteAttachment`), `ticket.updated`, audit `ticket.attachment.add` / `.remove` |
| stazeni | binarni odpoved chce `Authorization`, portal stahuje pres `apiBlob` a docasny odkaz |
Globalni `express.json` ma 256 kB, coz na soubory nestaci. Kontakt a prilohy
maji vlastni parser se stropem z `jsonLimitFor` (`src/routes/bodyLimit.ts`)
a globalni je preskakuje (`hasOwnBodyLimit` v `app.ts`), jinak by telo
odmitl driv, nez se k nemu router dostane.
Portal: sekce priloh v detailu ticketu (`TicketAttachments.tsx`), panel
resitele vyclenen do `TicketAssignPanel.tsx`; `TicketDetail` tim sel pod
500 radku. `FilePicker` v `ui/form`, prace se soubory v `lib/files.ts`.
### Verejny brand
`GET /api/public/brand` bez prihlaseni, `Cache-Control` na minutu, vraci
`name`, `legalName`, `ico`, `dic`, `address`, `legalForm`, `email`, `phone`,
`website` provozovatele, bez nej same `null`. Web to sklada v `hooks/useBrand.ts`
nad statickou zalohou `config/brand.ts`: `null` ze serveru nechava zalohu,
`claim`, `description`, `social`, `support` a `founded` zustavaji staticke.
Pouziva to `Contact`, `Footer`, `CallToAction`, `About`, `Login`, `Logo`
a `usePageMeta`. Jeden dotaz na nacteni stranky, sdileny pres
`useSyncExternalStore`.
### Testy
12 souboru, 130 testu (22 novych): uloziste priloh (`tests/data/attachments.test.ts`),
kontakt (`tests/routes/contact.test.ts`), routy priloh
(`tests/routes/attachments.test.ts`), verejny brand (`tests/routes/public.test.ts`).
## 2026-09-09 - Struktura podle zasad: rozdeleni souboru, lint, testy
`D:\GitHubRepository\CLAUDE.md` dostal zasady pro vsechny projekty (struktura
+25 -1
View File
@@ -22,7 +22,9 @@ import { authRouter } from './routes/auth.js';
import { contactRouter } from './routes/contact.js';
import { dashboardRouter } from './routes/dashboard/index.js';
import { publicInviteRouter } from './routes/invites.js';
import { publicRouter } from './routes/public.js';
import { webhookRouter } from './routes/webhook.js';
import { hasOwnBodyLimit } from './routes/bodyLimit.js';
const here = path.dirname(fileURLToPath(import.meta.url));
/** Zbuildovana SPA. Vite ji zapisuje do dist/public, viz vite.config.ts. */
@@ -34,6 +36,14 @@ const JSON_BODY_LIMIT = '256kb';
/** Rok. Soubory buildu maji hash v nazvu, takze se muzou cachovat na maximum. */
const STATIC_MAX_AGE_SEC = 31_536_000;
/** Cesta bez query a bez prefixu proxy, aby se dala porovnat se vzorem routy. */
function stripRootPath(url: string): string {
const path = url.split('?')[0] ?? '';
return config.rootPath && path.startsWith(config.rootPath)
? path.slice(config.rootPath.length)
: path;
}
/**
* Token v adrese je pristupovy udaj. Do logu jde jen jeho zacatek, aby slo
* volani dohledat, ale ne zopakovat.
@@ -89,7 +99,19 @@ function applyBaseMiddleware(app: express.Express): void {
credentials: true,
}),
);
app.use(express.json({ limit: JSON_BODY_LIMIT }));
/*
* Routy s prilohami (kontakt, prilohy ticketu) maji vlastni `express.json`
* s vetsim stropem. Globalni parser je musi preskocit, jinak telo odmitne
* driv, nez se k nemu router dostane. `type` rozhoduje, jestli se telo cte.
*/
app.use(
express.json({
limit: JSON_BODY_LIMIT,
type: (req) =>
!hasOwnBodyLimit(stripRootPath(req.url ?? '')) &&
(req.headers['content-type'] ?? '').startsWith('application/json'),
}),
);
/*
* Bezpecnostni hlavicky. Rucne a stridme: zadne CSP, ktere by rozbilo SPA
@@ -195,6 +217,8 @@ function buildApiRouter(): express.Router {
api.use('/api/invites', publicInviteRouter);
api.use('/api/admin', adminRouter);
api.use('/api/contact', contactRouter);
// Verejne udaje provozovatele pro web. Jen cteni, bez prihlaseni.
api.use('/api/public', publicRouter);
api.use('/webhook', webhookRouter);
return api;
+220
View File
@@ -0,0 +1,220 @@
/**
* Prilohy ticketu.
*
* Obsah se uklada jako base64 primo v zaznamu obecneho uloziste. V rezimu
* `file` to znamena, ze kazda zmena prepise cely `attachment.json` - pro
* jednotky MB je to prijatelne a dalsi krok je presun obsahu do blob uloziste
* (S3 nebo tabulka s bytea), az to zacne byt znat. Rozhrani tohoto modulu se
* tim nezmeni, meni se jen to, odkud `getAttachment` cte `content`.
*
* Mazani ticketu dnes neexistuje, proto tu neni kaskada. Az pribude, patri
* sem `removeAttachmentsOfTicket(ticketId)`.
*/
import { randomUUID } from 'node:crypto';
import type { Attachment, AttachmentUpload } from '../shared/attachments.js';
import type { Ticket } from '../shared/tickets.js';
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
import { noteAttachment } from './ticketStore.js';
export type { Attachment, AttachmentUpload };
/** Nejvetsi soubor po dekodovani. 5 MB staci na PDF i fotku, vic uz je archiv. */
export const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
/** Kolik priloh smi mit jeden ticket. Vic uz nikdo neprojde, patri to do archivu. */
export const MAX_ATTACHMENTS_PER_TICKET = 10;
/** Delka nazvu souboru. Delsi nazvy rozbiji hlavicku Content-Disposition. */
export const ALLOWED_NAME_LENGTH = 200;
/** Vychozi typ obsahu, kdyz ho klient neposle nebo posle nesmysl. */
const DEFAULT_MIME = 'application/octet-stream';
/** Nazev, kdyz po ocisteni nic nezbyde. */
const FALLBACK_NAME = 'priloha';
/** Typ obsahu jde do hlavicky odpovedi, proto jen `typ/podtyp` bez parametru. */
const MIME_PATTERN = /^[\w.+-]+\/[\w.+-]+$/;
/** Base64 bez bilych znaku, delka nasobek ctyr, nejvys dve rovnitka na konci. */
const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/;
/** Zaznam v ulozisti: priloha plus obsah, ktery se v seznamu nevraci. */
interface StoredAttachment extends TenantEntity, Attachment {
tenantId: string;
/** Obsah v base64. */
content: string;
}
export const attachmentStore = defineStore<StoredAttachment>('attachment');
/** Verejna podoba: bez obsahu a bez `updatedAt`, ktere priloha nemeni. */
function toPublic(stored: StoredAttachment): Attachment {
return {
id: stored.id,
tenantId: stored.tenantId,
ticketId: stored.ticketId,
name: stored.name,
mime: stored.mime,
size: stored.size,
uploadedBy: stored.uploadedBy,
createdAt: stored.createdAt,
};
}
/** Ridici znaky ASCII (0-31) a DEL (127). Do nazvu souboru ani do hlavicky nepatri. */
const LAST_CONTROL_CHAR = 31;
const DEL_CHAR = 127;
function isControlChar(char: string): boolean {
const code = char.charCodeAt(0);
return code <= LAST_CONTROL_CHAR || code === DEL_CHAR;
}
/**
* Nazev souboru bez cesty a ridicich znaku.
*
* Klient posila, co chce: `../../etc/passwd` nebo nazev s novym radkem, ktery
* by rozbil hlavicku. Bere se jen posledni cast za lomitkem.
*/
export function sanitizeName(name: string): string {
const base = name.split(/[\\/]/).pop() ?? '';
const cleaned = [...base].filter((char) => !isControlChar(char)).join('').trim();
const safe = cleaned === '' || cleaned === '.' || cleaned === '..' ? FALLBACK_NAME : cleaned;
return safe.slice(0, ALLOWED_NAME_LENGTH);
}
/** Typ obsahu do hlavicky. Co nevypada jako `typ/podtyp`, se nahradi vychozim. */
export function normalizeMime(mime: string | undefined): string {
const trimmed = (mime ?? '').trim().toLowerCase();
return MIME_PATTERN.test(trimmed) ? trimmed : DEFAULT_MIME;
}
/**
* Dekoduje base64. Vraci null, kdyz to base64 neni nebo je soubor prazdny.
* `Buffer.from` je shovivavy a neplatne znaky tise zahazuje, proto regex
* a kontrola delky pred nim.
*/
export function decodeBase64(content: string): Buffer | null {
if (content === '' || content.length % 4 !== 0 || !BASE64_PATTERN.test(content)) return null;
const buffer = Buffer.from(content, 'base64');
return buffer.length === 0 ? null : buffer;
}
/** Velikost pro cloveka do logu ticketu. */
export function formatBytes(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} kB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
/** Soubor po kontrole, pripraveny k ulozeni. */
interface CheckedUpload {
name: string;
mime: string;
content: string;
size: number;
}
export type UploadCheck =
| { ok: true; files: CheckedUpload[] }
| { ok: false; message: string };
/**
* Overi davku souboru proti limitum. Cista funkce, aby sla otestovat bez
* uloziste a aby ji kontaktni formular mohl zavolat driv, nez zalozi ticket.
*/
export function checkUploads(uploads: AttachmentUpload[], existingCount: number): UploadCheck {
if (existingCount + uploads.length > MAX_ATTACHMENTS_PER_TICKET) {
return {
ok: false,
message: `Ticket může mít nejvýš ${MAX_ATTACHMENTS_PER_TICKET} příloh.`,
};
}
const files: CheckedUpload[] = [];
for (const upload of uploads) {
const name = sanitizeName(upload.name);
const buffer = decodeBase64(upload.content);
if (!buffer) {
return { ok: false, message: `Soubor ${name} není platný base64 nebo je prázdný.` };
}
if (buffer.length > MAX_ATTACHMENT_BYTES) {
return {
ok: false,
message: `Soubor ${name} je větší než ${formatBytes(MAX_ATTACHMENT_BYTES)}.`,
};
}
files.push({ name, mime: normalizeMime(upload.mime), content: upload.content, size: buffer.length });
}
return { ok: true, files };
}
/** Prilohy ticketu bez obsahu. Cizi firma dostane prazdny seznam. */
export async function listAttachments(ticketId: string, tenantIds: string[]): Promise<Attachment[]> {
const rows = await attachmentStore.list({ tenantIds });
return rows
.filter((row) => row.ticketId === ticketId)
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
.map(toPublic);
}
export type AddAttachmentsResult =
| { ok: true; items: Attachment[] }
| { ok: false; message: string };
/**
* Ulozi soubory k ticketu a kazdy zapise do logu ticketu.
*
* Kontrola limitu bezi nad celou davkou pred prvnim zapisem: kdyz neprojde
* treti soubor, neulozi se ani prvni dva, jinak by klient nevedel, co uz tam je.
*/
export async function addAttachments(
ticket: Ticket,
uploads: AttachmentUpload[],
uploadedBy: string | null,
): Promise<AddAttachmentsResult> {
const existing = await listAttachments(ticket.id, [ticket.tenantId]);
const checked = checkUploads(uploads, existing.length);
if (!checked.ok) return checked;
const items: Attachment[] = [];
for (const file of checked.files) {
const timestamp = nowIso();
const stored = await attachmentStore.create({
id: `att_${randomUUID().slice(0, 8)}`,
tenantId: ticket.tenantId,
ticketId: ticket.id,
name: file.name,
mime: file.mime,
size: file.size,
uploadedBy,
content: file.content,
createdAt: timestamp,
updatedAt: timestamp,
});
items.push(toPublic(stored));
noteAttachment(ticket.id, [ticket.tenantId], `Příloha: ${file.name} (${formatBytes(file.size)})`);
}
return { ok: true, items };
}
/** Priloha vcetne obsahu. Cizi nebo k jinemu ticketu se chova jako neexistujici. */
export async function getAttachment(
id: string,
ticketId: string,
tenantIds: string[],
): Promise<(Attachment & { content: string }) | undefined> {
const stored = await attachmentStore.get(id, { tenantIds });
if (!stored || stored.ticketId !== ticketId) return undefined;
return { ...toPublic(stored), content: stored.content };
}
/** Smaze prilohu a zapise to do logu ticketu. Vraci false, kdyz neexistuje. */
export async function removeAttachment(
id: string,
ticketId: string,
tenantIds: string[],
): Promise<boolean> {
const stored = await attachmentStore.get(id, { tenantIds });
if (!stored || stored.ticketId !== ticketId) return false;
const removed = await attachmentStore.remove(id, { tenantIds });
if (removed) noteAttachment(ticketId, [stored.tenantId], `Příloha odebrána: ${stored.name}`);
return removed;
}
+3
View File
@@ -10,6 +10,7 @@
*/
import { actionPermissions, actionStore, seedActions } from './ticketActions.js';
import { attachmentStore } from './attachments.js';
import { customWidgetStore, seedCustomWidgets } from './customWidgets.js';
import { auditStore } from './audit.js';
import { notificationStore } from './notifications.js';
@@ -61,6 +62,8 @@ const entities: Array<{
{ store: notificationStore as EntityStore<TenantEntity> },
{ store: inviteStore as EntityStore<TenantEntity> },
{ store: tenantScriptStore as EntityStore<TenantEntity> },
// Prilohy ticketu. Zadna kopie v pameti, ctou se az u detailu ticketu.
{ store: attachmentStore as EntityStore<TenantEntity> },
];
/**
+7 -2
View File
@@ -166,8 +166,13 @@ export function validateWidget(widget: CustomWidget): string[] {
if (widget.render === 'stat' && !['ticketCount', 'connector'].includes(widget.source.kind)) {
problems.push('Jedno číslo umí jen zdroj Počet ticketů nebo Konektor.');
}
if (widget.render === 'chart' && widget.source.kind !== 'ticketSeries') {
problems.push('Graf umí jen zdroj Časová řada.');
// Graf je bud casova rada (krivka), nebo seskupeny pocet (kolac).
// Pocet bez seskupeni je jedno cislo a kolac z jedne vysece nic nerika.
const groupedCount = widget.source.kind === 'ticketCount' && widget.source.groupBy !== undefined;
if (widget.render === 'chart' && widget.source.kind !== 'ticketSeries' && !groupedCount) {
problems.push(
'Graf umí zdroj Časová řada (křivka), nebo Počet ticketů se seskupením (koláč).',
);
}
if (widget.render === 'table' && widget.source.kind === 'ticketList') {
problems.push('Tabulka potřebuje seskupení nebo výkon řešitelů, ne seznam ticketů.');
+36
View File
@@ -14,6 +14,7 @@
*/
import { randomBytes } from 'node:crypto';
import { publish } from '../events/bus.js';
import { timingSafeEqualString } from '../lib/secure.js';
import { defineStore, nowIso } from './store/index.js';
import { withCache } from './store/cached.js';
@@ -93,3 +94,38 @@ export function listActiveTenants(): Tenant[] {
export function findTenant(id: string): Tenant | undefined {
return cache.byId(id);
}
/**
* Provozovatel portalu: firma, ktere chodi poptavky z webu a ze ktere web
* bere kontaktni udaje. Vypnuta firma provozovatelem neni, i kdyz priznak ma.
*/
export function operatorTenant(): Tenant | undefined {
return cache.all().find((tenant) => tenant.enabled && tenant.portalOperator === true);
}
/**
* Provozovatel je jen jeden. Kdyz priznak dostane dalsi firma, ostatnim se
* sunda - dve firmy s poptavkami by znamenaly, ze se ticket zalozi jen jedne
* a nikdo nevi ktere. Zmenene firmy se ohlasi do streamu stejne jako zapis
* z nastaveni, aby si portal opravil sklad ciselniku.
*/
export async function clearOtherOperators(keepId: string): Promise<void> {
const others = cache.all().filter((tenant) => tenant.portalOperator === true && tenant.id !== keepId);
if (others.length === 0) return;
const changed: Tenant[] = [];
for (const tenant of others) {
// Firma je platformni zaznam (tenantId null), proto includeGlobal.
const updated = await tenantStore.update(
tenant.id,
{ portalOperator: false },
{ tenantIds: [], includeGlobal: true },
);
if (updated) changed.push(updated);
}
await cache.refresh();
for (const tenant of changed) {
publish('tenant.updated', `tenant updated: ${tenant.name}`, { id: tenant.id, tenant }, null);
}
}
+1
View File
@@ -60,6 +60,7 @@ export {
assignTicketGroup,
claimTicket,
createTicket,
noteAttachment,
setTicketTags,
setTicketType,
ticketAssignee,
+17
View File
@@ -403,3 +403,20 @@ export function addComment(
publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
return toTicket(ticket);
}
/**
* Poznamka o priloze. Priloha lezi ve vlastnim ulozisti (`data/attachments.ts`),
* ale ticket se o ni musi dozvedet: radek do logu, `updatedAt` a udalost,
* aby se detail v portalu prekreslil stejne jako po komentari.
*/
export function noteAttachment(id: string, tenantIds: string[], note: string): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
if (!ticket) {
console.warn(`[tickets] poznamka o priloze k nedostupnemu ticketu: ${id}`);
return undefined;
}
touch(ticket);
appendTrace(id, [{ kind: 'note', label: note, status: 'info' }]);
publish('ticket.updated', `Ticket ${ticket.id} má změnu příloh`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId);
return toTicket(ticket);
}
-612
View File
@@ -1,612 +0,0 @@
/** Schemata a zabezpeceni. Jen popis tvaru dat, zadna logika. */
export const components = {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Token z POST /api/auth/login. Vlozte samotny token bez slova Bearer.',
},
},
schemas: {
AresCompany: {
type: 'object',
properties: {
ico: { type: 'string', example: '27074358' },
name: { type: 'string', example: 'Asseco Central Europe, a.s.' },
dic: { type: 'string', nullable: true, example: 'CZ27074358' },
address: { type: 'string', example: 'Budejovicka 778/3a, Michle, 14000 Praha 4' },
legalFormCode: { type: 'string', example: '121' },
legalForm: { type: 'string', example: 'Akciova spolecnost' },
existingTenantId: {
type: 'string',
nullable: true,
description: 'ID firmy v portalu, kdyz uz je zalozena.',
},
},
},
Tenant: {
type: 'object',
properties: {
id: { type: 'string', example: 'tnt_automia' },
name: { type: 'string' },
note: { type: 'string' },
enabled: { type: 'boolean' },
helpdeskProviderId: { type: 'string', nullable: true },
ico: { type: 'string', nullable: true },
dic: { type: 'string', nullable: true },
address: { type: 'string', nullable: true },
legalForm: { type: 'string', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
Error: {
type: 'object',
properties: {
error: { type: 'string', example: 'validation_error' },
message: { type: 'string', example: 'Zadejte platny e-mail.' },
issues: {
type: 'array',
description:
'Jen u validation_error: vsechny problemy vstupu. `field` je cesta ' +
'k poli spojena teckou, prazdna u chyby celeho tela.',
items: {
type: 'object',
properties: {
field: { type: 'string', example: 'memberships.0.roleIds' },
message: { type: 'string' },
},
},
},
},
},
User: {
type: 'object',
description:
'Uzivatel muze patrit do vic firem. Role je vzdy az uvnitr firmy, ' +
'pristup napric firmami je zvlast jako platformAdmin.',
properties: {
id: { type: 'string', example: 'usr_1' },
email: { type: 'string', example: 'admin@automia.cz' },
name: { type: 'string', example: 'Jiri Uhlir' },
platformAdmin: {
type: 'boolean',
description: 'Vidi napric vsemi firmami a muze mezi nimi prepinat.',
},
memberships: {
type: 'array',
items: {
type: 'object',
properties: {
tenantId: { type: 'string', example: 'tnt_automia' },
role: { type: 'string', enum: ['admin', 'agent'] },
},
},
},
},
},
Access: {
type: 'object',
description: 'Co uzivatel smi. Klient podle toho kresli prepinac pohledu.',
properties: {
scopes: {
type: 'array',
items: { type: 'string', enum: ['all', 'tenant', 'mine'] },
},
tenants: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'tnt_automia' },
name: { type: 'string', example: 'Automia' },
},
},
},
defaultTenantId: { type: 'string', nullable: true },
canAssignOthers: {
type: 'boolean',
description: 'Smi prehazovat tickety mezi lidmi, ne jen brat na sebe.',
},
personId: { type: 'string', nullable: true },
permissions: {
type: 'array',
items: { type: 'string' },
description: 'Efektivni prava ve vybrane firme. Klient podle nich kresli tlacitka.',
},
roleNames: {
type: 'array',
items: { type: 'string' },
example: ['Spravce firmy'],
description:
'Nazvy roli uzivatele ve vybrane firme. Tohle se ukazuje jako popis uctu, ' +
'ne odhad z poctu prav.',
},
nav: { type: 'array', items: { type: 'object' }, description: 'Zalozky, ktere ma videt.' },
platformAdmin: { type: 'boolean' },
seesOthers: { type: 'boolean', description: 'Vidi i cizi tickety, ne jen svoje.' },
visibleGroups: {
type: 'array',
items: {
type: 'object',
properties: { id: { type: 'string' }, name: { type: 'string' } },
},
},
},
},
Connector: {
type: 'object',
description:
'Napojeni firmy na jednu sluzbu. Hodnoty pristupovych udaju tady zamerne ' +
'nejsou a nikdy nebudou - secrets se z beznych endpointu nevraci.',
properties: {
id: { type: 'string', example: 'con_1a2b3c4d' },
tenantId: { type: 'string', example: 'tnt_automia' },
serviceId: { type: 'string', example: 'idoklad' },
name: { type: 'string', example: 'iDoklad Automia' },
baseUrl: { type: 'string', nullable: true },
enabled: { type: 'boolean' },
status: { type: 'string', enum: ['untested', 'ok', 'error'] },
lastCheckAt: { type: 'string', format: 'date-time', nullable: true },
lastError: { type: 'string', nullable: true },
checkCount: {
type: 'integer',
description:
'Kolik zaznamu o overeni je v historii. Samotna historie se cte pres ' +
'/api/dashboard/connectors/{id}/checks - v seznamu by to byla tela odpovedi navic.',
},
isDefault: {
type: 'boolean',
description: 'Krok stromu bez vybraneho konektoru pouzije tenhle.',
},
filled: {
type: 'array',
items: { type: 'string' },
description: 'ID poli, ktera jsou vyplnena. Hodnoty se nevraci.',
},
missing: {
type: 'array',
items: { type: 'string' },
description: 'ID povinnych poli, ktera chybi.',
},
config: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Necitliva nastaveni. Tajna pole tu nejsou vubec.',
},
ready: { type: 'boolean' },
},
},
ScriptField: {
type: 'object',
description:
'Parametr skriptu. Stejny tvar pro vstup i vystup - kontrola je pak ' +
'jedna funkce, ne dve skoro stejne.',
required: ['id', 'label', 'type', 'required'],
properties: {
id: {
type: 'string',
example: 'invoiceId',
description: 'Pouziva se v sablonach jako {{invoiceId}}.',
},
label: { type: 'string', example: 'ID faktury v iDokladu' },
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
required: { type: 'boolean' },
hint: { type: 'string' },
options: {
type: 'array',
description: 'Vyber z hodnot. Jina hodnota neprojde kontrolou.',
items: {
type: 'object',
properties: { value: { type: 'string' }, label: { type: 'string' } },
},
},
pattern: { type: 'string', description: 'Jen u typu string.' },
multiline: { type: 'boolean', description: 'Jen u typu string.' },
default: { description: 'Dosadi se, kdyz hodnota chybi a parametr neni povinny.' },
},
},
ScriptManifest: {
type: 'object',
description: 'Co skript umi. Podle nej s nim umi pracovat strom automatizace.',
properties: {
id: {
type: 'string',
example: 'idoklad.get-issued-invoice',
description: 'Tvar sluzba.operace. Nazev souboru musi byt <id>.js.',
},
serviceId: { type: 'string', example: 'idoklad' },
serviceName: { type: 'string', example: 'iDoklad' },
operationId: { type: 'string', example: 'get-issued-invoice' },
name: { type: 'string', example: 'Získat vydanou fakturu' },
description: { type: 'string' },
inputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
outputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
timeoutMs: { type: 'integer', example: 15000 },
},
},
ScriptProblem: {
type: 'object',
description: 'Rozbity skript. Nesmi shodit ostatni ani tise zmizet, proto se vraci sem.',
properties: {
file: { type: 'string', example: 'idoklad.get-issued-invoice.js' },
scriptId: { type: 'string', nullable: true },
message: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
ConnectionStatus: {
type: 'object',
description:
'Stav napojeni konektoru. Hodnoty pristupovych udaju se nevraci nikdy, ' +
'jen jmena promennych, ktere chybi.',
properties: {
connectorId: { type: 'string', example: 'idoklad' },
baseUrl: { type: 'string', example: 'https://services.csbot.cz/apps/idoklad' },
ready: { type: 'boolean' },
missing: {
type: 'array',
items: { type: 'string' },
example: ['IDOKLAD_CLIENT_SECRET'],
},
headers: { type: 'array', items: { type: 'string' }, example: ['X-ClientId'] },
},
},
ScriptRunResult: {
type: 'object',
description:
'Vysledek behu skriptu. `retryable` rika, jestli ma smysl zkusit to znovu - ' +
'timeout ano, spatny vstup ne.',
properties: {
ok: { type: 'boolean' },
scriptId: { type: 'string' },
outputs: {
type: 'object',
additionalProperties: true,
description: 'Prazdne, kdyz beh selhal.',
},
logs: {
type: 'array',
items: {
type: 'object',
properties: {
at: { type: 'string', format: 'date-time' },
message: { type: 'string' },
detail: { type: 'string' },
},
},
},
durationMs: { type: 'integer' },
httpCalls: { type: 'integer' },
error: {
type: 'object',
nullable: true,
properties: {
kind: {
type: 'string',
enum: [
'not_found',
'config',
'validation',
'output',
'retryable',
'terminal',
'timeout',
'internal',
],
},
message: { type: 'string' },
retryable: { type: 'boolean' },
status: { type: 'integer' },
detail: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
},
},
LoginRequest: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email', example: 'admin@automia.cz' },
password: { type: 'string', format: 'password', example: 'demo1234' },
},
},
LoginResponse: {
type: 'object',
properties: {
token: { type: 'string' },
user: { $ref: '#/components/schemas/User' },
},
},
Person: {
type: 'object',
description:
'Resitel ticketu = clen firmy. Neni to vlastni zaznam: `id` je ID uctu, `tenantId` ' +
'firma clenstvi. Jmeno a e-mail jsou z uctu, role, kapacita a externi ID z clenstvi.',
properties: {
id: { type: 'string', example: 'usr_2', description: 'ID uctu.' },
tenantId: { type: 'string', example: 'tnt_automia' },
name: { type: 'string', example: 'Karel Vomacka' },
email: { type: 'string', format: 'email' },
role: { type: 'string', example: 'Servicedesk', description: 'Popisek, nic nerozhoduje.' },
capacity: {
type: 'integer',
description: 'Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez.',
},
enabled: {
type: 'boolean',
description:
'Zapnute clenstvi v teto firme. Vypnuty se nenabizi k prirazeni, ucet jinde bezi dal.',
},
externalIds: { type: 'array', items: { type: 'string' } },
roleIds: {
type: 'array',
items: { type: 'string' },
description: 'Role clenstvi v teto firme.',
},
},
},
TicketCustomer: {
type: 'object',
properties: {
id: {
type: 'string',
nullable: true,
description: 'ID firmy v CRM. null = zakaznika se nepodarilo dohledat.',
example: 'crm_1042',
},
company: { type: 'string', example: 'Firma s.r.o.' },
contact: { type: 'string', example: 'Petra Klientova' },
reply: {
type: 'string',
description: 'Adresa nebo cislo, odkud pozadavek prisel a kam se odpovida.',
},
},
},
Ticket: {
type: 'object',
properties: {
id: { type: 'string', example: 'TK-4821' },
subject: { type: 'string' },
body: {
type: 'string',
description:
'Cely text pozadavku. Prazdny retezec = krok "Zalozit ticket" obsah nenaplnil.',
},
sourceRef: {
type: 'string',
nullable: true,
description: 'Odkaz na zdrojovou zpravu u poskytovatele.',
example: 'wamid.HBgLNDIwNzc0OTAyMzMx',
},
channel: {
type: 'string',
enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'],
description: 'Odkud pozadavek prisel.',
},
customer: { $ref: '#/components/schemas/TicketCustomer' },
status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] },
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
assignee: {
type: 'object',
nullable: true,
description: 'Kdo ma ticket u sebe. null = ceka ve fronte.',
properties: {
id: { type: 'string', example: 'usr_2', description: 'ID uctu resitele.' },
name: { type: 'string', example: 'Karel Vomacka' },
},
},
automationId: {
type: 'string',
nullable: true,
description: 'Automatizace, ktera ticket zalozila. null = zalozeno rucne.',
},
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
TicketTraceEntry: {
type: 'object',
description:
'Jeden radek logu ticketu. Strom se sklada pres parentId - vetev podminky ' +
'visi na zaznamu te podminky.',
properties: {
id: { type: 'string', example: 'tr_12' },
parentId: {
type: 'string',
nullable: true,
description: 'null = zaznam v hlavni sekvenci.',
},
kind: { type: 'string', enum: ['trigger', 'action', 'condition', 'note'] },
connectorId: { type: 'string', nullable: true, example: 'raynet' },
operationId: { type: 'string', nullable: true, example: 'upsert-contact' },
label: { type: 'string' },
status: { type: 'string', enum: ['ok', 'error', 'skipped', 'info'] },
response: {
type: 'string',
nullable: true,
description: 'Co sluzba vratila. Kvuli tomuhle log existuje.',
},
durationMs: { type: 'integer', nullable: true },
at: { type: 'string', format: 'date-time' },
},
},
TicketDetail: {
allOf: [
{ $ref: '#/components/schemas/Ticket' },
{
type: 'object',
properties: {
trace: {
type: 'array',
items: { $ref: '#/components/schemas/TicketTraceEntry' },
},
},
},
],
},
Workload: {
type: 'object',
description: 'Prehled nad firmou - kdo ma kolik ticketu u sebe.',
properties: {
rows: {
type: 'array',
items: {
type: 'object',
properties: {
person: { $ref: '#/components/schemas/Person' },
open: { type: 'integer', description: 'Nevyresene tickety.' },
total: { type: 'integer' },
critical: { type: 'integer' },
oldestOpenAt: { type: 'string', format: 'date-time', nullable: true },
overloaded: { type: 'boolean' },
},
},
},
unassigned: { type: 'integer', description: 'Nevyresene tickety bez resitele.' },
openTotal: { type: 'integer' },
},
},
Incident: {
type: 'object',
properties: {
id: { type: 'string', example: 'INC-231' },
title: { type: 'string' },
service: { type: 'string' },
severity: { type: 'string', enum: ['sev1', 'sev2', 'sev3'] },
status: {
type: 'string',
enum: ['investigating', 'identified', 'monitoring', 'resolved'],
},
startedAt: { type: 'string', format: 'date-time' },
resolvedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
TriggerField: {
type: 'object',
required: ['id', 'name', 'type', 'required'],
properties: {
id: { type: 'string', example: 'f_42' },
name: {
type: 'string',
example: 'score',
description: 'Klic v prichozich datech, pismena, cislice a podtrzitko.',
},
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
required: { type: 'boolean' },
},
},
FlowStep: {
type: 'object',
description: 'Krok stromu. Bud akce nad konektorem, nebo podminka se dvema vetvemi.',
properties: {
id: { type: 'string' },
kind: { type: 'string', enum: ['action', 'condition'] },
connectorId: { type: 'string', example: 'email' },
operationId: { type: 'string', example: 'send' },
inputs: {
type: 'object',
additionalProperties: { type: 'string' },
description:
'Nastaveni akce. Klic je ID pole z katalogu, hodnota je sablona - ' +
'{{nazev}} se nahradi parametrem spoustece. Neznamy klic vraci 400.',
example: { subject: 'Reklamace od {{profileName}}', body: '{{text}}' },
},
fieldId: { type: 'string', example: 'f_42' },
operator: {
type: 'string',
enum: [
'eq',
'neq',
'gt',
'gte',
'lt',
'lte',
'contains',
'startsWith',
'isEmpty',
'isNotEmpty',
'isTrue',
'isFalse',
],
},
value: { type: 'string', example: '15' },
yes: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
no: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
},
},
AutomationFlow: {
type: 'object',
properties: {
trigger: {
type: 'object',
nullable: true,
properties: {
connectorId: { type: 'string', example: 'webhook' },
operationId: { type: 'string', example: 'received' },
fields: {
type: 'array',
items: { $ref: '#/components/schemas/TriggerField' },
},
webhookToken: {
type: 'string',
readOnly: true,
description: 'Generuje vyhradne server, hodnota od klienta se ignoruje.',
},
},
},
steps: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
},
},
Automation: {
type: 'object',
properties: {
id: { type: 'string', example: 'AUT-01' },
name: { type: 'string' },
kind: { type: 'string', enum: ['workflow', 'voicebot', 'integrace', 'report'] },
enabled: { type: 'boolean' },
runsToday: { type: 'integer' },
runsYesterday: { type: 'integer' },
runsTotal: { type: 'integer' },
successRate: { type: 'number' },
avgDurationMs: { type: 'integer' },
lastRunAt: { type: 'string', format: 'date-time' },
stepCount: { type: 'integer' },
configured: { type: 'boolean' },
issues: {
type: 'array',
items: { type: 'string' },
description: 'Co chybi k zapnuti. Prazdne pole znamena hotovo.',
},
},
},
AutomationDetail: {
allOf: [
{ $ref: '#/components/schemas/Automation' },
{
type: 'object',
properties: {
flow: { $ref: '#/components/schemas/AutomationFlow' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
],
},
},
};
+37
View File
@@ -0,0 +1,37 @@
/** Prilohy ticketu: zaznam bez obsahu a tvar nahravaneho souboru. */
export const attachmentSchemas = {
Attachment: {
type: 'object',
description: 'Priloha ticketu bez obsahu. Obsah se stahuje zvlast pres .../content.',
properties: {
id: { type: 'string', example: 'att_1a2b3c4d' },
tenantId: { type: 'string', example: 'tnt_automia' },
ticketId: { type: 'string', example: 'TK-4822' },
name: { type: 'string', example: 'zadani.pdf', description: 'Bez cesty, ocisteny.' },
mime: { type: 'string', example: 'application/pdf' },
size: { type: 'integer', description: 'Bajty po dekodovani.' },
uploadedBy: {
type: 'string',
nullable: true,
description: 'ID uctu. null = prislo z verejneho formulare.',
},
createdAt: { type: 'string', format: 'date-time' },
},
},
AttachmentUpload: {
type: 'object',
required: ['name', 'content'],
properties: {
name: { type: 'string', maxLength: 200 },
mime: {
type: 'string',
description: 'Nepovinny. Chybejici nebo neplatny = application/octet-stream.',
},
content: {
type: 'string',
description: 'Obsah v base64 bez prefixu data:. Po dekodovani nejvys 5 MB.',
},
},
},
};
+96
View File
@@ -0,0 +1,96 @@
/** Ucty a prihlaseni. Dve skupiny, protoze v seznamu schemat lezi Login* az za skripty. */
export const userSchemas = {
User: {
type: 'object',
description:
'Uzivatel muze patrit do vic firem. Role je vzdy az uvnitr firmy, ' +
'pristup napric firmami je zvlast jako platformAdmin.',
properties: {
id: { type: 'string', example: 'usr_1' },
email: { type: 'string', example: 'admin@automia.cz' },
name: { type: 'string', example: 'Jiri Uhlir' },
platformAdmin: {
type: 'boolean',
description: 'Vidi napric vsemi firmami a muze mezi nimi prepinat.',
},
memberships: {
type: 'array',
items: {
type: 'object',
properties: {
tenantId: { type: 'string', example: 'tnt_automia' },
role: { type: 'string', enum: ['admin', 'agent'] },
},
},
},
},
},
Access: {
type: 'object',
description: 'Co uzivatel smi. Klient podle toho kresli prepinac pohledu.',
properties: {
scopes: {
type: 'array',
items: { type: 'string', enum: ['all', 'tenant', 'mine'] },
},
tenants: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'tnt_automia' },
name: { type: 'string', example: 'Automia' },
},
},
},
defaultTenantId: { type: 'string', nullable: true },
canAssignOthers: {
type: 'boolean',
description: 'Smi prehazovat tickety mezi lidmi, ne jen brat na sebe.',
},
personId: { type: 'string', nullable: true },
permissions: {
type: 'array',
items: { type: 'string' },
description: 'Efektivni prava ve vybrane firme. Klient podle nich kresli tlacitka.',
},
roleNames: {
type: 'array',
items: { type: 'string' },
example: ['Spravce firmy'],
description:
'Nazvy roli uzivatele ve vybrane firme. Tohle se ukazuje jako popis uctu, ' +
'ne odhad z poctu prav.',
},
nav: { type: 'array', items: { type: 'object' }, description: 'Zalozky, ktere ma videt.' },
platformAdmin: { type: 'boolean' },
seesOthers: { type: 'boolean', description: 'Vidi i cizi tickety, ne jen svoje.' },
visibleGroups: {
type: 'array',
items: {
type: 'object',
properties: { id: { type: 'string' }, name: { type: 'string' } },
},
},
},
},
};
export const loginSchemas = {
LoginRequest: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email', example: 'admin@automia.cz' },
password: { type: 'string', format: 'password', example: 'demo1234' },
},
},
LoginResponse: {
type: 'object',
properties: {
token: { type: 'string' },
user: { $ref: '#/components/schemas/User' },
},
},
};
+115
View File
@@ -0,0 +1,115 @@
/** Automatizace: spoustec, strom kroku a souhrn behu. */
export const automationSchemas = {
TriggerField: {
type: 'object',
required: ['id', 'name', 'type', 'required'],
properties: {
id: { type: 'string', example: 'f_42' },
name: {
type: 'string',
example: 'score',
description: 'Klic v prichozich datech, pismena, cislice a podtrzitko.',
},
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
required: { type: 'boolean' },
},
},
FlowStep: {
type: 'object',
description: 'Krok stromu. Bud akce nad konektorem, nebo podminka se dvema vetvemi.',
properties: {
id: { type: 'string' },
kind: { type: 'string', enum: ['action', 'condition'] },
connectorId: { type: 'string', example: 'email' },
operationId: { type: 'string', example: 'send' },
inputs: {
type: 'object',
additionalProperties: { type: 'string' },
description:
'Nastaveni akce. Klic je ID pole z katalogu, hodnota je sablona - ' +
'{{nazev}} se nahradi parametrem spoustece. Neznamy klic vraci 400.',
example: { subject: 'Reklamace od {{profileName}}', body: '{{text}}' },
},
fieldId: { type: 'string', example: 'f_42' },
operator: {
type: 'string',
enum: [
'eq',
'neq',
'gt',
'gte',
'lt',
'lte',
'contains',
'startsWith',
'isEmpty',
'isNotEmpty',
'isTrue',
'isFalse',
],
},
value: { type: 'string', example: '15' },
yes: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
no: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
},
},
AutomationFlow: {
type: 'object',
properties: {
trigger: {
type: 'object',
nullable: true,
properties: {
connectorId: { type: 'string', example: 'webhook' },
operationId: { type: 'string', example: 'received' },
fields: {
type: 'array',
items: { $ref: '#/components/schemas/TriggerField' },
},
webhookToken: {
type: 'string',
readOnly: true,
description: 'Generuje vyhradne server, hodnota od klienta se ignoruje.',
},
},
},
steps: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } },
},
},
Automation: {
type: 'object',
properties: {
id: { type: 'string', example: 'AUT-01' },
name: { type: 'string' },
kind: { type: 'string', enum: ['workflow', 'voicebot', 'integrace', 'report'] },
enabled: { type: 'boolean' },
runsToday: { type: 'integer' },
runsYesterday: { type: 'integer' },
runsTotal: { type: 'integer' },
successRate: { type: 'number' },
avgDurationMs: { type: 'integer' },
lastRunAt: { type: 'string', format: 'date-time' },
stepCount: { type: 'integer' },
configured: { type: 'boolean' },
issues: {
type: 'array',
items: { type: 'string' },
description: 'Co chybi k zapnuti. Prazdne pole znamena hotovo.',
},
},
},
AutomationDetail: {
allOf: [
{ $ref: '#/components/schemas/Automation' },
{
type: 'object',
properties: {
flow: { $ref: '#/components/schemas/AutomationFlow' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
],
},
};
+24
View File
@@ -0,0 +1,24 @@
/** Spolecne tvary: chybova odpoved. */
export const commonSchemas = {
Error: {
type: 'object',
properties: {
error: { type: 'string', example: 'validation_error' },
message: { type: 'string', example: 'Zadejte platny e-mail.' },
issues: {
type: 'array',
description:
'Jen u validation_error: vsechny problemy vstupu. `field` je cesta ' +
'k poli spojena teckou, prazdna u chyby celeho tela.',
items: {
type: 'object',
properties: {
field: { type: 'string', example: 'memberships.0.roleIds' },
message: { type: 'string' },
},
},
},
},
},
};
+47
View File
@@ -0,0 +1,47 @@
/** Konektory: napojeni firmy na sluzbu bez hodnot pristupovych udaju. */
export const connectorSchemas = {
Connector: {
type: 'object',
description:
'Napojeni firmy na jednu sluzbu. Hodnoty pristupovych udaju tady zamerne ' +
'nejsou a nikdy nebudou - secrets se z beznych endpointu nevraci.',
properties: {
id: { type: 'string', example: 'con_1a2b3c4d' },
tenantId: { type: 'string', example: 'tnt_automia' },
serviceId: { type: 'string', example: 'idoklad' },
name: { type: 'string', example: 'iDoklad Automia' },
baseUrl: { type: 'string', nullable: true },
enabled: { type: 'boolean' },
status: { type: 'string', enum: ['untested', 'ok', 'error'] },
lastCheckAt: { type: 'string', format: 'date-time', nullable: true },
lastError: { type: 'string', nullable: true },
checkCount: {
type: 'integer',
description:
'Kolik zaznamu o overeni je v historii. Samotna historie se cte pres ' +
'/api/dashboard/connectors/{id}/checks - v seznamu by to byla tela odpovedi navic.',
},
isDefault: {
type: 'boolean',
description: 'Krok stromu bez vybraneho konektoru pouzije tenhle.',
},
filled: {
type: 'array',
items: { type: 'string' },
description: 'ID poli, ktera jsou vyplnena. Hodnoty se nevraci.',
},
missing: {
type: 'array',
items: { type: 'string' },
description: 'ID povinnych poli, ktera chybi.',
},
config: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Necitliva nastaveni. Tajna pole tu nejsou vubec.',
},
ready: { type: 'boolean' },
},
},
};
+35
View File
@@ -0,0 +1,35 @@
/**
* Schemata a zabezpeceni. Jen popis tvaru dat, zadna logika.
* Poradi spreadu je poradi schemat ve Swagger UI, drzi se puvodni seznam.
*/
import { tenantSchemas } from './settings.js';
import { attachmentSchemas } from './attachments.js';
import { commonSchemas } from './common.js';
import { userSchemas, loginSchemas } from './auth.js';
import { connectorSchemas } from './connectors.js';
import { scriptSchemas } from './scripts.js';
import { ticketSchemas } from './tickets.js';
import { automationSchemas } from './automations.js';
export const components = {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Token z POST /api/auth/login. Vlozte samotny token bez slova Bearer.',
},
},
schemas: {
...tenantSchemas,
...attachmentSchemas,
...commonSchemas,
...userSchemas,
...connectorSchemas,
...scriptSchemas,
...loginSchemas,
...ticketSchemas,
...automationSchemas,
},
};
+143
View File
@@ -0,0 +1,143 @@
/** Skripty: manifest, parametry, stav napojeni a vysledek behu. */
export const scriptSchemas = {
ScriptField: {
type: 'object',
description:
'Parametr skriptu. Stejny tvar pro vstup i vystup - kontrola je pak ' +
'jedna funkce, ne dve skoro stejne.',
required: ['id', 'label', 'type', 'required'],
properties: {
id: {
type: 'string',
example: 'invoiceId',
description: 'Pouziva se v sablonach jako {{invoiceId}}.',
},
label: { type: 'string', example: 'ID faktury v iDokladu' },
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
required: { type: 'boolean' },
hint: { type: 'string' },
options: {
type: 'array',
description: 'Vyber z hodnot. Jina hodnota neprojde kontrolou.',
items: {
type: 'object',
properties: { value: { type: 'string' }, label: { type: 'string' } },
},
},
pattern: { type: 'string', description: 'Jen u typu string.' },
multiline: { type: 'boolean', description: 'Jen u typu string.' },
default: { description: 'Dosadi se, kdyz hodnota chybi a parametr neni povinny.' },
},
},
ScriptManifest: {
type: 'object',
description: 'Co skript umi. Podle nej s nim umi pracovat strom automatizace.',
properties: {
id: {
type: 'string',
example: 'idoklad.get-issued-invoice',
description: 'Tvar sluzba.operace. Nazev souboru musi byt <id>.js.',
},
serviceId: { type: 'string', example: 'idoklad' },
serviceName: { type: 'string', example: 'iDoklad' },
operationId: { type: 'string', example: 'get-issued-invoice' },
name: { type: 'string', example: 'Získat vydanou fakturu' },
description: { type: 'string' },
inputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
outputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
timeoutMs: { type: 'integer', example: 15000 },
},
},
ScriptProblem: {
type: 'object',
description: 'Rozbity skript. Nesmi shodit ostatni ani tise zmizet, proto se vraci sem.',
properties: {
file: { type: 'string', example: 'idoklad.get-issued-invoice.js' },
scriptId: { type: 'string', nullable: true },
message: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
ConnectionStatus: {
type: 'object',
description:
'Stav napojeni konektoru. Hodnoty pristupovych udaju se nevraci nikdy, ' +
'jen jmena promennych, ktere chybi.',
properties: {
connectorId: { type: 'string', example: 'idoklad' },
baseUrl: { type: 'string', example: 'https://services.csbot.cz/apps/idoklad' },
ready: { type: 'boolean' },
missing: {
type: 'array',
items: { type: 'string' },
example: ['IDOKLAD_CLIENT_SECRET'],
},
headers: { type: 'array', items: { type: 'string' }, example: ['X-ClientId'] },
},
},
ScriptRunResult: {
type: 'object',
description:
'Vysledek behu skriptu. `retryable` rika, jestli ma smysl zkusit to znovu - ' +
'timeout ano, spatny vstup ne.',
properties: {
ok: { type: 'boolean' },
scriptId: { type: 'string' },
outputs: {
type: 'object',
additionalProperties: true,
description: 'Prazdne, kdyz beh selhal.',
},
logs: {
type: 'array',
items: {
type: 'object',
properties: {
at: { type: 'string', format: 'date-time' },
message: { type: 'string' },
detail: { type: 'string' },
},
},
},
durationMs: { type: 'integer' },
httpCalls: { type: 'integer' },
error: {
type: 'object',
nullable: true,
properties: {
kind: {
type: 'string',
enum: [
'not_found',
'config',
'validation',
'output',
'retryable',
'terminal',
'timeout',
'internal',
],
},
message: { type: 'string' },
retryable: { type: 'boolean' },
status: { type: 'integer' },
detail: { type: 'string' },
issues: {
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, message: { type: 'string' } },
},
},
},
},
},
},
};
+61
View File
@@ -0,0 +1,61 @@
/** Firmy: zaznam v ARES, firma v portalu a verejne udaje provozovatele. */
export const tenantSchemas = {
AresCompany: {
type: 'object',
properties: {
ico: { type: 'string', example: '27074358' },
name: { type: 'string', example: 'Asseco Central Europe, a.s.' },
dic: { type: 'string', nullable: true, example: 'CZ27074358' },
address: { type: 'string', example: 'Budejovicka 778/3a, Michle, 14000 Praha 4' },
legalFormCode: { type: 'string', example: '121' },
legalForm: { type: 'string', example: 'Akciova spolecnost' },
existingTenantId: {
type: 'string',
nullable: true,
description: 'ID firmy v portalu, kdyz uz je zalozena.',
},
},
},
Tenant: {
type: 'object',
properties: {
id: { type: 'string', example: 'tnt_automia' },
name: { type: 'string' },
note: { type: 'string' },
enabled: { type: 'boolean' },
helpdeskProviderId: { type: 'string', nullable: true },
ico: { type: 'string', nullable: true },
dic: { type: 'string', nullable: true },
address: { type: 'string', nullable: true },
legalForm: { type: 'string', nullable: true },
portalOperator: {
type: 'boolean',
description:
'Provozovatel portalu. Prave jedna firma: nastaveni na true sunda priznak ' +
'vsem ostatnim (kazda zmenena firma posle tenant.updated). Chodi ji poptavky ' +
'z webu a web z ni bere kontaktni udaje. Chybejici = false.',
},
contactEmail: { type: 'string', nullable: true, description: 'Kontakt pro verejny web.' },
contactPhone: { type: 'string', nullable: true, maxLength: 30 },
website: { type: 'string', nullable: true, description: 'Adresa webu vcetne https://.' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
PublicBrand: {
type: 'object',
description: 'Udaje provozovatele portalu pro verejny web. Bez provozovatele je vsechno null.',
properties: {
name: { type: 'string', nullable: true, example: 'Automia' },
legalName: { type: 'string', nullable: true, description: 'Dnes totez co name.' },
ico: { type: 'string', nullable: true },
dic: { type: 'string', nullable: true },
address: { type: 'string', nullable: true },
legalForm: { type: 'string', nullable: true },
email: { type: 'string', nullable: true },
phone: { type: 'string', nullable: true },
website: { type: 'string', nullable: true },
},
},
};
+168
View File
@@ -0,0 +1,168 @@
/** Tickety: resitel, zakaznik, ticket, log prubehu, vytizeni a incident. */
export const ticketSchemas = {
Person: {
type: 'object',
description:
'Resitel ticketu = clen firmy. Neni to vlastni zaznam: `id` je ID uctu, `tenantId` ' +
'firma clenstvi. Jmeno a e-mail jsou z uctu, role, kapacita a externi ID z clenstvi.',
properties: {
id: { type: 'string', example: 'usr_2', description: 'ID uctu.' },
tenantId: { type: 'string', example: 'tnt_automia' },
name: { type: 'string', example: 'Karel Vomacka' },
email: { type: 'string', format: 'email' },
role: { type: 'string', example: 'Servicedesk', description: 'Popisek, nic nerozhoduje.' },
capacity: {
type: 'integer',
description: 'Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez.',
},
enabled: {
type: 'boolean',
description:
'Zapnute clenstvi v teto firme. Vypnuty se nenabizi k prirazeni, ucet jinde bezi dal.',
},
externalIds: { type: 'array', items: { type: 'string' } },
roleIds: {
type: 'array',
items: { type: 'string' },
description: 'Role clenstvi v teto firme.',
},
},
},
TicketCustomer: {
type: 'object',
properties: {
id: {
type: 'string',
nullable: true,
description: 'ID firmy v CRM. null = zakaznika se nepodarilo dohledat.',
example: 'crm_1042',
},
company: { type: 'string', example: 'Firma s.r.o.' },
contact: { type: 'string', example: 'Petra Klientova' },
reply: {
type: 'string',
description: 'Adresa nebo cislo, odkud pozadavek prisel a kam se odpovida.',
},
},
},
Ticket: {
type: 'object',
properties: {
id: { type: 'string', example: 'TK-4821' },
subject: { type: 'string' },
body: {
type: 'string',
description:
'Cely text pozadavku. Prazdny retezec = krok "Zalozit ticket" obsah nenaplnil.',
},
sourceRef: {
type: 'string',
nullable: true,
description: 'Odkaz na zdrojovou zpravu u poskytovatele.',
example: 'wamid.HBgLNDIwNzc0OTAyMzMx',
},
channel: {
type: 'string',
enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'],
description: 'Odkud pozadavek prisel.',
},
customer: { $ref: '#/components/schemas/TicketCustomer' },
status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] },
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
assignee: {
type: 'object',
nullable: true,
description: 'Kdo ma ticket u sebe. null = ceka ve fronte.',
properties: {
id: { type: 'string', example: 'usr_2', description: 'ID uctu resitele.' },
name: { type: 'string', example: 'Karel Vomacka' },
},
},
automationId: {
type: 'string',
nullable: true,
description: 'Automatizace, ktera ticket zalozila. null = zalozeno rucne.',
},
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
TicketTraceEntry: {
type: 'object',
description:
'Jeden radek logu ticketu. Strom se sklada pres parentId - vetev podminky ' +
'visi na zaznamu te podminky.',
properties: {
id: { type: 'string', example: 'tr_12' },
parentId: {
type: 'string',
nullable: true,
description: 'null = zaznam v hlavni sekvenci.',
},
kind: { type: 'string', enum: ['trigger', 'action', 'condition', 'note'] },
connectorId: { type: 'string', nullable: true, example: 'raynet' },
operationId: { type: 'string', nullable: true, example: 'upsert-contact' },
label: { type: 'string' },
status: { type: 'string', enum: ['ok', 'error', 'skipped', 'info'] },
response: {
type: 'string',
nullable: true,
description: 'Co sluzba vratila. Kvuli tomuhle log existuje.',
},
durationMs: { type: 'integer', nullable: true },
at: { type: 'string', format: 'date-time' },
},
},
TicketDetail: {
allOf: [
{ $ref: '#/components/schemas/Ticket' },
{
type: 'object',
properties: {
trace: {
type: 'array',
items: { $ref: '#/components/schemas/TicketTraceEntry' },
},
},
},
],
},
Workload: {
type: 'object',
description: 'Prehled nad firmou - kdo ma kolik ticketu u sebe.',
properties: {
rows: {
type: 'array',
items: {
type: 'object',
properties: {
person: { $ref: '#/components/schemas/Person' },
open: { type: 'integer', description: 'Nevyresene tickety.' },
total: { type: 'integer' },
critical: { type: 'integer' },
oldestOpenAt: { type: 'string', format: 'date-time', nullable: true },
overloaded: { type: 'boolean' },
},
},
},
unassigned: { type: 'integer', description: 'Nevyresene tickety bez resitele.' },
openTotal: { type: 'integer' },
},
},
Incident: {
type: 'object',
properties: {
id: { type: 'string', example: 'INC-231' },
title: { type: 'string' },
service: { type: 'string' },
severity: { type: 'string', enum: ['sev1', 'sev2', 'sev3'] },
status: {
type: 'string',
enum: ['investigating', 'identified', 'monitoring', 'resolved'],
},
startedAt: { type: 'string', format: 'date-time' },
resolvedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
};
+8 -1
View File
@@ -1,9 +1,11 @@
import { config } from '../config.js';
import { components } from './components.js';
import { components } from './components/index.js';
import { opsPaths } from './paths/ops.js';
import { authPaths } from './paths/auth.js';
import { dashboardPaths } from './paths/dashboard.js';
import { ticketsPaths } from './paths/tickets.js';
import { attachmentsPaths } from './paths/attachments.js';
import { ticketActionsPaths } from './paths/ticketActions.js';
import { automationsPaths } from './paths/automations.js';
import { settingsPaths } from './paths/settings.js';
import { adminPaths } from './paths/admin.js';
@@ -13,6 +15,7 @@ import { webhookPaths } from './paths/webhook.js';
import { helpdeskPaths } from './paths/helpdesk.js';
import { invitesPaths } from './paths/invites.js';
import { contactPaths } from './paths/contact.js';
import { publicPaths } from './paths/public.js';
/**
* OpenAPI popis API.
@@ -50,6 +53,7 @@ export function buildOpenApiDocument() {
{ name: 'Pozvanky', description: 'Pozvanky do firmy a jejich prijeti' },
{ name: 'Portal', description: 'Pomocne endpointy klienta' },
{ name: 'Kontakt', description: 'Poptavkovy formular z webu' },
{ name: 'Verejne', description: 'Udaje provozovatele pro web, bez prihlaseni' },
],
components,
paths: {
@@ -58,6 +62,8 @@ export function buildOpenApiDocument() {
...authPaths,
...dashboardPaths,
...ticketsPaths,
...attachmentsPaths,
...ticketActionsPaths,
...automationsPaths,
...settingsPaths,
...adminPaths,
@@ -67,6 +73,7 @@ export function buildOpenApiDocument() {
...helpdeskPaths,
...invitesPaths,
...contactPaths,
...publicPaths,
},
};
}
+98
View File
@@ -0,0 +1,98 @@
/** Prilohy ticketu: seznam, nahrani, stazeni a odebrani. */
import { bearer, idParam, jsonBody, jsonResponse } from '../helpers.js';
export const attachmentsPaths: Record<string, unknown> = {
'/api/dashboard/tickets/{id}/attachments': {
get: {
tags: ['Tickety'],
summary: 'Prilohy ticketu',
description:
'Seznam bez obsahu. Ticket se hleda stejne jako detail: cizi nebo nad strop ' +
'viditelnosti je 404.',
security: bearer,
parameters: [idParam],
responses: {
'200': jsonResponse('Prilohy', {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/Attachment' } },
},
}),
'404': { description: 'Ticket neexistuje nebo na nej volajici nevidi' },
},
},
post: {
tags: ['Tickety'],
summary: 'Pridat prilohy',
description:
'Soubory v base64. Kazdy nejvys 5 MB po dekodovani, ticket nejvys 10 priloh; ' +
'davka se uklada cela nebo vubec. Kazdy soubor zapise radek do logu ticketu ' +
'a posle ticket.updated. Chce pravo ticket.comment za firmu ticketu.',
security: bearer,
parameters: [idParam],
requestBody: jsonBody({
type: 'object',
required: ['files'],
properties: {
files: {
type: 'array',
minItems: 1,
maxItems: 10,
items: { $ref: '#/components/schemas/AttachmentUpload' },
},
},
}),
responses: {
'201': jsonResponse('Ulozene prilohy (jen nove pridane)', {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/Attachment' } },
},
}),
'400': { description: 'Neplatny base64, prilis velky soubor nebo prekrocen pocet' },
'403': { description: 'Chybi pravo ticket.comment' },
'404': { description: 'Ticket neexistuje nebo na nej volajici nevidi' },
'413': { description: 'Telo presahlo strop pro davku souboru' },
},
},
},
'/api/dashboard/tickets/{id}/attachments/{attachmentId}/content': {
get: {
tags: ['Tickety'],
summary: 'Stahnout prilohu',
description:
'Binarni telo s Content-Type podle prilohy, Content-Length a ' +
"Content-Disposition: attachment; filename*=UTF-8''<nazev>.",
security: bearer,
parameters: [
idParam,
{ name: 'attachmentId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
'200': {
description: 'Obsah souboru',
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
},
'404': { description: 'Ticket nebo priloha neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/attachments/{attachmentId}': {
delete: {
tags: ['Tickety'],
summary: 'Odebrat prilohu',
description: 'Zapise radek do logu ticketu a posle ticket.updated. Chce ticket.comment.',
security: bearer,
parameters: [
idParam,
{ name: 'attachmentId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
'204': { description: 'Odebrano' },
'403': { description: 'Chybi pravo ticket.comment' },
'404': { description: 'Ticket nebo priloha neexistuje' },
},
},
},
};
+11 -1
View File
@@ -7,6 +7,10 @@ export const contactPaths: Record<string, unknown> = {
post: {
tags: ['Kontakt'],
summary: 'Odeslat poptavku z webu',
description:
'Poptavka se zaklada jako ticket provozovateli portalu (kanal form, tag Poptavka, ' +
'telo je JSON s poli formulare). Bez provozovatele se jen zaloguje. Odpoved je ' +
'v obou pripadech 202. Nejvys 3 prilohy po 5 MB, telo ma vlastni strop.',
requestBody: {
required: true,
content: {
@@ -24,6 +28,11 @@ export const contactPaths: Record<string, unknown> = {
enum: ['automatizace', 'voicebot', 'integrace', 'dashboard', 'podpora', 'jine'],
},
message: { type: 'string', minLength: 10 },
attachments: {
type: 'array',
maxItems: 3,
items: { $ref: '#/components/schemas/AttachmentUpload' },
},
},
},
},
@@ -31,7 +40,8 @@ export const contactPaths: Record<string, unknown> = {
},
responses: {
'202': { description: 'Prijato' },
'400': { description: 'Neplatny vstup' },
'400': { description: 'Neplatny vstup nebo neplatna priloha' },
'413': { description: 'Telo presahlo strop pro prilohy' },
...tooMany,
},
},
+19
View File
@@ -0,0 +1,19 @@
/** Verejne udaje bez prihlaseni. */
import { jsonResponse } from '../helpers.js';
export const publicPaths: Record<string, unknown> = {
'/api/public/brand': {
get: {
tags: ['Verejne'],
summary: 'Udaje provozovatele portalu',
description:
'Kontaktni a fakturacni udaje firmy s priznakem portalOperator. Web z nich ' +
'sklada paticku a kontakty. Bez provozovatele jsou vsechna pole null, odpoved ' +
'je i tak 200. Cache-Control: public, max-age=60.',
responses: {
'200': jsonResponse('Udaje provozovatele', { $ref: '#/components/schemas/PublicBrand' }),
},
},
},
};
+99
View File
@@ -0,0 +1,99 @@
/** Akce nad ticketem: co jde v dane situaci spustit a spusteni akce. */
export const ticketActionsPaths: Record<string, unknown> = {
'/api/dashboard/tickets/{id}/actions': {
get: {
tags: ['Tickety'],
summary: 'Akce dostupne k ticketu',
description:
'Vraci **jen akce, ktere v teto situaci opravdu jdou spustit**: sedi typ nebo ' +
'tag, projdou podminky a volajici na ne ma pravo. Klient nefiltruje nic.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Akce',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
label: { type: 'string', example: 'Odeslat do iDokladu' },
icon: { type: 'string' },
style: { type: 'string', enum: ['primary', 'default', 'danger'] },
confirm: { type: 'string', nullable: true },
form: { type: 'array', items: { type: 'object' } },
},
},
},
},
},
},
},
},
'404': { description: 'Ticket neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/actions/{actionId}': {
post: {
tags: ['Tickety'],
summary: 'Spustit akci',
description:
'Vraci 200 **i kdyz akce selhala** - selhani akce neni chyba API. Cely prubeh ' +
'vcetne toho, co sluzba vratila, se zapise do logu ticketu.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'actionId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: false,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
form: {
type: 'object',
description: 'Hodnoty poli, ktera si akce vyzada.',
additionalProperties: { type: 'string' },
},
},
},
},
},
},
responses: {
'200': {
description: 'Akce probehla nebo selhala, viz ok',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean' },
summary: { type: 'string' },
detail: {
type: 'string',
nullable: true,
description: 'Cele chybove hlaseni. Nikdy se nezkracuje.',
},
durationMs: { type: 'integer' },
},
},
},
},
},
'403': { description: 'Chybi pravo na tuto akci' },
'404': { description: 'Ticket nebo akce neexistuje' },
},
},
},
};
+1 -96
View File
@@ -1,4 +1,4 @@
/** Tickety: seznam, detail, resitele a vestavene akce. */
/** Tickety: seznam, detail, resitele a zmeny stavu. Prilohy a akce maji vlastni soubor. */
import {
bearer,
@@ -466,99 +466,4 @@ export const ticketsPaths: Record<string, unknown> = {
},
},
},
'/api/dashboard/tickets/{id}/actions': {
get: {
tags: ['Tickety'],
summary: 'Akce dostupne k ticketu',
description:
'Vraci **jen akce, ktere v teto situaci opravdu jdou spustit**: sedi typ nebo ' +
'tag, projdou podminky a volajici na ne ma pravo. Klient nefiltruje nic.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Akce',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
label: { type: 'string', example: 'Odeslat do iDokladu' },
icon: { type: 'string' },
style: { type: 'string', enum: ['primary', 'default', 'danger'] },
confirm: { type: 'string', nullable: true },
form: { type: 'array', items: { type: 'object' } },
},
},
},
},
},
},
},
},
'404': { description: 'Ticket neexistuje' },
},
},
},
'/api/dashboard/tickets/{id}/actions/{actionId}': {
post: {
tags: ['Tickety'],
summary: 'Spustit akci',
description:
'Vraci 200 **i kdyz akce selhala** - selhani akce neni chyba API. Cely prubeh ' +
'vcetne toho, co sluzba vratila, se zapise do logu ticketu.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'actionId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: false,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
form: {
type: 'object',
description: 'Hodnoty poli, ktera si akce vyzada.',
additionalProperties: { type: 'string' },
},
},
},
},
},
},
responses: {
'200': {
description: 'Akce probehla nebo selhala, viz ok',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean' },
summary: { type: 'string' },
detail: {
type: 'string',
nullable: true,
description: 'Cele chybove hlaseni. Nikdy se nezkracuje.',
},
durationMs: { type: 'integer' },
},
},
},
},
},
'403': { description: 'Chybi pravo na tuto akci' },
'404': { description: 'Ticket nebo akce neexistuje' },
},
},
},
};
+32
View File
@@ -0,0 +1,32 @@
/**
* Strop tela pro routy, ktere prijimaji soubory v base64.
*
* Globalni `express.json` ma 256 kB, coz na prilohy nestaci. Tyhle routy si
* nasazuji vlastni `express.json` a globalni je preskakuje (viz `app.ts`).
* Vypocet je na jednom miste, aby kontaktni formular a prilohy ticketu
* pocitaly stejne.
*/
/** Base64 zvetsi data na 4/3. */
const BASE64_OVERHEAD = 4 / 3;
/** Rezerva na zbytek JSONu: nazvy, typy, textova pole formulare. */
const JSON_SLACK_BYTES = 64 * 1024;
/** Strop tela v bajtech pro `count` souboru po `maxBytes` kazdy. */
export function jsonLimitFor(count: number, maxBytes: number): number {
return Math.ceil(count * maxBytes * BASE64_OVERHEAD) + JSON_SLACK_BYTES;
}
/**
* Cesty s vlastnim stropem tela. Globalni parser je preskoci, jinak by telo
* odmitl driv, nez se k nemu router dostane. Kontroluje se `req.path` bez
* prefixu proxy - aplikace je mountovana na koren i na prefix.
*/
const OWN_LIMIT_PATHS = [
/^\/api\/contact\/?$/,
/^\/api\/dashboard\/tickets\/[^/]+\/attachments\/?$/,
];
export function hasOwnBodyLimit(path: string): boolean {
return OWN_LIMIT_PATHS.some((pattern) => pattern.test(path));
}
+130 -18
View File
@@ -1,9 +1,28 @@
import { Router } from 'express';
/**
* Kontaktni formular z webu.
*
* Poptavka se zaklada jako ticket provozovateli portalu (`operatorTenant`).
* Kdyz zadna firma provozovatelem neni, poptavka se jen zaloguje - odpoved
* je v obou pripadech 202, zvenku nema byt poznat, jak je portal nastaveny.
*/
import express from 'express';
import { z } from 'zod';
import {
addAttachments,
ALLOWED_NAME_LENGTH,
checkUploads,
MAX_ATTACHMENT_BYTES,
} from '../data/attachments.js';
import { operatorTenant } from '../data/tenants.js';
import { createTicket } from '../data/ticketStore.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { rateLimit } from '../middleware/rateLimit.js';
import { validationError } from '../middleware/validation.js';
import { jsonLimitFor } from './bodyLimit.js';
export const contactRouter = Router();
// safeRouter: handler je async a odmitnuty promise ma skoncit jako 500, ne viset.
export const contactRouter = safeRouter();
/** Verejny formular bez prihlaseni. Pet poptavek za hodinu z jedne adresy staci. */
const CONTACT_WINDOW_MS = 60 * 60_000;
@@ -14,30 +33,123 @@ const contactLimiter = rateLimit({
max: CONTACT_MAX_PER_WINDOW,
});
/** Kolik souboru jde poslat s poptavkou. Tri staci na zadani a dve ukazky. */
export const CONTACT_MAX_ATTACHMENTS = 3;
/** Vlastni strop tela, globalni `express.json` tuhle cestu preskakuje (viz app.ts). */
const CONTACT_BODY_LIMIT = jsonLimitFor(CONTACT_MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES);
const topics = ['automatizace', 'voicebot', 'integrace', 'dashboard', 'podpora', 'jine'] as const;
type Topic = (typeof topics)[number];
/** Popisek tematu do predmetu ticketu. */
const topicLabels: Record<Topic, string> = {
automatizace: 'automatizace',
voicebot: 'voicebot',
integrace: 'integrace',
dashboard: 'dashboard',
podpora: 'podpora',
jine: 'jiné',
};
const contactSchema = z.object({
name: z.string().min(2, 'Zadejte jméno.'),
email: z.string().email('Zadejte platný e-mail.'),
company: z.string().optional().default(''),
phone: z.string().optional().default(''),
topic: z.enum(['automatizace', 'voicebot', 'integrace', 'dashboard', 'podpora', 'jine']),
topic: z.enum(topics),
message: z.string().min(10, 'Napište prosím alespoň pár slov (min. 10 znaků).'),
attachments: z
.array(
z.object({
name: z
.string()
.min(1)
.max(ALLOWED_NAME_LENGTH * 2),
mime: z.string().max(120).optional(),
content: z.string().min(1),
}),
)
.max(
CONTACT_MAX_ATTACHMENTS,
`K poptávce jdou přiložit nejvýš ${CONTACT_MAX_ATTACHMENTS} soubory.`,
)
.optional()
.default([]),
});
/**
* PROTOTYP: zpravu jen zalogujeme. Realne odeslani (SMTP / ticket system)
* pribude pozdeji - viz docs/04-backend-api.md.
/*
* Limit pokusu bezi pred parserem tela: kdo uz vycerpal pokusy, nema server
* nutit cist megabajty base64.
*/
contactRouter.post('/', contactLimiter, (req, res) => {
const parsed = contactSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error);
contactRouter.post(
'/',
contactLimiter,
express.json({ limit: CONTACT_BODY_LIMIT }),
async (req, res) => {
const parsed = contactSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error);
const data = parsed.data;
console.info(
`[contact] nova poptavka: ${data.name} <${data.email}> tema=${data.topic} firma=${data.company || '-'}`,
);
const data = parsed.data;
// Prilohy se kontroluji driv, nez vznikne ticket, at po chybe nezustane
// poptavka bez souboru, ktere k ni patrily.
const checked = checkUploads(data.attachments, 0);
if (!checked.ok) {
return res.status(400).json({ error: 'validation_error', message: checked.message });
}
return res.status(202).json({
ok: true,
message: 'Děkujeme, ozveme se do jednoho pracovního dne.',
});
});
console.info(
`[contact] nova poptavka: ${data.name} <${data.email}> tema=${data.topic} firma=${data.company || '-'}`,
);
const operator = operatorTenant();
if (!operator) {
console.warn('[contact] zadna firma neni provozovatel portalu, poptavka jen zalogovana');
} else {
const receivedAt = new Date().toISOString();
const ticket = createTicket({
tenantId: operator.id,
channel: 'form',
subject: `Poptávka: ${topicLabels[data.topic]}`,
body: JSON.stringify(
{
name: data.name,
email: data.email,
company: data.company,
phone: data.phone,
topic: data.topic,
message: data.message,
receivedAt,
},
null,
2,
),
tags: ['Poptávka', data.topic],
customer: { id: null, company: data.company, contact: data.name, reply: data.email },
priority: 'normal',
externalSource: 'web-form',
externalId: null,
createdById: null,
trace: [
{
kind: 'note',
label: 'Poptávka z webového formuláře',
status: 'info',
response: `Odesílatel ${data.name} <${data.email}>, téma ${topicLabels[data.topic]}.`,
},
],
});
if (data.attachments.length > 0) {
const stored = await addAttachments(ticket, data.attachments, null);
// Davka uz prosla kontrolou vyse, sem se dostane jen chyba uloziste.
if (!stored.ok)
console.error(`[contact] prilohy k ${ticket.id} se neulozily: ${stored.message}`);
}
}
return res.status(202).json({
ok: true,
message: 'Děkujeme, ozveme se do jednoho pracovního dne.',
});
},
);
+8
View File
@@ -78,6 +78,12 @@ export interface CrudOptions<T extends TenantEntity, C, U> {
event?: EntityEventKind;
/** true = zaznamy vidi jen spravce platformy. */
platformOnly?: boolean;
/**
* Co se ma stat po zapisu, kdyz zmena jednoho zaznamu sahne na jine
* (napr. jediny provozovatel portalu mezi firmami). Bezi az po ohlaseni
* zmeny, chyba z nej je 500 - zapis uz prosel a nema se tvarit, ze ne.
*/
afterWrite?: (verb: 'created' | 'updated', entity: T) => Promise<void> | void;
/**
* Zaznamy bez firmy (`tenantId: null`), ktere se presto spravuji po firmach.
*
@@ -233,6 +239,7 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
const created = await options.store.create(entity);
announce('created', created);
if (options.afterWrite) await options.afterWrite('created', created);
return res.status(201).json(publish(created));
});
@@ -266,6 +273,7 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' });
}
announce('updated', updated);
if (options.afterWrite) await options.afterWrite('updated', updated);
return res.json(publish(updated));
});
+142
View File
@@ -0,0 +1,142 @@
/**
* Prilohy ticketu: seznam, nahrani, stazeni, smazani.
*
* Ticket se hleda stejne jako u detailu (`visibleTicketOrDeny`): cizi nebo
* nad strop viditelnosti je 404. Zapis chce `ticket.comment` za firmu ticketu,
* stejne jako komentar - priloha je jen dalsi zprava k ticketu.
*/
import express from 'express';
import { z } from 'zod';
import {
addAttachments,
ALLOWED_NAME_LENGTH,
getAttachment,
listAttachments,
MAX_ATTACHMENT_BYTES,
MAX_ATTACHMENTS_PER_TICKET,
removeAttachment,
} from '../../data/attachments.js';
import { recordAudit } from '../../data/audit.js';
import { hasPermission } from '../../data/permissions.js';
import type { TicketDetail } from '../../data/ticketStore.js';
import { safeRouter } from '../../middleware/asyncHandler.js';
import { validationError } from '../../middleware/validation.js';
import { visibleTicketOrDeny } from '../ticketActions.js';
import { jsonLimitFor } from '../bodyLimit.js';
export const attachmentsRouter = safeRouter();
/**
* Vlastni strop tela: cela davka souboru v base64. Globalni `express.json`
* tuhle cestu preskakuje, viz `app.ts`.
*/
const ATTACHMENTS_BODY_LIMIT = jsonLimitFor(MAX_ATTACHMENTS_PER_TICKET, MAX_ATTACHMENT_BYTES);
const uploadSchema = z.object({
files: z
.array(
z.object({
name: z
.string()
.min(1, 'Soubor musí mít název.')
.max(ALLOWED_NAME_LENGTH * 2),
mime: z.string().max(120).optional(),
content: z.string().min(1, 'Soubor je prázdný.'),
}),
)
.min(1, 'Nebyl vybrán žádný soubor.')
.max(
MAX_ATTACHMENTS_PER_TICKET,
`Najednou jde nahrát nejvýš ${MAX_ATTACHMENTS_PER_TICKET} souborů.`,
),
});
const ATTACHMENTS_PATH = '/tickets/:id/attachments';
/** Pravo za firmu ticketu, ne za prepnutou firmu. */
function canWrite(req: express.Request, res: express.Response, ticket: TicketDetail): boolean {
if (hasPermission(req.user!, 'ticket.comment', ticket.tenantId)) return true;
console.warn(`[attachments] ${req.user!.email}: chybi pravo ticket.comment u ${ticket.id}`);
res.status(403).json({ error: 'forbidden', message: 'Nemáte právo přidávat přílohy.' });
return false;
}
attachmentsRouter.get(ATTACHMENTS_PATH, async (req, res) => {
const ticket = visibleTicketOrDeny(req, res);
if (!ticket) return;
return res.json({ items: await listAttachments(ticket.id, [ticket.tenantId]) });
});
attachmentsRouter.post(
ATTACHMENTS_PATH,
express.json({ limit: ATTACHMENTS_BODY_LIMIT }),
async (req, res) => {
const ticket = visibleTicketOrDeny(req, res);
if (!ticket) return;
if (!canWrite(req, res, ticket)) return;
const parsed = uploadSchema.safeParse(req.body);
if (!parsed.success) return validationError(res, parsed.error);
const result = await addAttachments(ticket, parsed.data.files, req.user!.id);
if (!result.ok) {
return res.status(400).json({ error: 'validation_error', message: result.message });
}
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId: ticket.tenantId,
action: 'ticket.attachment.add',
target: ticket.id,
detail: { files: result.items.map((item) => item.name) },
});
return res.status(201).json({ items: result.items });
},
);
attachmentsRouter.get(`${ATTACHMENTS_PATH}/:attachmentId/content`, async (req, res) => {
const ticket = visibleTicketOrDeny(req, res);
if (!ticket) return;
const attachment = await getAttachment(req.params.attachmentId ?? '', ticket.id, [
ticket.tenantId,
]);
if (!attachment) {
return res.status(404).json({ error: 'not_found', message: 'Příloha neexistuje.' });
}
const body = Buffer.from(attachment.content, 'base64');
res.setHeader('Content-Type', attachment.mime);
// RFC 5987: nazev s diakritikou v hlavicce musi byt zakodovany.
res.setHeader(
'Content-Disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
);
res.setHeader('Content-Length', String(body.length));
return res.end(body);
});
attachmentsRouter.delete(`${ATTACHMENTS_PATH}/:attachmentId`, async (req, res) => {
const ticket = visibleTicketOrDeny(req, res);
if (!ticket) return;
if (!canWrite(req, res, ticket)) return;
const removed = await removeAttachment(req.params.attachmentId ?? '', ticket.id, [
ticket.tenantId,
]);
if (!removed) {
return res.status(404).json({ error: 'not_found', message: 'Příloha neexistuje.' });
}
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId: ticket.tenantId,
action: 'ticket.attachment.remove',
target: ticket.id,
detail: { attachmentId: req.params.attachmentId },
});
return res.status(204).end();
});
+3
View File
@@ -18,6 +18,7 @@ import { streamRouter } from '../stream.js';
import { tenantScriptRouter } from '../tenantScripts.js';
import { ticketActionsRouter } from '../ticketActions.js';
import { widgetDataRouter } from '../widgetData.js';
import { attachmentsRouter } from './attachments.js';
import { automationsRouter } from './automations.js';
import { clientCrashRouter } from './clientCrash.js';
import { incidentsRouter } from './incidents.js';
@@ -42,6 +43,8 @@ dashboardRouter.use(intakeRouter);
dashboardRouter.use(notificationsRouter);
// Seznam, stavy a detail ticketu. `/tickets/:id` je tady, akce nize.
dashboardRouter.use(ticketsRouter);
// Prilohy ticketu. Vlastni strop tela, proto zvlast od akci.
dashboardRouter.use(attachmentsRouter);
// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo.
dashboardRouter.use('/stream', streamRouter);
+51
View File
@@ -0,0 +1,51 @@
/**
* Verejne udaje bez prihlaseni.
*
* Web bere kontaktni a fakturacni udaje z provozovatele portalu misto
* z natvrdo zapsaneho souboru. Cte se z kopie firem v pameti, takze bez
* limitu pokusu - je to levnejsi nez health.
*/
import { operatorTenant } from '../data/tenants.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import type { PublicBrand } from '../shared/tenants.js';
export const publicRouter = safeRouter();
/** Minuta. Udaje se meni jednou za rok, ale zmena se ma projevit bez restartu. */
const BRAND_MAX_AGE_SEC = 60;
/** Bez provozovatele same null, at web umi rict "neni nastaveno" misto pádu. */
const emptyBrand: PublicBrand = {
name: null,
legalName: null,
ico: null,
dic: null,
address: null,
legalForm: null,
email: null,
phone: null,
website: null,
};
export function brandOfOperator(): PublicBrand {
const tenant = operatorTenant();
if (!tenant) return emptyBrand;
return {
name: tenant.name,
// Nazev firmy je uz obchodni jmeno, zvlastni pole neni.
legalName: tenant.name,
ico: tenant.ico ?? null,
dic: tenant.dic ?? null,
address: tenant.address ?? null,
legalForm: tenant.legalForm ?? null,
email: tenant.contactEmail ?? null,
phone: tenant.contactPhone ?? null,
website: tenant.website ?? null,
};
}
publicRouter.get('/brand', (_req, res) => {
res.setHeader('Cache-Control', `public, max-age=${BRAND_MAX_AGE_SEC}`);
res.json(brandOfOperator());
});
+37 -1
View File
@@ -3,12 +3,29 @@
*/
import { z } from 'zod';
import { generateIntakeToken, tenantStore, type Tenant } from '../../data/tenants.js';
import {
clearOtherOperators,
generateIntakeToken,
tenantStore,
type Tenant,
} from '../../data/tenants.js';
import { crudRouter } from '../crud.js';
import { safeRouter } from '../../middleware/asyncHandler.js';
export const tenantsRouter = safeRouter();
/** Nejdelsi telefon vcetne predvolby a mezer. */
const PHONE_MAX_LENGTH = 30;
/** Prazdny retezec z formulare znamena "nic", uklada se jako null. */
function emptyToNull(schema: z.ZodString) {
return schema
.or(z.literal(''))
.nullable()
.optional()
.transform((value) => (value === '' ? null : value));
}
/** Udaje z ARES jsou nepovinne, rucne zalozena firma je mit nemusi. */
const tenantRegistryFields = {
ico: z
@@ -22,10 +39,20 @@ const tenantRegistryFields = {
legalForm: z.string().trim().max(120).nullable().optional(),
};
/** Kontakt pro verejny web. Bere se z provozovatele portalu. */
const tenantContactFields = {
contactEmail: emptyToNull(z.string().trim().email('Zadejte platný e-mail.')),
contactPhone: emptyToNull(z.string().trim().max(PHONE_MAX_LENGTH, 'Telefon je moc dlouhý.')),
website: emptyToNull(z.string().trim().url('Zadejte platnou adresu webu.')),
/** Provozovatel portalu. Prave jeden, ostatnim se priznak sunda. */
portalOperator: z.boolean().optional(),
};
const tenantCreate = z.object({
name: z.string().trim().min(2, 'Název firmy je moc krátký.').max(80),
note: z.string().trim().max(500).optional(),
...tenantRegistryFields,
...tenantContactFields,
});
/** Dve firmy se stejnym IC jsou jedna firma zalozena dvakrat. */
@@ -50,6 +77,7 @@ tenantsRouter.use(
/** Kdo teto firme resi helpdesk. null = nikdo, pozadavek nepujde poslat. */
helpdeskProviderId: z.string().trim().min(1).nullable().optional(),
...tenantRegistryFields,
...tenantContactFields,
}),
writePermission: 'tenant.manage',
platformOnly: true,
@@ -68,6 +96,10 @@ tenantsRouter.use(
dic: input.dic ?? null,
address: input.address ?? null,
legalForm: input.legalForm ?? null,
contactEmail: input.contactEmail ?? null,
contactPhone: input.contactPhone ?? null,
website: input.website ?? null,
portalOperator: input.portalOperator ?? false,
}),
validate: (tenant, all) => [
...(all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase())
@@ -75,5 +107,9 @@ tenantsRouter.use(
: []),
...duplicateIco(tenant, all),
],
// Provozovatel je jen jeden: kdo priznak dostal, ostatnim ho bere.
afterWrite: async (_verb, tenant) => {
if (tenant.portalOperator === true) await clearOtherOperators(tenant.id);
},
}),
);
+30
View File
@@ -0,0 +1,30 @@
/**
* Priloha ticketu: soubor, ktery prisel s poptavkou z webu nebo ho nekdo
* pripojil v portalu. Obsah se v seznamu nikdy nevraci, jen pres
* `/attachments/:id/content`.
*/
export interface Attachment {
id: string;
/** Firma ticketu. Hranice viditelnosti, stejne jako u ticketu. */
tenantId: string;
ticketId: string;
/** Nazev souboru bez cesty, uz ocisteny. */
name: string;
/** Typ obsahu, napr. `application/pdf`. Neznamy = `application/octet-stream`. */
mime: string;
/** Velikost v bajtech po dekodovani. */
size: number;
/** Ucet, ktery prilohu pripojil. null = prisla z verejneho formulare. */
uploadedBy: string | null;
createdAt: string;
}
/** Soubor tak, jak ho posila klient: obsah v base64. */
export interface AttachmentUpload {
name: string;
/** Nepovinny, chybejici se doplni na `application/octet-stream`. */
mime?: string;
/** Obsah souboru v base64 (bez prefixu `data:`). */
content: string;
}
+1
View File
@@ -7,6 +7,7 @@
*/
export type * from './access.js';
export type * from './attachments.js';
export type * from './automations.js';
export type * from './conditions.js';
export type * from './connectors.js';
+27
View File
@@ -37,4 +37,31 @@ export interface Tenant extends TenantEntity {
address?: string | null;
/** Nazev pravni formy, napr. "Spolecnost s rucenim omezenym". */
legalForm?: string | null;
/**
* Provozovatel portalu. Prave jedna firma: chodi ji poptavky z verejneho
* webu a web z ni bere kontaktni udaje. Nastaveni priznaku u jine firmy ho
* te puvodni sunda (viz `routes/settings/tenants.ts`). Chybejici = false.
*/
portalOperator?: boolean;
/** Kontaktni udaje pro verejny web. Vyplnuje se u provozovatele. */
contactEmail?: string | null;
contactPhone?: string | null;
website?: string | null;
}
/**
* Udaje provozovatele pro verejny web (`GET /api/public/brand`). Vsechno
* `null`, kdyz zadna firma provozovatelem neni - odpoved je i tak 200.
*/
export interface PublicBrand {
name: string | null;
/** Obchodni jmeno. Dnes totez co `name`. */
legalName: string | null;
ico: string | null;
dic: string | null;
address: string | null;
legalForm: string | null;
email: string | null;
phone: string | null;
website: string | null;
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Prilohy ticketu (src/data/attachments.ts): limity, ocisteni nazvu, base64,
* seznam bez obsahu a filtr na firmu. Uloziste v pameti.
*/
import { beforeAll, describe, expect, test } from 'vitest';
import {
addAttachments,
attachmentStore,
checkUploads,
getAttachment,
listAttachments,
MAX_ATTACHMENT_BYTES,
MAX_ATTACHMENTS_PER_TICKET,
removeAttachment,
sanitizeName,
} from '../../src/data/attachments.js';
import { initStores } from '../../src/data/store/index.js';
import { createTicket, getTicket, initTickets, type Ticket } from '../../src/data/ticketStore.js';
import { refreshUsers, seedUsers, userStore } from '../../src/data/users.js';
let ticket: Ticket;
function upload(name: string, bytes: number, mime?: string) {
return { name, mime, content: Buffer.alloc(bytes, 1).toString('base64') };
}
beforeAll(async () => {
initStores({ databaseReady: false });
await userStore.init(seedUsers);
await refreshUsers();
await attachmentStore.init();
await initTickets();
ticket = createTicket({
tenantId: 'tnt_automia',
subject: 'Ticket s prilohami',
channel: 'portal',
priority: 'normal',
});
});
describe('checkUploads', () => {
test('soubor nad limit velikosti neprojde', () => {
const result = checkUploads([upload('velky.bin', MAX_ATTACHMENT_BYTES + 1)], 0);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.message).toContain('velky.bin');
});
test('soubor presne na limitu projde', () => {
expect(checkUploads([upload('hranice.bin', MAX_ATTACHMENT_BYTES)], 0).ok).toBe(true);
});
test('pocet priloh na ticket ma strop', () => {
const result = checkUploads([upload('dalsi.txt', 10)], MAX_ATTACHMENTS_PER_TICKET);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.message).toContain(String(MAX_ATTACHMENTS_PER_TICKET));
});
test('neplatny base64 a prazdny obsah se odmitnou', () => {
expect(checkUploads([{ name: 'a.txt', content: 'to neni base64!' }], 0).ok).toBe(false);
expect(checkUploads([{ name: 'a.txt', content: 'abc' }], 0).ok).toBe(false);
expect(checkUploads([{ name: 'a.txt', content: '' }], 0).ok).toBe(false);
});
test('neznamy typ obsahu spadne na octet-stream', () => {
const result = checkUploads([upload('a.txt', 3, 'nesmysl bez lomitka')], 0);
expect(result.ok).toBe(true);
if (result.ok) expect(result.files[0]?.mime).toBe('application/octet-stream');
});
});
describe('sanitizeName', () => {
test('zahodi cestu a ridici znaky', () => {
expect(sanitizeName('../../etc/passwd')).toBe('passwd');
expect(sanitizeName('C:\\Users\\x\\zprava.pdf')).toBe('zprava.pdf');
expect(sanitizeName('a\r\nb\u0000c.txt')).toBe('abc.txt');
});
test('prazdny nazev dostane nahradu a dlouhy se zkrati', () => {
expect(sanitizeName('/')).toBe('priloha');
expect(sanitizeName('..')).toBe('priloha');
expect(sanitizeName('x'.repeat(500)).length).toBe(200);
});
});
describe('addAttachments a listAttachments', () => {
test('ulozi prilohy, seznam je bez obsahu a ticket dostane radek do logu', async () => {
const result = await addAttachments(
ticket,
[upload('zadani.pdf', 1500, 'application/pdf'), upload('foto.jpg', 20, 'image/jpeg')],
'usr_1',
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.items).toHaveLength(2);
expect(result.items[0]).toMatchObject({
ticketId: ticket.id,
tenantId: 'tnt_automia',
name: 'zadani.pdf',
mime: 'application/pdf',
size: 1500,
uploadedBy: 'usr_1',
});
expect('content' in (result.items[0] as object)).toBe(false);
const items = await listAttachments(ticket.id, ['tnt_automia']);
expect(items).toHaveLength(2);
for (const item of items) expect('content' in (item as object)).toBe(false);
const detail = getTicket(ticket.id, ['tnt_automia']);
const labels = detail?.trace.map((entry) => entry.label) ?? [];
expect(labels).toContain('Příloha: zadani.pdf (1.5 kB)');
});
test('cizi firma nevidi nic a nedostane obsah', async () => {
expect(await listAttachments(ticket.id, ['tnt_nordis'])).toEqual([]);
const [first] = await listAttachments(ticket.id, ['tnt_automia']);
expect(await getAttachment(first!.id, ticket.id, ['tnt_nordis'])).toBeUndefined();
expect(await removeAttachment(first!.id, ticket.id, ['tnt_nordis'])).toBe(false);
});
test('obsah se vraci jen pres getAttachment a odpovida ulozenemu', async () => {
const [first] = await listAttachments(ticket.id, ['tnt_automia']);
const full = await getAttachment(first!.id, ticket.id, ['tnt_automia']);
expect(full?.content).toBe(Buffer.alloc(1500, 1).toString('base64'));
// Jiny ticket se stejnym ID prilohy je taky "neexistuje".
expect(await getAttachment(first!.id, 'TK-0', ['tnt_automia'])).toBeUndefined();
});
test('davka nad strop poctu se neulozi ani castecne', async () => {
const before = (await listAttachments(ticket.id, ['tnt_automia'])).length;
const many = Array.from({ length: MAX_ATTACHMENTS_PER_TICKET }, (_, i) =>
upload(`f${i}.txt`, 4),
);
const result = await addAttachments(ticket, many, 'usr_1');
expect(result.ok).toBe(false);
expect(await listAttachments(ticket.id, ['tnt_automia'])).toHaveLength(before);
});
test('odebrani prilohy zapise poznamku do logu', async () => {
const [first] = await listAttachments(ticket.id, ['tnt_automia']);
expect(await removeAttachment(first!.id, ticket.id, ['tnt_automia'])).toBe(true);
expect(await listAttachments(ticket.id, ['tnt_automia'])).toHaveLength(1);
const labels = getTicket(ticket.id, ['tnt_automia'])?.trace.map((entry) => entry.label) ?? [];
expect(labels).toContain('Příloha odebrána: zadani.pdf');
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, test } from 'vitest';
import { validateWidget, type CustomWidget } from '../../src/data/customWidgets.js';
/** Widget se zakladem, ktery validace pousti; testy prepisuji jen to, o co jde. */
function widget(overrides: Partial<CustomWidget>): CustomWidget {
const now = '2026-09-09T00:00:00.000Z';
return {
id: 'cw_test',
tenantId: 'tnt_automia',
ownerId: null,
name: 'Test',
description: '',
render: 'chart',
size: 'half',
source: { kind: 'ticketSeries', filter: {}, bucket: 'day' },
createdAt: now,
updatedAt: now,
...overrides,
};
}
describe('validateWidget: graf', () => {
test('casova rada projde', () => {
expect(validateWidget(widget({}))).toEqual([]);
});
test('pocet ticketu se seskupenim projde jako kolac', () => {
const result = validateWidget(
widget({ source: { kind: 'ticketCount', filter: { closed: false }, groupBy: 'status' } }),
);
expect(result).toEqual([]);
});
test('pocet ticketu bez seskupeni je pro graf chyba', () => {
const result = validateWidget(widget({ source: { kind: 'ticketCount', filter: {} } }));
expect(result.some((problem) => problem.includes('Graf'))).toBe(true);
});
test('seznam ticketu se jako graf nekresli', () => {
const result = validateWidget(
widget({ source: { kind: 'ticketList', filter: {}, limit: 5 } }),
);
expect(result.some((problem) => problem.includes('Graf'))).toBe(true);
});
test('graf ve tretine sirky neprojde', () => {
const result = validateWidget(widget({ size: 'third' }));
expect(result.some((problem) => problem.includes('Šířka'))).toBe(true);
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* Prilohy ticketu pres HTTP (src/routes/dashboard/attachments.ts): nahrani
* jako resitel s ticket.comment, seznam, stazeni, smazani, cizi firma 404,
* prilis velky soubor 400.
*/
import request from 'supertest';
import { beforeAll, describe, expect, test } from 'vitest';
import type { Express } from 'express';
import { createApp } from '../../src/app.js';
import { MAX_ATTACHMENT_BYTES } from '../../src/data/attachments.js';
import { bootstrapData } from '../../src/data/bootstrap.js';
import { createTicket, type Ticket } from '../../src/data/ticketStore.js';
let app: Express;
let agentToken: string;
let foreignToken: string;
let ticket: Ticket;
const payload = Buffer.from('obsah prilohy s diakritikou: příloha');
async function login(email: string): Promise<string> {
const response = await request(app).post('/api/auth/login').send({ email, password: 'demo1234' });
expect(response.status).toBe(200);
return response.body.token as string;
}
function base(): string {
return `/api/dashboard/tickets/${ticket.id}/attachments`;
}
beforeAll(async () => {
await bootstrapData({ databaseReady: false });
app = createApp();
// Karel je v Automii agent (ma ticket.comment), Ondrej je jen v LogiTransu.
agentToken = await login('karel.vomacka@automia.cz');
foreignToken = await login('ondrej.kadlec@logitrans.cz');
ticket = createTicket({
tenantId: 'tnt_automia',
subject: 'Ticket pro prilohy',
channel: 'portal',
priority: 'normal',
});
});
describe('prilohy ticketu', () => {
let attachmentId: string;
test('nahrani vrati 201 a jen nove pridane prilohy', async () => {
const response = await request(app)
.post(base())
.set('Authorization', `Bearer ${agentToken}`)
.send({
files: [{ name: 'zadání.txt', mime: 'text/plain', content: payload.toString('base64') }],
});
expect(response.status).toBe(201);
expect(response.body.items).toHaveLength(1);
expect(response.body.items[0]).toMatchObject({
ticketId: ticket.id,
name: 'zadání.txt',
mime: 'text/plain',
size: payload.length,
uploadedBy: 'usr_2',
});
expect(response.body.items[0].content).toBeUndefined();
attachmentId = response.body.items[0].id as string;
});
test('seznam vrati prilohu bez obsahu', async () => {
const response = await request(app).get(base()).set('Authorization', `Bearer ${agentToken}`);
expect(response.status).toBe(200);
expect(response.body.items).toHaveLength(1);
expect(response.body.items[0].id).toBe(attachmentId);
expect(response.body.items[0].content).toBeUndefined();
});
test('stazeni vrati stejne bajty a hlavicky', async () => {
const response = await request(app)
.get(`${base()}/${attachmentId}/content`)
.set('Authorization', `Bearer ${agentToken}`)
.buffer(true)
.parse((res, callback) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => callback(null, Buffer.concat(chunks)));
});
expect(response.status).toBe(200);
expect(response.headers['content-type']).toBe('text/plain');
expect(response.headers['content-length']).toBe(String(payload.length));
expect(response.headers['content-disposition']).toBe(
`attachment; filename*=UTF-8''${encodeURIComponent('zadání.txt')}`,
);
expect(Buffer.compare(response.body as Buffer, payload)).toBe(0);
});
test('cizi firma dostane 404 na seznam, nahrani i stazeni', async () => {
const auth = `Bearer ${foreignToken}`;
expect((await request(app).get(base()).set('Authorization', auth)).status).toBe(404);
expect(
(
await request(app)
.post(base())
.set('Authorization', auth)
.send({ files: [{ name: 'x.txt', content: 'YWJj' }] })
).status,
).toBe(404);
expect(
(await request(app).get(`${base()}/${attachmentId}/content`).set('Authorization', auth))
.status,
).toBe(404);
});
test('soubor nad limit je 400', async () => {
const response = await request(app)
.post(base())
.set('Authorization', `Bearer ${agentToken}`)
.send({
files: [
{ name: 'velky.bin', content: Buffer.alloc(MAX_ATTACHMENT_BYTES + 1).toString('base64') },
],
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('validation_error');
});
test('smazani vrati 204 a priloha zmizi', async () => {
const auth = `Bearer ${agentToken}`;
expect(
(await request(app).delete(`${base()}/${attachmentId}`).set('Authorization', auth)).status,
).toBe(204);
const list = await request(app).get(base()).set('Authorization', auth);
expect(list.body.items).toHaveLength(0);
expect(
(await request(app).delete(`${base()}/${attachmentId}`).set('Authorization', auth)).status,
).toBe(404);
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* Kontaktni formular (src/routes/contact.ts): s provozovatelem portalu vznika
* ticket s prilohou, bez nej jen 202, limit pokusu zustava.
*
* Limit je na adresu a supertest vola vzdy z te same, proto test pocita
* odeslane pokusy a na konci overi, ze sesty je 429.
*/
import request from 'supertest';
import { beforeAll, describe, expect, test } from 'vitest';
import type { Express } from 'express';
import { createApp } from '../../src/app.js';
import { listAttachments } from '../../src/data/attachments.js';
import { bootstrapData } from '../../src/data/bootstrap.js';
import { refreshTenants, tenantStore } from '../../src/data/tenants.js';
import { listTickets } from '../../src/data/ticketStore.js';
let app: Express;
/** Kolik pokusu uz slo z teto adresy. Limit je 5 za hodinu. */
let attempts = 0;
const CONTACT_MAX_PER_WINDOW = 5;
const form = {
name: 'Petra Klientová',
email: 'petra@example.com',
company: 'Klient s.r.o.',
phone: '+420 777 000 111',
topic: 'integrace',
message: 'Potřebujeme napojit e-shop na iDoklad, prosím o nabídku.',
};
async function setOperator(portalOperator: boolean): Promise<void> {
await tenantStore.update(
'tnt_automia',
{ portalOperator },
{ tenantIds: [], includeGlobal: true },
);
await refreshTenants();
}
function send(body: Record<string, unknown>): request.Test {
attempts += 1;
return request(app).post('/api/contact').send(body);
}
function ticketsOfAutomia() {
return listTickets({ tenantIds: ['tnt_automia'], visibility: { kind: 'all' } });
}
beforeAll(async () => {
await bootstrapData({ databaseReady: false });
app = createApp();
});
describe('POST /api/contact', () => {
test('s provozovatelem zalozi ticket z formulare vcetne prilohy', async () => {
await setOperator(true);
const content = Buffer.from('ukazkovy obsah').toString('base64');
const response = await send({
...form,
attachments: [{ name: 'zadani.txt', mime: 'text/plain', content }],
});
expect(response.status).toBe(202);
expect(response.body.ok).toBe(true);
const [ticket] = ticketsOfAutomia();
expect(ticket).toBeDefined();
expect(ticket!.channel).toBe('form');
expect(ticket!.subject).toBe('Poptávka: integrace');
expect(ticket!.tags).toEqual(['Poptávka', 'integrace']);
expect(ticket!.externalSource).toBe('web-form');
expect(ticket!.assignee).toBeNull();
expect(ticket!.customer).toEqual({
id: null,
company: 'Klient s.r.o.',
contact: 'Petra Klientová',
reply: 'petra@example.com',
});
const body = JSON.parse(ticket!.body) as Record<string, unknown>;
expect(body).toMatchObject({ ...form });
expect(typeof body.receivedAt).toBe('string');
const attachments = await listAttachments(ticket!.id, ['tnt_automia']);
expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({
name: 'zadani.txt',
mime: 'text/plain',
uploadedBy: null,
});
});
test('neplatna priloha je 400 a ticket nevznikne', async () => {
const before = ticketsOfAutomia().length;
const response = await send({
...form,
attachments: [{ name: 'x.txt', content: 'to neni base64!' }],
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('validation_error');
expect(ticketsOfAutomia()).toHaveLength(before);
});
test('bez provozovatele je 202 a zadny ticket', async () => {
await setOperator(false);
const before = ticketsOfAutomia().length;
const response = await send(form);
expect(response.status).toBe(202);
expect(ticketsOfAutomia()).toHaveLength(before);
});
test('limit pokusu z jedne adresy zustava', async () => {
while (attempts < CONTACT_MAX_PER_WINDOW) {
expect((await send(form)).status).toBe(202);
}
const blocked = await send(form);
expect(blocked.status).toBe(429);
expect(blocked.headers['retry-after']).toBeDefined();
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* Verejne udaje provozovatele (src/routes/public.ts): s provozovatelem jsou
* pole vyplnena, bez nej same null a porad 200.
*/
import request from 'supertest';
import { beforeAll, describe, expect, test } from 'vitest';
import type { Express } from 'express';
import { createApp } from '../../src/app.js';
import { bootstrapData } from '../../src/data/bootstrap.js';
import { refreshTenants, tenantStore } from '../../src/data/tenants.js';
let app: Express;
beforeAll(async () => {
await bootstrapData({ databaseReady: false });
app = createApp();
});
describe('GET /api/public/brand', () => {
test('bez provozovatele jsou vsechna pole null', async () => {
const response = await request(app).get('/api/public/brand');
expect(response.status).toBe(200);
expect(response.headers['cache-control']).toBe('public, max-age=60');
expect(response.body).toEqual({
name: null,
legalName: null,
ico: null,
dic: null,
address: null,
legalForm: null,
email: null,
phone: null,
website: null,
});
});
test('s provozovatelem vraci jeho udaje', async () => {
await tenantStore.update(
'tnt_automia',
{
portalOperator: true,
ico: '12345678',
dic: 'CZ12345678',
address: 'Náměstí Míru 12, Brno',
legalForm: 'Společnost s ručením omezeným',
contactEmail: 'info@automia.cz',
contactPhone: '+420 777 123 456',
website: 'https://www.automia.cz',
},
{ tenantIds: [], includeGlobal: true },
);
await refreshTenants();
const response = await request(app).get('/api/public/brand');
expect(response.status).toBe(200);
expect(response.body).toEqual({
name: 'Automia',
legalName: 'Automia',
ico: '12345678',
dic: 'CZ12345678',
address: 'Náměstí Míru 12, Brno',
legalForm: 'Společnost s ručením omezeným',
email: 'info@automia.cz',
phone: '+420 777 123 456',
website: 'https://www.automia.cz',
});
});
test('spravce platformy prepne provozovatele a puvodnimu priznak zmizi', async () => {
const login = await request(app)
.post('/api/auth/login')
.send({ email: 'admin@automia.cz', password: 'demo1234' });
const response = await request(app)
.patch('/api/dashboard/settings/tenants/tnt_nordis')
.set('Authorization', `Bearer ${login.body.token}`)
.send({ portalOperator: true, contactEmail: '' });
expect(response.status).toBe(200);
expect(response.body.portalOperator).toBe(true);
expect(response.body.contactEmail).toBeNull();
const automia = await tenantStore.getRaw('tnt_automia');
expect(automia?.portalOperator).toBe(false);
const brand = await request(app).get('/api/public/brand');
expect(brand.body.name).toBe('Nordis a.s.');
});
});
@@ -0,0 +1,216 @@
import { UserCheck } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useCollection } from '@/lib/collections';
import type { TicketDetail as Detail } from '@/types/dashboard';
/**
* Doporucene stavy, kdyz si typ ticketu zadne nenadefinoval.
*
* Je to nabidka, ne ciselnik - stav je volny retezec a ticket muze mit
* i hodnotu, kterou poslala cizi aplikace.
*/
const defaultStatuses = ['Nový', 'V řešení', 'Čeká na klienta', 'Vyřešeno'];
/**
* Karta "Resitel": prevzeti, prirazeni, skupina a stav.
*
* Vsechny zmeny jdou pres `mutate` z detailu, aby chyba a `busy` byly na
* jednom miste se zbytkem stranky. Ciselniky si bere z ulozist sama a
* **nic z nich nebrani otevrit ticket**: kdo nema pravo na lidi, dostane
* kartu bez roletky resitele, ne prazdnou stranku s chybou.
*/
export function TicketAssignPanel({
ticket,
busy,
mutate,
}: {
ticket: Detail;
busy: boolean;
/** POST `/tickets/:id{path}` s telem. Vraci true pri uspechu. */
mutate: (path: string, body: unknown) => Promise<boolean>;
}) {
const people = useCollection('people');
const groups = useCollection('groups');
const types = useCollection('ticketTypes');
const meId = people.extra?.meId ?? null;
/** Stavy, ktere firma uz nekde pouziva. Jen naseptavac, ne omezeni. */
const [usedStatuses, setUsedStatuses] = useState<string[]>([]);
const [statusesError, setStatusesError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
apiFetch<{ items: string[] }>('/api/dashboard/tickets/statuses')
.then((data) => {
if (!cancelled) setUsedStatuses(data.items);
})
.catch((err: unknown) => {
// Bez naseptavace se da zit, ale musi byt videt, ze chybi a proc.
console.warn('[ticket] stavy se nepodarilo nacist:', err);
if (!cancelled) setStatusesError('Nabídku stavů se nepodařilo načíst.');
});
return () => {
cancelled = true;
};
}, []);
/*
* Co nabidnout ve vyberu stavu.
*
* Stav je volny retezec, takze nabidka je jen pohodli. Sklada se ze stavu
* typu ticketu (kdyz si je firma nadefinovala), z doporucenych a **z toho,
* co ticket ma prave ted** - jinak by hodnota z cizi aplikace ze seznamu
* zmizela a prvni zmena stavu by ji nenavratne prepsala.
*/
const statusChoices = useMemo(() => {
const type = types.items.find((item) => item.id === ticket.typeId);
const own = type?.statuses ?? [];
const base = own.length > 0 ? own : defaultStatuses;
return [...new Set([...base, ...usedStatuses, ticket.status])];
}, [types.items, usedStatuses, ticket.typeId, ticket.status]);
/*
* Rozepsany stav.
*
* Drzi se zvlast od ticketu, protoze zapis nesmi odejit na server po kazdem
* pismenu - "Ce" na ceste k "Ceka na zakaznika" je platny stav a ulozil by se.
* Posila se az pri opusteni pole nebo pri Enteru.
*/
const [statusDraft, setStatusDraft] = useState<string | null>(null);
return (
<div className="glass rounded-card p-5">
<h2 className="flex items-center gap-2 font-semibold text-white">
<UserCheck className="size-4 text-brand-300" />
Řešitel
</h2>
<p className={cn('mt-3 text-sm', ticket.assignee ? 'text-white/85' : 'text-warn-400')}>
{ticket.assignee ? ticket.assignee.name : 'Ve frontě, bez řešitele'}
</p>
{/*
Prevzeti: kdyz ticket lezi ve fronte skupiny, vezme si ho clovek sam.
Je to jina vec nez prehazovani prace shora, proto tlacitko a ne
polozka v selectu.
*/}
{meId && ticket.assignee?.id !== meId && (
<Button
size="sm"
disabled={busy}
onClick={() => void mutate('/claim', {})}
className="mt-3 w-full justify-center"
>
Převzít
</Button>
)}
{/*
Bez prava na lidi se roletka nenabidne. Ticket jde otevrit a resit
dal, jen ho nejde predat - a rekne se to.
*/}
{people.error ? (
<p className="mt-3 text-xs text-white/40">
Seznam řešitelů není k dispozici, ticket nejde předat.
</p>
) : (
<>
<label htmlFor="ticket-assignee" className="sr-only">
Přiřadit řešiteli
</label>
<Select
id="ticket-assignee"
value={ticket.assignee?.id ?? ''}
disabled={busy}
onChange={(event) =>
void mutate('/assign', {
assigneeId: event.target.value === '' ? null : event.target.value,
})
}
size="lg"
className="mt-3"
placeholder="Vrátit do fronty"
options={people.items.map((person) => ({
value: person.id,
label: `${person.name}, ${person.role}`,
}))}
/>
</>
)}
{/*
Skupina je vedle resitele, ne misto nej. Ticket muze byt "u ucetnich"
a zaroven u konkretniho cloveka z nich.
*/}
{groups.items.length > 0 && (
<Field
label={<span className="text-xs font-semibold tracking-wide uppercase">Skupina</span>}
htmlFor="ticket-group"
className="mt-4"
>
<Select
id="ticket-group"
value={ticket.assigneeGroupId ?? ''}
disabled={busy}
onChange={(event) =>
void mutate('/group', {
groupId: event.target.value === '' ? null : event.target.value,
})
}
size="lg"
placeholder="Bez skupiny"
options={groups.items.map((group) => ({ value: group.id, label: group.name }))}
/>
</Field>
)}
{/*
Otevreny naseptavac, ne ciselnik. Stav je volny retezec: ticket muze
prijit z cizi aplikace s jejim vlastnim stavem a firma si smi zavest
vlastni, aniz by ho nekdo predem zapisoval do nastaveni. Nabidka je
pohodli, ne omezeni.
*/}
<Field
label={<span className="text-xs font-semibold tracking-wide uppercase">Stav</span>}
htmlFor="ticket-status"
className="mt-4"
hint={statusesError ?? 'Stav je volný text. Nabídka je jen z toho, co už používáte.'}
>
<Input
id="ticket-status"
list="ticket-status-options"
value={statusDraft ?? ticket.status}
disabled={busy}
onChange={(event) => setStatusDraft(event.target.value)}
onBlur={(event) => {
const next = event.target.value.trim();
setStatusDraft(null);
if (next === '' || next === ticket.status) return;
void mutate('/status', { status: next });
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
// Escape zahodi rozepsane a vrati puvodni hodnotu.
if (event.key === 'Escape') {
setStatusDraft(null);
event.currentTarget.blur();
}
}}
placeholder="Napište stav, nebo vyberte z nabídky"
size="lg"
/>
<datalist id="ticket-status-options">
{statusChoices.map((option) => (
<option key={option} value={option} />
))}
</datalist>
</Field>
</div>
);
}
@@ -0,0 +1,203 @@
import { Download, Paperclip, Trash2, Upload } from 'lucide-react';
import { useState } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { Button } from '@/components/ui/Button';
import { FilePicker } from '@/components/ui/form/FilePicker';
import { Modal } from '@/components/ui/Modal';
import { apiBlob, apiFetch } from '@/lib/api';
import {
MAX_ATTACHMENT_BYTES,
TICKET_MAX_ATTACHMENTS,
formatBytes,
readFileAsBase64,
} from '@/lib/files';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/hooks/useApiQuery';
import { useSubmit } from '@/hooks/useSubmit';
import type { Attachment, AttachmentUpload } from '@/types/dashboard';
/**
* Prilohy ticketu: seznam, stazeni, nahrani a smazani.
*
* Seznam ma vlastni dotaz, ne pole na detailu ticketu: soubory se meni
* nezavisle na ticketu a po nahrani server posle `ticket.updated`, na ktere
* se seznam obnovi sam.
*
* Stazeni jde pres fetch s tokenem a docasny odkaz - obsah chce Authorization
* hlavicku, kterou by prohlizec do obycejneho odkazu nedal.
*/
export function TicketAttachments({
ticketId,
canWrite,
people,
}: {
ticketId: string;
/** Pravo `ticket.comment`: bez nej se nenahrava ani nemaze. */
canWrite: boolean;
/** Jmena k ID uctu ve sloupci "kdo". Bez shody se ukaze ID. */
people: Array<{ id: string; name: string }>;
}) {
const endpoint = `/api/dashboard/tickets/${ticketId}/attachments`;
const list = useApiQuery<{ items: Attachment[] }>(endpoint, { refetchOn: ['ticket.updated'] });
const items = list.data?.items ?? [];
const [files, setFiles] = useState<File[]>([]);
const [toDelete, setToDelete] = useState<Attachment | null>(null);
const upload = useSubmit(async () => {
const payload: AttachmentUpload[] = await Promise.all(
files.map(async (file) => ({
name: file.name,
mime: file.type || 'application/octet-stream',
content: await readFileAsBase64(file),
})),
);
await apiFetch<{ items: Attachment[] }>(endpoint, { method: 'POST', body: { files: payload } });
setFiles([]);
list.reload();
}, 'Přílohy se nepodařilo nahrát.');
const removal = useSubmit(async (attachment: Attachment) => {
await apiFetch<undefined>(`${endpoint}/${attachment.id}`, { method: 'DELETE' });
setToDelete(null);
list.reload();
}, 'Přílohu se nepodařilo smazat.');
const download = useSubmit(async (attachment: Attachment) => {
const { blob, filename } = await apiBlob(`${endpoint}/${attachment.id}/content`);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename ?? attachment.name;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}, 'Soubor se nepodařilo stáhnout.');
const remaining = Math.max(0, TICKET_MAX_ATTACHMENTS - items.length);
const busy = upload.busy || removal.busy || download.busy;
const error = upload.error ?? removal.error ?? download.error;
function who(attachment: Attachment): string {
if (attachment.uploadedBy === null) return 'z webu';
return (
people.find((person) => person.id === attachment.uploadedBy)?.name ?? attachment.uploadedBy
);
}
return (
<section className="glass rounded-card p-5 sm:p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-semibold text-white">
<Paperclip className="size-4 text-brand-300" />
Přílohy
</h2>
<span className="text-xs text-white/35">
{items.length} / {TICKET_MAX_ATTACHMENTS}
</span>
</div>
<div className="mt-3">
<DataState
loading={list.loading}
refreshing={list.refreshing}
error={list.error}
onRetry={list.reload}
empty={items.length === 0}
emptyLabel="Ticket zatím nemá žádnou přílohu."
>
<ul className="divide-y divide-ink-600/40">
{items.map((attachment) => (
<li key={attachment.id} className="flex items-center gap-3 py-2 text-sm">
<div className="min-w-0 flex-1">
<p className="truncate text-white/85">{attachment.name}</p>
<p className="text-xs text-white/40">
{formatBytes(attachment.size)}, {formatDateTime(attachment.createdAt)},{' '}
{who(attachment)}
</p>
</div>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => void download.submit(attachment)}
aria-label={`Stáhnout ${attachment.name}`}
>
<Download className="size-4" />
</Button>
{canWrite && (
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => setToDelete(attachment)}
aria-label={`Smazat ${attachment.name}`}
>
<Trash2 className="size-4" />
</Button>
)}
</li>
))}
</ul>
</DataState>
</div>
{canWrite && (
<div className="mt-4 space-y-3 border-t border-ink-600/50 pt-4">
<FilePicker
label={
<span className="text-xs font-semibold tracking-wide uppercase">Přidat přílohy</span>
}
hint={
remaining === 0
? 'Ticket už má nejvyšší počet příloh.'
: `Ještě ${remaining}, každá do ${formatBytes(MAX_ATTACHMENT_BYTES)}.`
}
files={files}
onChange={setFiles}
maxCount={remaining}
disabled={busy}
/>
{files.length > 0 && (
<Button size="sm" disabled={busy} onClick={() => void upload.submit()}>
<Upload className="size-4" />
{upload.busy ? 'Nahrávám...' : 'Nahrát'}
</Button>
)}
</div>
)}
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
<Modal
open={toDelete !== null}
onClose={() => setToDelete(null)}
title="Smazat přílohu"
description={toDelete ? `${toDelete.name} zmizí nenávratně.` : undefined}
className="max-w-md"
>
<div className="flex gap-3 p-5">
<Button
size="sm"
disabled={removal.busy}
onClick={() => {
if (toDelete) void removal.submit(toDelete);
}}
>
<Trash2 className="size-4" />
Smazat
</Button>
<Button
size="sm"
variant="secondary"
disabled={removal.busy}
onClick={() => setToDelete(null)}
>
Zrušit
</Button>
</div>
</Modal>
</section>
);
}
@@ -0,0 +1,175 @@
import { useState } from 'react';
import { AresTenantDialog } from '@/components/dashboard/AresTenantDialog';
import { EntityAdmin, type NamedRecord } from '@/components/dashboard/EntityAdmin';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { useApiQuery } from '@/hooks/useApiQuery';
/**
* Pole firmy, u kterych prazdny retezec znamena "nevyplneno".
*
* Prazdne IC neni "IC s nula znaky", ale zadne IC. Server ma na vyplnene
* osm cislic, tak se prazdne posila jako null. Totez u kontaktu a webu.
*/
const NULLABLE_TENANT_FIELDS = [
'ico',
'dic',
'address',
'legalForm',
'contactEmail',
'contactPhone',
'website',
'helpdeskProviderId',
];
/**
* Zalozka Firmy v nastaveni: tabulka, formular a zalozeni pres ARES.
*
* Stoji na `EntityAdmin`, tady je jen to, co je u firmy jine: sloupce, pole
* a prevod prazdnych hodnot na null. Bylo to v `Settings.tsx`, ale s poli
* provozovatele a kontaktu uz se tam neveslo.
*/
export function TenantsAdmin() {
/*
* Seznam firem zvlast, i kdyz si ho `EntityAdmin` nacita sam. Potrebuje se
* do nabidky "helpdesk resi firma", tedy do konfigurace tehoz seznamu -
* dostat se k tomu, co si komponenta nacetla dovnitr, by znamenalo
* protahnout to skrz ni jen kvuli jednomu poli.
*/
const tenants = useApiQuery<{ items: Array<{ id: string; name: string }> }>(
'/api/dashboard/settings/tenants',
);
const [aresOpen, setAresOpen] = useState(false);
return (
<EntityAdmin
title="Firmy"
description="Firmy, které portál používají. Firma je hranice viditelnosti."
endpoint="/api/dashboard/settings/tenants"
refetchOn={['tenant.created', 'tenant.updated', 'tenant.deleted']}
toBody={(values) => ({
...Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key,
NULLABLE_TENANT_FIELDS.includes(key) && value === '' ? null : value,
]),
),
portalOperator: values.portalOperator === true,
})}
columns={[
{ label: 'Název', render: (tenant: NamedRecord) => String(tenant.name ?? '') },
{
label: 'IČ',
narrow: true,
render: (tenant: NamedRecord) =>
tenant.ico ? (
<code>{String(tenant.ico)}</code>
) : (
<span className="text-white/30">bez IČ</span>
),
},
{ label: 'ID', render: (tenant: NamedRecord) => <code>{tenant.id}</code> },
{
label: 'E-mail',
render: (tenant: NamedRecord) =>
tenant.contactEmail ? (
String(tenant.contactEmail)
) : (
<span className="text-white/30">-</span>
),
},
{ label: 'Poznámka', render: (tenant: NamedRecord) => String(tenant.note ?? '') },
{
label: 'Helpdesk řeší',
render: (tenant: NamedRecord) => {
const provider = tenants.data?.items.find(
(item) => item.id === tenant.helpdeskProviderId,
);
return provider ? provider.name : <span className="text-white/30">nikdo</span>;
},
},
{
label: 'Stav',
narrow: true,
render: (tenant: NamedRecord) => (
<span className="flex flex-wrap gap-1">
{tenant.enabled === false ? (
<Badge tone="warn">vypnutá</Badge>
) : (
<Badge tone="ok">aktivní</Badge>
)}
{tenant.portalOperator === true && <Badge tone="brand">provozovatel</Badge>}
</span>
),
},
]}
fields={[
{ name: 'name', label: 'Název firmy', kind: 'text', required: true },
{
name: 'ico',
label: 'IČ',
kind: 'text',
hint: '8 číslic. Vyplní se samo při založení přes ARES.',
},
{ name: 'dic', label: 'DIČ', kind: 'text' },
{ name: 'address', label: 'Sídlo', kind: 'text' },
{ name: 'legalForm', label: 'Právní forma', kind: 'text' },
{ name: 'contactEmail', label: 'E-mail', kind: 'text' },
{ name: 'contactPhone', label: 'Telefon', kind: 'text' },
{ name: 'website', label: 'Web', kind: 'text' },
{
name: 'note',
label: 'Poznámka',
kind: 'textarea',
hint: 'Pro nás, klient ji nevidí.',
},
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
{
/*
Jedna firma je provozovatel: jeji kontakty se ukazuji na verejnem
webu a poptavky z formulare se ji zakladaji jako tickety. Server
pri zapnuti odebere priznak ostatnim, tady se nic nehlida.
*/
name: 'portalOperator',
label: 'Provozovatel portálu',
kind: 'checkbox',
hint: 'Sem chodí poptávky z webu jako tickety. Může být jen jedna firma.',
},
{
/*
Komu firma posila pozadavky z helpdesku. Urcuje to spravce
platformy, ne firma sama: kdo koho obsluhuje je obchodni vztah.
Prazdna hodnota znamena, ze helpdesk nema komu poslat, a rekne
se to na strance Helpdesk nahlas.
*/
name: 'helpdeskProviderId',
label: 'Helpdesk řeší firma',
kind: 'select',
hint: 'Komu půjdou požadavky z helpdesku této firmy. Bez vyplnění je poslat nelze.',
options: [
{ value: '', label: 'Nikdo' },
...(tenants.data?.items ?? []).map((tenant) => ({
value: tenant.id,
label: tenant.name,
})),
],
},
]}
>
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-white/10 p-3 text-sm text-white/70">
<span>
Novou firmu jde založit z registru ARES: dotáhne se název, IČ, DIČ, sídlo a lidé, kteří za
ni jednají.
</span>
<Button size="sm" variant="secondary" onClick={() => setAresOpen(true)}>
Založit přes ARES
</Button>
</div>
<AresTenantDialog
open={aresOpen}
onClose={() => setAresOpen(false)}
onCreated={() => tenants.reload()}
/>
</EntityAdmin>
);
}
@@ -8,6 +8,7 @@ import {
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { TicketCard } from '@/components/dashboard/TicketCard';
import { PieChart } from '@/components/dashboard/widgets/PieChart';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { formatDuration, formatNumber, formatRelative } from '@/lib/format';
@@ -93,10 +94,14 @@ export function CustomWidgetCard({
}
if (value.kind === 'groups') {
// Seskupeni se kresli jako sloupce (tabulka) nebo kolac (graf).
// Stejna data, jina otazka: sloupce srovnavaji, kolac ukazuje podily.
return (
<Shell definition={definition}>
{value.rows.length === 0 ? (
<p className="text-sm text-white/40">Nic, co by sem patřilo.</p>
) : definition.render === 'chart' ? (
<PieChart rows={value.rows} />
) : (
<Bars rows={value.rows} />
)}
@@ -0,0 +1,152 @@
import { Link } from 'react-router-dom';
import { cn } from '@/lib/cn';
import { formatNumber } from '@/lib/format';
/**
* Kolacovy graf nad seskupenymi hodnotami (tickety podle stavu, kanalu,
* resitele). Kresli se jako SVG vysece, zadna knihovna: osm barev z tokenu
* palety, zbytek nad osm skupin se slouci do "ostatni", protoze vic vysecu
* uz nikdo nerozezna. Legenda vedle grafu nese cislo i podil.
*/
/** Kolik vysecu nejvys. Dal uz je kolac necitelny. */
const MAX_SLICES = 8;
/** Barvy vysecu z tokenu palety, v poradi, ve kterem se pridavaji. */
const SLICE_COLORS = [
'var(--color-brand-400)',
'var(--color-ok-400)',
'var(--color-warn-400)',
'var(--color-danger-400)',
'var(--color-brand-600)',
'var(--color-ok-500)',
'var(--color-warn-500)',
'var(--color-danger-500)',
];
/** Kdyby paleta byla prazdna; typ pole to nevylucuje. */
const FALLBACK_COLOR = 'var(--color-brand-400)';
const OTHER_KEY = '__other__';
interface PieRow {
key: string;
label: string;
value: number;
href?: string;
}
/** Sloz vysece: nejvetsi napred, zbytek nad strop do jedne polozky. */
function slicesOf(rows: PieRow[]): PieRow[] {
const sorted = [...rows].filter((row) => row.value > 0).sort((a, b) => b.value - a.value);
if (sorted.length <= MAX_SLICES) return sorted;
const shown = sorted.slice(0, MAX_SLICES - 1);
const rest = sorted.slice(MAX_SLICES - 1).reduce((sum, row) => sum + row.value, 0);
return [...shown, { key: OTHER_KEY, label: 'ostatní', value: rest }];
}
/** Bod na kruznici pro dany podil (0 az 1), zacatek nahore. */
function pointAt(fraction: number, radius: number): [number, number] {
const angle = fraction * 2 * Math.PI - Math.PI / 2;
return [radius * Math.cos(angle), radius * Math.sin(angle)];
}
/** Cesta jedne vysece od podilu `from` do `to`. */
function arcPath(from: number, to: number, radius: number): string {
const [x1, y1] = pointAt(from, radius);
const [x2, y2] = pointAt(to, radius);
const large = to - from > 0.5 ? 1 : 0;
return `M 0 0 L ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2} Z`;
}
interface Shape {
row: PieRow;
from: number;
to: number;
color: string;
}
/** Rozlozi vysece po obvodu: kazda zacina tam, kde predchozi skoncila. */
function layoutSlices(slices: PieRow[], total: number): Shape[] {
const shapes: Shape[] = [];
let cursor = 0;
for (const [index, row] of slices.entries()) {
const from = cursor;
cursor += row.value / total;
shapes.push({
row,
from,
to: cursor,
color: SLICE_COLORS[index % SLICE_COLORS.length] ?? FALLBACK_COLOR,
});
}
return shapes;
}
export function PieChart({ rows }: { rows: PieRow[] }) {
const slices = slicesOf(rows);
const total = slices.reduce((sum, row) => sum + row.value, 0);
if (total === 0) {
return <p className="text-sm text-white/40">Nic, co by sem patřilo.</p>;
}
const radius = 48;
const shapes = layoutSlices(slices, total);
return (
<div className="flex flex-wrap items-center gap-5">
<svg
viewBox="-50 -50 100 100"
className="size-32 shrink-0"
role="img"
aria-label={`Koláčový graf, celkem ${formatNumber(total)}`}
>
{shapes.map((shape) =>
// Jedina vysec je cely kruh, oblouk by se sam do sebe nezavrel.
shapes.length === 1 ? (
<circle key={shape.row.key} r={radius} fill={shape.color} />
) : (
<path
key={shape.row.key}
d={arcPath(shape.from, shape.to, radius)}
fill={shape.color}
stroke="var(--color-ink-900)"
strokeWidth="1"
/>
),
)}
</svg>
<ul className="min-w-0 flex-1 space-y-1.5 text-sm">
{shapes.map((shape) => {
const share = Math.round((shape.row.value / total) * 100);
const body = (
<>
<span
className="inline-block size-2.5 shrink-0 rounded-full"
style={{ background: shape.color }}
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate text-white/70 group-hover:text-brand-200">
{shape.row.label}
</span>
<span className="font-semibold text-white">{formatNumber(shape.row.value)}</span>
<span className="w-10 text-right text-xs text-white/40">{share} %</span>
</>
);
return (
<li key={shape.row.key} className={cn('flex items-center gap-2')}>
{shape.row.href ? (
<Link to={shape.row.href} className="group flex min-w-0 flex-1 items-center gap-2">
{body}
</Link>
) : (
body
)}
</li>
);
})}
</ul>
</div>
);
}
+5 -4
View File
@@ -1,12 +1,13 @@
import { ArrowRight, Mail, Phone } from 'lucide-react';
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { brand } from '@/config/brand';
import { useT } from '@/i18n';
import { useBrand } from '@/hooks/useBrand';
/** Zaverecna vyzva k akci pred paticku. */
export function CallToAction() {
const t = useT();
const operator = useBrand();
return (
<section className="py-20 sm:py-24">
@@ -26,15 +27,15 @@ export function CallToAction() {
{t('cta.write')}
<ArrowRight className="size-4" />
</ButtonLink>
<ButtonLink to={`tel:${brand.phoneHref}`} variant="secondary" size="lg">
<ButtonLink to={`tel:${operator.phoneHref}`} variant="secondary" size="lg">
<Phone className="size-4" />
{brand.phone}
{operator.phone}
</ButtonLink>
</div>
<p className="relative mt-6 inline-flex items-center gap-2 text-sm text-white/45">
<Mail className="size-4" />
{brand.email}
{operator.email}
</p>
</div>
</Container>
+14 -9
View File
@@ -2,12 +2,13 @@ import { Mail, MapPin, Phone } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Logo } from '@/components/layout/Logo';
import { Container } from '@/components/ui/Container';
import { brand } from '@/config/brand';
import { footerNav } from '@/data/navigation';
import { useT } from '@/i18n';
import { useBrand } from '@/hooks/useBrand';
export function Footer() {
const t = useT();
const operator = useBrand();
const year = new Date().getFullYear();
return (
@@ -44,28 +45,32 @@ export function Footer() {
<ul className="space-y-3 text-sm text-white/55">
<li>
<a
href={`mailto:${brand.email}`}
href={`mailto:${operator.email}`}
className="inline-flex items-center gap-2 transition-colors hover:text-brand-300"
>
<Mail className="size-4 shrink-0 text-brand-400" />
{brand.email}
{operator.email}
</a>
</li>
<li>
<a
href={`tel:${brand.phoneHref}`}
href={`tel:${operator.phoneHref}`}
className="inline-flex items-center gap-2 transition-colors hover:text-brand-300"
>
<Phone className="size-4 shrink-0 text-brand-400" />
{brand.phone}
{operator.phone}
</a>
</li>
<li className="flex items-start gap-2">
<MapPin className="mt-0.5 size-4 shrink-0 text-brand-400" />
<span>
{brand.address.street}
<br />
{brand.address.zip} {brand.address.city}
{/* Bez zeme: v paticce staci ulice a mesto. */}
{operator.addressLines.slice(0, 2).map((line, index) => (
<span key={line}>
{index > 0 && <br />}
{line}
</span>
))}
</span>
</li>
</ul>
@@ -74,7 +79,7 @@ export function Footer() {
<div className="mt-12 flex flex-col gap-3 border-t border-ink-600/50 pt-6 text-xs text-white/40 sm:flex-row sm:items-center sm:justify-between">
<p>
© {year} {brand.legalName} · IČO {brand.ico}
© {year} {operator.legalName} · IČO {operator.ico}
</p>
<p className="font-mono">{t('footer.prototype')}</p>
</div>
+4 -1
View File
@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom';
import { brand } from '@/config/brand';
import { cn } from '@/lib/cn';
import { useBrand } from '@/hooks/useBrand';
/**
* Znacka: plochy znak plus slovni znacka.
@@ -48,12 +49,14 @@ export function Logo({
/** Podtitulek pod slovni znackou. Jen tam, kde je na nej misto. */
withClaim?: boolean;
}) {
const { name } = useBrand();
return (
<Link to={to} className={cn('group inline-flex items-center gap-2.5', className)}>
<LogoMark className="size-8 text-brand-400 transition-transform duration-300 group-hover:-translate-y-0.5" />
<span className="flex flex-col leading-none">
<span className="font-display text-lg font-extrabold tracking-tight text-white">
{brand.name}
{name}
</span>
{withClaim && (
<span className="mt-1 font-mono text-[0.6rem] tracking-[0.22em] text-white/40 uppercase">
+121
View File
@@ -0,0 +1,121 @@
import { Paperclip, X } from 'lucide-react';
import { useId, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { formatBytes, MAX_ATTACHMENT_BYTES, validateFiles } from '@/lib/files';
/**
* Vyber priloh: tlacitko, skryty `<input type="file">` a seznam vybranych.
*
* Nativni vstup se nekresli, protoze se neda ostylovat jednotne s ostatnimi
* poli a jeho popisek "Soubor nevybran" se neda prelozit. Vybrane soubory
* drzi rodic (`files` / `onChange`), komponenta jen pridava a odebira.
*
* Kontrola poctu a velikosti bezi pri vyberu: kdo vybere prilis, dostane
* chybu hned a vyber se nepouzije. `error` zvenku ma prednost, tam chodi
* odpoved serveru.
*/
export function FilePicker({
label,
hint,
files,
onChange,
maxCount,
maxBytes = MAX_ATTACHMENT_BYTES,
error,
disabled = false,
size = 'sm',
buttonLabel = 'Vybrat soubory',
accept,
}: {
label: ReactNode;
hint?: ReactNode;
files: File[];
onChange: (files: File[]) => void;
/** Kolik souboru smi byt vybrano celkem. */
maxCount: number;
maxBytes?: number;
/** Chyba zvenku, napr. ze serveru. Prebije chybu z vyberu. */
error?: string | null;
disabled?: boolean;
size?: 'sm' | 'md';
/** Text tlacitka. Verejny web ho preklada. */
buttonLabel?: string;
/** Omezeni typu pro dialog prohlizece, napr. `image/*,.pdf`. */
accept?: string;
}) {
const inputId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [pickError, setPickError] = useState<string | null>(null);
function pick(list: FileList | null) {
if (!list || list.length === 0) return;
const next = [...files, ...Array.from(list)];
const problem = validateFiles(next, { maxCount, maxBytes });
setPickError(problem);
if (!problem) onChange(next);
// Vynulovat, aby sel tentyz soubor vybrat znovu po odebrani.
if (inputRef.current) inputRef.current.value = '';
}
function remove(index: number) {
setPickError(null);
onChange(files.filter((_, position) => position !== index));
}
const full = files.length >= maxCount;
return (
<Field label={label} hint={hint} error={error ?? pickError} as="div" size={size}>
<input
ref={inputRef}
id={inputId}
type="file"
multiple
accept={accept}
className="sr-only"
disabled={disabled || full}
onChange={(event) => pick(event.target.files)}
/>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
size="sm"
variant="secondary"
disabled={disabled || full}
onClick={() => inputRef.current?.click()}
>
<Paperclip className="size-4" />
{buttonLabel}
</Button>
<span className="text-xs text-white/35">
{files.length} / {maxCount}
</span>
</div>
{files.length > 0 && (
<ul className="mt-2 space-y-1">
{files.map((file, index) => (
<li
key={`${file.name}-${file.size}-${index}`}
className="flex items-center gap-2 rounded-lg border border-ink-600/50 bg-ink-900/40 px-3 py-1.5 text-sm"
>
<span className="min-w-0 flex-1 truncate text-white/80">{file.name}</span>
<span className="shrink-0 text-xs text-white/40">{formatBytes(file.size)}</span>
<button
type="button"
onClick={() => remove(index)}
disabled={disabled}
className="grid size-6 shrink-0 place-items-center rounded text-white/40 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-50"
aria-label={`Odebrat ${file.name}`}
>
<X className="size-3.5" />
</button>
</li>
))}
</ul>
)}
</Field>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useSyncExternalStore } from 'react';
import { brand } from '@/config/brand';
import { apiFetch } from '@/lib/api';
import type { PublicBrand } from '@/types/dashboard';
/**
* Kontaktni a fakturacni udaje pro verejny web.
*
* Zdrojem je firma oznacena jako provozovatel portalu (`Tenant.portalOperator`),
* server ji vraci z `/api/public/brand` bez prihlaseni. `config/brand.ts` je
* jen zaloha: kdyz server posle `null` nebo neodpovi, zustane staticka hodnota.
*
* Nacita se jednou za nacteni stranky a sdili mezi vsemi komponentami:
* paticka, kontakt i vyzva k akci by jinak poslaly tri stejne dotazy.
* Staticke hodnoty se vykresli hned, po odpovedi se jen vymeni.
*/
export interface BrandInfo {
name: string;
legalName: string;
email: string;
phone: string;
/** Telefon bez mezer do `tel:` odkazu. */
phoneHref: string;
/**
* Adresa po radcich. Firma z portalu ma jednu textovou radku, staticka
* zaloha tri (ulice, PSC a mesto, zeme).
*/
addressLines: string[];
ico: string;
dic: string;
/** Web firmy. null = neni odkud vzit, staticka zaloha ho nema. */
website: string | null;
}
const fallback: BrandInfo = {
name: brand.name,
legalName: brand.legalName,
email: brand.email,
phone: brand.phone,
phoneHref: brand.phoneHref,
addressLines: [
brand.address.street,
`${brand.address.zip} ${brand.address.city}`,
brand.address.country,
],
ico: brand.ico,
dic: brand.dic,
website: null,
};
function merge(server: PublicBrand): BrandInfo {
const phone = server.phone ?? fallback.phone;
return {
name: server.name ?? fallback.name,
legalName: server.legalName ?? fallback.legalName,
email: server.email ?? fallback.email,
phone,
phoneHref: phone.replace(/\s+/g, ''),
addressLines: server.address ? [server.address] : fallback.addressLines,
ico: server.ico ?? fallback.ico,
dic: server.dic ?? fallback.dic,
website: server.website,
};
}
// ---------------------------------------------------------------- mezipamet
let current: BrandInfo = fallback;
let inflight: Promise<void> | null = null;
const listeners = new Set<() => void>();
function load(): Promise<void> {
if (inflight) return inflight;
inflight = apiFetch<PublicBrand>('/api/public/brand', { auth: false })
.then((server) => {
current = merge(server);
for (const listener of listeners) listener();
})
.catch((err: unknown) => {
// Zaloha zustava, web bez udaju provozovatele porad funguje.
console.warn('[brand] udaje provozovatele se nepodarilo nacist:', err);
});
return inflight;
}
/** Odber pro `useSyncExternalStore`. Prvni odberatel spusti nacteni. */
function subscribe(listener: () => void): () => void {
listeners.add(listener);
void load();
return () => {
listeners.delete(listener);
};
}
function snapshot(): BrandInfo {
return current;
}
/** Udaje provozovatele. Vraci hned zalohu, po nacteni se prekresli. */
export function useBrand(): BrandInfo {
return useSyncExternalStore(subscribe, snapshot, snapshot);
}
+6 -3
View File
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { brand } from '@/config/brand';
import { useBrand } from '@/hooks/useBrand';
/**
* Nastavi <title> a meta description pro danou stranku.
@@ -21,11 +22,13 @@ export function usePageMeta({
/** `portal` prida slovo "portal" - stranky za prihlasenim. */
area?: 'portal';
}) {
// Jmeno bere od provozovatele portalu, claim zustava staticky.
const { name } = useBrand();
const fullTitle = !title
? `${brand.name} - ${brand.claim}`
? `${name} - ${brand.claim}`
: area === 'portal'
? `${title} - portál ${brand.name}`
: `${title} - ${brand.name}`;
? `${title} - portál ${name}`
: `${title} - ${name}`;
useEffect(() => {
document.title = fullTitle;
+4
View File
@@ -319,6 +319,10 @@ export const cs = {
'contact.submitting': 'Odesílám...',
'contact.consent': 'Odesláním souhlasíte se zpracováním údajů pro účely odpovědi.',
'contact.failed': 'Zprávu se nepodařilo odeslat.',
'contact.attachments': 'Přílohy',
'contact.attachmentsHint': 'Nejvíc {count} soubory, každý do {size}.',
'contact.pickFiles': 'Vybrat soubory',
'contact.website': 'Web',
'contact.directTitle': 'Přímé kontakty',
'contact.availability': 'Dostupnost',
'contact.address': 'Adresa',
+4
View File
@@ -321,6 +321,10 @@ export const en: Partial<Record<MessageKey, string>> = {
'contact.consent':
'By sending you agree to the processing of your data for the purpose of a reply.',
'contact.failed': 'The message could not be sent.',
'contact.attachments': 'Attachments',
'contact.attachmentsHint': 'Up to {count} files, each up to {size}.',
'contact.pickFiles': 'Choose files',
'contact.website': 'Website',
'contact.directTitle': 'Direct contacts',
'contact.availability': 'Availability',
'contact.address': 'Address',
+53
View File
@@ -171,3 +171,56 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
const result = await apiFetchWithMeta<T>(path, options);
return result.data;
}
/** Binarni odpoved: obsah a nazev souboru z `Content-Disposition`. */
export interface ApiBlob {
blob: Blob;
/** Nazev z hlavicky. null = server ho neposlal, volajici si dosadi svuj. */
filename: string | null;
}
/**
* Stazeni souboru s tokenem.
*
* Obsah prilohy chce Authorization hlavicku, takze obycejny `<a href>` nestaci:
* prohlizec ji do navigace nepridava. Stahne se pres fetch a volajici z blobu
* udela docasny odkaz. Chyby se zpracuji stejne jako u JSONu.
*/
export async function apiBlob(path: string): Promise<ApiBlob> {
const token = getToken();
const target = withTenant(path);
const response = await fetch(apiUrl(target), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload: unknown = isJson ? await response.json().catch(() => null) : null;
const message =
payload !== null && typeof payload === 'object' && 'message' in payload
? String((payload as { message: unknown }).message)
: `Soubor se nepodařilo stáhnout (HTTP ${response.status}).`;
console.error(`[api] ${path} -> ${response.status} ${message}`);
if (response.status === 401 && token) notifyAuthExpired();
throw new ApiError(message, response.status);
}
return { blob: await response.blob(), filename: filenameFrom(response.headers) };
}
/** Nazev z `Content-Disposition`, prednostne z `filename*` (UTF-8). */
function filenameFrom(headers: Headers): string | null {
const disposition = headers.get('content-disposition');
if (!disposition) return null;
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
if (encoded?.[1]) {
try {
return decodeURIComponent(encoded[1]);
} catch (err) {
console.warn('[api] nazev souboru v hlavicce nejde dekodovat:', err);
}
}
const plain = /filename="?([^";]+)"?/i.exec(disposition);
return plain?.[1] ?? null;
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Prace se soubory pred odeslanim: prevod na base64, velikost, kontrola.
*
* Limity musi sedet se serverem (`MAX_ATTACHMENT_BYTES` v routach priloh).
* Kontrola na klientovi je jen pohodli - server ji dela znovu.
*/
import { plural } from '@/lib/format';
/** Nejvetsi povolena priloha. Stejna hodnota je na serveru. */
export const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
/** Kolik priloh jde poslat s poptavkou z webu. */
export const CONTACT_MAX_ATTACHMENTS = 3;
/** Kolik priloh muze mit jeden ticket celkem. */
export const TICKET_MAX_ATTACHMENTS = 10;
const KILOBYTE = 1024;
const MEGABYTE = KILOBYTE * 1024;
/** "12 kB" / "1,4 MB". Pod kilobajt se pise v bajtech. */
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '-';
if (bytes < KILOBYTE) return `${bytes} B`;
if (bytes < MEGABYTE) return `${Math.round(bytes / KILOBYTE)} kB`;
return `${(bytes / MEGABYTE).toFixed(1).replace('.', ',')} MB`;
}
/**
* Obsah souboru jako base64 bez prefixu `data:...;base64,`.
*
* Server dostane jen data; typ a nazev jdou vedle v JSONu, takze prefix by
* byl duplicitni a jeste by ho musel na serveru nekdo odriznout.
*/
export function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => {
console.error('[files] cteni souboru selhalo:', file.name, reader.error);
reject(reader.error ?? new Error(`Soubor ${file.name} se nepodařilo přečíst.`));
};
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
const comma = result.indexOf(',');
resolve(comma === -1 ? result : result.slice(comma + 1));
};
reader.readAsDataURL(file);
});
}
/**
* Kontrola pred odeslanim: pocet a velikost. Vraci ceskou chybu, nebo null.
*
* `maxCount` je kolik jeste jde pridat (u ticketu limit minus uz nahrane),
* ne absolutni strop - to si spocita volajici.
*/
export function validateFiles(
files: File[],
{ maxCount, maxBytes }: { maxCount: number; maxBytes: number },
): string | null {
if (files.length > maxCount) {
return maxCount === 0
? 'Další přílohy už přidat nejde.'
: `Nejvíc ${maxCount} ${plural(maxCount, ['soubor', 'soubory', 'souborů'])}.`;
}
const oversized = files.find((file) => file.size > maxBytes);
if (oversized) {
return `Soubor ${oversized.name} je větší než ${formatBytes(maxBytes)}.`;
}
const empty = files.find((file) => file.size === 0);
if (empty) return `Soubor ${empty.name} je prázdný.`;
return null;
}
+3 -1
View File
@@ -5,6 +5,7 @@ import { PageHeader } from '@/components/ui/PageHeader';
import { Section, SectionHeading } from '@/components/ui/Section';
import { brand } from '@/config/brand';
import { useT, type MessageKey } from '@/i18n';
import { useBrand } from '@/hooks/useBrand';
import { usePageMeta } from '@/hooks/usePageMeta';
const values: Array<{ icon: typeof Compass; title: MessageKey; text: MessageKey }> = [
@@ -31,6 +32,7 @@ const milestones: Array<{ year: string; text: MessageKey }> = [
export default function About() {
const t = useT();
const operator = useBrand();
usePageMeta({ title: t('about.title'), description: t('about.meta') });
@@ -43,7 +45,7 @@ export default function About() {
{t('about.titleFirst')} <span className="text-gradient">{t('about.titleAccent')}</span>
</>
}
subtitle={t('about.subtitle', { brand: brand.name, founded: brand.founded })}
subtitle={t('about.subtitle', { brand: operator.name, founded: brand.founded })}
/>
<Section>
+68 -14
View File
@@ -1,10 +1,11 @@
import { AlertCircle, CheckCircle2, Clock, Mail, MapPin, Phone, Send } from 'lucide-react';
import { AlertCircle, CheckCircle2, Clock, Globe, Mail, MapPin, Phone, Send } from 'lucide-react';
import { useState } from 'react';
import type { FormEvent, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Field } from '@/components/ui/form/Field';
import { FilePicker } from '@/components/ui/form/FilePicker';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { Textarea } from '@/components/ui/form/Textarea';
@@ -13,7 +14,15 @@ import { Section } from '@/components/ui/Section';
import { brand } from '@/config/brand';
import { useT, type MessageKey } from '@/i18n';
import { apiFetch } from '@/lib/api';
import {
CONTACT_MAX_ATTACHMENTS,
MAX_ATTACHMENT_BYTES,
formatBytes,
readFileAsBase64,
} from '@/lib/files';
import { useBrand } from '@/hooks/useBrand';
import { usePageMeta } from '@/hooks/usePageMeta';
import type { AttachmentUpload } from '@/types/dashboard';
/** Hodnota jde na server cesky, popisek se preklada. */
const topics: Array<{ value: string; label: MessageKey }> = [
@@ -33,10 +42,12 @@ type Status =
export default function Contact() {
const t = useT();
const operator = useBrand();
usePageMeta({ title: t('contact.title'), description: t('contact.meta') });
const [status, setStatus] = useState<Status>({ kind: 'idle' });
const [files, setFiles] = useState<File[]>([]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -45,13 +56,23 @@ export default function Contact() {
setStatus({ kind: 'sending' });
try {
// Prilohy jdou v tele jako base64. Formular je maly a limit 3 x 5 MB
// se do JSONu vejde, multipart by znamenal druhy zpusob parsovani.
const attachments: AttachmentUpload[] = await Promise.all(
files.map(async (file) => ({
name: file.name,
mime: file.type || 'application/octet-stream',
content: await readFileAsBase64(file),
})),
);
const response = await apiFetch<{ message: string }>('/api/contact', {
method: 'POST',
auth: false,
body: data,
body: { ...data, attachments },
});
setStatus({ kind: 'ok', message: response.message });
form.reset();
setFiles([]);
} catch (err) {
const message = err instanceof Error ? err.message : t('contact.failed');
console.error('[contact] odeslani selhalo:', err);
@@ -140,6 +161,20 @@ export default function Contact() {
/>
</Field>
<FilePicker
label={t('contact.attachments')}
hint={t('contact.attachmentsHint', {
count: CONTACT_MAX_ATTACHMENTS,
size: formatBytes(MAX_ATTACHMENT_BYTES),
})}
buttonLabel={t('contact.pickFiles')}
files={files}
onChange={setFiles}
maxCount={CONTACT_MAX_ATTACHMENTS}
disabled={status.kind === 'sending'}
size="md"
/>
<div className="flex flex-wrap items-center gap-4 pt-2">
<Button type="submit" disabled={status.kind === 'sending'}>
<Send className="size-4" />
@@ -168,26 +203,45 @@ export default function Contact() {
<h2 className="font-semibold text-white">{t('contact.directTitle')}</h2>
<ul className="mt-4 space-y-4 text-sm">
<ContactRow icon={<Mail className="size-4" />} label={t('contact.email')}>
<a href={`mailto:${brand.email}`} className="text-white/70 hover:text-brand-300">
{brand.email}
<a
href={`mailto:${operator.email}`}
className="text-white/70 hover:text-brand-300"
>
{operator.email}
</a>
</ContactRow>
<ContactRow icon={<Phone className="size-4" />} label={t('contact.phone')}>
<a href={`tel:${brand.phoneHref}`} className="text-white/70 hover:text-brand-300">
{brand.phone}
<a
href={`tel:${operator.phoneHref}`}
className="text-white/70 hover:text-brand-300"
>
{operator.phone}
</a>
</ContactRow>
{operator.website && (
<ContactRow icon={<Globe className="size-4" />} label={t('contact.website')}>
<a
href={operator.website}
target="_blank"
rel="noreferrer"
className="text-white/70 hover:text-brand-300"
>
{operator.website}
</a>
</ContactRow>
)}
<ContactRow icon={<Clock className="size-4" />} label={t('contact.availability')}>
<span className="text-white/70">{brand.support.hours}</span>
<span className="mt-0.5 block text-xs text-white/40">{brand.support.sla}</span>
</ContactRow>
<ContactRow icon={<MapPin className="size-4" />} label={t('contact.address')}>
<span className="text-white/70">
{brand.address.street}
<br />
{brand.address.zip} {brand.address.city}
<br />
{brand.address.country}
{operator.addressLines.map((line, index) => (
<span key={line}>
{index > 0 && <br />}
{line}
</span>
))}
</span>
</ContactRow>
</ul>
@@ -198,15 +252,15 @@ export default function Contact() {
<dl className="mt-4 space-y-2 font-mono text-sm text-white/60">
<div className="flex justify-between gap-4">
<dt className="text-white/40">{t('contact.billingCompany')}</dt>
<dd>{brand.legalName}</dd>
<dd>{operator.legalName}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-white/40">{t('contact.billingIco')}</dt>
<dd>{brand.ico}</dd>
<dd>{operator.ico}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-white/40">{t('contact.billingDic')}</dt>
<dd>{brand.dic}</dd>
<dd>{operator.dic}</dd>
</div>
</dl>
</Card>
+4 -3
View File
@@ -7,8 +7,8 @@ import { Logo } from '@/components/layout/Logo';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { brand } from '@/config/brand';
import { useT, type MessageKey } from '@/i18n';
import { useBrand } from '@/hooks/useBrand';
import { usePageMeta } from '@/hooks/usePageMeta';
/**
@@ -35,10 +35,11 @@ const [defaultAccount] = demoAccounts;
/** Prihlaseni do klientskeho portalu. Demo ucty jsou vypsane pod formularem. */
export default function Login() {
const t = useT();
const operator = useBrand();
usePageMeta({
title: t('login.title'),
description: t('login.meta', { brand: brand.name }),
description: t('login.meta', { brand: operator.name }),
});
const { user, loading, login } = useAuth();
@@ -195,7 +196,7 @@ export default function Login() {
<Link to="/kontakt" className="font-medium text-brand-300 hover:text-brand-200">
{t('login.contactUs')}
</Link>{' '}
{t('login.noAccountTail', { brand: brand.name })}
{t('login.noAccountTail', { brand: operator.name })}
</p>
</div>
</div>
+2 -119
View File
@@ -1,6 +1,5 @@
import { useMemo, useState } from 'react';
import { useAuth } from '@/auth/AuthContext';
import { AresTenantDialog } from '@/components/dashboard/AresTenantDialog';
import { EntityAdmin, parseJsonField, type NamedRecord } from '@/components/dashboard/EntityAdmin';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
@@ -15,6 +14,7 @@ import { usePageMeta } from '@/hooks/usePageMeta';
import { useSubmit } from '@/hooks/useSubmit';
import { FeaturesAdmin } from '@/components/dashboard/settings/FeaturesAdmin';
import { AuditView } from '@/components/dashboard/settings/AuditView';
import { TenantsAdmin } from '@/components/dashboard/settings/TenantsAdmin';
import type { SettingsCatalog } from '@/components/dashboard/settings/types';
/** Nejkratsi heslo. Musi sedet s validaci na serveru. */
@@ -38,17 +38,7 @@ export default function Settings() {
const { user } = useAuth();
const catalog = useApiQuery<SettingsCatalog>('/api/dashboard/settings/catalog');
/*
* Seznam firem zvlast, i kdyz si ho `EntityAdmin` nacita sam. Potrebuje se
* do nabidky "helpdesk resi firma", tedy do konfigurace tehoz seznamu -
* dostat se k tomu, co si komponenta nacetla dovnitr, by znamenalo
* protahnout to skrz ni jen kvuli jednomu poli.
*/
const tenants = useApiQuery<{ items: Array<{ id: string; name: string }> }>(
'/api/dashboard/settings/tenants',
);
const [tab, setTab] = useState('ucet');
const [aresOpen, setAresOpen] = useState(false);
/*
* Vsechny zalozky krome uctu jsou sprava platformy, ne firmy. Priznak
@@ -94,114 +84,7 @@ export default function Settings() {
{tab === 'users' && <UsersAdmin />}
{tab === 'tenants' && (
<EntityAdmin
title="Firmy"
description="Firmy, které portál používají. Firma je hranice viditelnosti."
endpoint="/api/dashboard/settings/tenants"
refetchOn={['tenant.created', 'tenant.updated', 'tenant.deleted']}
// Prazdne IC neni "IC s nula znaky", ale zadne IC. Server ma na
// vyplnene osm cislic, tak se prazdne posila jako null.
toBody={(values) =>
Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key,
['ico', 'dic', 'address', 'legalForm', 'helpdeskProviderId'].includes(key) &&
value === ''
? null
: value,
]),
)
}
columns={[
{ label: 'Název', render: (tenant: NamedRecord) => String(tenant.name ?? '') },
{
label: 'IČ',
narrow: true,
render: (tenant: NamedRecord) =>
tenant.ico ? (
<code>{String(tenant.ico)}</code>
) : (
<span className="text-white/30">bez IČ</span>
),
},
{ label: 'ID', render: (tenant: NamedRecord) => <code>{tenant.id}</code> },
{ label: 'Poznámka', render: (tenant: NamedRecord) => String(tenant.note ?? '') },
{
label: 'Helpdesk řeší',
render: (tenant: NamedRecord) => {
const provider = tenants.data?.items.find(
(item) => item.id === tenant.helpdeskProviderId,
);
return provider ? provider.name : <span className="text-white/30">nikdo</span>;
},
},
{
label: 'Stav',
narrow: true,
render: (tenant: NamedRecord) =>
tenant.enabled === false ? (
<Badge tone="warn">vypnutá</Badge>
) : (
<Badge tone="ok">aktivní</Badge>
),
},
]}
fields={[
{ name: 'name', label: 'Název firmy', kind: 'text', required: true },
{
name: 'ico',
label: 'IČ',
kind: 'text',
hint: '8 číslic. Vyplní se samo při založení přes ARES.',
},
{ name: 'dic', label: 'DIČ', kind: 'text' },
{ name: 'address', label: 'Sídlo', kind: 'text' },
{ name: 'legalForm', label: 'Právní forma', kind: 'text' },
{
name: 'note',
label: 'Poznámka',
kind: 'textarea',
hint: 'Pro nás, klient ji nevidí.',
},
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
{
/*
Komu firma posila pozadavky z helpdesku. Urcuje to spravce
platformy, ne firma sama: kdo koho obsluhuje je obchodni vztah.
Prazdna hodnota znamena, ze helpdesk nema komu poslat, a rekne
se to na strance Helpdesk nahlas.
*/
name: 'helpdeskProviderId',
label: 'Helpdesk řeší firma',
kind: 'select',
hint: 'Komu půjdou požadavky z helpdesku této firmy. Bez vyplnění je poslat nelze.',
options: [
{ value: '', label: 'Nikdo' },
...(tenants.data?.items ?? []).map((tenant) => ({
value: tenant.id,
label: tenant.name,
})),
],
},
]}
>
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-white/10 p-3 text-sm text-white/70">
<span>
Novou firmu jde založit z registru ARES: dotáhne se název, IČ, DIČ, sídlo a lidé,
kteří za ni jednají.
</span>
<Button size="sm" variant="secondary" onClick={() => setAresOpen(true)}>
Založit přes ARES
</Button>
</div>
<AresTenantDialog
open={aresOpen}
onClose={() => setAresOpen(false)}
onCreated={() => tenants.reload()}
/>
</EntityAdmin>
)}
{tab === 'tenants' && <TenantsAdmin />}
{tab === 'features' && <FeaturesAdmin catalog={catalog.data} />}
{tab === 'audit' && <AuditView />}
+18 -206
View File
@@ -6,9 +6,8 @@ import {
Inbox,
MessageSquarePlus,
ScrollText,
UserCheck,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
@@ -18,17 +17,17 @@ import {
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { TicketActions } from '@/components/dashboard/TicketActions';
import { TicketAssignPanel } from '@/components/dashboard/TicketAssignPanel';
import { TicketAttachments } from '@/components/dashboard/TicketAttachments';
import { DataTable, TicketBody } from '@/components/dashboard/TicketBody';
import { TicketEvents } from '@/components/dashboard/TicketEvents';
import { TicketTrace, TraceSummary } from '@/components/dashboard/TicketTrace';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useCollection } from '@/lib/collections';
import { useAccess, useCollection } from '@/lib/collections';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
@@ -38,14 +37,6 @@ import type { TicketDetail as Detail } from '@/types/dashboard';
/** Nejkratsi komentar, ktery se odesle. Jedno pismeno je preklep, ne zprava. */
const MIN_COMMENT_LENGTH = 2;
/**
* Doporucene stavy, kdyz si typ ticketu zadne nenadefinoval.
*
* Je to nabidka, ne ciselnik - stav je volny retezec a ticket muze mit
* i hodnotu, kterou poslala cizi aplikace.
*/
const defaultStatuses = ['Nový', 'V řešení', 'Čeká na klienta', 'Vyřešeno'];
export default function TicketDetail() {
const { id = '' } = useParams();
@@ -55,61 +46,20 @@ export default function TicketDetail() {
/*
* Ciselniky z ulozist, ne vlastni dotazy. A hlavne **nic z nich nebrani
* otevrit ticket**: kdo nema pravo na lidi nebo katalog, dostane detail
* bez roletky resitele, ne prazdnou stranku s chybou.
* otevrit ticket**: kdo nema pravo na katalog, dostane log s ID sluzeb
* misto jmen, ne prazdnou stranku s chybou. Resitele a stav resi
* `TicketAssignPanel`, ten si sve ciselniky bere sam.
*/
const people = useCollection('people');
const groups = useCollection('groups');
const services = useCollection('services');
const types = useCollection('ticketTypes');
const access = useAccess();
/** Nahravat a mazat prilohy smi ten, kdo smi komentovat. */
const canAttach = access.data?.permissions.includes('ticket.comment') === true;
const [comment, setComment] = useState('');
/** Stavy, ktere firma uz nekde pouziva. Jen naseptavac, ne omezeni. */
const [usedStatuses, setUsedStatuses] = useState<string[]>([]);
const [statusesError, setStatusesError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
apiFetch<{ items: string[] }>('/api/dashboard/tickets/statuses')
.then((data) => {
if (!cancelled) setUsedStatuses(data.items);
})
.catch((err: unknown) => {
// Bez naseptavace se da zit, ale musi byt videt, ze chybi a proc.
console.warn('[ticket] stavy se nepodarilo nacist:', err);
if (!cancelled) setStatusesError('Nabídku stavů se nepodařilo načíst.');
});
return () => {
cancelled = true;
};
}, []);
/*
* Co nabidnout ve vyberu stavu.
*
* Stav je volny retezec, takze nabidka je jen pohodli. Sklada se ze stavu
* typu ticketu (kdyz si je firma nadefinovala), z doporucenych a **z toho,
* co ticket ma prave ted** - jinak by hodnota z cizi aplikace ze seznamu
* zmizela a prvni zmena stavu by ji nenavratne prepsala.
*/
const statusChoices = useMemo(() => {
const type = types.items.find((item) => item.id === ticket.data?.typeId);
const own = type?.statuses ?? [];
const base = own.length > 0 ? own : defaultStatuses;
const current = ticket.data?.status;
return [...new Set([...base, ...usedStatuses, ...(current ? [current] : [])])];
}, [types.items, usedStatuses, ticket.data?.typeId, ticket.data?.status]);
/*
* Rozepsany stav.
*
* Drzi se zvlast od ticketu, protoze zapis nesmi odejit na server po kazdem
* pismenu - "Ce" na ceste k "Ceka na zakaznika" je platny stav a ulozil by se.
* Posila se az pri opusteni pole nebo pri Enteru.
*/
const [statusDraft, setStatusDraft] = useState<string | null>(null);
/**
* Ktery list detailu je navrchu.
*
@@ -160,8 +110,6 @@ export default function TicketDetail() {
if (await mutate('/comment', { text })) setComment('');
}
const meId = people.extra?.meId ?? null;
return (
<div className="space-y-6">
<Link
@@ -339,152 +287,16 @@ export default function TicketDetail() {
</>
)}
</section>
<TicketAttachments
ticketId={ticket.data.id}
canWrite={canAttach}
people={people.items}
/>
</div>
<aside className="space-y-4">
<div className="glass rounded-card p-5">
<h2 className="flex items-center gap-2 font-semibold text-white">
<UserCheck className="size-4 text-brand-300" />
Řešitel
</h2>
<p
className={cn(
'mt-3 text-sm',
ticket.data.assignee ? 'text-white/85' : 'text-warn-400',
)}
>
{ticket.data.assignee ? ticket.data.assignee.name : 'Ve frontě, bez řešitele'}
</p>
{/*
Prevzeti: kdyz ticket lezi ve fronte skupiny, vezme si ho
clovek sam. Je to jina vec nez prehazovani prace shora, proto
tlacitko a ne polozka v selectu.
*/}
{meId && ticket.data.assignee?.id !== meId && (
<Button
size="sm"
disabled={busy}
onClick={() => void mutate('/claim', {})}
className="mt-3 w-full justify-center"
>
Převzít
</Button>
)}
{/*
Bez prava na lidi se roletka nenabidne. Ticket jde otevrit
a resit dal, jen ho nejde predat - a rekne se to.
*/}
{people.error ? (
<p className="mt-3 text-xs text-white/40">
Seznam řešitelů není k dispozici, ticket nejde předat.
</p>
) : (
<>
<label htmlFor="ticket-assignee" className="sr-only">
Přiřadit řešiteli
</label>
<Select
id="ticket-assignee"
value={ticket.data.assignee?.id ?? ''}
disabled={busy}
onChange={(event) =>
void mutate('/assign', {
assigneeId: event.target.value === '' ? null : event.target.value,
})
}
size="lg"
className="mt-3"
placeholder="Vrátit do fronty"
options={people.items.map((person) => ({
value: person.id,
label: `${person.name}, ${person.role}`,
}))}
/>
</>
)}
{/*
Skupina je vedle resitele, ne misto nej. Ticket muze byt
"u ucetnich" a zaroven u konkretniho cloveka z nich.
*/}
{groups.items.length > 0 && (
<Field
label={
<span className="text-xs font-semibold tracking-wide uppercase">
Skupina
</span>
}
htmlFor="ticket-group"
className="mt-4"
>
<Select
id="ticket-group"
value={ticket.data.assigneeGroupId ?? ''}
disabled={busy}
onChange={(event) =>
void mutate('/group', {
groupId: event.target.value === '' ? null : event.target.value,
})
}
size="lg"
placeholder="Bez skupiny"
options={groups.items.map((group) => ({
value: group.id,
label: group.name,
}))}
/>
</Field>
)}
{/*
Otevreny naseptavac, ne ciselnik. Stav je volny retezec:
ticket muze prijit z cizi aplikace s jejim vlastnim stavem
a firma si smi zavest vlastni, aniz by ho nekdo predem
zapisoval do nastaveni. Nabidka je pohodli, ne omezeni.
*/}
<Field
label={
<span className="text-xs font-semibold tracking-wide uppercase">Stav</span>
}
htmlFor="ticket-status"
className="mt-4"
hint={
statusesError ?? 'Stav je volný text. Nabídka je jen z toho, co už používáte.'
}
>
<Input
id="ticket-status"
list="ticket-status-options"
value={statusDraft ?? ticket.data.status}
disabled={busy}
onChange={(event) => setStatusDraft(event.target.value)}
onBlur={(event) => {
const next = event.target.value.trim();
setStatusDraft(null);
if (next === '' || next === ticket.data?.status) return;
void mutate('/status', { status: next });
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
// Escape zahodi rozepsane a vrati puvodni hodnotu.
if (event.key === 'Escape') {
setStatusDraft(null);
event.currentTarget.blur();
}
}}
placeholder="Napište stav, nebo vyberte z nabídky"
size="lg"
/>
<datalist id="ticket-status-options">
{statusChoices.map((option) => (
<option key={option} value={option} />
))}
</datalist>
</Field>
</div>
<TicketAssignPanel ticket={ticket.data} busy={busy} mutate={mutate} />
<div className="glass rounded-card p-5">
<h2 className="flex items-center gap-2 font-semibold text-white">
+4 -3
View File
@@ -32,8 +32,9 @@ interface WidgetRecord {
}
const sourceHint = `Příklady:
{"kind":"ticketCount","filter":{"status":["new","open","waiting"]}}
{"kind":"ticketCount","filter":{"closed":false}}
{"kind":"ticketCount","filter":{},"groupBy":"assignee"}
{"kind":"ticketCount","filter":{"closed":false},"groupBy":"status"} (s kreslením Graf = koláč)
{"kind":"ticketList","filter":{"status":["new"]},"limit":5}
{"kind":"ticketSeries","filter":{},"bucket":"day"}
{"kind":"agentStats","period":"30d"}
@@ -57,7 +58,7 @@ export default function Widgets() {
{ value: 'stat', label: 'Jedno číslo' },
{ value: 'table', label: 'Tabulka' },
{ value: 'list', label: 'Seznam' },
{ value: 'chart', label: 'Graf' },
{ value: 'chart', label: 'Graf (křivka z časové řady, koláč ze seskupení)' },
{ value: 'gauge', label: 'Ukazatel k cíli' },
],
},
@@ -83,7 +84,7 @@ export default function Widgets() {
label: 'Odkud data',
kind: 'json',
required: true,
initial: '{"kind":"ticketCount","filter":{"status":["new","open","waiting"]}}',
initial: '{"kind":"ticketCount","filter":{"closed":false}}',
hint: sourceHint,
},
{
+2 -1
View File
@@ -6,6 +6,7 @@
* ktere existuji jen na webu.
*/
export type { Attachment, AttachmentUpload } from '@shared/attachments';
export type {
Access,
ModuleKey,
@@ -82,7 +83,7 @@ export type {
ServiceVisibility,
ServiceWithUsage,
} from '@shared/services';
export type { Tenant, TenantRef } from '@shared/tenants';
export type { PublicBrand, Tenant, TenantRef } from '@shared/tenants';
export type {
AgentStatsRow,
Ticket,