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