diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index 8491376..9c89322 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -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 diff --git a/documentation/03-architektura-a-mapa-kodu.md b/documentation/03-architektura-a-mapa-kodu.md index 6a2da37..78e7e9e 100644 --- a/documentation/03-architektura-a-mapa-kodu.md +++ b/documentation/03-architektura-a-mapa-kodu.md @@ -64,8 +64,8 @@ viz [99-zmeny.md](99-zmeny.md). | `src/config.ts` | **jedine misto, kde se cte `process.env`**; `serviceBaseUrlOverride(variable)` pro `_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. diff --git a/documentation/04-api.md b/documentation/04-api.md index 49bcfa7..bc7c017 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -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 `: | 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: `, 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 `` 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 | diff --git a/documentation/06-tickety.md b/documentation/06-tickety.md index f2f1e03..796b418 100644 --- a/documentation/06-tickety.md +++ b/documentation/06-tickety.md @@ -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: ` (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 | diff --git a/documentation/07-firmy-a-prava.md b/documentation/07-firmy-a-prava.md index ba80204..6c5c341 100644 --- a/documentation/07-firmy-a-prava.md +++ b/documentation/07-firmy-a-prava.md @@ -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 | diff --git a/documentation/14-databaze.md b/documentation/14-databaze.md index 75d5c06..ea7261c 100644 --- a/documentation/14-databaze.md +++ b/documentation/14-databaze.md @@ -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` | diff --git a/documentation/15-rejstrik-funkci.md b/documentation/15-rejstrik-funkci.md index 8d4f37d..1f43542 100644 --- a/documentation/15-rejstrik-funkci.md +++ b/documentation/15-rejstrik-funkci.md @@ -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/.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 ``, 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. | diff --git a/documentation/17-nastaveni-a-prava.md b/documentation/17-nastaveni-a-prava.md index a2c0f74..61ebc08 100644 --- a/documentation/17-nastaveni-a-prava.md +++ b/documentation/17-nastaveni-a-prava.md @@ -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ů. diff --git a/documentation/18-ticketovaci-system.md b/documentation/18-ticketovaci-system.md index 75fe7f1..663626a 100644 --- a/documentation/18-ticketovaci-system.md +++ b/documentation/18-ticketovaci-system.md @@ -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 | diff --git a/documentation/22-znacka-a-design.md b/documentation/22-znacka-a-design.md index ca7c208..e8dc6d5 100644 --- a/documentation/22-znacka-a-design.md +++ b/documentation/22-znacka-a-design.md @@ -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 diff --git a/documentation/25-navrh-pristupny-portal.md b/documentation/25-navrh-pristupny-portal.md index b34edfc..d8d0cab 100644 --- a/documentation/25-navrh-pristupny-portal.md +++ b/documentation/25-navrh-pristupny-portal.md @@ -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 `` 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 `` ve +strance je od ted stejna chyba jako vlastni `inputClass`. + --- ## 3 - Hledani jako modal s kriterii diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 8a5c043..3b0332c 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -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: `, 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 diff --git a/src/app.ts b/src/app.ts index a3b60cc..24a22af 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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; diff --git a/src/data/attachments.ts b/src/data/attachments.ts new file mode 100644 index 0000000..751d35f --- /dev/null +++ b/src/data/attachments.ts @@ -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('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 { + 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 { + 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 { + 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; +} diff --git a/src/data/bootstrap.ts b/src/data/bootstrap.ts index 8ab0bf3..7f9cef4 100644 --- a/src/data/bootstrap.ts +++ b/src/data/bootstrap.ts @@ -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 }, { store: inviteStore as EntityStore }, { store: tenantScriptStore as EntityStore }, + // Prilohy ticketu. Zadna kopie v pameti, ctou se az u detailu ticketu. + { store: attachmentStore as EntityStore }, ]; /** diff --git a/src/data/customWidgets.ts b/src/data/customWidgets.ts index bb32d15..6170079 100644 --- a/src/data/customWidgets.ts +++ b/src/data/customWidgets.ts @@ -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ů.'); diff --git a/src/data/tenants.ts b/src/data/tenants.ts index c0c7956..4b9a568 100644 --- a/src/data/tenants.ts +++ b/src/data/tenants.ts @@ -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 { + 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); + } +} diff --git a/src/data/tickets/index.ts b/src/data/tickets/index.ts index a7ef51e..b5a940f 100644 --- a/src/data/tickets/index.ts +++ b/src/data/tickets/index.ts @@ -60,6 +60,7 @@ export { assignTicketGroup, claimTicket, createTicket, + noteAttachment, setTicketTags, setTicketType, ticketAssignee, diff --git a/src/data/tickets/store.ts b/src/data/tickets/store.ts index b211815..a5f6611 100644 --- a/src/data/tickets/store.ts +++ b/src/data/tickets/store.ts @@ -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); +} diff --git a/src/openapi/components.ts b/src/openapi/components.ts deleted file mode 100644 index e95e492..0000000 --- a/src/openapi/components.ts +++ /dev/null @@ -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 .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' }, - }, - }, - ], - }, - }, -}; diff --git a/src/openapi/components/attachments.ts b/src/openapi/components/attachments.ts new file mode 100644 index 0000000..33fefa3 --- /dev/null +++ b/src/openapi/components/attachments.ts @@ -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.', + }, + }, + }, +}; diff --git a/src/openapi/components/auth.ts b/src/openapi/components/auth.ts new file mode 100644 index 0000000..58785d4 --- /dev/null +++ b/src/openapi/components/auth.ts @@ -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' }, + }, + }, +}; diff --git a/src/openapi/components/automations.ts b/src/openapi/components/automations.ts new file mode 100644 index 0000000..aea7d2d --- /dev/null +++ b/src/openapi/components/automations.ts @@ -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' }, + }, + }, + ], + }, +}; diff --git a/src/openapi/components/common.ts b/src/openapi/components/common.ts new file mode 100644 index 0000000..9a8959d --- /dev/null +++ b/src/openapi/components/common.ts @@ -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' }, + }, + }, + }, + }, + }, +}; diff --git a/src/openapi/components/connectors.ts b/src/openapi/components/connectors.ts new file mode 100644 index 0000000..20b7f45 --- /dev/null +++ b/src/openapi/components/connectors.ts @@ -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' }, + }, + }, +}; diff --git a/src/openapi/components/index.ts b/src/openapi/components/index.ts new file mode 100644 index 0000000..48a38ff --- /dev/null +++ b/src/openapi/components/index.ts @@ -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, + }, +}; diff --git a/src/openapi/components/scripts.ts b/src/openapi/components/scripts.ts new file mode 100644 index 0000000..216da96 --- /dev/null +++ b/src/openapi/components/scripts.ts @@ -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 .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' } }, + }, + }, + }, + }, + }, + }, +}; diff --git a/src/openapi/components/settings.ts b/src/openapi/components/settings.ts new file mode 100644 index 0000000..11b352e --- /dev/null +++ b/src/openapi/components/settings.ts @@ -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 }, + }, + }, +}; diff --git a/src/openapi/components/tickets.ts b/src/openapi/components/tickets.ts new file mode 100644 index 0000000..5664454 --- /dev/null +++ b/src/openapi/components/tickets.ts @@ -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 }, + }, + }, +}; diff --git a/src/openapi/index.ts b/src/openapi/index.ts index 3768209..0d0c171 100644 --- a/src/openapi/index.ts +++ b/src/openapi/index.ts @@ -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, }, }; } diff --git a/src/openapi/paths/attachments.ts b/src/openapi/paths/attachments.ts new file mode 100644 index 0000000..4349ad0 --- /dev/null +++ b/src/openapi/paths/attachments.ts @@ -0,0 +1,98 @@ +/** Prilohy ticketu: seznam, nahrani, stazeni a odebrani. */ + +import { bearer, idParam, jsonBody, jsonResponse } from '../helpers.js'; + +export const attachmentsPaths: Record = { + '/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''.", + 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' }, + }, + }, + }, +}; diff --git a/src/openapi/paths/contact.ts b/src/openapi/paths/contact.ts index 28f98c1..863b797 100644 --- a/src/openapi/paths/contact.ts +++ b/src/openapi/paths/contact.ts @@ -7,6 +7,10 @@ export const contactPaths: Record = { 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 = { 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 = { }, responses: { '202': { description: 'Prijato' }, - '400': { description: 'Neplatny vstup' }, + '400': { description: 'Neplatny vstup nebo neplatna priloha' }, + '413': { description: 'Telo presahlo strop pro prilohy' }, ...tooMany, }, }, diff --git a/src/openapi/paths/public.ts b/src/openapi/paths/public.ts new file mode 100644 index 0000000..355806a --- /dev/null +++ b/src/openapi/paths/public.ts @@ -0,0 +1,19 @@ +/** Verejne udaje bez prihlaseni. */ + +import { jsonResponse } from '../helpers.js'; + +export const publicPaths: Record = { + '/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' }), + }, + }, + }, +}; diff --git a/src/openapi/paths/ticketActions.ts b/src/openapi/paths/ticketActions.ts new file mode 100644 index 0000000..2e85867 --- /dev/null +++ b/src/openapi/paths/ticketActions.ts @@ -0,0 +1,99 @@ +/** Akce nad ticketem: co jde v dane situaci spustit a spusteni akce. */ + +export const ticketActionsPaths: Record = { + '/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' }, + }, + }, + }, +}; diff --git a/src/openapi/paths/tickets.ts b/src/openapi/paths/tickets.ts index ea81403..ce51879 100644 --- a/src/openapi/paths/tickets.ts +++ b/src/openapi/paths/tickets.ts @@ -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 = { }, }, }, - '/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' }, - }, - }, - }, }; diff --git a/src/routes/bodyLimit.ts b/src/routes/bodyLimit.ts new file mode 100644 index 0000000..f03f7bb --- /dev/null +++ b/src/routes/bodyLimit.ts @@ -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)); +} diff --git a/src/routes/contact.ts b/src/routes/contact.ts index 84f94c3..1458000 100644 --- a/src/routes/contact.ts +++ b/src/routes/contact.ts @@ -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 = { + 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.', + }); + }, +); diff --git a/src/routes/crud.ts b/src/routes/crud.ts index 4e40384..cb2997d 100644 --- a/src/routes/crud.ts +++ b/src/routes/crud.ts @@ -78,6 +78,12 @@ export interface CrudOptions { 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; /** * Zaznamy bez firmy (`tenantId: null`), ktere se presto spravuji po firmach. * @@ -233,6 +239,7 @@ export function crudRouter(options: CrudOptions(options: CrudOptions { + 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(); +}); diff --git a/src/routes/dashboard/index.ts b/src/routes/dashboard/index.ts index cb3270f..9cc2178 100644 --- a/src/routes/dashboard/index.ts +++ b/src/routes/dashboard/index.ts @@ -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); diff --git a/src/routes/public.ts b/src/routes/public.ts new file mode 100644 index 0000000..bb7510c --- /dev/null +++ b/src/routes/public.ts @@ -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()); +}); diff --git a/src/routes/settings/tenants.ts b/src/routes/settings/tenants.ts index cd36c53..3b274d9 100644 --- a/src/routes/settings/tenants.ts +++ b/src/routes/settings/tenants.ts @@ -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); + }, }), ); diff --git a/src/shared/attachments.ts b/src/shared/attachments.ts new file mode 100644 index 0000000..bba6184 --- /dev/null +++ b/src/shared/attachments.ts @@ -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; +} diff --git a/src/shared/index.ts b/src/shared/index.ts index eb3c905..af5b940 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -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'; diff --git a/src/shared/tenants.ts b/src/shared/tenants.ts index dba96f6..c27468a 100644 --- a/src/shared/tenants.ts +++ b/src/shared/tenants.ts @@ -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; } diff --git a/tests/data/attachments.test.ts b/tests/data/attachments.test.ts new file mode 100644 index 0000000..4c5cb83 --- /dev/null +++ b/tests/data/attachments.test.ts @@ -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'); + }); +}); diff --git a/tests/data/customWidgets.test.ts b/tests/data/customWidgets.test.ts new file mode 100644 index 0000000..db31b56 --- /dev/null +++ b/tests/data/customWidgets.test.ts @@ -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 { + 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); + }); +}); diff --git a/tests/routes/attachments.test.ts b/tests/routes/attachments.test.ts new file mode 100644 index 0000000..c0b1d9b --- /dev/null +++ b/tests/routes/attachments.test.ts @@ -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 { + 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); + }); +}); diff --git a/tests/routes/contact.test.ts b/tests/routes/contact.test.ts new file mode 100644 index 0000000..0d1eaf7 --- /dev/null +++ b/tests/routes/contact.test.ts @@ -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 { + await tenantStore.update( + 'tnt_automia', + { portalOperator }, + { tenantIds: [], includeGlobal: true }, + ); + await refreshTenants(); +} + +function send(body: Record): 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; + 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(); + }); +}); diff --git a/tests/routes/public.test.ts b/tests/routes/public.test.ts new file mode 100644 index 0000000..3b154f0 --- /dev/null +++ b/tests/routes/public.test.ts @@ -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.'); + }); +}); diff --git a/web/src/components/dashboard/TicketAssignPanel.tsx b/web/src/components/dashboard/TicketAssignPanel.tsx new file mode 100644 index 0000000..57d39f8 --- /dev/null +++ b/web/src/components/dashboard/TicketAssignPanel.tsx @@ -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; +}) { + 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([]); + const [statusesError, setStatusesError] = useState(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(null); + + return ( +
+

+ + Řešitel +

+ +

+ {ticket.assignee ? ticket.assignee.name : 'Ve frontě, bez řešitele'} +

+ + {/* + 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 && ( + + )} + + {/* + Bez prava na lidi se roletka nenabidne. Ticket jde otevrit a resit + dal, jen ho nejde predat - a rekne se to. + */} + {people.error ? ( +

+ Seznam řešitelů není k dispozici, ticket nejde předat. +

+ ) : ( + <> + + + 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 }))} + /> + + )} + + {/* + 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. + */} + Stav} + htmlFor="ticket-status" + className="mt-4" + hint={statusesError ?? 'Stav je volný text. Nabídka je jen z toho, co už používáte.'} + > + 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" + /> + + {statusChoices.map((option) => ( + + +
+ ); +} diff --git a/web/src/components/dashboard/TicketAttachments.tsx b/web/src/components/dashboard/TicketAttachments.tsx new file mode 100644 index 0000000..1b2d5ee --- /dev/null +++ b/web/src/components/dashboard/TicketAttachments.tsx @@ -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([]); + const [toDelete, setToDelete] = useState(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(`${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 ( +
+
+

+ + Přílohy +

+ + {items.length} / {TICKET_MAX_ATTACHMENTS} + +
+ +
+ +
    + {items.map((attachment) => ( +
  • +
    +

    {attachment.name}

    +

    + {formatBytes(attachment.size)}, {formatDateTime(attachment.createdAt)},{' '} + {who(attachment)} +

    +
    + + {canWrite && ( + + )} +
  • + ))} +
+
+
+ + {canWrite && ( +
+ Přidat přílohy + } + 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 && ( + + )} +
+ )} + + {error &&

{error}

} + + setToDelete(null)} + title="Smazat přílohu" + description={toDelete ? `${toDelete.name} zmizí nenávratně.` : undefined} + className="max-w-md" + > +
+ + +
+
+
+ ); +} diff --git a/web/src/components/dashboard/settings/TenantsAdmin.tsx b/web/src/components/dashboard/settings/TenantsAdmin.tsx new file mode 100644 index 0000000..e3b3e5f --- /dev/null +++ b/web/src/components/dashboard/settings/TenantsAdmin.tsx @@ -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 ( + ({ + ...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 ? ( + {String(tenant.ico)} + ) : ( + bez IČ + ), + }, + { label: 'ID', render: (tenant: NamedRecord) => {tenant.id} }, + { + label: 'E-mail', + render: (tenant: NamedRecord) => + tenant.contactEmail ? ( + String(tenant.contactEmail) + ) : ( + - + ), + }, + { 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 : nikdo; + }, + }, + { + label: 'Stav', + narrow: true, + render: (tenant: NamedRecord) => ( + + {tenant.enabled === false ? ( + vypnutá + ) : ( + aktivní + )} + {tenant.portalOperator === true && provozovatel} + + ), + }, + ]} + 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, + })), + ], + }, + ]} + > +
+ + Novou firmu jde založit z registru ARES: dotáhne se název, IČ, DIČ, sídlo a lidé, kteří za + ni jednají. + + +
+ setAresOpen(false)} + onCreated={() => tenants.reload()} + /> +
+ ); +} diff --git a/web/src/components/dashboard/widgets/CustomWidget.tsx b/web/src/components/dashboard/widgets/CustomWidget.tsx index b4196d5..801f232 100644 --- a/web/src/components/dashboard/widgets/CustomWidget.tsx +++ b/web/src/components/dashboard/widgets/CustomWidget.tsx @@ -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 ( {value.rows.length === 0 ? (

Nic, co by sem patřilo.

+ ) : definition.render === 'chart' ? ( + ) : ( )} diff --git a/web/src/components/dashboard/widgets/PieChart.tsx b/web/src/components/dashboard/widgets/PieChart.tsx new file mode 100644 index 0000000..af2fbbf --- /dev/null +++ b/web/src/components/dashboard/widgets/PieChart.tsx @@ -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

Nic, co by sem patřilo.

; + } + + const radius = 48; + const shapes = layoutSlices(slices, total); + + return ( +
+ + {shapes.map((shape) => + // Jedina vysec je cely kruh, oblouk by se sam do sebe nezavrel. + shapes.length === 1 ? ( + + ) : ( + + ), + )} + +
    + {shapes.map((shape) => { + const share = Math.round((shape.row.value / total) * 100); + const body = ( + <> +
+
+ ); +} diff --git a/web/src/components/home/CallToAction.tsx b/web/src/components/home/CallToAction.tsx index 644629e..7bfa919 100644 --- a/web/src/components/home/CallToAction.tsx +++ b/web/src/components/home/CallToAction.tsx @@ -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 (
@@ -26,15 +27,15 @@ export function CallToAction() { {t('cta.write')} - + - {brand.phone} + {operator.phone}

- {brand.email} + {operator.email}

diff --git a/web/src/components/layout/Footer.tsx b/web/src/components/layout/Footer.tsx index 739c347..44834c7 100644 --- a/web/src/components/layout/Footer.tsx +++ b/web/src/components/layout/Footer.tsx @@ -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() {
@@ -74,7 +79,7 @@ export function Footer() {

- © {year} {brand.legalName} · IČO {brand.ico} + © {year} {operator.legalName} · IČO {operator.ico}

{t('footer.prototype')}

diff --git a/web/src/components/layout/Logo.tsx b/web/src/components/layout/Logo.tsx index 4ac6bd4..e5af177 100644 --- a/web/src/components/layout/Logo.tsx +++ b/web/src/components/layout/Logo.tsx @@ -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 ( - {brand.name} + {name} {withClaim && ( diff --git a/web/src/components/ui/form/FilePicker.tsx b/web/src/components/ui/form/FilePicker.tsx new file mode 100644 index 0000000..bed3528 --- /dev/null +++ b/web/src/components/ui/form/FilePicker.tsx @@ -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 `` 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(null); + const [pickError, setPickError] = useState(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 ( + + pick(event.target.files)} + /> +
+ + + {files.length} / {maxCount} + +
+ + {files.length > 0 && ( +
    + {files.map((file, index) => ( +
  • + {file.name} + {formatBytes(file.size)} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/web/src/hooks/useBrand.ts b/web/src/hooks/useBrand.ts new file mode 100644 index 0000000..1f8c1c8 --- /dev/null +++ b/web/src/hooks/useBrand.ts @@ -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 | null = null; +const listeners = new Set<() => void>(); + +function load(): Promise { + if (inflight) return inflight; + inflight = apiFetch('/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); +} diff --git a/web/src/hooks/usePageMeta.ts b/web/src/hooks/usePageMeta.ts index 6273068..abd009a 100644 --- a/web/src/hooks/usePageMeta.ts +++ b/web/src/hooks/usePageMeta.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { brand } from '@/config/brand'; +import { useBrand } from '@/hooks/useBrand'; /** * Nastavi 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; diff --git a/web/src/i18n/cs.ts b/web/src/i18n/cs.ts index f47ec48..456e5b9 100644 --- a/web/src/i18n/cs.ts +++ b/web/src/i18n/cs.ts @@ -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', diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index da3c83f..44ce049 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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', diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9ec78cc..f1ff21e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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; +} diff --git a/web/src/lib/files.ts b/web/src/lib/files.ts new file mode 100644 index 0000000..4a723c0 --- /dev/null +++ b/web/src/lib/files.ts @@ -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; +} diff --git a/web/src/pages/About.tsx b/web/src/pages/About.tsx index 27d848c..c971335 100644 --- a/web/src/pages/About.tsx +++ b/web/src/pages/About.tsx @@ -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> diff --git a/web/src/pages/Contact.tsx b/web/src/pages/Contact.tsx index 4e4a5b4..b42b7bb 100644 --- a/web/src/pages/Contact.tsx +++ b/web/src/pages/Contact.tsx @@ -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> diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 0f72ddb..f13cfe5 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -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> diff --git a/web/src/pages/dashboard/Settings.tsx b/web/src/pages/dashboard/Settings.tsx index ae9294f..85a781c 100644 --- a/web/src/pages/dashboard/Settings.tsx +++ b/web/src/pages/dashboard/Settings.tsx @@ -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 />} diff --git a/web/src/pages/dashboard/TicketDetail.tsx b/web/src/pages/dashboard/TicketDetail.tsx index f71926a..a7a14fc 100644 --- a/web/src/pages/dashboard/TicketDetail.tsx +++ b/web/src/pages/dashboard/TicketDetail.tsx @@ -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"> diff --git a/web/src/pages/dashboard/Widgets.tsx b/web/src/pages/dashboard/Widgets.tsx index b530f14..37d1d48 100644 --- a/web/src/pages/dashboard/Widgets.tsx +++ b/web/src/pages/dashboard/Widgets.tsx @@ -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, }, { diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index e0cefff..23d354f 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -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,