From 104ae36783b2edf0c643012dfdf3b3d3e057bc04 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:26:07 +0200 Subject: [PATCH] Revize projektu: prava, vykon, runtime, portal a ARES Prava a bezpecnost: spravce firmy uz nemuze nastavit priznak spravce platformy ani clenstvi v cizi firme; pozvanky, konektory a automatizace kontroluji sve pravo; cizi firma v query je 404; zivy stream posila udalosti jen firmam, kterych se tykaji; akce nad ticketem maji kontrolu prava za firmu ticketu a strop viditelnosti; tokeny se nelogujou; limit pokusu na prihlaseni, kontakt a pozvanky; bezpecnostni hlavicky; zachyceni chyb v async handlerech; timing-safe porovnani tokenu. Vykon: audit neskenuje celou kolekci pri kazdem zapisu a konecne maze firemni zaznamy; ticket se uklada jednou misto trikrat; zapisy do Postgresu jsou serializovane podle ID; prava se pocitaji jednou na request; widgety nacitaji tickety jednou; strankovani seznamu; worker je pool misto kol; na webu udalost ze streamu neodmontuje stranku, dotazy maji spolecny debounce a cache, ciselniky drzi typovany sklad. Runtime: opakuji se jen chyby oznacene retryable; smycka nenarazi na strop 50 kroku (novy strop 1000 akci); podminka nad datem funguje; vystup MCP nastroje neprepisuje spoustec; sandbox skriptu firmy nejde opustit; MCP session id se drzi mezi volanimi; incident z kroku patri firme; jedno rozhodnuti o rezimu uloziste; snapshot neprepise soubor po chybe cteni. Refaktory: sdilene typy API v src/shared (web nic nekopiruje, osm rozjetych tvaru sjednoceno); spolecny modul net/guard pro volani ven; formularova vrstva ui/form; rozdeleni Connectors a FlowCanvas; jeden helper pro firmu z query, validaci a CRUD udalosti; pomucky ctx.util pro skripty konektoru; i18n verejneho webu vcetne anglictiny. Nova funkce: zalozeni firmy z registru ARES v Nastaveni (IC nebo nazev, dotazeni IC, DIC, sidla a pravni formy, vyber soucasnych statutarnich zastupcu a prokury, ucty spravce firmy s nahradnim e-mailem IC-poradi@placeholder.cz). Dokumentace: zaznam v 99-zmeny.md a aktualizace 15 dalsich dokumentu. Co-Authored-By: Claude Fable 5.1 --- Dockerfile | 6 +- documentation/00-pro-programatory.md | 59 +- documentation/01-prehled-a-stav.md | 51 +- documentation/03-architektura-a-mapa-kodu.md | 69 +- documentation/04-api.md | 104 +- documentation/05-dashboard-a-builder.md | 63 +- documentation/07-firmy-a-prava.md | 61 +- documentation/08-dashboard-widgety.md | 19 +- documentation/11-skripty-konektoru.md | 50 +- documentation/14-databaze.md | 62 +- documentation/15-rejstrik-funkci.md | 40 +- documentation/17-nastaveni-a-prava.md | 41 +- documentation/20-fronta-a-runtime.md | 77 +- documentation/23-jazyky.md | 28 +- documentation/24-mcp-konektory.md | 48 +- documentation/25-navrh-pristupny-portal.md | 47 +- documentation/99-zmeny.md | 246 ++++ scripts/_sablona.js | 6 +- scripts/csob.list-transactions.js | 20 +- scripts/google.send-email.js | 11 +- scripts/idoklad.create-issued-invoice.js | 14 +- scripts/idoklad.register-payment.js | 8 +- scripts/microsoft365.send-mail.js | 12 +- scripts/sap-bo.find-business-partner.js | 11 +- scripts/sap-bo.list-orders.js | 7 +- scripts/sklik.campaign-report.js | 6 +- src/ares/client.ts | 247 ++++ src/config.ts | 7 + src/data/access.ts | 62 +- src/data/audit.ts | 29 +- src/data/automationStore.ts | 368 ++---- src/data/bootstrap.ts | 67 +- src/data/conditions.ts | 55 +- src/data/connectorStore.ts | 41 +- src/data/connectors/local.ts | 117 +- src/data/connectors/postgres.ts | 15 +- src/data/connectors/types.ts | 121 +- src/data/customWidgets.ts | 94 +- src/data/dashboardLayouts.ts | 32 +- src/data/egressIp.ts | 46 +- src/data/incidentStore.ts | 94 +- src/data/invites.ts | 9 +- src/data/mock.ts | 5 +- src/data/model.ts | 13 +- src/data/notifications.ts | 38 +- src/data/people.ts | 41 +- src/data/permissions.ts | 25 +- src/data/refresh.ts | 78 +- src/data/services.ts | 317 +---- src/data/snapshot.ts | 31 +- src/data/store/cached.ts | 50 +- src/data/store/index.ts | 36 +- src/data/store/local.ts | 28 + src/data/store/mirror.ts | 42 +- src/data/store/postgres.ts | 26 + src/data/store/types.ts | 71 +- src/data/tenantFeatures.ts | 23 +- src/data/tenantScripts.ts | 25 +- src/data/tenants.ts | 33 +- src/data/ticketActions.ts | 8 +- src/data/ticketStore.ts | 710 +++++------- src/data/ticketTypes.ts | 38 +- src/data/users.ts | 13 +- src/data/widgets.ts | 46 +- src/events/bus.ts | 54 +- src/index.ts | 69 +- src/lib/secure.ts | 16 + src/mail/smtp.ts | 11 +- src/mcp/auth.ts | 135 ++- src/mcp/client.ts | 183 +-- src/mcp/easyweb/session.ts | 66 +- src/mcp/errors.ts | 23 + src/mcp/schema.ts | 6 +- src/middleware/asyncHandler.ts | 45 + src/middleware/rateLimit.ts | 58 + src/middleware/tenant.ts | 125 ++ src/middleware/validation.ts | 27 + src/net/guard.ts | 152 +++ src/openapi.ts | 769 ++++++++++++- src/routes/admin.ts | 44 +- src/routes/ares.ts | 188 +++ src/routes/auth.ts | 26 +- src/routes/connectors.ts | 96 +- src/routes/contact.ts | 15 +- src/routes/crud.ts | 177 +-- src/routes/dashboard.ts | 481 +++----- src/routes/helpdesk.ts | 28 +- src/routes/invites.ts | 71 +- src/routes/scripts.ts | 51 +- src/routes/settings.ts | 218 +++- src/routes/stream.ts | 21 +- src/routes/tenantScripts.ts | 51 +- src/routes/ticketActions.ts | 239 +++- src/routes/webhook.ts | 2 +- src/routes/widgetData.ts | 134 +-- src/runtime/builtinSteps.ts | 35 +- src/runtime/executor.ts | 164 ++- src/runtime/queue.ts | 30 +- src/runtime/sandbox.ts | 126 +- src/runtime/triggers.ts | 4 +- src/runtime/worker.ts | 114 +- src/scripts/connections.ts | 19 +- src/scripts/http.ts | 83 +- src/scripts/mapping.ts | 15 +- src/scripts/registry.ts | 10 +- src/scripts/runner.ts | 5 +- src/scripts/types.ts | 133 +-- src/scripts/util.ts | 146 ++- src/scripts/values.ts | 19 +- src/shared/access.ts | 55 + src/shared/automations.ts | 163 +++ src/shared/conditions.ts | 37 + src/shared/connectors.ts | 167 +++ src/shared/dashboard.ts | 24 + src/shared/entities.ts | 24 + src/shared/events.ts | 52 + src/shared/incidents.ts | 27 + src/shared/index.ts | 24 + src/shared/people.ts | 34 + src/shared/permissions.ts | 27 + src/shared/scripts.ts | 157 +++ src/shared/services.ts | 168 +++ src/shared/tenants.ts | 40 + src/shared/tickets.ts | 225 ++++ src/shared/users.ts | 44 + src/shared/widgets.ts | 174 +++ src/types.ts | 54 +- vite.config.ts | 6 +- web/src/App.tsx | 11 +- web/src/auth/AuthContext.tsx | 73 +- web/src/auth/RequireAuth.tsx | 12 +- web/src/components/ErrorBoundary.tsx | 13 +- .../components/dashboard/AresTenantDialog.tsx | 353 ++++++ .../components/dashboard/DashboardLayout.tsx | 18 +- web/src/components/dashboard/DataState.tsx | 32 +- web/src/components/dashboard/EntityAdmin.tsx | 162 ++- web/src/components/dashboard/EventToasts.tsx | 38 +- web/src/components/dashboard/GroupPanel.tsx | 19 +- web/src/components/dashboard/InvitePanel.tsx | 74 +- .../components/dashboard/NewTicketDialog.tsx | 223 ++-- web/src/components/dashboard/QuickAssign.tsx | 70 +- web/src/components/dashboard/RolesAdmin.tsx | 11 +- .../components/dashboard/TenantScripts.tsx | 55 +- .../components/dashboard/TicketActions.tsx | 196 ++-- web/src/components/dashboard/TicketCard.tsx | 174 +++ web/src/components/dashboard/TicketTable.tsx | 236 ++-- .../components/dashboard/TicketTypesAdmin.tsx | 11 +- .../components/dashboard/flow/ActionCard.tsx | 159 +++ .../dashboard/flow/ConditionCard.tsx | 292 +++++ .../components/dashboard/flow/FlowCanvas.tsx | 869 +++----------- .../components/dashboard/flow/ForeachCard.tsx | 94 ++ .../dashboard/flow/MappingEditor.tsx | 182 +-- .../dashboard/flow/StepControls.tsx | 122 ++ .../components/dashboard/flow/StepInputs.tsx | 136 +-- .../components/dashboard/flow/StepPicker.tsx | 61 +- .../components/dashboard/flow/canvasTypes.ts | 54 + .../dashboard/widgets/CustomWidget.tsx | 24 +- .../dashboard/widgets/WidgetCard.tsx | 39 +- .../dashboard/widgets/WidgetPicker.tsx | 6 +- web/src/components/home/Process.tsx | 41 +- web/src/components/home/Products.tsx | 23 +- web/src/components/home/References.tsx | 24 +- web/src/components/layout/Footer.tsx | 8 +- web/src/components/ui/Chip.tsx | 44 + web/src/components/ui/Modal.tsx | 33 +- web/src/components/ui/form/Field.tsx | 57 + web/src/components/ui/form/Input.tsx | 23 + web/src/components/ui/form/Select.tsx | 48 + web/src/components/ui/form/Textarea.tsx | 22 + web/src/components/ui/form/controlClass.ts | 71 ++ web/src/data/navigation.ts | 22 +- web/src/data/products.ts | 103 +- web/src/data/references.ts | 50 +- web/src/i18n/cs.ts | 264 +++++ web/src/i18n/en.ts | 265 +++++ web/src/lib/api.ts | 57 +- web/src/lib/collections.tsx | 393 +++++++ web/src/lib/eventStream.ts | 15 +- web/src/lib/flow.ts | 3 +- web/src/lib/format.ts | 13 + web/src/lib/options.ts | 18 + web/src/lib/ticketEvents.ts | 31 + web/src/lib/useApiQuery.ts | 246 +++- web/src/lib/useMediaQuery.ts | 27 + web/src/lib/useSubmit.ts | 57 + web/src/lib/useUnsavedChanges.ts | 48 + web/src/pages/About.tsx | 106 +- web/src/pages/Contact.tsx | 165 ++- web/src/pages/Home.tsx | 7 +- web/src/pages/Invite.tsx | 79 +- web/src/pages/Login.tsx | 81 +- web/src/pages/NotFound.tsx | 14 +- web/src/pages/Services.tsx | 28 +- web/src/pages/dashboard/ActionDetail.tsx | 165 +-- web/src/pages/dashboard/Actions.tsx | 28 +- web/src/pages/dashboard/AutomationDetail.tsx | 366 +++--- web/src/pages/dashboard/Automations.tsx | 65 +- web/src/pages/dashboard/Connectors.tsx | 964 +--------------- web/src/pages/dashboard/Helpdesk.tsx | 339 +++--- web/src/pages/dashboard/Overview.tsx | 33 +- web/src/pages/dashboard/People.tsx | 31 +- web/src/pages/dashboard/PersonDetail.tsx | 86 +- web/src/pages/dashboard/Scripts.tsx | 92 +- web/src/pages/dashboard/Services.tsx | 44 +- web/src/pages/dashboard/Settings.tsx | 270 +++-- web/src/pages/dashboard/TicketDetail.tsx | 310 +++-- web/src/pages/dashboard/Tickets.tsx | 369 +++--- web/src/pages/dashboard/Widgets.tsx | 13 +- .../dashboard/connectors/ConnectorCard.tsx | 226 ++++ .../dashboard/connectors/ConnectorEditor.tsx | 332 ++++++ .../dashboard/connectors/ConnectorLogs.tsx | 109 ++ .../dashboard/connectors/ConnectorTools.tsx | 86 ++ web/src/types/dashboard.ts | 1023 +++-------------- web/src/types/events.ts | 32 +- web/tsconfig.json | 5 +- 215 files changed, 13226 insertions(+), 8320 deletions(-) create mode 100644 src/ares/client.ts create mode 100644 src/lib/secure.ts create mode 100644 src/mcp/errors.ts create mode 100644 src/middleware/asyncHandler.ts create mode 100644 src/middleware/rateLimit.ts create mode 100644 src/middleware/tenant.ts create mode 100644 src/middleware/validation.ts create mode 100644 src/net/guard.ts create mode 100644 src/routes/ares.ts create mode 100644 src/shared/access.ts create mode 100644 src/shared/automations.ts create mode 100644 src/shared/conditions.ts create mode 100644 src/shared/connectors.ts create mode 100644 src/shared/dashboard.ts create mode 100644 src/shared/entities.ts create mode 100644 src/shared/events.ts create mode 100644 src/shared/incidents.ts create mode 100644 src/shared/index.ts create mode 100644 src/shared/people.ts create mode 100644 src/shared/permissions.ts create mode 100644 src/shared/scripts.ts create mode 100644 src/shared/services.ts create mode 100644 src/shared/tenants.ts create mode 100644 src/shared/tickets.ts create mode 100644 src/shared/users.ts create mode 100644 src/shared/widgets.ts create mode 100644 web/src/components/dashboard/AresTenantDialog.tsx create mode 100644 web/src/components/dashboard/TicketCard.tsx create mode 100644 web/src/components/dashboard/flow/ActionCard.tsx create mode 100644 web/src/components/dashboard/flow/ConditionCard.tsx create mode 100644 web/src/components/dashboard/flow/ForeachCard.tsx create mode 100644 web/src/components/dashboard/flow/StepControls.tsx create mode 100644 web/src/components/dashboard/flow/canvasTypes.ts create mode 100644 web/src/components/ui/Chip.tsx create mode 100644 web/src/components/ui/form/Field.tsx create mode 100644 web/src/components/ui/form/Input.tsx create mode 100644 web/src/components/ui/form/Select.tsx create mode 100644 web/src/components/ui/form/Textarea.tsx create mode 100644 web/src/components/ui/form/controlClass.ts create mode 100644 web/src/lib/collections.tsx create mode 100644 web/src/lib/options.ts create mode 100644 web/src/lib/ticketEvents.ts create mode 100644 web/src/lib/useMediaQuery.ts create mode 100644 web/src/lib/useSubmit.ts create mode 100644 web/src/lib/useUnsavedChanges.ts create mode 100644 web/src/pages/dashboard/connectors/ConnectorCard.tsx create mode 100644 web/src/pages/dashboard/connectors/ConnectorEditor.tsx create mode 100644 web/src/pages/dashboard/connectors/ConnectorLogs.tsx create mode 100644 web/src/pages/dashboard/connectors/ConnectorTools.tsx diff --git a/Dockerfile b/Dockerfile index 53ab696..7ef6a3f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ FROM node:20-slim AS build WORKDIR /app COPY package*.json ./ -RUN npm install +# `npm ci` instaluje presne to, co je v lockfile. `npm install` by ho mohl +# prepsat a build by pak jel na jinych verzich nez vyvoj. +RUN npm ci COPY . . # Zbuilduje server (tsc -> dist) i web (vite -> dist/public) RUN npm run build @@ -12,7 +14,7 @@ ENV NODE_ENV=production ENV PORT=3000 EXPOSE 3000 COPY package*.json ./ -RUN npm install --omit=dev +RUN npm ci --omit=dev COPY --from=build /app/dist ./dist # Skripty konektoru jsou obycejny JavaScript, nekompiluji se. Musi se ale # dostat do image, jinak by konektory nemely zadnou vykonnou cast. diff --git a/documentation/00-pro-programatory.md b/documentation/00-pro-programatory.md index ac2d725..55c9c6c 100644 --- a/documentation/00-pro-programatory.md +++ b/documentation/00-pro-programatory.md @@ -86,6 +86,38 @@ s tim, co uzivatel smi. Dvoji vypocet se jednou rozejde. firmu jako povinny argument. Clovek muze byt spravce v jedne firme a bezny uzivatel v druhe. +**Pravo se kontroluje u kazde route, za firmu zaznamu.** Clenstvi ve firme +neni pravo. Konektor chce `connector.manage`, automatizace `automation.edit`, +uzivatele `user.manage` a jen ve sve firme, firmy jen spravce platformy. +Vestavene akce na ticketu jdou pres `builtinAction` v `ticketActions.ts`, kde +se pravo pta za firmu **toho ticketu** a ticket se nejdriv hleda pres strop +viditelnosti. Pristup se pocita jednou na request (`attachAccess`, +`req.access`) a routy si firmu berou pres `tenantOrDeny`. + +**Udalost nese firmu.** `publish(kind, message, payload, tenantId)` ma +`tenantId` povinny a posledni, `null` znamena cela platforma. Stream ji +pouziva k filtru, takze udalost bez firmy by videl kazdy. Zmena entity +v nastaveni publikuje `.created|updated|deleted` s celym zaznamem, aby +klient nemusel po kazde udalosti znovu nacitat. + +**Ciselniky ma klient ve skladu, provozni data ne.** Lide, skupiny, typy, +sluzby, konektory a pristup se berou z `lib/collections.tsx` +(`useCollection`), ktery se opravuje z udalosti. Tickety, behy a statistiky +zustavaji `useApiQuery` se strankovanim - jsou velke, meni se porad a strop +viditelnosti pocita server. Nova stranka nema volat `apiFetch` na ciselnik +primo. + +**Opakuje se jen to, co samo rekne `retryable`.** Vychozi je ne. Chyba +spojeni, timeout pred odeslanim, 5xx a 429 ano; 401, 403, 404, spatny vstup, +`ctx.fail` a chyba v kodu ne - ty konci hned a zakladaji incident. Timeout uz +odeslaneho volani MCP se neopakuje, protoze MCP nema idempotencni klic. +Pravidlo je nad `StepResult` v `executor.ts` a plati pro kazdy novy druh kroku. + +**Typ odpovedi API je jednou, v `src/shared`.** Web ho nekopiruje, bere ho +pres alias `@shared/*`; `web/src/types/` je jen fasada. Kopie na klientovi se +rozejde se serverem a prekladac to nepozna. Prevod je hotovy: 16 modulu +v `src/shared` vcetne uctu (`users.ts`), web nema zadny vlastni typ API. + ## Jak vznika operace, kterou clovek vybere v builderu Tri cesty, kazda ma svuj duvod: @@ -131,14 +163,16 @@ tam je operace vlastnost sluzby, ne napojeni. ## Uloziste ma tri rezimy -| Rezim | Kdy | Prezije | -| ---------- | ----------------------------------- | -------------------- | -| `postgres` | je `DATABASE_URL` i `SECRETS_KEY` | vse | -| `file` | neni databaze, ale je datova slozka | restart, ne redeploy | -| `memory` | ani jedno | nic | +| Rezim | Kdy | Prezije | +| ---------- | ------------------------------------------------------ | -------------------- | +| `postgres` | je `DATABASE_URL`, migrace prosly a je cim sifrovat | vse | +| `file` | neni databaze, ale je datova slozka | restart, ne redeploy | +| `memory` | ani jedno | nic | -**Rozhodnuti je na jednom miste** (`connectorStore.ts`). Kdyby se rozlezlo po -kodu, jedno misto by se zapomnelo a chovalo by se pak jinak nez zbytek. +**Rozhodnuti je na jednom miste** (`initStores` v `store/index.ts`) a plati +pro vsechna uloziste vcetne konektoru. Kdyby se rozlezlo po kodu, jedno misto +by se zapomnelo a chovalo by se pak jinak nez zbytek - presne to se stalo, +kdyz mely konektory vlastni rozhodnuti. Nasazeni bezi v rezimu `file`. Ma to dusledek, ktery je poznat az pri zatezi: **kazda zmena prepisuje celou kolekci** jako formatovany JSON. U ticketu, ktere @@ -167,6 +201,17 @@ ho napsal. **Novy text v portalu** patri do `web/src/i18n/cs.ts` a klic do `en.ts`. Anglictina je `Partial`, takze nemusi byt uplna - co chybi, spadne na cestinu. +**Novy typ odpovedi** patri do `src/shared`, web ho nekopiruje. + +**Nova route** vznika pres `safeRouter`, ne `Router()`, aby odmitnuta promise +skoncila jako 500 a ne padem procesu. Chyba validace jde pres +`validationError`, aby mela `issues` ve stejnem tvaru jako zbytek. Kazde +volani ven (HTTP, MCP, SMTP, ARES) cte telo pres `src/net/guard.ts`. + +**Novy formular** sklada `Field`, `Input`, `Select`, `Textarea` +z `components/ui/form/` a odeslani pres `useSubmit`. Vlastni tridy vstupniho +pole ve strance jsou to, co se prave odstranilo z patnacti mist. + **Novy sloupec** znamena novou migraci **a** doplneni obou uloziste, souborového i databazoveho. Rozhrani je jedno, implementace dve. diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index 62d6f49..3690c37 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -67,6 +67,16 @@ React aplikaci ze slozky `dist/public`. | 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 | +| 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 | +| Prava za firmu u kazde route | hotovo | firmy, uzivatele, konektory, automatizace, akce ticketu | +| Udalosti za firmu | hotovo | stream filtruje, entity hlasi vznik, zmenu a smazani | +| Strankovani ticketu a behu | hotovo | `limit`, `offset`, `X-Total-Count` | +| Klientsky sklad ciselniku | hotovo | lide, skupiny, typy, sluzby, konektory, pristup | +| Formularova vrstva | hotovo | `ui/form`, `useSubmit`, `options`, jedna sada trid | +| Firma z registru ARES | hotovo | IC nebo nazev, statutari jako ucty, jen spravce platformy | +| Sdilene typy `src/shared` | hotovo | web je re-exportuje pres `@shared/*`, nic nekopiruje | ## Znama omezeni @@ -74,17 +84,24 @@ Data prezijou restart, ale ne redeploy, kdyz neni databaze. Rezim se pozna v portalu i v `/health/ready` a rozhoduje o nem jedno misto, viz [14-databaze.md](14-databaze.md): -| Rezim | Kdy | Nasledek | -| ---------- | ---------------------------------- | ---------------------------- | -| `postgres` | je `DATABASE_URL` a migrace prosly | data se neztraci | -| `file` | neni databaze, je `DATA_DIR` | prezije restart, ne redeploy | -| `memory` | neni ani `DATA_DIR` | ztrati se pri restartu | +| Rezim | Kdy | Nasledek | +| ---------- | ------------------------------------------------- | ---------------------------- | +| `postgres` | je `DATABASE_URL`, migrace prosly a je cim sifrovat | data se neztraci | +| `file` | neni databaze, je `DATA_DIR` | prezije restart, ne redeploy | +| `memory` | neni ani `DATA_DIR` | ztrati se pri restartu | -Beh automatizaci uz existuje, ale je **synchronni v requestu**: webhook ceka, -nez cely strom dobehne, a pri padu procesu se rozdelany beh ztrati. Neni fronta -ani opakovani. Co to znamena pro vetsi provoz a co s tim, je zmerene -v [19-kapacita-200-firem.md](19-kapacita-200-firem.md), navrh fronty -v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md). +Rezim je jeden pro vsechna uloziste vcetne konektoru. Driv se mohlo stat, ze +konektory jely z databaze a tickety ze souboru. + +Beh automatizaci jde pres frontu a worker: webhook odpovi 202 a strom se +vykona na pozadi, pri chybe se opakuje jen to, co muze pominout. Fronta je ale +**v pameti jednoho procesu**: vic instanci by si vzalo tentyz beh, nad +Postgresem to chce `SKIP LOCKED`. Popis je +v [20-fronta-a-runtime.md](20-fronta-a-runtime.md), rozbor kapacity +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 @@ -99,21 +116,17 @@ co ukazat i na prazdne instanci. ## Dalsi krok -Nejuzitecnejsi pristavek je runtime. Strom uz nese vsechno potrebne: spoustec -s parametry, podminky a u ticketu i kanalu nastavena pole se sablonami. Chybi -jen to, co ho vykona. Do te doby je ulozena automatizace popis zameru, ne provoz. +Runtime je hotovy: fronta, worker jako pool, opakovani jen u chyb, ktere +mohou pominout, incident z koncove chyby. Dalsi krok je **vic instanci**, tedy +vyber z fronty nad Postgresem se `SKIP LOCKED`, sdileny kanal pro stream +a sdileny citac limitu requestu. -Vedle toho zbyva prevest na `inputs` i ostatni konektory a doplnit odkazy -na vystup predchoziho kroku, ne jen na spoustec. Podrobnosti +Vedle toho zbyva prevest na `inputs` i ostatni konektory. Podrobnosti v [05-dashboard-a-builder.md](05-dashboard-a-builder.md). Za rozmysleni stoji evidence bugs a wishes. Zamerne to nejsou tickety, duvod je v [06-tickety.md](06-tickety.md). -Prvni cast navrhu uz je hotova: vykonna cast konektoru, tedy skripty -s manifestem a kontrolou parametru, viz [11-skripty-konektoru.md](11-skripty-konektoru.md). -Runner je pripraveny, chybi nad nim fronta. - Datove modely a prava z [09-navrh-rozsireni.md](09-navrh-rozsireni.md) jsou hotove, popis stavu je v [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md). Navrh k rozhodnuti zustava [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md) diff --git a/documentation/03-architektura-a-mapa-kodu.md b/documentation/03-architektura-a-mapa-kodu.md index 53ac957..2c1363a 100644 --- a/documentation/03-architektura-a-mapa-kodu.md +++ b/documentation/03-architektura-a-mapa-kodu.md @@ -31,28 +31,44 @@ image jen `dist`, takze staci jedna slozka. | `src/config.ts` | cteni environment variables, normalizace `ROOT_PATH` | | `src/openapi.ts` | OpenAPI definice vcetne `servers` s prefixem proxy | | `src/types.ts` | typy uzivatele a JWT payloadu | -| `src/middleware/auth.ts` | `requireAuth`, `requireRole` | -| `src/events/bus.ts` | sbernice udalosti, ze ktere cerpa SSE stream | +| `src/shared/` | ciste typove moduly API, jediny zdroj typu pro server i web | +| `src/middleware/auth.ts` | `requireAuth`, `requireRole`, `requirePlatformAdmin` | +| `src/middleware/asyncHandler.ts` | `wrap`, `safeRouter`: odchyceni odmitnute promise v handleru | +| `src/middleware/tenant.ts` | `attachAccess`, `tenantOrDeny`, `scopeOrDeny`: firma requestu na jednom miste | +| `src/middleware/rateLimit.ts` | limit requestu v pameti, 429 s `Retry-After` | +| `src/middleware/validation.ts` | `validationError`, jeden tvar chyby validace | +| `src/lib/secure.ts` | `timingSafeEqualString` pro tokeny v adrese | +| `src/net/guard.ts` | kontrola adresy, cteni tela s limitem, popis chyby site - pro vsechno, co vola ven | +| `src/events/bus.ts` | sbernice udalosti, ze ktere cerpa SSE stream, udalost nese firmu | | `src/routes/auth.ts` | prihlaseni, odhlaseni, kdo jsem | -| `src/routes/dashboard.ts` | data portalu, katalog konektoru, CRUD automatizaci | -| `src/routes/stream.ts` | SSE stream zmen | -| `src/routes/simulate.ts` | vyvolani provoznich udalosti | +| `src/routes/dashboard.ts` | data portalu, tickety, behy, CRUD automatizaci | +| `src/routes/ticketActions.ts` | akce nad ticketem vcetne vestavenych, pravo za firmu ticketu | +| `src/routes/settings.ts` | CRUD entit pres `crud.ts`, uzivatele, ARES | +| `src/routes/ares.ts` | firma z registru ARES, jen spravce platformy | +| `src/routes/connectors.ts` | konektory firmy, overeni, nastroje MCP | +| `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/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` | tickety, jejich resitele, log prubehu, prehled vytizeni | | `src/data/people.ts` | resitele ticketu - oddeleni od uzivatelu portalu | -| `src/data/tenants.ts` | firmy, ktere portal pouzivaji | +| `src/data/tenants.ts` | firmy, ktere portal pouzivaji, vcetne udaju z ARES | | `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 | -| `src/data/incidentStore.ts` | incidenty vcetne zmen a udalosti | +| `src/data/incidentStore.ts` | incidenty vcetne zmen a udalosti, filtr na firmu povinny | | `src/data/automationStore.ts` | automatizace, strom akci, tokeny webhooku | -| `src/data/connectors.ts` | katalog konektoru, jejich spousteču a akci | +| `src/data/services.ts` | katalog sluzeb, jejich spousteču a akci | | `src/data/conditions.ts` | typy parametru a operatory podminek | | `src/data/templates.ts` | sablony `{{parametr}}` v nastaveni kroku | | `src/data/flowScope.ts` | co je videt v kterem miste stromu | -| `src/data/users.ts` | demo uzivatele | +| `src/data/users.ts` | uzivatele portalu, demo ucty | | `src/data/mock.ts` | souhrn pro prehled a casova rada grafu | +| `src/runtime/` | fronta, worker, executor stromu, vestavene kroky, sandbox skriptu firmy | +| `src/scripts/` | skripty konektoru: registr, runner, HTTP, pomocne funkce | +| `src/mcp/` | klient MCP, prihlaseni, dialekty, `errors.ts` se spolecnou chybou prihlaseni | ## Mapa kodu - web @@ -62,16 +78,25 @@ image jen `dist`, takze staci jedna slozka. | `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/lib/api.ts` | fetch wrapper, sprava tokenu, skladani adres | +| `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/useApiQuery.ts` | nacitani dat vcetne obnoveni pri udalosti | +| `web/src/lib/useApiQuery.ts` | nacitani dat, cache, spolecny debounce, `refreshing` misto odmontovani | +| `web/src/lib/collections.tsx` | klientsky sklad ciselniku za firmu, opravovany z udalosti | +| `web/src/lib/ticketEvents.ts` | oprava seznamu ticketu z `payload.ticket` bez dotazu | +| `web/src/lib/useSubmit.ts` | odeslani formulare: `saving`, chyba, reset na jednom miste | +| `web/src/lib/options.ts` | pevne ciselniky (priority) | +| `web/src/lib/useUnsavedChanges.ts` | varovani pri odchodu z rozepsaneho formulare | | `web/src/lib/flow.ts` | ciste funkce nad stromem automatizace | -| `web/src/components/dashboard/` | shell portalu, dlazdice, graf, stream, simulace | -| `web/src/components/dashboard/flow/` | strom akci, vyber kroku, nastaveni poli akce | +| `web/src/types/` | fasada nad `src/shared` (alias `@shared/*`), zadne vlastni typy API | +| `web/src/components/ui/` | zakladni prvky, `Chip` | +| `web/src/components/ui/form/` | `Field`, `Input`, `Select`, `Textarea`, `controlClass`: jedna sada trid | +| `web/src/components/dashboard/` | shell portalu, dlazdice, graf, stream, `TicketCard` | +| `web/src/components/dashboard/flow/` | strom akci: `FlowCanvas` a karty `ActionCard`, `ConditionCard`, `ForeachCard`, `StepControls` | | `web/src/components/dashboard/TicketTrace.tsx` | log ticketu jako strom | | `web/src/components/dashboard/TicketWorkload.tsx` | prehled, kdo co ma u sebe | | `web/src/components/home/` | sekce homepage | | `web/src/pages/` | jedna stranka je jeden soubor | +| `web/src/pages/dashboard/connectors/` | casti stranky Konektory: karta, editor, log, nastroje | ## Klicova rozhodnuti @@ -88,8 +113,22 @@ Cenou je rucni parsovani a rucni znovupripojeni v `web/src/lib/eventStream.ts`. **Ceske cesty v URL.** `/sluzby`, `/o-nas`, `/prihlaseni`, `/dashboard/tickety`. Kod zustava anglicky. -**Data v pameti.** Vedome zjednoduseni prototypu. Uloziste jsou oddelena od rout, -takze napojeni na databazi znamena prepsat soubory v `src/data/`, ne endpointy. +**Uloziste je za rozhranim.** Tri rezimy (Postgres, soubor, pamet), jedno +rozhrani a **jedno rozhodnuti** v `initStores`. Routy nevedi, ktery rezim jede. +Podrobnosti v [14-databaze.md](14-databaze.md). + +**Typy API jsou jednou.** Ciste typove moduly v `src/shared` ctou server i web, +web pres alias `@shared/*`. Kopie typu na klientovi se jednou rozejde se +serverem a prekladac to nepozna. `web/src/types/` je jen fasada, ktera je +re-exportuje; ucet uzivatele bere i `AuthContext` ze `@shared/users`. + +**Kazdy async handler je odchyceny.** Routy vznikaji pres `safeRouter`, +odmitnuta promise skonci jako 500 s logem, ne padem procesu. Bez toho stacil +jeden zapomenuty `try` a AppFactory restartovala container. + +**Firma requestu se pocita jednou.** `attachAccess` da do `req.access`, co +uzivatel smi, a `tenantOrDeny` z toho odvodi firmu. Kazda route, ktera si to +pocitala sama, to delala trochu jinak. **Filtr na firmu je povinny argument.** `listTickets`, `listPeople` i `listAutomations` vyzaduji `tenantIds`. Zapomenuty filtr tak neznamena "vse", diff --git a/documentation/04-api.md b/documentation/04-api.md index c8be750..a6fc652 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -85,6 +85,9 @@ Vyzaduji `Authorization: Bearer `: | GET | `/api/dashboard/settings/people-overview` | | GET | `/api/dashboard/settings/users-overview` | | PATCH | `/api/dashboard/settings/users/:id/password` | +| GET | `/api/dashboard/settings/ares/companies` | +| GET | `/api/dashboard/settings/ares/companies/:ico/persons` | +| POST | `/api/dashboard/settings/ares/tenants` | | POST | `/api/admin/impersonate` | | POST | `/api/admin/impersonate/stop` | | GET | `/api/admin/impersonate/candidates` | @@ -102,20 +105,35 @@ fabrika (`src/routes/crud.ts`): Jednotny pro cele API: ```json -{ "error": "validation_error", "message": "Zadejte platny e-mail." } +{ + "error": "validation_error", + "message": "Zadejte platny e-mail.", + "issues": [{ "field": "email", "message": "Zadejte platny e-mail." }] +} ``` | HTTP | `error` | Kdy | | ---- | --------------------- | ------------------------------------------- | -| 400 | `validation_error` | vstup neprosel schematem | +| 400 | `validation_error` | vstup neprosel schematem, `issues` po polich | | 401 | `unauthorized` | chybi nebo neplatny token | | 401 | `invalid_credentials` | spatny e-mail nebo heslo | -| 403 | `forbidden` | nedostatecna role | -| 404 | `not_found` | zaznam nebo endpoint neexistuje | +| 403 | `forbidden` | nedostatecne pravo v dane firme | +| 404 | `not_found` | zaznam nebo endpoint neexistuje, nebo je cizi firmy | | 409 | ruzne | operace nedava v danem stavu smysl | +| 429 | `too_many_requests` | prekrocen limit requestu, hlavicka `Retry-After` | | 500 | `internal_error` | neodchycena chyba, detail jen mimo produkci | -`message` je vzdy cesky a je urcena k zobrazeni uzivateli. +`message` je vzdy cesky a je urcena k zobrazeni uzivateli. Chybu validace +sklada `validationError` v `src/middleware/validation.ts`, aby `issues` mely +vsude stejny tvar a formular umel chybu ukazat u pole. + +Limity (`src/middleware/rateLimit.ts`) jsou jen na verejnych endpointech, kde +se da hadat: prihlaseni 20 pokusu za 15 minut, kontakt 5 za hodinu, prijeti +pozvanky 5 za 15 minut. Pocita se podle adresy klienta, proto ma Express +`trust proxy` = 1 - bez toho by vsichni za Caddy sdileli jeden limit. + +Kazdy asynchronni handler je obaleny (`safeRouter` v `src/middleware/asyncHandler.ts`). +Odmitnuta promise je 500 s logem, ne pad procesu. ## Autentizace @@ -139,6 +157,21 @@ Typy udalosti: `ticket.created`, `ticket.updated`, `ticket.assigned`, `automation.created`, `automation.updated`, `automation.deleted`, `automation.run`, `webhook.received`. +K tomu udalosti entit `tenant`, `user`, `role`, `person`, `group`, +`ticketType`, `action`, `widget`, `connector`, `feature` s priponou +`.created`, `.updated`, `.deleted`. Payload je `{ id, : zaznam }`, +u smazani jen `{ id }`. Publikuje je `crudRouter` (volba `event`), routy +konektoru a PUT features. Klient z nich opravuje sklad ciselniku bez dotazu. + +**Kazda udalost nese `tenantId`** (`null` = cela platforma). Stream posila jen +udalosti firem, do kterych uzivatel patri, a to i v historii po pripojeni. +Udalost s `payload.userId` jde jen tomu cloveku. Spravce platformy vidi vse. +Driv videl kazdy prihlaseny udalosti vsech firem - nazev ticketu cizi firmy +v bubline je unik dat, i kdyz se na ticket nedostane. + +`ticket.updated`, `ticket.assigned` a `ticket.resolved` nesou +v `payload.ticket` cely ticket, aby klient opravil seznam na miste. + Klient se pripojuje pres fetch s hlavickou `Authorization`, ne pres EventSource. Duvod je v [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md). @@ -166,8 +199,9 @@ curl -X POST https://services.csbot.cz/apps//webhook/ \ -d '{"customer":"Nordis","score":18}' ``` -Prototyp pozadavek prijme, zvaliduje a zapocita do metrik, ale strom akci -nevykona - runtime neexistuje. +Token se porovnava v konstantnim case (`timingSafeEqualString` +v `src/lib/secure.ts`), stejne jako token prijmu a kod pozvanky. V logu +requestu je z tokenu videt jen prvnich sest znaku. ## Firmy a pohledy @@ -192,6 +226,11 @@ bez vysvetleni. Odpoved nese vedle `items` jeste `meId`. Klient podle nej pozna, ktere tickety jsou jeho, a jestli ma vubec smysl nabizet filtr "moje". +**Strankovani.** `/tickets` a `/runs` berou `limit` a `offset`, `limit` nejvys +500. Celkovy pocet je v hlavicce `X-Total-Count`, u ticketu i v tele jako +`total`. Bez `limit` se vraci vse jako driv (u behu poslednich 50), aby se +nerozbily stavajici odkazy. Klient cte hlavicku pres `apiFetchWithMeta`. + Filtrovani dela **server**, ne klient. Seznam a prehled vytizeni tak nikdy neukazuji jina cisla. Vyhledavaci pole v portalu je jina vec - to jen dohledava v uz nactenem seznamu. @@ -246,7 +285,26 @@ selhala**. Selhani akce neni chyba API. V odpovedi je `ok`, `summary`, `detail` s celym chybovym hlasenim a `durationMs`. Cely prubeh se zapise do logu ticketu. Vestavene akce (`type`, `tags`, `group`, `assign`, `status`, `comment`) jsou -zvlast: meni ticket sam, ne cizi sluzbu, a kazda ma vlastni pravo. +zvlast: meni ticket sam, ne cizi sluzbu, a kazda ma vlastni pravo. Vsechny +vcetne `claim` jdou pres `builtinAction` v `src/routes/ticketActions.ts`, kde +se pravo pta za firmu ticketu a ticket se nejdriv najde pres strop +viditelnosti (`visibleTicketOrDeny`). Driv mely `assign`, `status` a `comment` +vlastni handlery a kazdy se ptal jinak. + +## Firma z registru ARES + +Jen spravce platformy (`/api/dashboard/settings/ares`). Popis rozhodnuti je +v [07-firmy-a-prava.md](07-firmy-a-prava.md). + +| Endpoint | Co vraci | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `GET ares/companies?query=` | same cislice (1 az 8) hledaji IC presne, jinak nazev, nejvys 10. U firmy, ktera uz v portalu je, `existingTenantId` | +| `GET ares/companies/{ico}/persons` | soucasni statutari a prokura z verejneho rejstriku, u kazdeho navrzeny e-mail `IC-poradi@placeholder.cz` | +| `POST ares/tenants` | zalozi firmu a ucty vybranych osob, vraci firmu a seznam uctu | + +Chyba registru je `ares_error` s kodem podle toho, co ARES vratil - neni to +chyba naseho API a nema se opakovat automaticky. Adresa registru je +`ARES_BASE_URL`. ## Prijem udalosti do ticketu @@ -284,9 +342,33 @@ a kdy se to zkusi znovu. ## Prava a navigace `GET /api/dashboard/access` vraci `permissions` (efektivni prava po slouceni -roli), `nav` (zalozky, ktere ma volajici videt) a `platformAdmin`. Klient podle -toho kresli, ale **nic si nedovozuje** - kdo co smi, rozhoduje server u kazdeho -requestu znovu. +roli), `nav` (zalozky, ktere ma volajici videt), `platformAdmin` a `roleNames` +(nazvy roli v prepnute firme, pro popisek u uctu). Klient podle toho kresli, +ale **nic si nedovozuje** - kdo co smi, rozhoduje server u kazdeho requestu +znovu. + +### Kdo co smi, po routach + +Pravo se vzdy pta **za firmu zaznamu**, ne za prepnutou firmu. Cizi firma je +404, chybejici pravo 403. + +| Co | Pravo | +| ----------------------------------------------- | --------------------------------------------------------------------- | +| firmy CRUD, ARES | spravce platformy | +| uzivatele CRUD | spravce platformy, nebo `user.manage` jen v ramci sve firmy | +| pozvanky | `user.manage`, role jen z te firmy | +| konektory create, update, delete, test | `connector.manage` | +| 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 | +| `/api/admin/impersonate*` | `impersonate` | +| `/api/admin/audit` | `audit.view` | +| `/storage`, `/scripts` s cestami na serveru | cesty jen spravci platformy, ostatni dostanou odpoved bez nich | + +Spravce firmy s `user.manage` **nenastavi `platformAdmin`**, neprida clenstvi +v jine firme, nesahne na spravce platformy a nesmaze cloveka, ktery je i +v jine firme - ten ucet neni jen jeho. Podrobnosti +v [07-firmy-a-prava.md](07-firmy-a-prava.md). `GET /api/dashboard/settings/catalog` vraci katalog prav a modulu, aby formular role nemel seznam prav napsany v kodu klienta. diff --git a/documentation/05-dashboard-a-builder.md b/documentation/05-dashboard-a-builder.md index d472aa7..1dfbcd1 100644 --- a/documentation/05-dashboard-a-builder.md +++ b/documentation/05-dashboard-a-builder.md @@ -25,10 +25,49 @@ Portal drzi jedno SSE spojeni pro celou aplikaci. Zajistuje ho - Prichozi udalosti ukazuje `EventToasts` jako bubliny vpravo dole. - Data se obnovuji sama. `useApiQuery` ma volitelny `refetchOn` se seznamem typu udalosti, po kterych se ma dotaz zopakovat. Vice udalosti tesne po sobe se - slouci do jednoho nacteni. + slouci do jednoho nacteni - debounce 150 ms je **spolecny pro vsechny + hooky**, takze jedna udalost je jedna vlna requestu, ne pet v peti chvilich. Pri vypadku se stream znovu pripojuje s exponencialne rostoucim odstupem -az do 15 sekund, aby pri vypadku serveru neubijel provoz. +az do 15 sekund, aby pri vypadku serveru neubijel provoz. Na 401 a 403 se +pripojovat prestane: token vyprsel a klient vyvola `auth:expired`, po kterem +se portal odhlasi a Login rekne, ze prihlaseni vyprselo. + +### Obnova neodmontuje stranku + +`useApiQuery` vraci `loading` jen **do prvnich dat**, potom uz `refreshing`. +`DataState` pri `refreshing` nechava deti vykreslene a jen ukaze, ze bezi +obnova. Prvni verze prepnula `loading` pri kazde udalosti ze streamu a +`DataState` vykreslil spinner misto obsahu - pri behu automatizace se stranka +nekolikrat za sekundu odmontovala a namontovala, vcetne ztraty kurzoru +v rozepsanem poli. + +```ts +useApiQuery(path, { refetchOn?, body?, enabled?, patchOn? }) + -> { data, loading, refreshing, error, total, reload } +``` + +Dotazy sdili cache modulu a deduplikaci bezicich requestu (klic je firma, +cesta a telo), takze dve komponenty se stejnym dotazem se ptaji jednou. +`patchOn` opravi data v cache primo z udalosti: `lib/ticketEvents.ts` bere +`payload.ticket` z `ticket.updated` a vymeni radek v seznamu bez dotazu. + +### Ciselniky jsou v klientskem skladu + +Lide, skupiny, typy ticketu, sluzby, konektory a pristup se nectou stranka po +strance, ale ze skladu `lib/collections.tsx`: `useCollection(key)`, +`useAccess()`, `useCollectionSelector`. Kolekce se nacte pri prvnim pouziti, +vymaze se pri prepnuti firmy a odhlaseni a **opravuje se z udalosti entit** +(`person.updated` a podobne): zaznam z payloadu se vlozi nebo smaze, a kdyz +payload zaznam nenese, nacte se ta jedna kolekce znovu. + +Rozhodnuti majitele produktu je stredni cesta: ciselniky do skladu, **tickety, +behy a statistiky zustavaji dotazy na server** se strankovanim. Jsou velke, +meni se porad a strop viditelnosti pocita server - klientska kopie by je +ukazovala jinak nez prehled vytizeni. + +Filtry seznamu ticketu jsou v URL. Nalez jde poslat kolegovi a tlacitko zpet +vrati predchozi filtr; driv byl filtr stav stranky a po obnoveni zmizel. ## Simulace provozu @@ -102,6 +141,15 @@ Vetve ANO a NE jsou vedle sebe jen tehdy, kdyz je na to v dane karte misto. Rozhoduje **sirka karty, ne sirka okna** - pouzivaji se container queries (`@container` a `@2xl:grid-cols-2` v `FlowCanvas.tsx`). +Karty jsou rozdelene po druhu kroku: `flow/ActionCard.tsx`, +`ConditionCard.tsx`, `ForeachCard.tsx`, `StepControls.tsx`, spolecne typy +v `canvasTypes.ts`. `FlowCanvas.tsx` uz jen sklada. Karty jsou v `memo`, +callbacky se predavaji podle ID kroku a `collectScopes` je memoizovane - +u stromu o padesati krocich byl driv kazdy stisk klavesy v poli prekreslenim +vseho. + +Odchod z rozepsaneho stromu hlida `lib/useUnsavedChanges.ts`. + Duvod: kazde zanoreni pulí dostupnou sirku. S beznym `lg:grid-cols-2` vypadal strom na sirokem monitoru dobre v prvni urovni a ve treti uz mel karty siroke par desitek pixelu, takze se popisy lamaly po jednom slove. Container query se @@ -157,8 +205,10 @@ je vypise. Rozdelana prace se nikdy nezahazuje. ## Pridani konektoru -1. Pridat zaznam do `connectors` v `src/data/connectors.ts` vcetne `triggers` - a `actions`. +1. Pridat zaznam do katalogu v `src/data/services.ts` vcetne `triggers` + a `actions`. ID operace musi byt v ramci sluzby unikatni, + `checkOperationIds()` duplicitu pri nacteni zaloguje - druha by tise + prekryla prvni. 2. Pokud pouziva novou ikonu, doplnit klic do `web/src/lib/connectorIcons.ts`. Musi existovat v `lucide-react`. 3. Pokud patri do nove kategorie, doplnit ji do `connectorCategories` a do typu @@ -173,11 +223,8 @@ Builder i katalog ji vezmou automaticky. | Chybi | Poznamka | | ---------------------------- | --------------------------------------------------------- | | `inputs` u zbylych konektoru | zatim ticket, kanaly, CRM a AI, ostatni maji jen `fields` | -| Vazba logu ticketu na beh | log plni simulace, ne vykonany strom | -| Kombinovane podminky | jedna podminka je jedno porovnani, AND a OR jen vnorenim | -| Beh automatizaci | ulozeny strom se nevykonava | -| Historie behu a logy | prazdne, chybi runtime | | Drag and drop | presouvani je zatim tlacitky nahoru a dolu | +| Strankovani v portalu | server `limit` a `offset` umi, seznam ticketu si zatim bere vse | ## Co je videt v kterem kroku diff --git a/documentation/07-firmy-a-prava.md b/documentation/07-firmy-a-prava.md index fdaf44c..3c6b74b 100644 --- a/documentation/07-firmy-a-prava.md +++ b/documentation/07-firmy-a-prava.md @@ -132,10 +132,19 @@ rozlezlo po routach, driv nebo pozdeji vznikne endpoint, ktery filtr zapomene. "tenants": [{ "id": "tnt_automia", "name": "Automia" }], "defaultTenantId": "tnt_automia", "canAssignOthers": true, - "personId": "ppl_uhlir" + "personId": "ppl_uhlir", + "roleNames": ["Správce"] } ``` +Pristup se pocita **jednou na request** (`attachAccess` +v `src/middleware/tenant.ts`, vysledek v `req.access`) a routy si z nej berou +firmu pres `tenantOrDeny`. Kazda route, ktera si to pocitala sama, to delala +o neco jinak a jedna z nich spatne. + +Stream udalosti tutez informaci pouziva k filtru: uzivatel dostane jen udalosti +svych firem, viz [04-api.md](04-api.md). + Klient podle toho kresli prepinac. **Nesmi si to dovozovat sam** - jinak by se prava pocitala na dvou mistech a jednou se rozejdou. @@ -197,15 +206,57 @@ Heslo je u vsech `demo1234`. Druhy ucet je ten zajimavy: ukazuje prepinac firem i to, ze prava se lisi podle toho, ktera firma je prave zvolena. +## Kdo koho zaklada + +Rozhodnuti z revize v zari 2026: **firmy zaklada spravce platformy, lidi ve +firme spravuje spravce firmy.** Je to hranice mezi nasim pravem a zakaznickym +a drzi se vsude, kde se neco zaklada: + +| Co | Kdo smi | Kde se to kontroluje | +| -------------------------- | ------------------------------------------------------------ | ---------------------------------------- | +| firma | jen spravce platformy (`platformOnly` u CRUD firem) | `src/routes/settings.ts` | +| firma z registru ARES | jen spravce platformy | `src/routes/ares.ts` | +| uzivatel | spravce platformy, nebo `user.manage` jen ve sve firme | `src/routes/settings.ts` | +| pozvanka | `user.manage`, role jen z te firmy | `src/routes/invites.ts` | +| konektor | `connector.manage` | `src/routes/connectors.ts`, `dashboard.ts` | +| automatizace | `automation.edit` za firmu automatizace | `src/routes/dashboard.ts` | +| akce nad ticketem | pravo akce za firmu ticketu a strop viditelnosti | `src/routes/ticketActions.ts` | + +Spravce firmy s `user.manage` ma **jen svou firmu**: nenastavi `platformAdmin`, +neprida cizi clenstvi, nesahne na spravce platformy a nesmaze cloveka, ktery +je i v jine firme. Posledni bod neni formalita - ucet ve dvou firmach neni jen +jeho a smazat ho znamena vzit pristup i te druhe. + +Driv u vetsiny rout stacilo clenstvi ve firme. Clen s roli `viewer` tak mohl +zalozit konektor nebo smazat automatizaci. Prava v katalogu byla, jen se na ne +routy neptaly. + +### Firma z registru ARES + +Zalozit firmu rucne znamena opsat nazev, IC, DIC a adresu a pak zvlast +zakladat ucty lidem, kteri ji povedou. Proto `Nastaveni`, firma z ARES: +spravce platformy zada IC nebo nazev, vybere firmu a z verejneho rejstriku +dostane **soucasne cleny statutarniho organu a prokuru**. Vybrani dostanou +ucet s roli `role_admin` v nove firme a nahodnym heslem. + +Rejstrik e-maily nezna. Kazda osoba proto dostane zastupnou adresu +`IC-poradi@placeholder.cz`, pokud spravce nezada skutecnou. **Zastupna adresa +se musi nahradit** - s ni se clovek neprihlasi a nedostane pozvanku. Portal ji +pozna (`isPlaceholderEmail`), aby slo upozornit. + +Firma nese `ico`, `dic`, `address` a `legalForm`; IC je unikatni a CRUD firem +to hlida, takze tataz firma nevznikne dvakrat. Hledani v ARES u uz zalozene +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. + ## Co chybi | Chybi | Poznamka | | ------------------------- | ------------------------------------------------ | -| Sprava clenstvi z portalu | memberships jdou zmenit jen v kodu | -| Pozvanky uzivatelu | zadny onboarding | -| Tenant u incidentu | incidenty jsou zatim spolecne, nefiltruji se | -| Tenant u konektoru | katalog je spolecny, napojeni se zatim neeviduje | | Audit pristupu | odepreni se jen loguje, nikde se neuklada | +| Nahrada zastupnych adres | portal je pozna, ale nenuti spravce je vymenit | ## Strop viditelnosti diff --git a/documentation/08-dashboard-widgety.md b/documentation/08-dashboard-widgety.md index 729eccc..6d43d6d 100644 --- a/documentation/08-dashboard-widgety.md +++ b/documentation/08-dashboard-widgety.md @@ -19,8 +19,10 @@ Dokud se neklikne na Ulozit, nic se neuklada. Zrusit vrati puvodni rozlozeni. Ne za uzivatele. Clovek ve dvou firmach chce v kazde videt neco jineho a smichat mu to dohromady by bylo horsi nez zadne nastaveni. -Klic je `${userId}:${tenantId}`, uloziste je `src/data/dashboardLayouts.ts`. -Kdo si dashboard jeste neupravil, dostane vychozi rozlozeni a `custom: false`. +Klic je `${userId}:${tenantId}`, uloziste je `src/data/dashboardLayouts.ts` +(pres `withMirror`, prezije restart). Kdo si dashboard jeste neupravil, +dostane vychozi rozlozeni a `custom: false`. Ulozene rozlozeni si drzi +`createdAt` i po uprave. ## Katalog widgetu @@ -54,6 +56,18 @@ deset stejnych dotazu na server. Prehled drzi ctyri dotazy (souhrn, tickety, incidenty, vytizeni) a rozdava je vsem widgetum. Dlazdice, ktera zadna data nepotrebuje, o nich proste nevi. +Totez plati na serveru: `POST /widget-data` nacte seznam ticketu **jednou na +request** a kazdy widget si z nej filtruje svoje. Driv sel `listTickets` za +kazdy widget zvlast, tedy desetkrat za otevreni prehledu. + +Pri udalosti ze streamu se dotazy obnovi na pozadi (`refreshing`) a dlazdice +zustavaji vykreslene. Driv se cely prehled pri kazde udalosti odmontoval +a ukazal spinner, viz [05-dashboard-a-builder.md](05-dashboard-a-builder.md). + +Ukazkove widgety "Moje tickety" a "Fronta bez resitele" filtruji +`closed: false`, ne podle nazvu stavu - stav je volny retezec a slovnik +`defaultStatuses` je cesky (Novy, V reseni, Ceka na klienta, Vyreseno). + ## Mrizka Sest sloupcu, sirky mapuji na `col-span`: tretina 2, polovina 3, cela 6. @@ -96,4 +110,3 @@ Nabidka i rozlozeni ho vezmou automaticky. | Nastaveni jednotlivych widgetu | napr. kolik radku ukazat, za jake obdobi | | Vlastni metriky | katalog je pevny, nejde si nadefinovat vlastni | | Sdilene rozlozeni pro firmu | kazdy si upravuje jen to svoje | -| Databaze | rozlozeni je v pameti, restart je vrati na vychozi | diff --git a/documentation/11-skripty-konektoru.md b/documentation/11-skripty-konektoru.md index 9c57b93..e47a117 100644 --- a/documentation/11-skripty-konektoru.md +++ b/documentation/11-skripty-konektoru.md @@ -132,7 +132,7 @@ export async function run(inputs, ctx) { /* ... */ } | `ctx.http` | `get`, `post`, `patch`, `put`, `del`, `postForm` nad adresou napojeni | | `ctx.util` | pomocne funkce, viz nize | | `ctx.log` | radek do logu behu, vzdy zredigovany a zkraceny | -| `ctx.config` | necitliva cast nastaveni napojeni | +| `ctx.config` | necitliva cast nastaveni napojeni, tajna pole **nikdy** (`scriptConfig`) | | `ctx.idempotencyKey` | stabilni pres vsechny pokusy tehoz kroku | | `ctx.fail` | koncova chyba, neopakuje se | | `ctx.retry` | docasna chyba, ma smysl zkusit znovu | @@ -175,6 +175,20 @@ z nich by to resil spatne. | `text`, `num`, `bool`, `date` | prevody s fallbackem | | `round(value, decimals)` | zaokrouhleni, uctuje se v halerich | | `need(value, label)` | vrati hodnotu, nebo skonci citelnou chybou | +| `day(value)` | datum bez casu, `YYYY-MM-DD`, jinak prazdny retezec | +| `list(body, ...names)` | seznam z odpovedi: pole primo, nebo pod danym klicem, `data`, `content`, `items`, `results`. Jinak `null` | +| `addresses(value)` | adresy z pole "Prijemci" oddelene carkou nebo strednikem, bez prazdnych | +| `quote(value)` | hodnota v jednoduchych uvozovkach pro filtr OData nebo SQL, apostrof zdvojeny | + +Ctyri posledni pribyly v zari 2026, kdyz se ukazalo, ze osm skriptu ma kazdy +svou verzi. Sablona `scripts/_sablona.js` je vsechny vypisuje, aby se nehledaly +v kodu serveru. + +`ctx.config` je slozene z `scriptConfig` v `src/scripts/connections.ts`: +z nastaveni napojeni vynecha kazde pole, jehoz hodnota je mezi tajnymi. Skript +tedy heslo SMTP ani tajemstvi OAuth nedostane ani omylem, i kdyz je runtime +(SMTP, MCP) potrebuje - ty si je berou z `serviceConfig`, ke kteremu skript +nema pristup. ## Chyby: opakovatelne a koncove @@ -225,6 +239,20 @@ chyby, projde nahradou znamych tajnych hodnot za hvezdicky. Neni to volitelne dolazeni, je to soucast zapisu. +Redaktor (`createRedactor`) maskuje tajemstvi ve **ctyrech tvarech**: +`Bearer abc`, hole `abc`, URL-encoded a JSON-escaped. Cizi sluzby vraceji +prijaty token v chybe i uvnitr adresy nebo v zaescapovanem JSONu, a tam by +hola hodnota nesedela. + +Zkracovani ma jednu konstantu, `DETAIL_BYTES` v `src/scripts/util.ts` +(`SCRIPT_ERROR_DETAIL_BYTES`, vychozi 8 kB). Detail chyby MCP mel driv vlastnich +600 znaku a prave u nej byla cela odpoved potreba nejvic. + +Cteni tela odpovedi jde pres `readBodyLimited` v `src/net/guard.ts`: cte +**proudem a usekne se u limitu**. Prvni verze nacetla cele telo a teprve pak +ho zmerila, takze `SCRIPT_MAX_RESPONSE_BYTES` chranil zaznam behu, ale ne +pamet procesu. + ## Bezpecnostni hranice a co jeste chybi Skripty ve slozce jsou **nase**, prosly gitem a code review. Bezi proto v procesu @@ -241,6 +269,21 @@ u zakaznickych ne - ti musi bezet v izolovanem enginu ve vlastnim vlakne. Podrobnosti v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md), sekce o skriptech. +### Vlastni skripty firmy bezi jinde + +Skript, ktery si napise spravce firmy v portalu (krok "Vlastni skript"), neni +tenhle druh skriptu. Je to **cisty prevod hodnot** bez site a bezi +v `node:vm` (`src/runtime/sandbox.ts`) s casovym limitem. Od zari 2026 se +`utils` i `input` stavi **uvnitr kontextu vm**, ne v hostiteli: funkce +hostitele predane dovnitr nesly s sebou svuj `constructor` a pres +`constructor('return process')` se z nich dalo dostat ven. Zkompilovane +skripty se drzi v LRU cache (100) podle otisku kodu, aby se stejny skript +nekompiloval pri kazdem behu. + +Porad plati, co je v komentari toho souboru: `node:vm` je izolace proti +nehode, ne proti utocnikovi. Skript pise spravce firmy, ktery jeji data uz +vidi. + ## Napojeni do katalogu Skript se domeri do katalogu sluzeb jako akce s `implementation: 'script'` @@ -330,9 +373,8 @@ Katalog, builder i stranka skriptu si ho vezmou samy. Nic se nerestartuje. | Chybi | Poznamka | | ---------------------- | ---------------------------------------------------------- | -| Skripty od zakazniku | potrebuji sandbox a vlastni vlakno, viz vyse | +| Skripty od zakazniku s pristupem ven | dnes jen prevod hodnot ve `vm`, volani ven chce vlastni proces | | Verzovani skriptu | uprava prepise soubor, historie je jen v gitu | -| Vykonavani ze stromu | runner je hotovy, ale runtime automatizaci neni | -| Skripty jako spoustece | zatim jen akce, spoustec potrebuje runtime | +| Skripty jako spoustece | zatim jen akce | | Metriky pro widgety | manifest to zatim nezna, viz bod 4 navrhu | | Ulozeni uprav mimo git | portal zapisuje do souboru v containeru, redeploy je vrati | diff --git a/documentation/14-databaze.md b/documentation/14-databaze.md index 5dafe0d..7319fcb 100644 --- a/documentation/14-databaze.md +++ b/documentation/14-databaze.md @@ -1,21 +1,26 @@ # 14 - Databaze -Naprogramovano a overeno. Zatim se ukladaji **konektory**, tedy pristupove udaje -k sluzbam. Zbytek je v pameti procesu, poradi dalsich kroku je na konci. +Naprogramovano a overeno. Uklada se vsechno: konektory, tickety, automatizace, +incidenty, rozlozeni dashboardu, entity nastaveni, audit a notifikace. Ukladat jde tremi zpusoby a rezim se vybira sam podle toho, co je k dispozici. -## Tri rezimy, jedno rozhrani +## Tri rezimy, jedno rozhrani, jedno rozhodnuti -Rozdil se resi **na jednom miste**, v `src/data/connectorStore.ts`. Nikde jinde -se nezjistuje, ktery rezim jede - kdyby se to rozlezlo po kodu, jedno misto by -se zapomnelo a chovalo by se pak jinak nez zbytek. +Rozdil se resi **na jednom miste**, v `initStores` v `src/data/store/index.ts`. +Nikde jinde se nezjistuje, ktery rezim jede - kdyby se to rozlezlo po kodu, +jedno misto by se zapomnelo a chovalo by se pak jinak nez zbytek. -| Rezim | Kdy | Prezije restart | Prezije redeploy | -| ---------- | --------------------------------- | --------------- | ---------------- | -| `postgres` | je `DATABASE_URL` i `SECRETS_KEY` | ano | ano | -| `file` | neni databaze, ale je `DATA_DIR` | ano | **ne** | -| `memory` | ani jedno, nebo nejde zapsat | ne | ne | +Presne to se stalo: konektory mely vlastni rozhodnuti v `connectorStore.ts` +s jinymi podminkami nez zbytek, takze konektory mohly jet z databaze a tickety +ze souboru. Ted `connectorStore` jen vola `initStores` a rezim je **jeden pro +vsechna uloziste**. + +| Rezim | Kdy | Prezije restart | Prezije redeploy | +| ---------- | --------------------------------------------------------- | --------------- | ---------------- | +| `postgres` | je `DATABASE_URL`, migrace prosly a je cim sifrovat (`SECRETS_KEY`) | ano | ano | +| `file` | neni databaze, ale je `DATA_DIR` | ano | **ne** | +| `memory` | ani jedno, nebo nejde zapsat | ne | ne | Rezim `file` je pro mockup. Filesystem containeru je docasny, takze soubor prezije restart procesu i containeru, ale nove nasazeni ho smaze. Je to @@ -87,8 +92,38 @@ Kdyby to byly dve implementace, jedna by se casem opravila a druha ne. | Slucovani zapisu | deset uprav za sebou znamena jeden zapis na disk | | Zapis pri ukonceni | `SIGTERM` dokonci rozepsany zapis, jinak by se posledni zmena ztratila | | Rozbity soubor | prejmenuje se na `.broken`, zaloguje a jede se s prazdnymi daty. Aplikace, ktera nenastartuje, je pro AppFactory nefunkcni sluzba | +| Necitelny soubor | jen `ENOENT` je prvni start. Jina chyba cteni (prava, plny disk) **zamkne zapisy** a zaloguje se - jinak by se soubor s daty prepsal prazdnym | | Sifrovani | tajne hodnoty jsou v souboru zasifrovane, plaintext nikdy | +Rozdil mezi poslednimi dvema radky je zamer. Rozbity JSON je zalozeny bokem +a nic se neztrati. Chyba cteni ale neznamena, ze data neexistuji - a start +s prazdnem, ktery by je pri prvnim zapisu prepsal, je jedina cesta, jak +o ne v rezimu `file` opravdu prijit. + +## Vrstvy nad ulozistem + +Dve obalky, kazda pro jiny druh dat (podrobne +v [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md)): + +**`withCache`** pro entity, ktere se ctou pri kazdem requestu a meni zridka. +Kopie v pameti, `byId` je `Map`. Vsechny cache jsou v registru +(`src/data/store/cached.ts`): `refreshCache(kind)` obnovi jednu, +`refreshAllCaches()` vsechny. Route nastaveni driv po kazdem zapisu obnovovala +vsechny cache, ted jen entitu, do ktere psala (`bootstrapDataRefresh(route)` +v `src/data/refresh.ts`). `listByTenant(tenantIds, sortBy)` je jeden filtr +a razeni misto sedmi kopii v modulech. + +**`withMirror`** pro provozni data: meni se v pameti, po zmene se zapise cely +zaznam. Zapisy tehoz ID jsou **serazene za sebou** retezem promise. Bez toho +mohl Postgres potvrdit dva `put` tehoz ticketu v opacnem poradi, nez prisly, +a v tabulce zustala starsi verze - v pameti to nebylo videt, po restartu ano. +Tickety navic slucuji vic zmen v jednom tiku do jednoho zapisu +(`persist` / `flushPersist`). + +**Audit** se jen pripisuje a oreza se davkou (`removeMany`) po 50 zapisech +nebo nejvys jednou za minutu. Prvni verze mazala jen radky platformy +(`tenantId: null`) a audit firem rostl donekonecna. + ### Klic mimo databazi Bez `SECRETS_KEY` si aplikace v rezimu `file` vygeneruje klic do @@ -221,10 +256,7 @@ Proti Postgresu 16 v kontejneru: | Chybi | Poznamka | | ------------------------------------ | ----------------------------------------------------------------------------- | -| Automatizace v ulozisti | dalsi na rade, je to to, co si clovek nastavi. Pujde do souboru i do databaze | -| Rozlozeni dashboardu | male a samostatne, hned po automatizacich | -| Tickety a incidenty | naposled, dnes je generuje simulace | -| Uzivatele, firmy, resitele | v prototypu je to spis konfigurace nez data | +| Fronta nad Postgresem | vyber behu je v pameti jedne instance, chce to `SKIP LOCKED` | | 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 | diff --git a/documentation/15-rejstrik-funkci.md b/documentation/15-rejstrik-funkci.md index aa8a1c6..b5e2f97 100644 --- a/documentation/15-rejstrik-funkci.md +++ b/documentation/15-rejstrik-funkci.md @@ -15,7 +15,11 @@ Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět. | Co | Kde | K čemu | | ------------------------------------------ | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `defineStore(kind)` | `src/data/store/index.ts` | Založí úložiště pro nový druh záznamu. Jeden řádek na entitu. | -| `initStores({databaseReady})` | `src/data/store/index.ts` | Vybere režim. Volá se jednou při startu, nikde jinde. | +| `initStores({databaseReady})` | `src/data/store/index.ts` | Vybere režim pro **všechna** úložiště včetně konektorů. Volá se jednou při startu, nikde jinde. | +| `refreshCache(kind)`, `refreshAllCaches()` | `src/data/store/cached.ts` | Registr cache. Po zápisu obnovit jen tu entitu, ne všechny. | +| `listByTenant(tenantIds, sortBy)` | `src/data/store/cached.ts` | Filtr na firmu plus řazení nad cache. Místo sedmi kopií `filter` + `sort` v modulech entit. | +| `nowIso`, `minutesAgo`, `highestNumber`, `writableOrWarn` | `src/data/store/types.ts` | Drobnosti pro moduly úložišť: časová značka, čas před N minutami, nejvyšší číslo ID pro čítač, varování při zápisu do úložiště jen pro čtení. | +| `mergeValues(current, patch)` | `src/data/connectors/types.ts` | Sloučení hodnot konektoru při `PATCH`: prázdný řetězec maže, chybějící klíč nechává. Jedna implementace pro soubor i Postgres. | | `flushStores()` | `src/data/store/index.ts` | Dopíše rozepsané zápisy. Jen při ukončení procesu. | | `withCache(store)` | `src/data/store/cached.ts` | Kopie v paměti pro **konfigurační** entity, které se čtou při každém requestu (uživatelé, role, firmy). Čte se synchronně, obnovuje se po zápisu. | | `withMirror(store)` | `src/data/store/mirror.ts` | Opačný směr než `withCache`: data se mění v paměti a po každé změně se celý záznam zapíše. Pro **provozní** data (tickety, automatizace, incidenty, rozložení). | @@ -38,7 +42,17 @@ Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět. | `navFor(...)` | `src/data/tenantFeatures.ts` | Průnik toho, co firma má, a toho, na co má člověk právo. Navigace chodí ze serveru. | | `recordAudit(input)` | `src/data/audit.ts` | Zápis do auditu. Nevrací chybu a nečeká se - rozbitý audit nesmí rozbít aplikaci. | | `enqueue(input)` | `src/runtime/queue.ts` | Zařadí běh. Klíč proti dvojímu zařazení drží jeden běh na jednu událost. | -| `claimBatch(limit)` | `src/runtime/queue.ts` | Vezme další práci, spravedlivě po firmách. Místo, kde nad Postgresem musí být SKIP LOCKED. | +| `claimBatch(limit, active)` | `src/runtime/queue.ts` | Vezme další práci, spravedlivě po firmách, přeskočí to, co už běží. Místo, kde nad Postgresem musí být SKIP LOCKED. | +| `touchClaim(item)` | `src/runtime/queue.ts` | Tlukot běžícího běhu. Bez něj se dlouhý běh po 30 minutách považuje za zaseknutý a vykoná se podruhé. | +| `findOpenIncident(source, tenantIds)` | `src/data/incidentStore.ts` | Otevřený incident téže příčiny v téže firmě. Stejná chyba nezakládá druhý. | +| `attachAccess`, `tenantOrDeny`, `optionalTenantOrDeny`, `scopeOrDeny` | `src/middleware/tenant.ts` | Přístup jednou na request do `req.access`, firma requestu z něj. Route si to nepočítá sama. | +| `safeRouter()`, `wrap(handler)` | `src/middleware/asyncHandler.ts` | Router, ve kterém odmítnutá promise skončí jako 500 s logem. Každá nová route vzniká tady. | +| `rateLimit({name, windowMs, max})` | `src/middleware/rateLimit.ts` | Limit requestů v paměti, 429 s `Retry-After`. Jen na veřejných endpointech, kde se dá hádat. | +| `validationError(res, zodError, message?)` | `src/middleware/validation.ts` | Jeden tvar chyby validace: z chyby zodu udělá `issues` po polích. | +| `timingSafeEqualString(a, b)` | `src/lib/secure.ts` | Porovnání tokenu v konstantním čase. Webhook, příjem, pozvánky. | +| `assertAllowedUrl`, `readBodyLimited`, `readJsonLimited`, `describeFetchError` | `src/net/guard.ts` | Vše, co volá ven: zákaz vnitřní sítě, čtení těla proudem s limitem, čitelný popis chyby sítě. HTTP skriptů, MCP, SMTP i ARES. | +| `publishOutputs(...)` | `src/runtime/executor.ts` | Zápis výstupů kroku do kontextu, jednou pro vestavěné kroky i skripty. Holé jméno nepřepíše, co už v kontextu je. | +| `lookupCompany`, `searchCompanies`, `listCompanyPersons`, `placeholderEmail`, `isPlaceholderEmail` | `src/ares/client.ts` | Registr ARES: firma podle IČ nebo názvu, statutáři, zástupná adresa a její rozpoznání. | | `onTicketEvent(kind, ticket)` | `src/runtime/triggers.ts` | Změna ticketu zařadí navázané automatizace, včetně ochrany proti smyčce. | | `withRun(marker, work)` | `src/runtime/context.ts` | Označí, který běh práci způsobil. Bez toho automatizace spouští sama sebe. | | `findBuiltinStep(...)` | `src/runtime/builtinSteps.ts` | Kroky, které sahají do našeho úložiště, ne ven přes HTTP. | @@ -50,7 +64,7 @@ Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět. | `getAgentStats(...)` | `src/data/ticketStore.ts` | Výkon řešitelů: odbavené, mediány časů, vrácené, fronta. Používá to widget i detail osoby, aby čísla seděla. | | `findByExternalId(...)` | `src/data/ticketStore.ts` | Ticket firmy podle externího ID. Klíč je dvojice firma a ID. | | `findByIntakeToken(token)` | `src/data/tenants.ts` | Firma podle tokenu příjmu. Určuje i to, v jakém rozsahu je externí ID unikátní. | -| `refreshCaches()` | `src/data/bootstrap.ts` | Obnoví všechny kopie v paměti. Volá se po zápisu, který je může změnit. | +| `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. | ## Skripty a konektory (server) @@ -61,8 +75,11 @@ Viz [11-skripty-konektoru.md](11-skripty-konektoru.md). | ------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `runScript(id, inputs, ctx)` | `src/scripts/runner.ts` | Spustí skript. **Nikdy nevyhodí výjimku**, chybu vrací jako výsledek s celým hlášením. | | `validateValues(...)` | `src/scripts/values.ts` | Jedna kontrola pro vstupy i výstupy skriptu podle manifestu. | -| `scriptUtil` | `src/scripts/util.ts` | Nádobíčko pro skripty: `pick`, `first`, `num`, `date`, `need`, `get`, `applyRules`, `fillJson`. Skript nemá sahat na nic jiného. | -| `createRedactor(...)` | `src/scripts/util.ts` | Vyškrtá tajemství z textu **před** logováním. Používá se u všeho, co jde do logu. | +| `scriptUtil` | `src/scripts/util.ts` | Nádobíčko pro skripty: `pick`, `first`, `num`, `date`, `day`, `list`, `addresses`, `quote`, `need`, `get`, `applyRules`, `fillJson`. Skript nemá sahat na nic jiného. | +| `pick`, `pickText`, `jwtExpiry`, `parseBool`, `parseNumber` | `src/scripts/util.ts` | Totéž pro server: pole bez ohledu na velikost písmen, `exp` z JWT, převody. Než napíšeš `Number(x)` s kontrolou `NaN`, je to tady. | +| `DETAIL_BYTES`, `truncate(value)` | `src/scripts/util.ts` | Jeden limit na zkracování detailu chyby pro všechny vrstvy. Žádné vlastní `slice(0, 600)`. | +| `createRedactor(...)` | `src/scripts/util.ts` | Vyškrtá tajemství z textu **před** logováním, i v URL-encoded a JSON-escaped tvaru. Používá se u všeho, co jde do logu. | +| `scriptConfig(target)` | `src/scripts/connections.ts` | Nastavení napojení bez tajných polí. Jediné, co skript dostane jako `ctx.config`. | | `applyRules`, `fillJson` | `src/scripts/mapping.ts` | Transformace dat: pole na pole s převody, nebo objekt na objekt. Viz [13-transformace-dat.md](13-transformace-dat.md). | | `getPath(obj, path)` | `src/scripts/mapping.ts` | Čtení `zakaznik.adresa.mesto` z neznámého objektu. | | `resolveTarget(...)` | `src/scripts/connections.ts` | Z konektoru poskládá adresu a hlavičky. Přístupové údaje nikam jinam nevedou. | @@ -92,7 +109,18 @@ Viz [11-skripty-konektoru.md](11-skripty-konektoru.md). | `MappingEditor` | `components/dashboard/flow/MappingEditor.tsx` | Editor transformací v obou režimech (pole na pole, JSON). | | `DataState` | `components/dashboard/DataState.tsx` | Načítání, chyba, prázdno. Ať to každá stránka nekreslí po svém. | | `apiFetch` | `lib/api.ts` | Jediná cesta na API: base path, token, `ApiError` s celým hlášením ze serveru. | -| `useApiQuery` | `lib/useApiQuery.ts` | Načtení dat do stránky včetně `reload`. S `body` pošle POST (dávkové načtení), s `enabled: false` se neptá vůbec. | +| `useApiQuery` | `lib/useApiQuery.ts` | Načtení dat do stránky včetně `reload`, `refreshing` a `total`. S `body` pošle POST, s `enabled: false` se neptá, `patchOn` opraví data z události bez dotazu. | +| `useCollection(key)`, `useAccess()`, `useCollectionSelector` | `lib/collections.tsx` | Číselníky za firmu (lidé, skupiny, typy, služby, konektory, přístup) ze sdíleného skladu, opravované z událostí. Ne `apiFetch` na číselník ze stránky. | +| `patchTicketList(...)` | `lib/ticketEvents.ts` | Oprava seznamu ticketů z `payload.ticket` v události. Použít jako `patchOn`. | +| `apiFetchWithMeta` | `lib/api.ts` | Jako `apiFetch`, ale vrací i `X-Total-Count`. Pro stránkované seznamy. | +| `useSubmit(fn)` | `lib/useSubmit.ts` | Odeslání formuláře: `saving`, chyba, reset. Dvanáct řádků, které si dřív psal každý formulář zvlášť. | +| `useUnsavedChanges(dirty)` | `lib/useUnsavedChanges.ts` | Varování při odchodu z rozepsaného formuláře nebo stromu. | +| `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. | +| `Chip` | `components/ui/Chip.tsx` | Štítek. | +| `TicketCard` | `components/dashboard/TicketCard.tsx` | Karta ticketu pro dlaždice a mobil, varianta `compact` pro widgety. | +| `useMediaQuery(query)` | `lib/useMediaQuery.ts` | Tabulka nebo karty podle šířky. `TicketTable` podle toho kreslí obojí, druhá komponenta není. | | `cn(...)` | `lib/cn.ts` | Skládání tříd. Podmíněné třídy nikdy ručně přes šablonu. | | `format*` | `lib/format.ts` | Čísla, procenta, datum, relativní čas, trvání. Formátování se nepíše v komponentě. | | `serviceIcon(key)` | `lib/serviceIcons.ts` | Klíč ikony ze serveru na komponentu. Server neposílá komponenty. | diff --git a/documentation/17-nastaveni-a-prava.md b/documentation/17-nastaveni-a-prava.md index ae9a119..87b3729 100644 --- a/documentation/17-nastaveni-a-prava.md +++ b/documentation/17-nastaveni-a-prava.md @@ -24,6 +24,11 @@ Nová entita v nastavení pak znamená: `defineStore` v modulu entity, jeden ř v `bootstrap.ts`, jeden `crudRouter` v `settings.ts`, jeden popis v `Settings.tsx`. Nic víc. +`crudRouter` navic s volbou `event` publikuje `.created`, `.updated` +a `.deleted` s celym zaznamem v payloadu, takze klientsky sklad ciselniku +(`lib/collections.tsx`) se opravi bez dotazu. Po zapisu se obnovi **jen cache +te entity** (`bootstrapDataRefresh(route)`), ne vsechny. + ## Práva jsou data Práv je 26 a jsou v katalogu (`src/data/permissions.ts`). Role je **záznam**, @@ -37,6 +42,36 @@ v Automii řešitel a u Nordisu správce jejich servicedesku. Neznámé právo se **odmítne** už při ukládání role. Kdyby se jen ignorovalo, překlep by znamenal roli, která tiše nic nesmí. +### Prava se kontroluji u kazde route, za firmu zaznamu + +Do zari 2026 se vetsina rout ptala jen "je clen firmy". Prava v katalogu +byla, ale `viewer` mohl zalozit konektor nebo smazat automatizaci. Ted: + +| Co | Pravo | +| ---------------------------------------- | -------------------------------------------------------- | +| firmy | jen spravce platformy | +| uzivatele | spravce platformy, nebo `user.manage` jen ve sve firme | +| pozvanky | `user.manage`, role jen z te firmy | +| konektory (zalozeni, uprava, smazani, test) | `connector.manage` | +| automatizace (zalozeni, uprava, smazani, novy token) | `automation.edit` | +| vestavene akce na ticketu | pravo akce za firmu ticketu plus strop viditelnosti | +| prepnuti na jiny ucet, audit | `impersonate`, `audit.view` | + +Spravce firmy s `user.manage` nenastavi `platformAdmin`, neprida clenstvi +v jine firme, nesahne na spravce platformy a nesmaze cloveka, ktery je i +v jine firme. Duvody a rozhodnuti "kdo koho zaklada" jsou +v [07-firmy-a-prava.md](07-firmy-a-prava.md). + +### Firma z registru ARES + +Zalozka Firmy ma vedle rucniho zalozeni cestu pres ARES +(`components/dashboard/AresTenantDialog.tsx`), jen pro spravce +platformy: IC nebo nazev, vyber firmy, vyber statutaru, kteri dostanou ucet +s roli spravce a zastupnou adresou `IC-poradi@placeholder.cz`. Zastupne +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). + ## Navigace chodí ze serveru Co uživatel vidí za záložky, je průnik dvou věcí: @@ -119,9 +154,9 @@ takže ho nemůže ani omylem prodloužit. | Data | Jak | Proč | | ----------------------------------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------- | -| Firmy, uživatelé, role, řešitelé, skupiny, typy, akce, widgety, záložky | `withCache` | Čtou se při každém requestu, mění se zřídka. Kopie v paměti, obnova po zápisu. | -| Tickety včetně logu, automatizace, incidenty, rozložení dashboardu | `withMirror` | Mění se v paměti za provozu, po každé změně se celý záznam zapíše. | -| Audit | přímo do úložiště | Jen se připisuje, nikdy nečte při každém requestu. | +| Firmy, uživatelé, role, řešitelé, skupiny, typy, akce, widgety, záložky | `withCache` | Čtou se při každém requestu, mění se zřídka. Kopie v paměti (`byId` je `Map`), obnova jen te entity po zápisu. | +| Tickety včetně logu, automatizace, incidenty, rozložení dashboardu | `withMirror` | Mění se v paměti za provozu, po každé změně se celý záznam zapíše. Zápisy téhož ID jdou za sebou, ne naráz. | +| Audit | přímo do úložiště | Jen se připisuje, nikdy nečte při každém requestu. Oreza se davkou po 50 zapisech nebo jednou za minutu. | | Konektory | vlastní úložiště | Nesou šifrovaná tajemství a potřebují částečný unikátní index. Viz [14-databaze.md](14-databaze.md). | Čítače ID se při startu dopočítají z uložených záznamů, takže nový ticket diff --git a/documentation/20-fronta-a-runtime.md b/documentation/20-fronta-a-runtime.md index 74adbcb..cab692c 100644 --- a/documentation/20-fronta-a-runtime.md +++ b/documentation/20-fronta-a-runtime.md @@ -84,7 +84,23 @@ odmítnout ho kvůli tomu by znamenalo, že webhook nejde zapojit. těla. V portálu je u adresy vidět totéž včetně metody, a kopíruje se **celá adresa včetně domény**. -## Opakování a vzdání se +## Worker je pool + +`CONCURRENCY` (4) behu naraz, ale **nezavisle na sobe**. Worker drzi mnozinu +`active`, kazde kolo si vezme `CONCURRENCY - active.size` behu +(`claimBatch(limit, active)` to, co uz bezi, preskoci) a spusti je bez cekani +na ostatni. Prvni verze brala davku a cekala, az dobehne cela: jeden pomaly +krok MCP na deset minut blokoval tri prazdne sloty. + +Bezici beh posila kazdou minutu **tlukot** (`touchClaim`). Za zaseknuty se +povazuje az 30 minut od posledniho tlukotu (`STUCK_AFTER_MS` v `queue.ts`), +ne od vzeti z fronty. Driv to bylo deset minut od vzeti, takze beh s dlouhou +ulohou MCP se vratil do fronty a **vykonal se podruhe**, i kdyz porad bezel. + +Planovac zarazuje behy s triggerem `poll`, ne `manual`, aby slo v seznamu +behu poznat, co spustil clovek a co cas. + +## Opakovani a vzdani se | Pokus | Kdy | | ----- | --------- | @@ -94,12 +110,52 @@ adresa včetně domény**. | 4. | za 10 min | | 5. | za hodinu | -Pak běh skončí jako `failed` a zůstane k nahlédnutí. Nemaže se: bez záznamu -by nikdo nezjistil, že se něco nestalo. +Pak beh skonci jako `failed` a zustane k nahlednuti. Nemaze se: bez zaznamu +by nikdo nezjistil, ze se neco nestalo. -**Marná chyba se neopakuje vůbec.** Chybějící skript nebo neexistující skupina -za minutu existovat nezačne, takže se běh rovnou vzdá. Opakuje se jen to, co -může pominout: nedostupná služba, timeout. +**Opakuje se jen to, co samo rekne `retryable`.** Vychozi je "ne". Pravidlo +je stejne ve vsech vrstvach a je napsane v komentari nad `StepResult` +v `executor.ts`: + +| Opakuje se | Konci hned a zaklada incident | +| ------------------------------------------------------- | ---------------------------------------------------------- | +| chyba spojeni, timeout pred odeslanim | 401, 403, 404 | +| 5xx a 429 od cizi sluzby | spatny vstup, chybejici vystup, `ctx.fail` | +| vypadek uloziste konektoru | chybejici nebo pozastavena automatizace, chybejici skupina | +| MCP: chyba spojeni, 408, 425, 429, 502, 503, 504 | MCP: timeout uz odeslaneho `tools/call`, chyba v kodu | + +Driv se opakovala **kazda** chyba skriptu, petkrat za 72 minut. Spatny vstup +tak petkrat zopakoval tutez hlasku a u kroku, ktery neni idempotentni, mohl +cizi sluzbu zavolat podruhe. Timeout uz odeslaneho volani MCP je proto +neopakovatelny: MCP nema idempotencni klic a jestli druhy pokus znamena druhou +objednavku, vi jen server, ktery neni nas. + +## Dva stropy na velikost behu + +| Strop | Co pocita | +| -------------------- | --------------------------------------------------- | +| `MAX_STEPS = 50` | kroky ve stromu, kontroluje se pri ulozeni | +| `MAX_ACTIONS = 1000` | vykonane kroky vcetne pruchodu smyckou, za behu | + +Jeden strop nestacil: smycka se dvema kroky nad 26 polozkami je 52 vykonanych +kroku a beh padal na limitu 50, i kdyz strom mel kroky ctyri. Kdyz se strop +vycerpa, beh se zastavi a rekne to; tise useknout smycku by vypadalo jako +uspech. + +## Podminka nad datem + +Pole typu `date` a hodnoty, ktere vypadaji jako ISO datum, se v podmince +porovnavaji pres `Date.parse`. Driv slo vsechno pres `Number()`, ISO retezec +vysel jako `NaN` a `gt` i `lt` nad datem byly **vzdycky nesplnene**, bez chyby +a bez radku v logu. + +## Vystupy kroku + +`publishOutputs()` v `executor.ts` je jedno misto pro vestavene kroky +i skripty. Vystup se zapise pod jmenem kroku (`st_x.status`) vzdycky, **hole +jmeno (`status`) jen kdyz v kontextu jeste neni**. Nastroj MCP, ktery vraci +`status`, driv prepsal `status` spoustece a podminka za nim se ptala na +spatnou hodnotu. ## Incident z chyby @@ -118,6 +174,12 @@ informace pro zákazníka. Stejná příčina nezakládá druhý incident, dokud je první otevřený. Jinak by deset stejných chyb znamenalo deset incidentů a nikdo by se v tom nevyznal. +Incident patri **firme behu**. `findOpenIncident(source, tenantIds)` hleda jen +v ni a krok `incident/create` predava `tenantId` - prvni verze zakladala +globalni incident, ktery videly vsechny firmy. Neocekavana vyjimka v kroku +(chyba v kodu) je taky koncova: neopakuje se a zaklada incident, protoze za +minutu nezmizi. + ## Ochrana proti smyčce Automatizace navázaná na změnu ticketu ticket změní, čímž se spustí znovu. @@ -205,8 +267,7 @@ Ověřeno 19 kontrolami proti běžícímu serveru. to musí být `SELECT ... FOR UPDATE SKIP LOCKED`, jinak si dva workery vezmou tentýž běh. Místo je označené v `runtime/queue.ts`. - **Strop souběžných volání na dvojici firma a služba** a vypnutí služby po - sérii chyb. Timeout a rozlišení "zkusit znovu / marné" už ve - `scripts/http.ts` je. + sérii chyb. Rozliseni "zkusit znovu / marne" uz plati ve vsech vrstvach. - **Dlouhé čekání** (pošli e-mail za tři dny) přes běh naplánovaný na později. Krok `flow/pause` umí nejvýš minutu, protože blokuje běh. diff --git a/documentation/23-jazyky.md b/documentation/23-jazyky.md index 57e961e..8fe4847 100644 --- a/documentation/23-jazyky.md +++ b/documentation/23-jazyky.md @@ -1,6 +1,7 @@ # 23 - Jazyky -Rozdelane. Mechanismus je hotovy a overeny, prevod obsahu bezi po castech. +Mechanismus je hotovy a overeny. Verejny web je prelozeny cely, portal za +prihlasenim jen ve spolecnych castech. ## Jak to funguje @@ -51,19 +52,18 @@ v nadpisu. ## Co uz je prelozene -| Cast | Stav | -| ----------------------------------- | ------------------------------------ | -| Hlavicka a mobilni menu | hotovo | -| Hero vcetne snimku portalu | hotovo | -| Pas s logy klientu | hotovo | -| Cisla | hotovo | -| Zaverecna vyzva | hotovo | -| Paticka | castecne, sloupce odkazu zatim cesky | -| Sekce Produkty, Reference, Postup | ne | -| Stranky O nas, Sluzby, Kontakt, 404 | ne | -| Portal za prihlasenim | ne | +| Cast | Stav | +| ----------------------------------------- | --------------------------------------------- | +| Hlavicka, navigace a mobilni menu | hotovo | +| Homepage vcetne Produktu, Referenci a Postupu | hotovo | +| Paticka | hotovo | +| Stranky O nas, Sluzby, Kontakt, 404 | hotovo | +| Prihlaseni | hotovo, vcetne hlasky o vyprselem prihlaseni | +| `DataState` a `ErrorBoundary` | hotovo - stavy nacitani a chyby jsou vsude | +| Portal za prihlasenim | ne, stranky dashboardu maji texty v kodu | -Prevod zbytku je mechanicky: text ven do `cs.ts`, klic do `en.ts`, v komponente +Pro prelozene casti je `en.ts` **uplna**, nikde nespada na cestinu. Zbytek je +mechanicky: text ven do `cs.ts`, klic do `en.ts`, v komponente `const t = useT()` a `{t('klic')}`. Zadna dalsi prace na vrstve uz potreba neni. ## Co se neprekada @@ -78,7 +78,7 @@ Claim se **prekada** (`brand.claim`), protoze to je text, ne udaj. | Chybi | Poznamka | | ---------------------- | ----------------------------------------------------------------- | -| Prevod zbytku obsahu | viz tabulka vys | +| Stranky portalu | viz tabulka vys | | Jazyk v adrese | `/en/sluzby` misto volby v prohlizeci. Chce to kvuli vyhledavacum | | Preklad dat ze serveru | nazvy sluzeb, stavu a chybovych hlasek chodi z API cesky | | Format cisel a datumu | `toLocaleString` se zatim vola natvrdo s `cs-CZ` | diff --git a/documentation/24-mcp-konektory.md b/documentation/24-mcp-konektory.md index 9456a90..262821f 100644 --- a/documentation/24-mcp-konektory.md +++ b/documentation/24-mcp-konektory.md @@ -202,6 +202,41 @@ vedet, jak s nimi portal nalozil, nez zacne hledat chybu jinde. token**, ne pred kazdym volanim. Server drzi sezeni u tokenu, takze po jeho vymene se to musi zopakovat, jinak odpovi, ze relace neni inicializovana. +Plati to i pro server **bez prihlaseni** - i ten ma sezeni a handshake stoji +dve volani. Prvni verze cachovala jen podle tokenu, takze server bez tokenu +dostaval handshake pred kazdym krokem. + +`Mcp-Session-Id`, ktery server vyda pri handshaku, se **uklada s handshakem** +a posila v kazdem dalsim volani (u dialektu, ktere hlavicku pouzivaji). +Sezeni bez ID by server nepoznal a kazde volani by zacinalo znovu. Kdyz +server na preskoceny handshake odpovi 400 nebo 404, znamena to podle +specifikace "sezeni neznam": handshake se zopakuje **jednou** a volani se +posle znovu. Vic nez jednou ne - kdyby to nepomohlo, je chyba jinde a smycka +by ji jen schovala. + +Prihlaseni OAuth u obecne sluzby sdili **jednu rozdelanou operaci na +konektor**, stejne jako EasyWeb: dva behy nad tymz napojenim udelaji jedno +prihlaseni, ne dve. + +## Co se opakuje a co ne + +| Situace | Opakuje se | +| ----------------------------------------------- | ---------- | +| spojeni se nenavazalo, DNS, TLS | ano | +| 408, 425, 429, 502, 503, 504 | ano | +| timeout **uz odeslaneho** `tools/call` | **ne** | +| 401, 403, 404, chyba prihlaseni | ne | +| `isError` v odpovedi nastroje | ne | + +Timeout po odeslani je ta zvlastni radka. MCP nema idempotencni klic, takze +druhy pokus by nastroj provedl podruhe - a jestli to znamena druhou +objednavku, vi jen server, ktery neni nas. Rozhoduje `McpFailure.retryable`, +stejne pravidlo jako v [20-fronta-a-runtime.md](20-fronta-a-runtime.md). + +Chyby prihlaseni maji spolecneho predka `AuthFailure` v `src/mcp/errors.ts`, +`EasyWebAuthError` z nej dedi. Krok tak pozna chybu prihlaseni jednou +kontrolou a detail je vzdy zredigovany, at prisel odkudkoliv. + ## Odpoved muze byt stream Server si sam vybira, jestli odpovi JSON telem, nebo SSE streamem, a **streamem @@ -348,11 +383,14 @@ a krok to rekne misto toho, aby predstiral selhani. ostatni tajne hodnoty. - **Cizi napojeni se chova jako neexistujici.** Krok si konektor nacita pres filtr na firmu, takze strom s cizim ID konektoru selze. -- **Krok se neopakuje.** MCP nema idempotencni klic, takze druhy pokus po - timeoutu by nastroj provedl podruhe - a jestli to znamena druhou objednavku, - vi jen server, ktery neni nas. +- **Odeslane volani se neopakuje.** MCP nema idempotencni klic, takze druhy + pokus po timeoutu by nastroj provedl podruhe. Opakuje se jen to, co selhalo + pred odeslanim nebo co server odmitl docasne, viz vyse. - Plati stejny strop na velikost odpovedi jako u skriptu (`SCRIPT_MAX_RESPONSE_BYTES`), a to i u streamu, kde se pocita prubezne. + Cte se pres `readBodyLimited` z `src/net/guard.ts`, stejne jako u HTTP + skriptu a SMTP. Detail chyby ma stejny limit jako vsechno ostatni + (`DETAIL_BYTES`), driv mel vlastnich 600 znaku. - Seznamy se strankuji nejvys stokrat, volani nastroje dvacetkrat. ## Co se **nedela** @@ -373,6 +411,8 @@ a krok to rekne misto toho, aby predstiral selhani. | Rozdily serveru | `src/mcp/dialect.ts` | | Protokol | `src/mcp/client.ts` | | Prihlaseni obecne | `src/mcp/auth.ts` | +| Chyby prihlaseni | `src/mcp/errors.ts` | +| Sit a limity tela | `src/net/guard.ts` | | Klice EasyWebu | `src/mcp/easyweb/crypto.ts` | | Zarizeni u konektoru | `src/mcp/easyweb/device.ts` | | Tokeny EasyWebu | `src/mcp/easyweb/session.ts` | @@ -382,7 +422,7 @@ a krok to rekne misto toho, aby predstiral selhani. | Nacteni nastroju | `src/routes/connectors.ts` | | Vykonna cast kroku | `src/runtime/builtinSteps.ts`, `runMcpTool` | | Ulozeni u konektoru | `src/data/connectors/*`, migrace `004` | -| Portal | `web/src/pages/dashboard/Connectors.tsx` | +| Portal | `web/src/pages/dashboard/Connectors.tsx` a `connectors/{ConnectorCard,ConnectorEditor,ConnectorLogs,ConnectorTools}.tsx` | ## Co jeste chybi diff --git a/documentation/25-navrh-pristupny-portal.md b/documentation/25-navrh-pristupny-portal.md index 178ab82..a735007 100644 --- a/documentation/25-navrh-pristupny-portal.md +++ b/documentation/25-navrh-pristupny-portal.md @@ -6,9 +6,12 @@ [07-firmy-a-prava.md](07-firmy-a-prava.md), - sekce 6, kontrakt webhooku, - z prvni sekce dlazdice "Moje tickety" a "Fronta bez resitele" vcetne noveho - vychoziho rozlozeni. + vychoziho rozlozeni, +- sekce 2, formularova vrstva (zari 2026, viz zacatek te sekce), +- ze sekce 3 strankovani na serveru: `/tickets` bere `limit` a `offset` + a vraci `X-Total-Count`, viz [04-api.md](04-api.md). -Zbyva widget akci, "Zaciname", formularova vrstva a hledani. Az se cast udela, +Zbyva widget akci, "Zaciname" a hledani. Az se cast udela, prepise se do prislusneho souboru dokumentace a odsud zmizi - stejne pravidlo jako u [09-navrh-rozsireni.md](09-navrh-rozsireni.md). @@ -170,9 +173,23 @@ coz je cil, to sedi. --- -## 2 - Formularova vrstva +## 2 - Formularova vrstva (hotovo) -### Cim to je +**Naprogramovano v zari 2026**, presne podle navrhu nize: + +| Navrh | Kde to je | +| ------------------------------- | ---------------------------------------------------------------- | +| `Field`, `Input`, `Select`, `Textarea` | `components/ui/form/`, tridy v `controlClass.ts` | +| `useSubmit` | `lib/useSubmit.ts` | +| ciselniky ven | `lib/options.ts` (priority), stavy a kanaly z `/widget-data/options` | +| kompaktni karta ticketu | `components/dashboard/TicketCard.tsx`, varianta `compact` | +| stitek | `components/ui/Chip.tsx` | +| sklonovani poctu | `plural()` v `lib/format.ts` | + +Zmizelo 15 kopii trid vstupniho pole. Navrh zustava nize jako zduvodneni, +proc to vypada tak, jak vypada. + +### Cim to bylo V `components/ui/` je Badge, Button, Card, Container, Modal, PageHeader, Section a Spinner. **Zadny formularovy prvek.** Takze si ho kazda stranka pise znovu. @@ -278,12 +295,14 @@ Klientsky filtr pres ctyri veci: `id`, `subject`, `customer.company`, polich**, ve **stitcich** ani v **externim ID**. To posledni je zrovna to, cim se dohledava hovor: clovek ma `CAbc75a8...` a chce ten ticket. Dnes ho nenajde. -A funguje to jen proto, ze `GET /api/dashboard/tickets` vraci **vsechny tickety -firmy najednou**, bez limitu a bez strankovani. Pri par desitkach to nevadi, pri -deseti tisicich jsou to megabajty do prohlizece pri kazdem otevreni seznamu. +A funguje to jen proto, ze seznam v portalu si bere **vsechny tickety firmy +najednou**. Server uz strankovani umi (`limit`, `offset`, `X-Total-Count`), +klient ho zatim nepouziva. Pri par desitkach to nevadi, pri deseti tisicich +jsou to megabajty do prohlizece pri kazdem otevreni seznamu. Hledani pres modal proto znamena **serverovy endpoint**, a je to zaroven -prilezitost prestat posilat vsechno. +chvile, kdy seznam prejde na strankovani - jinak by klientske hledani +a serverove ukazovaly jina cisla. ### Tvar @@ -576,14 +595,10 @@ prestane stacit. ## Poradi praci -1. **Formularove primitivy** a rozdeleni dvou formularu (ticket, helpdesk). Nic - dalsiho na nich nestoji, ale stoji na nich vsechno ostatni v rozhrani. -2. **"Moje tickety" a "Fronta bez resitele".** Formulare nepotrebuji, jsou skoro - zadarmo a jsou hned videt v provozu. -3. **Viditelnost.** Model, strop v `resolveScope`, vynuceni v jedne ceste ke - ticketum, panel skupiny. Delat to pred hledanim, ne po nem, jinak se hledani - pise dvakrat. -4. **Modal hledani** a serverovy endpoint se strankovanim. +1. **Formularove primitivy** - hotovo. +2. **"Moje tickety" a "Fronta bez resitele"** - hotovo. +3. **Viditelnost** - hotovo, viz [07-firmy-a-prava.md](07-firmy-a-prava.md). +4. **Modal hledani** a prechod seznamu na strankovani, ktere server uz umi. 5. **Widget akci** a "Zaciname". Sahne uz jen na hotove formulare. Postgres kdykoliv mezi tim, nezavisle na ostatnim. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 8f7d12d..35abbf6 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,252 @@ Nejnovejsi nahore. +## 2026-09-09 - Revize projektu: prava, vykon, runtime, portal a ARES + +Velka sada oprav napric celym projektem. Zadna nova obrazovka, ale skoro kazda +vrstva se zmenila v tom, **co dela pri zatezi a pri chybe**. K tomu jedna nova +funkce: zalozeni firmy podle registru ARES. Zaznam je dlouhy schvalne - tohle +je misto, kde se za pul roku hleda, proc se neco chova tak, jak se chova. + +### Uloziste: jeden rezim pro vsechno + +Rozhodnuti o rezimu (`postgres`, `file`, `memory`) delal `connectorStore.ts` +pro konektory a `initStores` pro zbytek, kazdy podle svych podminek. Mohlo se +stat, ze konektory jely z databaze a tickety ze souboru. Ted rozhoduje +**jen `initStores` v `src/data/store/index.ts`**: Postgres jen kdyz je +`DATABASE_URL`, migrace prosly a je cim sifrovat (`SECRETS_KEY`), jinak soubor +nebo pamet pro vsechna uloziste vcetne konektoru. `connectorStore` uz jen vola +`initStores`. + +Dalsi opravy v ulozisti, kazda ma za sebou konkretni problem: + +| Co | Proc | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| audit se maze davkou (`removeMany`) | orezavani mazalo jen radky platformy, audit firem rostl donekonecna. Bezi po 50 zapisech nebo nejvys jednou za minutu | +| `markRead` pres `updateMany` | notifikace delaly jeden zapis a jednu obnovu cache na kazdy zaznam | +| `persist` / `flushPersist` u ticketu | vic zmen tehoz ticketu v jednom tiku je jeden zapis, ne pet | +| `withMirror` radi zapisy za sebou | dva `put` tehoz zaznamu mohl Postgres potvrdit v opacnem poradi a v tabulce zustala starsi verze. Ted je na kazde ID retez promise | +| `issues` a `stepCount` ulozene | `listAutomations` validoval vsechny stromy pri kazdem cteni. Ted se spocitaji pri ulozeni (`withDerived`) a jednou pri startu | +| registr cache (`refreshCache(kind)`) | route nastaveni obnovovala vsechny cache, ted jen tu entitu, do ktere psala (`bootstrapDataRefresh(route)`) | +| `listByTenant(tenantIds, sortBy)` | sedm kopii filtr + razeni v modulech entit | +| mapy misto `find` | `withCache.byId` je `Map`, ticketStore ma `ticketsById` a `ticketsByExternal`, vytizeni a statistiky se seskupi jednim pruchodem | +| `create` v lokalnim ulozisti hazi na duplicitu | Postgres to delal, soubor tise prepsal | +| `snapshot.ts` prepise soubor jen pri ENOENT | jina chyba cteni (prava, plny disk) driv znamenala start s prazdnymi daty a **prepsani souboru prazdnym obsahem**. Ted se zapisy zamknou a zaloguje se to | +| `listIncidents(tenantIds)` povinne | stejne pravidlo jako u ticketu, incident byl posledni seznam bez filtru | + +Slovnik stavu ticketu je sjednoceny na cesky `defaultStatuses` (Novy, V reseni, +Ceka na klienta, Vyreseno) a ukazkove widgety filtruji `closed: false`, ne +podle nazvu stavu. Ukazkove tickety TK-4817 a TK-4812 vznikaji jen se +`SEED_DEMO=1`. V `services.ts` byla dvakrat operace `set-status`, druha tise +prekryvala prvni; `checkOperationIds()` to ted pri nacteni zaloguje. + +Spolecne pomocne funkce, aby se nepsaly po modulech: `nowIso`, `minutesAgo`, +`highestNumber`, `writableOrWarn` v `store/types.ts`, `mergeValues` +v `connectors/types.ts`. + +### Runtime: worker je pool a opakuje se jen to, co muze pominout + +Worker bral davku ctyr behu a cekal, az dobehnou vsechny. Jeden pomaly beh +tak blokoval tri volne sloty. A beh delsi nez deset minut se povazoval za +zaseknuty, vratil se do fronty a **vykonal se podruhe**. Ted: + +- `active` je mnozina bezicich ID, kazde kolo si vezme `CONCURRENCY - active.size` + behu a spusti je bez cekani na ostatni, +- `claimBatch(limit, active)` preskakuje to, co uz bezi, +- beh kazdou minutu posle tlukot (`touchClaim`) a za zaseknuty se povazuje az + 30 minut od posledniho tlukotu (`STUCK_AFTER_MS`), ne od vzeti z fronty. + +**Opakovani.** Kazda chyba skriptu se opakovala petkrat za 72 minut, i 403 +a spatny vstup. Ted se krok opakuje jen kdyz sam rekne `retryable`: chyba +spojeni, timeout, 5xx a 429, vypadek uloziste konektoru. 401, 403, 404, +validace a `ctx.fail` konci hned a zakladaji incident. U MCP jsou opakovatelne +chyby spojeni a 408, 425, 429, 502, 503, 504; **timeout uz odeslaneho +`tools/call` opakovatelny neni**, protoze MCP nema idempotencni klic a nastroj +by se provedl podruhe. Pravidlo je v komentari nad `StepResult` +v `executor.ts`, aby ho nasel kazdy, kdo pise novy druh kroku. + +Dalsi zmeny v behu: + +| Co | Proc | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `MAX_ACTIONS = 1000` | `MAX_STEPS = 50` pocita staticky strom. Smycka se dvema kroky nad 26 polozkami narazila na 50 a beh spadl. Vykonane kroky maji vlastni strop | +| datum v podmince pres `Date.parse` | `gt` a `lt` nad datem prevadely ISO retezec cislem, vyslo NaN a podminka byla vzdycky nesplnena | +| `publishOutputs()` spolecne | vestavene kroky a skripty publikovaly vystupy kazdy jinak. Hole jmeno se zapise jen kdyz v kontextu jeste neni - nastroj MCP vracejici `status` prepisoval `status` spoustece | +| sandbox stavi `utils` i `input` uvnitr vm | funkce hostitele prosakovaly do skriptu firmy a `constructor('return process')` z nich utekl ven. Zkompilovane skripty se cachuji (LRU 100) | +| chybejici nebo pozastavena automatizace | neopakovatelna chyba plus incident, driv se to zkouselo dokola | +| `incident/create` nese `tenantId` | krok zakladal globalni incident, ktery videly vsechny firmy | +| planovac zarazuje s triggerem `poll` | bylo `manual`, takze se v behu nedalo poznat, ze to spustil planovac | + +### Sit a tajemstvi + +Novy `src/net/guard.ts` sdruzuje to, co melo kazde volani ven zvlast: +`assertAllowedUrl` (zakaz vnitrni site), `describeFetchError`, +`readBodyLimited` a `readJsonLimited`. Telo se **cte proudem a usekne se +u limitu** - driv se nacetlo cele a teprve pak zmerilo, takze limit nechranil +pamet. Pouziva to HTTP skriptu, klient MCP, prihlaseni MCP i SMTP. + +Redaktor masky navic maskuje tajemstvi v **URL-encoded a JSON-escaped** tvaru, +protoze cizi sluzby je v chybach vraceji i tak. `ctx.config` skriptu uz nikdy +neobsahuje tajna pole (`scriptConfig`). `EasyWebAuthError` dedi z `AuthFailure` +(novy `src/mcp/errors.ts`), takze se chyby prihlaseni poznaji jednou +kontrolou a detail je vzdy zredigovany. + +Klient MCP: handshake se cachuje i pro server bez prihlaseni, `Mcp-Session-Id` +se uklada s handshakem a posila znovu, 400 nebo 404 po preskocenem handshaku +vyvola jeden novy handshake. OAuth prihlaseni sdili rozdelanou operaci na +konektor. V `delay()` unikal posluchac abortu. + +`src/scripts/util.ts` dostal `pick`, `pickText`, `jwtExpiry`, `parseBool`, +`parseNumber` a jednu konstantu `DETAIL_BYTES` na zkracovani (MCP mel 600 +znaku, zbytek 8 kB). `ctx.util` skriptu ma navic `day`, `list`, `addresses`, +`quote`; sablona `scripts/_sablona.js` je vypisuje a osm skriptu je pouziva. + +### API: prava se kontroluji za firmu a u kazde route + +Prava byla ve vetsine rout jen "je prihlaseny" nebo "je clen firmy". Ted: + +| Route | Kdo smi | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| firmy CRUD | jen spravce platformy | +| uzivatele CRUD | spravce platformy vse. Spravce firmy (`user.manage`) jen lidi sve firmy, nenastavi `platformAdmin`, neprida clenstvi jinde, nesahne na spravce platformy a nesmaze cloveka, ktery je i v jine firme | +| pozvanky | `user.manage` a jen role te firmy | +| konektory create, update, delete, test | `connector.manage` | +| automatizace create, update, delete, regenerate | `automation.edit` za firmu automatizace | +| `/services`, `/connectors/services` | clenstvi ve firme, cizi firma je 404 | +| assign, status, comment, claim | `builtinAction` v `ticketActions.ts`, pravo za firmu ticketu a strop viditelnosti (`visibleTicketOrDeny`) | +| `/api/admin/*` | `impersonate` a `audit.view` se ted opravdu kontroluji | + +Nove middleware, kazde s jednim ukolem: + +| Soubor | Co | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `middleware/asyncHandler.ts` | `wrap`, `safeRouter`: odmitnuta promise v handleru driv zabila proces. `unhandledRejection` se loguje, `uncaughtException` loguje a ukonci | +| `middleware/rateLimit.ts` | klouzave okno v pameti: login 20 za 15 min, kontakt 5 za hodinu, prijeti pozvanky 5 za 15 min. 429 s `Retry-After` | +| `middleware/tenant.ts` | `attachAccess` spocita pristup jednou na request do `req.access`; `tenantOrDeny`, `optionalTenantOrDeny`, `scopeOrDeny` misto kopii v routach | +| `middleware/validation.ts` | `validationError`, jeden tvar `{ error, message, issues: [{ field, message }] }` | +| `lib/secure.ts` | `timingSafeEqualString` pro tokeny webhooku, prijmu a pozvanek | + +V `index.ts`: log requestu maskuje tokeny za `/webhook/`, `/webhook/ticket/` +a `/invites/`; bezpecnostni hlavicky (nosniff, `X-Frame-Options SAMEORIGIN`, +`Referrer-Policy`, `Permissions-Policy`); `trust proxy` = 1, aby limit +pocital s adresou klienta a ne proxy; rozpoznani API 404 bere +`config.rootPath` misto natvrdo `/apps/`. Dockerfile instaluje `npm ci`. + +Vykon: data widgetu nactou seznam ticketu jednou na request, ne za kazdy +widget; `/tickets` a `/runs` berou `limit` a `offset` (nejvys 500) a vraceji +`X-Total-Count`, `/tickets` i `total` v tele; `hashPassword` je asynchronni, +aby bcrypt neblokoval smycku; `/people/:id` prochazi tickety jednou. + +`GET /api/dashboard/access` vraci `roleNames`, aby klient nehadal popisek role. +`/storage` a `/scripts` vraceji cesty na serveru jen spravci platformy. +V `openapi.ts` pribylo 22 chybejicich cest, `/whoami` je opraveny a `features` +uz nejsou popsane jako CRUD. + +### Udalosti nesou firmu + +`DashboardEvent.tenantId` (null = cela platforma). Stream SSE filtruje zive +udalosti i historii podle firem uzivatele, udalost s `payload.userId` jde jen +tomu cloveku. Driv videl kazdy prihlaseny udalosti vsech firem. + +Nove udalosti entit `tenant|user|role|person|group|ticketType|action|widget|connector|feature` +s `.created|.updated|.deleted`, publikuje je `crudRouter` (volba `event`), +routy konektoru a PUT features. Payload je `{ id, : zaznam }`, u smazani +`{ id }`. Udalosti ticketu `ticket.updated`, `ticket.assigned`, +`ticket.resolved` nesou v `payload.ticket` cely ticket, takze klient opravi +seznam na miste a nemusi se ptat znovu. + +### Portal: obnova bez odmontovani a klientsky sklad ciselniku + +Kazda udalost ze streamu odmontovala stranku: `useApiQuery` prepnul `loading` +a `DataState` vykreslil spinner misto deti. Ted je `loading` jen do prvnich +dat, potom `refreshing`, a deti zustavaji. Hooky sdili jeden debounce 150 ms, +cache modulu a deduplikaci bezicich dotazu (klic firma + cesta + telo). +`patchOn` opravi data v cache z udalosti (`lib/ticketEvents.ts` bere +`payload.ticket`). Rozhrani: + +```ts +useApiQuery(path, { refetchOn?, body?, enabled?, patchOn? }) + -> { data, loading, refreshing, error, total, reload } +``` + +**Rozhodnuti majitele produktu: stredni cesta.** Ciselniky (lide, skupiny, +typy ticketu, sluzby, konektory, pristup) jsou v klientskem skladu +`lib/collections.tsx`: nacitaji se line pri prvnim pouziti, mazou se pri +prepnuti firmy a odhlaseni, opravuji se z udalosti entit (upsert nebo smazani +ze zaznamu v payloadu, jinak jedno nacteni te kolekce). Tickety, behy +a statistiky **zustavaji dotazy na server** se strankovanim - jsou velke +a meni se porad. Hooky: `useCollection(key)`, `useAccess()`, +`useCollectionSelector`. + +Dalsi opravy klienta: + +| Co | Proc | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| 401 maze token a vyvola `auth:expired` | po vyprseni tokenu portal ukazoval prazdne stranky. Login rekne "Prihlaseni vyprselo", stream se prestane pripojovat na 401 a 403 | +| `restore()` maze token jen na 401 | vypadek site pri startu odhlasoval | +| `onClose` modalu v ref | fokus se pri kazdem prekresleni vracel na zacatek | +| toast ma jeden casovac | dva toasty za sebou si rusily odpocet | +| zrusene asynchronni efekty | odpoved pro uz odmontovanou stranku prepisovala stav te nove | +| tiche `catch` nahrazene chybou | pravidlo "zadna ticha selhani" platilo na serveru, na klientovi ne vsude | +| filtry ticketu v URL | nalez slo poslat kolegovi a vratit se pres zpet | +| detail ticketu neblokuje chyba `/people` | jeden padly dotaz na ciselnik schoval cely ticket | +| `MappingEditor` stabilni klice radku | smazani radku prekreslilo vsechny nasledujici a ztratil se kurzor | +| `lib/useUnsavedChanges.ts` | odchod z rozepsaneho builderu bez varovani | +| `DashboardLayout` lazy, sourcemapy vypnute | verejny web nenacital kod portalu, produkce neposila zdrojaky | +| `TicketTable` tabulka nebo karty | `useMediaQuery` misto duplicitni komponenty | + +**Builder.** `collectScopes` memoizovane, karty v `memo`, callbacky podle ID +kroku; `FlowCanvas` je rozdeleny do `flow/{ActionCard,ConditionCard,ForeachCard,StepControls}.tsx` +a `canvasTypes.ts`. Stranka Konektory je rozdelena do +`pages/dashboard/connectors/{ConnectorCard,ConnectorEditor,ConnectorLogs,ConnectorTools}.tsx`. +Pred tim byl kazdy stisk klavesy ve strome o padesati krocich prekresleni +vseho. + +**Formularova vrstva** podle navrhu v dokumentu 25, sekce 2, je hotova: +`components/ui/form/{controlClass,Field,Input,Select,Textarea}.tsx`, +`lib/useSubmit.ts`, `lib/options.ts`, `components/ui/Chip.tsx`, +`components/dashboard/TicketCard.tsx` (kompaktni varianta), `plural()` +v `lib/format.ts`. Zmizelo 15 kopii trid vstupniho pole. + +**Jazyky.** Verejne stranky (Postup, Produkty, Reference, O nas, Kontakt, 404, +Prihlaseni, Sluzby, paticka, navigace) plus `DataState` a `ErrorBoundary` jdou +pres i18n a `en.ts` je pro ne uplna. + +**Sdilene typy**: ciste typove moduly v `src/shared/*.ts` (16 souboru, +vcetne `users.ts` pro ucet a clenstvi) jsou jediny zdroj typu API. +`web/src/types/dashboard.ts`, `events.ts` i `AuthContext` je re-exportuji +pres alias `@shared/*` (`web/tsconfig.json` paths a `vite.config.ts` alias). +Pri prevodu se nasly rozjete tvary, vsechny vyresene ve prospech serveru: +webovy `Ticket` nemel `createdById`, `Access.roleNames` bylo nepovinne, +`Person` nemel `enabled`, `Incident` nemel `tenantId` ani `source`, `Service` +neznal kategorii `transformace`, seznam operatoru podminky u typu `list` +na webu nemel `contains`, takze builder nenabizel podminku nad stitky, ktera +na serveru funguje. Serverovy ulozeny tvar (`StoredTicket`, `Connector` +s `values`) zustava na serveru; web dostava `PublicConnector` jako `Connector`. +Pravidlo od ted: novy typ odpovedi patri do `src/shared`, web ho nekopiruje. + +### Nova funkce: firma z registru ARES + +Zalozit firmu znamenalo opsat nazev, IC, DIC a adresu rucne a pak zvlast +zakladat ucty. Ted je to na `/api/dashboard/settings/ares`, jen pro spravce +platformy: + +| Endpoint | Co | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `GET ares/companies?query=` | podle IC presne, jinak podle nazvu. U firmy, ktera uz v portalu je, vraci `existingTenantId` | +| `GET ares/companies/{ico}/persons` | soucasni clenove statutarniho organu a prokura z verejneho rejstriku, kazdy s navrzenym e-mailem `IC-poradi@placeholder.cz` | +| `POST ares/tenants` | zalozi firmu s `ico`, `dic`, `address`, `legalForm` z ARES a ucty vybranych osob, vsechny s roli `role_admin` v nove firme, nahodne heslo, audit `tenant.create.ares` | + +Zaznam firmy ma nove nepovinne `ico`, `dic`, `address`, `legalForm`; CRUD +firem hlida unikatni IC. Adresa registru je `ARES_BASE_URL`, vychozi +`https://ares.gov.cz/ekonomicke-subjekty-v-be/rest`. Klient ARES pouziva +tentyz `net/guard.ts` jako vsechno ostatni, co vola ven. + +**Rozhodnuti o rolich:** spravce platformy zaklada firmy, spravce firmy pak +spravuje skupiny, vedouci a cleny uvnitr firmy. Rejstrik nezna e-maily, proto +zastupne adresy - **spravce je musi nahradit skutecnymi**, jinak se ti lide +neprihlasi a nedostanou pozvanku. + ## 2026-09-08 - Otevrena stranka sekala prehravani videa Pri otevrenem portalu zacalo vedle nej sekat prehravani videa, po zavreni diff --git a/scripts/_sablona.js b/scripts/_sablona.js index 42fd3c6..bf7e049 100644 --- a/scripts/_sablona.js +++ b/scripts/_sablona.js @@ -60,8 +60,10 @@ export const manifest = { * @param {{ * http: { get: Function, post: Function, patch: Function, put: Function, del: Function, * postForm: Function }, - * util: { unwrap: Function, pick: Function, first: Function, text: Function, - * num: Function, bool: Function, date: Function, round: Function, need: Function }, + * util: { unwrap: Function, pick: Function, first: Function, list: Function, + * text: Function, num: Function, bool: Function, date: Function, day: Function, + * round: Function, need: Function, get: Function, addresses: Function, + * quote: Function, applyRules: Function, fillJson: Function }, * log: Function, config: Record, idempotencyKey: string, * fail: Function, retry: Function, * }} ctx diff --git a/scripts/csob.list-transactions.js b/scripts/csob.list-transactions.js index 67331a9..6e095be 100644 --- a/scripts/csob.list-transactions.js +++ b/scripts/csob.list-transactions.js @@ -60,26 +60,12 @@ function digits(value) { return String(value ?? '').replace(/[^0-9]/g, ''); } -/** - * Seznam z odpovedi banky. - * - * COBS obaluje kolekci pod vlastni nazev (`accounts`, `transactions`), jiny - * standard pouziva `content` nebo `items`. Hadat jeden tvar by znamenalo, ze - * pri zmene obalky krok tise vrati nula zaznamu misto chyby. - */ -function collection(ctx, body, ...names) { - if (Array.isArray(body)) return body; - const found = ctx.util.pick(body, ...names, 'data', 'content', 'items'); - if (Array.isArray(found)) return found; - const unwrapped = ctx.util.unwrap(body); - return Array.isArray(unwrapped) ? unwrapped : null; -} - export async function run(inputs, ctx) { const { pick, text, need } = ctx.util; const { body: accountsBody } = await ctx.http.get('/accounts', { query: { size: 50 } }); - const accounts = collection(ctx, accountsBody, 'accounts'); + // COBS obaluje kolekci pod vlastni nazev, `list` zkusi i obvykle obalky. + const accounts = ctx.util.list(accountsBody, 'accounts'); if (accounts === null) ctx.fail('Odpověď banky nemá seznam účtů.', accountsBody); if (accounts.length === 0) { ctx.fail('Souhlas nedosáhne na žádný účet. Zkontrolujte platnost souhlasu v ČSOB.'); @@ -117,7 +103,7 @@ export async function run(inputs, ctx) { }, }); - const transactions = collection(ctx, body, 'transactions'); + const transactions = ctx.util.list(body, 'transactions'); if (transactions === null) ctx.fail('Odpověď banky nemá seznam pohybů.', body); return { diff --git a/scripts/google.send-email.js b/scripts/google.send-email.js index 47d41f4..f2ffa26 100644 --- a/scripts/google.send-email.js +++ b/scripts/google.send-email.js @@ -56,14 +56,6 @@ export const manifest = { timeoutMs: 30000, }; -/** Adresy z jednoho radku. Clovek pise carky, Gmail chce jeden retezec. */ -function addresses(value) { - return String(value ?? '') - .split(/[,;]/) - .map((item) => item.trim()) - .filter((item) => item !== ''); -} - /** Base64 z textu. Pres bajty, aby diakritika prezila. */ function base64(value) { const bytes = new TextEncoder().encode(value); @@ -86,8 +78,9 @@ function header(value) { } export async function run(inputs, ctx) { - const { pick, text, need } = ctx.util; + const { pick, text, need, addresses } = ctx.util; + // Clovek pise carky, Gmail chce jeden retezec. const to = addresses(inputs.to); if (to.length === 0) ctx.fail('Nezůstal žádný příjemce, zkontrolujte pole Příjemci.'); const cc = addresses(inputs.cc); diff --git a/scripts/idoklad.create-issued-invoice.js b/scripts/idoklad.create-issued-invoice.js index 616dd60..13ad953 100644 --- a/scripts/idoklad.create-issued-invoice.js +++ b/scripts/idoklad.create-issued-invoice.js @@ -88,11 +88,6 @@ export const manifest = { ], }; -/** Datum ve tvaru, ktery iDoklad ceka. */ -function isoDay(value) { - return new Date(value).toISOString().slice(0, 10); -} - function addDays(value, days) { const date = new Date(value); date.setUTCDate(date.getUTCDate() + days); @@ -100,7 +95,8 @@ function addDays(value, days) { } export async function run(inputs, ctx) { - const { unwrap, pick, text, num, date, need } = ctx.util; + // `day` dava datum ve tvaru, ktery iDoklad ceka (2026-08-20). + const { unwrap, pick, text, num, date, need, day } = ctx.util; if (inputs.unitPrice < 0) ctx.fail('Cena za jednotku nemůže být záporná.'); const amount = inputs.amount ?? 1; @@ -120,9 +116,9 @@ export async function run(inputs, ctx) { ...defaults, partnerId: inputs.partnerId, description: inputs.description, - dateOfIssue: isoDay(issuedAt), - dateOfTaxing: isoDay(issuedAt), - dateOfMaturity: isoDay(maturityAt), + dateOfIssue: day(issuedAt), + dateOfTaxing: day(issuedAt), + dateOfMaturity: day(maturityAt), items: [ { name: inputs.itemName, diff --git a/scripts/idoklad.register-payment.js b/scripts/idoklad.register-payment.js index 5ec9f47..4b21323 100644 --- a/scripts/idoklad.register-payment.js +++ b/scripts/idoklad.register-payment.js @@ -52,12 +52,8 @@ export const manifest = { ], }; -function isoDay(value) { - return new Date(value).toISOString().slice(0, 10); -} - export async function run(inputs, ctx) { - const { unwrap, pick, num, date, need } = ctx.util; + const { unwrap, pick, num, date, need, day } = ctx.util; if (inputs.amount !== null && inputs.amount <= 0) { ctx.fail('Uhrazená částka musí být větší než nula.'); @@ -81,7 +77,7 @@ export async function run(inputs, ctx) { ...defaults, invoiceId: inputs.invoiceId, paymentAmount: amount, - dateOfPayment: isoDay(paidAt), + dateOfPayment: day(paidAt), sendPaymentConfirmation: inputs.sendConfirmation ?? false, }; diff --git a/scripts/microsoft365.send-mail.js b/scripts/microsoft365.send-mail.js index 72d67b7..0042d2d 100644 --- a/scripts/microsoft365.send-mail.js +++ b/scripts/microsoft365.send-mail.js @@ -66,16 +66,10 @@ export const manifest = { timeoutMs: 30000, }; -/** Seznam adres z jednoho radku. Graph chce pole objektu, clovek pise carky. */ -function addresses(value) { - return String(value ?? '') - .split(/[,;]/) - .map((item) => item.trim()) - .filter((item) => item !== '') - .map((address) => ({ address })); -} - export async function run(inputs, ctx) { + // Graph chce pole objektu, clovek pise carky. + const addresses = (value) => ctx.util.addresses(value).map((address) => ({ address })); + const to = addresses(inputs.to); if (to.length === 0) ctx.fail('Nezůstal žádný příjemce, zkontrolujte pole Příjemci.'); diff --git a/scripts/sap-bo.find-business-partner.js b/scripts/sap-bo.find-business-partner.js index 5c8d1eb..12a5955 100644 --- a/scripts/sap-bo.find-business-partner.js +++ b/scripts/sap-bo.find-business-partner.js @@ -53,11 +53,6 @@ export const manifest = { timeoutMs: 30000, }; -/** Retezec do OData filtru. Apostrof se zdvojuje, jinak dotaz spadne. */ -function quote(value) { - return `'${String(value).replace(/'/g, "''")}'`; -} - const notFound = { found: false, cardCode: null, @@ -76,9 +71,9 @@ export async function run(inputs, ctx) { // Poradi je zamer: kod je klic, ICO je jednoznacne, nazev se pise pokazde jinak. const attempts = []; - if (inputs.cardCode) attempts.push([`CardCode eq ${quote(inputs.cardCode)}`, 'kód']); - if (inputs.taxId) attempts.push([`FederalTaxID eq ${quote(inputs.taxId)}`, 'IČO']); - if (inputs.name) attempts.push([`contains(CardName,${quote(inputs.name)})`, 'název']); + if (inputs.cardCode) attempts.push([`CardCode eq ${ctx.util.quote(inputs.cardCode)}`, 'kód']); + if (inputs.taxId) attempts.push([`FederalTaxID eq ${ctx.util.quote(inputs.taxId)}`, 'IČO']); + if (inputs.name) attempts.push([`contains(CardName,${ctx.util.quote(inputs.name)})`, 'název']); for (const [filter, label] of attempts) { const { body } = await ctx.http.get('/api/business-partners', { diff --git a/scripts/sap-bo.list-orders.js b/scripts/sap-bo.list-orders.js index c356c1f..88d46aa 100644 --- a/scripts/sap-bo.list-orders.js +++ b/scripts/sap-bo.list-orders.js @@ -51,13 +51,10 @@ export const manifest = { timeoutMs: 30000, }; -function quote(value) { - return `'${String(value).replace(/'/g, "''")}'`; -} - export async function run(inputs, ctx) { - const { pick, num, text, round } = ctx.util; + const { pick, num, text, round, quote } = ctx.util; + // Apostrof v OData filtru se zdvojuje, to dela `quote`. const conditions = []; if (inputs.cardCode) conditions.push(`CardCode eq ${quote(inputs.cardCode)}`); if (inputs.dateFrom) conditions.push(`DocDate ge ${quote(inputs.dateFrom.slice(0, 10))}`); diff --git a/scripts/sklik.campaign-report.js b/scripts/sklik.campaign-report.js index a21b58c..35341ac 100644 --- a/scripts/sklik.campaign-report.js +++ b/scripts/sklik.campaign-report.js @@ -66,11 +66,7 @@ function statRows(ctx, body) { }); } - const stats = pick(body, 'stats'); - if (Array.isArray(stats)) return stats; - - const data = pick(body, 'data'); - return Array.isArray(data) ? data : []; + return ctx.util.list(body, 'stats') ?? []; } export async function run(inputs, ctx) { diff --git a/src/ares/client.ts b/src/ares/client.ts new file mode 100644 index 0000000..cbc2749 --- /dev/null +++ b/src/ares/client.ts @@ -0,0 +1,247 @@ +/** + * Klient registru ARES (ekonomicke subjekty CR). + * + * Slouzi spravci platformy k zalozeni firmy: podle IC nebo nazvu se dotahne + * nazev, DIC, adresa a pravni forma, a z verejneho rejstriku osoby, ktere + * za firmu jednaji. ARES je verejne API bez klice, takze tu neni zadny + * pristupovy udaj ani konektor. + * + * Tri volani, kazde s vlastnim tvarem odpovedi: + * - GET /ekonomicke-subjekty/{ico} zakladni udaje (registr ROS) + * - POST /ekonomicke-subjekty/vyhledat hledani podle nazvu + * - GET /ekonomicke-subjekty-vr/{ico} verejny rejstrik vcetne organu + * + * Odpovedi se nekesuji: zaklada se jedna firma jednou a ARES ma svuj limit + * pocitany na IP, ktery tenhle objem nedosahne. + */ + +import { config } from '../config.js'; +import { describeFetchError, readJsonLimited, tooLargeMessage } from '../net/guard.js'; + +/** Odpoved ARES ma desitky kB i u male firmy (historie zapisu), proto vyssi strop. */ +const BODY_LIMIT = 4_000_000; +const TIMEOUT_MS = 15_000; + +export class AresError extends Error { + constructor( + message: string, + /** HTTP stav od ARES, 0 = chyba spojeni. */ + readonly status: number, + ) { + super(message); + this.name = 'AresError'; + } +} + +export interface AresCompany { + ico: string; + name: string; + /** null = neni platce DPH nebo ARES DIC neuvadi. */ + dic: string | null; + /** Textova adresa sidla tak, jak ji sklada ARES. */ + address: string; + /** Kod pravni formy podle ciselniku ARES, napr. 112. */ + legalFormCode: string; + /** Nazev pravni formy, kdyz kod zname; jinak samotny kod. */ + legalForm: string; +} + +export interface AresPerson { + /** Jmeno v beznem tvaru ("Jan Novak"), ARES ho vraci velkymi pismeny. */ + name: string; + firstName: string; + lastName: string; + /** Organ a funkce, napr. "jednatel" nebo "clen predstavenstva". */ + roles: string[]; +} + +/** + * Nejcastejsi pravni formy. ARES ma vlastni ciselnik na dalsim endpointu, + * ale kvuli popisku v jednom formulari ho nema smysl stahovat; neznamy kod + * se ukaze jako cislo a nic se tim nerozbije. + */ +const LEGAL_FORMS: Record = { + '100': 'Podnikající fyzická osoba', + '101': 'Fyzická osoba podnikající dle živnostenského zákona', + '105': 'Fyzická osoba podnikající dle jiných zákonů', + '111': 'Veřejná obchodní společnost', + '112': 'Společnost s ručením omezeným', + '113': 'Komanditní společnost', + '121': 'Akciová společnost', + '141': 'Obecně prospěšná společnost', + '145': 'Společenství vlastníků jednotek', + '161': 'Ústav', + '205': 'Družstvo', + '301': 'Státní podnik', + '331': 'Příspěvková organizace', + '421': 'Odštěpný závod zahraniční právnické osoby', + '424': 'Zahraniční fyzická osoba', + '601': 'Vysoká škola', + '641': 'Školská právnická osoba', + '661': 'Veřejná výzkumná instituce', + '701': 'Spolek', + '706': 'Pobočný spolek', + '801': 'Obec', + '804': 'Kraj', + '941': 'Evropská společnost', +}; + +/** IC ma 8 cislic; kratsi se doplnuje nulami zleva, jak to dela i ARES. */ +export function normalizeIco(value: string): string | null { + const digits = value.replace(/\s+/g, ''); + if (!/^\d{1,8}$/.test(digits)) return null; + return digits.padStart(8, '0'); +} + +function text(value: unknown): string { + return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : ''; +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function list(value: unknown): Record[] { + return Array.isArray(value) ? value.map(record).filter((v): v is Record => v !== null) : []; +} + +async function call(path: string, init?: RequestInit): Promise { + const url = `${config.aresBaseUrl}${path}`; + let response: Response; + try { + response = await fetch(url, { + ...init, + headers: { accept: 'application/json', ...(init?.headers ?? {}) }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch (err) { + const info = describeFetchError(err, 'ARES'); + console.error(`[ares] ${info.detail}`); + throw new AresError(info.message, 0); + } + + const body = await readJsonLimited(response, BODY_LIMIT); + if (body.tooLarge) throw new AresError(tooLargeMessage(BODY_LIMIT), response.status); + + if (response.status === 404) return null; + if (!response.ok) { + // ARES vraci v tele `popis`, ktery rika vic nez samotny kod. + const reason = text(record(body.json)?.popis) || text(record(body.json)?.subKod); + throw new AresError( + `ARES vrátil HTTP ${response.status}${reason ? `: ${reason}` : ''}.`, + response.status, + ); + } + if (body.json === undefined) throw new AresError('ARES vrátil odpověď, která není JSON.', response.status); + return body.json; +} + +function toCompany(subject: Record): AresCompany | null { + const ico = normalizeIco(text(subject.ico)); + const name = text(subject.obchodniJmeno); + if (ico === null || name === '') return null; + const legalFormCode = text(subject.pravniForma); + const seat = record(subject.sidlo); + return { + ico, + name, + dic: text(subject.dic) || null, + address: text(seat?.textovaAdresa), + legalFormCode, + legalForm: LEGAL_FORMS[legalFormCode] ?? legalFormCode, + }; +} + +/** Firma podle IC. `null` = ARES ji nezna. */ +export async function lookupCompany(ico: string): Promise { + const normalized = normalizeIco(ico); + if (normalized === null) throw new AresError('IČ musí být 1 až 8 číslic.', 400); + const json = await call(`/ekonomicke-subjekty/${normalized}`); + const subject = record(json); + return subject ? toCompany(subject) : null; +} + +/** Firmy podle casti nazvu, nejvys `limit`. */ +export async function searchCompanies(name: string, limit = 10): Promise { + const query = name.trim(); + if (query.length < 2) throw new AresError('Název pro hledání je moc krátký.', 400); + const json = await call('/ekonomicke-subjekty/vyhledat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ obchodniJmeno: query, start: 0, pocet: Math.min(Math.max(limit, 1), 50) }), + }); + return list(record(json)?.ekonomickeSubjekty) + .map(toCompany) + .filter((c): c is AresCompany => c !== null); +} + +/** "NOVAK" -> "Novak", "DE LA CRUZ-NOVA" -> "De La Cruz-Nova". */ +function properCase(value: string): string { + return value + .toLocaleLowerCase('cs-CZ') + .replace(/(^|[\s\-'])(\p{L})/gu, (_, sep: string, ch: string) => sep + ch.toLocaleUpperCase('cs-CZ')); +} + +/** + * Osoby, ktere za firmu dnes jednaji: clenove statutarnich organu a prokura. + * + * Verejny rejstrik vraci celou historii vcetne vymazanych clenu, proto se + * bere jen zaznam bez `datumVymazu`. Tataz osoba muze sedet ve dvou organech, + * slucuje se podle jmena a data narozeni. + */ +export async function listCompanyPersons(ico: string): Promise { + const normalized = normalizeIco(ico); + if (normalized === null) throw new AresError('IČ musí být 1 až 8 číslic.', 400); + const json = await call(`/ekonomicke-subjekty-vr/${normalized}`); + const records = list(record(json)?.zaznamy); + // Firma muze mit vic zaznamu (napr. po presunu k jinemu soudu), primarni je ten platny. + const primary = records.find((r) => r.primarniZaznam === true) ?? records[0]; + if (!primary) return []; + + const found = new Map(); + for (const organ of list(primary.statutarniOrgany)) { + if (organ.datumVymazu) continue; + const organName = text(organ.nazevOrganu); + for (const member of list(organ.clenoveOrganu)) { + if (member.datumVymazu) continue; + const person = record(member.fyzickaOsoba); + if (!person) continue; + const firstName = properCase(text(person.jmeno)); + const lastName = properCase(text(person.prijmeni)); + if (firstName === '' && lastName === '') continue; + + const membership = record(member.clenstvi); + const role = text(record(membership?.funkce)?.nazev) || organName || 'člen orgánu'; + const key = `${firstName}|${lastName}|${text(person.datumNarozeni)}`; + const existing = found.get(key); + if (existing) { + if (!existing.roles.includes(role)) existing.roles.push(role); + continue; + } + found.set(key, { + name: [text(person.titulPred), firstName, lastName].filter(Boolean).join(' '), + firstName, + lastName, + roles: [role], + }); + } + } + return [...found.values()]; +} + +/** + * Nahradni e-mail pro osobu z ARES: registr e-maily nevede, ale ucet bez + * e-mailu neexistuje. Tvar IC-poradi je dohodnuty a portal ho pozna, aby + * mohl upozornit, ze se ma nahradit skutecnym. + */ +export const PLACEHOLDER_DOMAIN = 'placeholder.cz'; + +export function placeholderEmail(ico: string, index: number): string { + return `${ico}-${index}@${PLACEHOLDER_DOMAIN}`; +} + +export function isPlaceholderEmail(email: string): boolean { + return email.toLowerCase().endsWith(`@${PLACEHOLDER_DOMAIN}`); +} diff --git a/src/config.ts b/src/config.ts index 10b8822..ec641dd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -188,6 +188,13 @@ export const config = { * Jen pro lokalni vyvoj, v nasazeni musi zustat vypnute. */ allowPrivateTargets: process.env.ALLOW_PRIVATE_TARGETS === 'true', + /** + * Registr ARES pro zalozeni firmy podle IC nebo nazvu. Verejne API bez + * klice; promenna je tu jen pro testovaci prostredi nebo zrcadlo. + */ + aresBaseUrl: (process.env.ARES_BASE_URL ?? 'https://ares.gov.cz/ekonomicke-subjekty-v-be/rest') + .trim() + .replace(/\/+$/, ''), /** * Zpracovava tenhle proces frontu behu? * diff --git a/src/data/access.ts b/src/data/access.ts index 8751e7a..f0f3bbc 100644 --- a/src/data/access.ts +++ b/src/data/access.ts @@ -16,12 +16,15 @@ */ import { findPersonByEmail, groupMembers, groupsOfPerson, listGroups } from './people.js'; -import { hasPermission, permissionsOf } from './permissions.js'; +import { hasPermission, permissionsOf, rolesFor } from './permissions.js'; import { listActiveTenants, findTenant, type Tenant } from './tenants.js'; -import { navFor, type NavItem } from './tenantFeatures.js'; -import type { User } from '../types.js'; +import { navFor } from './tenantFeatures.js'; +import { rolesIn, type User } from '../types.js'; -export type TicketScope = 'all' | 'tenant' | 'mine'; +import type { Access, TicketScope } from '../shared/access.js'; + +/** Tvar prav je sdileny s webem, viz src/shared/access.ts. */ +export type { Access, TicketScope }; /** * Strop viditelnosti: co uzivatel **vubec smi** videt. @@ -85,39 +88,6 @@ export function seesWholeTenant(user: User, tenantId: string | null): boolean { return visibilityFor(user, tenantId).kind === 'all'; } -export interface Access { - /** Pohledy, ktere smi uzivatel pouzit. Klient podle toho kresli prepinac. */ - scopes: TicketScope[]; - /** Firmy, mezi kterymi muze prepinat. */ - tenants: Array<{ id: string; name: string }>; - /** Vychozi firma, kdyz zadnou nezvoli. null = zadna, nema kam. */ - defaultTenantId: string | null; - /** Smi prehazovat tickety mezi lidmi, ne jen brat na sebe. */ - canAssignOthers: boolean; - /** ID resitele odpovidajiciho uzivateli, nebo null. */ - personId: string | null; - /** Efektivni prava. Klient podle nich kresli tlacitka. */ - permissions: string[]; - /** Zalozky, ktere ma videt. Prunik toho, co firma ma, a toho, na co ma pravo. */ - nav: NavItem[]; - /** true = vidi napric firmami a smi platformni nastaveni. */ - platformAdmin: boolean; - /** - * Vidi i cizi tickety, ne jen svoje a frontu? - * - * Podle toho se nabizi zalozka "Vsechny tickety". Kdo vidi jen svoje, - * nema co prepinat - zalozka by mu ukazala totez co ta prvni. - */ - seesOthers: boolean; - /** - * Sekce, na ktere clovek vidi. Filtr v zalozce "Vsechny tickety". - * - * Kdo vidi celou firmu, dostane vsechny. Vedouci jen ty, ktere vede - - * nabidnout mu sekci, ze ktere stejne nic neuvidi, je jen matouci. - */ - visibleGroups: Array<{ id: string; name: string }>; -} - /** * Co uzivatel smi. * @@ -154,6 +124,14 @@ export function accessFor(user: User, tenantId?: string | null): Access { ? groups : groups.filter((group) => visibility.groupIds.includes(group.id)); + // Odkaz v clenstvi smi byt ID role i klic systemove role, viz `permissionsOf`. + const roles = activeTenant ? rolesFor(activeTenant) : []; + const roleNames = activeTenant + ? rolesIn(user, activeTenant) + .map((ref) => roles.find((role) => role.id === ref || role.key === ref)?.name) + .filter((name): name is string => name !== undefined) + : []; + return { scopes, tenants: tenants.map((tenant) => ({ id: tenant.id, name: tenant.name })), @@ -164,6 +142,7 @@ export function accessFor(user: User, tenantId?: string | null): Access { // Prava se pocitaji za **vybranou firmu**. Kdo je spravce v jedne firme // a resitel v druhe, uvidi po prepnuti jen to, co smi tam. permissions: [...permissionsOf(user, activeTenant)].sort(), + roleNames, nav: navFor(user, activeTenant), platformAdmin: user.platformAdmin, seesOthers: visibility.kind === 'all' || visibility.groupIds.length > 0, @@ -178,8 +157,9 @@ export function accessFor(user: User, tenantId?: string | null): Access { * Vraci `null`, kdyz uzivatel do zadane firmy nepatri nebo zadnou nema - * volajici pak nema pravo na nic, coz je spravna odpoved. */ -export function actingTenant(user: User, requested?: string): string | null { - const access = accessFor(user, requested); +export function actingTenant(user: User, requested?: string, precomputed?: Access): string | null { + // Prava uz za request spocital middleware, druhy vypocet by byl stejny. + const access = precomputed ?? accessFor(user, requested); const tenantId = requested ?? access.defaultTenantId; if (!tenantId) return null; if (!access.tenants.some((tenant) => tenant.id === tenantId)) { @@ -231,8 +211,10 @@ export interface ScopeDenied { export function resolveScope( user: User, requested: { scope?: string; tenantId?: string }, + precomputed?: Access, ): ResolvedScope | ScopeDenied { - const access = accessFor(user, requested.tenantId); + // Prava uz za request spocital middleware, druhy vypocet by byl stejny. + const access = precomputed ?? accessFor(user, requested.tenantId); const scope = (requested.scope ?? (access.scopes.includes('tenant') ? 'tenant' : 'mine')) as | TicketScope diff --git a/src/data/audit.ts b/src/data/audit.ts index fd7bf6e..08fa3c8 100644 --- a/src/data/audit.ts +++ b/src/data/audit.ts @@ -68,28 +68,47 @@ export function recordAudit(input: AuditInput): void { void auditStore .create(entry) - .then(() => trim()) + .then(() => trimIfDue()) .catch((err: unknown) => { console.error('[audit] zaznam se nepodarilo ulozit:', err); }); } +/** + * Uklid je drahy - cte cely audit. Proto ne po kazdem zapisu, ale az po + * `TRIM_EVERY` zapisech, nebo nejdriv po `TRIM_INTERVAL_MS` od minuleho. + * Par zaznamu nad limit mezitim nikomu nevadi. + */ +const TRIM_EVERY = 50; +const TRIM_INTERVAL_MS = 60_000; + +let writesSinceTrim = 0; +let lastTrimAt = 0; let trimming = false; +function trimIfDue(): Promise { + writesSinceTrim += 1; + const due = writesSinceTrim >= TRIM_EVERY || Date.now() - lastTrimAt >= TRIM_INTERVAL_MS; + if (!due) return Promise.resolve(); + return trim(); +} + /** Odmaze nejstarsi zaznamy nad limit. Bezi nejvyse jednou naraz. */ async function trim(): Promise { if (trimming) return; trimming = true; + writesSinceTrim = 0; + lastTrimAt = Date.now(); try { const all = await auditStore.listAll(); if (all.length <= MAX_ENTRIES) return; const sorted = all.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); const excess = sorted.slice(0, all.length - MAX_ENTRIES); - for (const entry of excess) { - await auditStore.remove(entry.id, { tenantIds: [], includeGlobal: true }); - } - console.info(`[audit] odmazano ${excess.length} nejstarsich zaznamu`); + // Bez filtru na firmu: maze se to, co se prave nacetlo pres listAll. + // Driv se mazalo s prazdnym seznamem firem a firemni zaznamy zustavaly. + const removed = await auditStore.removeMany(excess.map((entry) => entry.id)); + console.info(`[audit] odmazano ${removed} z ${excess.length} nejstarsich zaznamu`); } catch (err) { console.error('[audit] uklid selhal:', err); } finally { diff --git a/src/data/automationStore.ts b/src/data/automationStore.ts index 255a688..77e8bc6 100644 --- a/src/data/automationStore.ts +++ b/src/data/automationStore.ts @@ -1,154 +1,54 @@ /** * Uloziste automatizaci vcetne jejich stromu akci (flow). * - * POZOR: data jsou v pameti procesu - restart API je vrati na vychozi sadu. - * To je vedome zjednoduseni prototypu, nahrada za databazi je popsana - * v docs/04-backend-api.md, sekce "Kam dal". + * Automatizace se drzi v pameti kvuli synchronnimu cteni a po kazde zmene se + * cela zapise do uloziste (`withMirror`). Kam - databaze, soubor, nebo nikam - + * rozhoduje `data/store/index.ts`; viz documentation/14-databaze.md. */ import { randomBytes } from 'node:crypto'; import { config } from '../config.js'; import { publish } from '../events/bus.js'; -import { defineStore } from './store/index.js'; +import { timingSafeEqualString } from '../lib/secure.js'; +import { defineStore, highestNumber, minutesAgo, writableOrWarn } from './store/index.js'; import { withMirror } from './store/mirror.js'; import { isUnary, type ConditionMatch, - type ConditionOperator, type ConditionRule, - type FieldType, } from './conditions.js'; import { actionInputsFor, findService } from './services.js'; import { collectScopes, duplicateNames, scopeFor } from './flowScope.js'; import { referencedFields, rootOf } from './templates.js'; -import { describeModel, rootsOf, type ModelNode } from './model.js'; +import { describeModel, rootsOf } from './model.js'; -export type AutomationKind = 'workflow' | 'voicebot' | 'integrace' | 'report'; - -/** Jeden vstupni parametr, ktery spoustec preda dal do stromu. */ -export interface TriggerField { - id: string; - /** Klic v prichozich datech - napr. "orderTotal". Musi byt unikatni. */ - name: string; - type: FieldType; - required: boolean; - /** - * Kde ta hodnota v tele je, kdyz to neni primo `name`. - * - * Odesilatele posilaji ruzne tvary: jeden `{"a":"aaa"}`, druhy cely model - * s vnorenymi objekty a poli. Bez cesty by slo napojit jen ploche telo - * a slozitejsi odesilatel by se musel prizpusobovat nam, coz nejde. - * - * Priklady: `customer.id`, `errors.0.message`, `data.items`. - */ - path?: string; -} - -export interface FlowTrigger { - serviceId: string; - operationId: string; - /** Deklarovane vstupni parametry. Podminky se odkazuji na jejich `id`. */ - fields: TriggerField[]; - /** - * Ukazka toho, co odesilatel opravdu posila. - * - * Vlepi se cele telo z realneho volani. Z nej se odvodi model, tedy seznam - * cest i s typy, ktery se v builderu ukaze jako strom a klikanim se z nej - * vklada odkaz do sablony. - * - * Proc ukazka a ne rucne psany seznam poli: model objednavky ze Shoptetu ma - * desitky cest a seznam polozek. Vypsat ho po jednom nikdo nebude a pole - * do plocheho seznamu nepatri vubec. - */ - sample?: unknown; - /** - * Jak casto se ma sluzba obvolavat, kdyz nam sama nezavola (v sekundach). - * Plati jen u spoustecu, ktere se musi ptat. Minimum je 10 s. - */ - intervalSec?: number; - /** - * Neodhadnutelny token v adrese webhooku. Generuje VZDY server, - * klient ho nesmi urcovat ani menit. - */ - webhookToken?: string; -} +import type { + Automation, + AutomationDetail, + AutomationFlow, + AutomationKind, + FlowStep, + FlowTrigger, + TriggerField, + WebhookCall, + WebhookCallField, +} from '../shared/automations.js'; /** - * Krok stromu. `action` je jeden ukon nad sluzbou, `condition` rozdeluje - * beh na dve vetve podle hodnoty vstupniho parametru - proto je to strom. + * Tvar automatizace a stromu je sdileny s webem, viz src/shared/automations.ts. + * Tady zustava uloziste a prace se stromem. */ -export type FlowStep = - | { - id: string; - kind: 'action'; - /** Ktera sluzba a jeji operace. Sluzba je to, co umime. */ - serviceId: string; - operationId: string; - /** - * Ktery konektor firmy se pouzije, tedy pod jakymi udaji se to zavola. - * - * `null` znamena vychozi konektor firmy pro tuhle sluzbu. Diky tomu je - * vzorovy strom prenositelny mezi firmami - kazde se dosadi jeji vlastni - * napojeni. U obecnych sluzeb (webhook, pauza, transformace) je vzdy null, - * ty konektor nepotrebuji. - */ - connectorId?: string | null; - /** - * Nastaveni akce: klic je `OperationField.id` z katalogu, hodnota je - * sablona s odkazy na parametry spoustece (`{{subject}}`). - * Chybi u akci, ktere zatim nemaji `inputs`. - */ - inputs?: Record; - } - | { - id: string; - kind: 'condition'; - /** - * Otazky, ktere se ptaji naraz. Cte se **vzdycky pres `rulesOf`**. - * - * Driv byla podminka prave jedna otazka a slozitejsi vetveni se skladalo - * z vnorenych podminek: "vysledek dorazil" a v nem "vysledek je X" byly - * dve urovne stromu misto jedne vety. - */ - rules?: ConditionRule[]; - /** `all` = a zaroven, `any` = nebo. Chybi u jedne otazky, tam nema co spojovat. */ - match?: ConditionMatch; - /** - * Stara podoba: jedna otazka primo na kroku. - * - * Zaznamy ulozene driv ji maji misto `rules`. Prevadi ji `rulesOf`, - * zapisuje se uz vzdycky `rules`. - */ - fieldId?: string; - operator?: ConditionOperator; - value?: string; - yes: FlowStep[]; - no: FlowStep[]; - } - | { - id: string; - /** - * Podstrom, ktery se vykona **nad kazdou polozkou seznamu**. - * - * Bez tohohle nejde rict "kazdou polozku objednavky zaloz v CRM". Jedno - * volani se seznamem uvnitr se da poskladat transformaci, ale kdyz ma - * kazda polozka vyvolat vlastni akci a vratit vlastni ID, je potreba - * smycka. - * - * Uvnitr je k dispozici `item` (cela polozka) a `index`. Po skonceni - * `krok.results`, coz je seznam vysledku za kazdou polozku - prave to se - * pak posila dal jako radky objednavky. - */ - kind: 'foreach'; - /** Cesta k seznamu v datech, napr. `data.order.items`. */ - path: string; - steps: FlowStep[]; - }; - -export interface AutomationFlow { - trigger: FlowTrigger | null; - steps: FlowStep[]; -} +export type { + Automation, + AutomationDetail, + AutomationFlow, + AutomationKind, + FlowStep, + FlowTrigger, + TriggerField, + WebhookCall, + WebhookCallField, +}; /** * Otazky podminky, at uz je krok v jakekoliv podobe. @@ -173,85 +73,6 @@ export function generateWebhookToken(): string { return randomBytes(24).toString('base64url'); } -/** Polozka v seznamu automatizaci - bez celeho stromu. */ -export interface Automation { - id: string; - /** Firma, ktere automatizace patri. Hranice viditelnosti. */ - tenantId: string; - name: string; - kind: AutomationKind; - enabled: boolean; - /** - * Behy za **dnesni den**. Po pulnoci zacina od nuly. - * - * Driv to byl citac od zalozeni automatizace, jen se jmenoval "dnes". - * U automatizace, ktera bezi tri mesice, to rikalo cislo, ktere s dneskem - * nemelo nic spolecneho. - */ - runsToday: number; - /** Behy za vcerejsi den, at je s cim srovnat. */ - runsYesterday: number; - /** Behy od zalozeni. Puvodni citac se neztratil, jen se spravne jmenuje. */ - runsTotal: number; - /** Uspesnost dnesnich behu. Kdyz dnes zadny nebyl, posledniho dne, kdy byly. */ - successRate: number; - avgDurationMs: number; - lastRunAt: string; - /** Pocet vsech kroku vcetne vnorenych vetvi. */ - stepCount: number; - /** false = automatizace jeste nema spoustec, je to koncept. */ - configured: boolean; - /** - * Co chybi k tomu, aby se dala zapnout. Prazdne = je hotova. - * Zobrazuje se uzivateli, nesmi zmizet tise. - */ - issues: string[]; -} - -/** Jak dopadl jeden deklarovany parametr v jednom volani. */ -export interface WebhookCallField { - name: string; - /** Cesta v tele. Shodna se jmenem, kdyz se nezadala vlastni. */ - path: string; - required: boolean; - /** Deklarovany typ z kontraktu. */ - type: string; - /** Co na te ceste opravdu bylo: text, cislo, objekt, seznam, nepreslo. */ - got: string; - /** Kratky nahled prijate hodnoty. */ - preview: string; - /** false = tenhle parametr je duvod, proc se volani odmitlo. */ - ok: boolean; -} - -/** - * Jedno volani webhooku, jak dopadlo. - * - * Odmitnute volani do ted skoncilo jako radek v logu kontejneru, kam se nikdo - * nedostane. Odesilatel dostal 400 a vedel o tom, ale ten, kdo kontrakt napsal, - * se nedozvedel nic - automatizace svitila zelene a jen do ni nic nechodilo. - * - * Drzi se **cele prijate telo i rozpad po parametrech**. Duvod odmitnuti sam - * nestaci: "parametr data ma mit typ text" nerekne, co tedy prislo, a bez toho - * zbyva hadat. Tickety si cela prijata tela u udalosti drzi uz davno, takze to - * neni novy druh dat, jen se konecne daji videt i u volani, ktera neprosla. - */ -export interface WebhookCall { - at: string; - /** true = prevzato do fronty, false = odmitnuto pro neplatna data. */ - ok: boolean; - /** Duvody odmitnuti. Prazdne u prevzatych volani. */ - problems: string[]; - /** Beh, ktery z volani vznikl. null u odmitnutych a u duplicit. */ - runId: string | null; - /** Kazdy deklarovany parametr a jak dopadl. */ - fields: WebhookCallField[]; - /** Cele prijate telo jako JSON. Delsi nez `MAX_BODY` se usekne. */ - body: string; - /** true = telo bylo delsi a je useknute. */ - truncated: boolean; -} - /** Kolik znaku tela se u volani drzi. Vic uz je v pameti zbytecne. */ const MAX_BODY = 8_000; @@ -286,27 +107,6 @@ export function recentWebhookCalls(automationId: string): WebhookCall[] { return calls.get(automationId) ?? []; } -export interface AutomationDetail extends Automation { - flow: AutomationFlow; - /** - * Poslednich `MAX_CALLS` volani webhooku, nejnovejsi prvni. - * - * Drzi se v pameti, restart je zahodi. Je to diagnostika posledni hodiny, - * ne historie - na tu jsou behy. - */ - recentCalls: WebhookCall[]; - /** - * Model prichozich dat odvozeny z ukazky u spoustece. - * - * Pocita se pri cteni, neuklada se - je to pohled na `trigger.sample`. - * Kdyby se ukladal, sel by rozejit s ukazkou a nikdo by nepoznal, ktery - * z nich plati. - */ - model: ModelNode[]; - createdAt: string; - updatedAt: string; -} - interface StoredAutomation { id: string; tenantId: string; @@ -327,6 +127,16 @@ interface StoredAutomation { avgDurationMs: number; lastRunAt: string; flow: AutomationFlow; + /** + * Nedodelky a pocet kroku **spocitane pri ulozeni**, ne pri kazdem cteni. + * + * Seznam automatizaci se cte pri kazdem nacteni prehledu i pri kazde zmene + * ticketu (spoustece), a prochazet kvuli tomu pokazde cely strom vsech + * automatizaci bylo znat. Nepovinne, protoze starsi zaznamy je nemaji - + * dopocitaji se pri startu. + */ + issues?: string[]; + stepCount?: number; createdAt: string; updatedAt: string; } @@ -378,10 +188,6 @@ function statsOf(stored: StoredAutomation): { }; } -function minutesAgo(minutes: number): string { - return new Date(Date.now() - minutes * 60_000).toISOString(); -} - /** Rekurzivne secte kroky vcetne obou vetvi podminek. */ export function countSteps(steps: FlowStep[]): number { return steps.reduce((sum, step) => { @@ -597,6 +403,15 @@ function save(automation: StoredAutomation): void { mirror.save(automation); } +/** Doplni to, co se ze stromu dopocitava a uklada s nim (nedodelky, pocet kroku). */ +function withDerived(automation: StoredAutomation): StoredAutomation { + return { + ...automation, + stepCount: countSteps(automation.flow.steps), + issues: collectFlowIssues(automation.flow), + }; +} + /** * Nacte automatizace z uloziste. Vola se pri startu, viz data/bootstrap.ts. * @@ -607,14 +422,16 @@ export async function initAutomations(): Promise { const rows = await mirror.load(() => [...store.values()]); store.clear(); - for (const row of rows) store.set(row.id, row); + /* + * Nedodelky se pri startu prepocitaji u vsech, ne jen u zaznamu bez nich: + * zavisi na katalogu sluzeb a ten se mezi nasazenimi meni (nove povinne + * pole kroku). Jednou pri startu je to levne, pri kazdem cteni ne. + */ + for (const row of rows) store.set(row.id, withDerived(row)); // Citac musi pokracovat za nejvyssim ulozenym cislem, jinak by nova // automatizace prepsala starou. - for (const id of store.keys()) { - const number = Number(/^AUT-(\d+)$/.exec(id)?.[1] ?? 0); - if (number > idCounter) idCounter = number; - } + idCounter = Math.max(idCounter, highestNumber(store.keys(), 'AUT')); } function nextId(): string { @@ -628,15 +445,18 @@ function seed( }, ) { const id = nextId(); - store.set(id, { - // Ukazkova data patri Automii, kdyz neni receno jinak. - tenantId: 'tnt_automia', - ...automation, + store.set( id, - kind: deriveKind(automation.flow), - createdAt: minutesAgo(60 * 24 * 90), - updatedAt: minutesAgo(60 * 12), - }); + withDerived({ + // Ukazkova data patri Automii, kdyz neni receno jinak. + tenantId: 'tnt_automia', + ...automation, + id, + kind: deriveKind(automation.flow), + createdAt: minutesAgo(60 * 24 * 90), + updatedAt: minutesAgo(60 * 12), + }), + ); } /** @@ -1102,15 +922,17 @@ function seedDemoAutomations(): void { /** * Automatizace, ktere na instanci opravdu bezi. * - * Dokud data neprezivaji redeploy, je tohle jedine misto, kde nastaveni - * prezije nasazeni. Az bude Postgres, patri to zpatky do dat a odsud pryc. + * Je to **vychozi sada pro prazdne uloziste**, ne zdroj pravdy: pouzije se + * jen pri prvnim startu (nebo po ztrate dat, napr. redeploy bez databaze + * a bez svazku). Kdyz uloziste uz neco ma, tenhle kod se nepouzije a strom + * na instanci muze byt jiny. Zdrojem pravdy je uloziste, viz + * documentation/14-databaze.md. * - * Token webhooku se bere z `WEBHOOK_TOKEN_TEST`, aby se adresa po nasazeni + * Token webhooku se bere z `WEBHOOK_TOKEN_TEST`, aby se adresa po ztrate dat * nemenila a odesilatel ji nemusel prepisovat. * - * **Opsano z bezici instance, ne vymysleno.** Kdyz se strom na instanci zmeni, - * patri ta zmena sem, jinak ji dalsi nasazeni zahodi. Naposledy srovnano - * 2026-09-02. + * Opsano z bezici instance 2026-09-02. Kdyz se strom na instanci zmeni a ma + * prezit i ztratu dat, patri ta zmena sem. */ function seedRealAutomations(): void { seed({ @@ -1254,9 +1076,11 @@ function toSummary(stored: StoredAutomation): Automation { * je "dnes" nula, dokud opravdu neco nebezi. */ ...statsOf(stored), - stepCount: countSteps(stored.flow.steps), + // Spocitane pri ulozeni nebo pri startu, viz StoredAutomation. Zaloha + // pro pripad, ze by zaznam prisel jinudy nez pres `withDerived`. + stepCount: stored.stepCount ?? countSteps(stored.flow.steps), configured: stored.flow.trigger !== null, - issues: collectFlowIssues(stored.flow), + issues: stored.issues ?? collectFlowIssues(stored.flow), }; } @@ -1295,7 +1119,7 @@ export function getAutomation(id: string, tenantIds: string[]): AutomationDetail export function createAutomation(name: string, tenantId: string): AutomationDetail { const id = nextId(); const now = new Date().toISOString(); - const stored: StoredAutomation = { + const stored = withDerived({ id, tenantId, name, @@ -1308,22 +1132,16 @@ export function createAutomation(name: string, tenantId: string): AutomationDeta flow: { trigger: null, steps: [] }, createdAt: now, updatedAt: now, - }; + }); save(stored); console.info(`[automations] vytvorena automatizace ${id} "${name}"`); - publish('automation.created', `Vytvořena automatizace ${id}: ${name}`, { automationId: id }); + publish('automation.created', `Vytvořena automatizace ${id}: ${name}`, { automationId: id }, tenantId); return toDetail(stored); } /** Automatizace z povolenych firem. Cizi se chova jako neexistujici. */ function findWritable(id: string, tenantIds: string[]): StoredAutomation | undefined { - const stored = store.get(id); - if (!stored) return undefined; - if (!tenantIds.includes(stored.tenantId)) { - console.warn(`[automations] pokus o zapis do ${id} mimo povolene firmy`); - return undefined; - } - return stored; + return writableOrWarn(store.get(id), id, tenantIds, 'automations'); } export function updateAutomation( @@ -1338,18 +1156,18 @@ export function updateAutomation( } const flow = withWebhookToken(patch.flow ?? stored.flow, stored.flow, id); - const updated: StoredAutomation = { + const updated = withDerived({ ...stored, name: patch.name ?? stored.name, enabled: patch.enabled ?? stored.enabled, flow, kind: deriveKind(flow), updatedAt: new Date().toISOString(), - }; + }); // Nedokoncenou automatizaci nepustime do provozu - "aktivni" by nic nedelala // nebo by delala neco jineho, nez uzivatel ceka. - const issues = collectFlowIssues(updated.flow); + const issues = updated.issues ?? []; if (updated.enabled && issues.length > 0) { console.warn(`[automations] ${id}: zapnuti odmitnuto - ${issues.join(' ')}`); updated.enabled = false; @@ -1357,12 +1175,12 @@ export function updateAutomation( save(updated); console.info( - `[automations] ulozena automatizace ${id} (kroku: ${countSteps(updated.flow.steps)}, aktivni: ${updated.enabled}, nedodelku: ${issues.length})`, + `[automations] ulozena automatizace ${id} (kroku: ${updated.stepCount}, aktivni: ${updated.enabled}, nedodelku: ${issues.length})`, ); publish('automation.updated', `Automatizace ${id} uložena: ${updated.name}`, { automationId: id, enabled: updated.enabled, - }); + }, updated.tenantId); return toDetail(updated); } @@ -1414,14 +1232,15 @@ export function regenerateWebhookToken( return undefined; } - const updated: StoredAutomation = { + // Token je soucast nedodelku ("webhook nema adresu"), proto se dopocitava znovu. + const updated = withDerived({ ...stored, flow: { ...stored.flow, trigger: { ...stored.flow.trigger, webhookToken: generateWebhookToken() }, }, updatedAt: new Date().toISOString(), - }; + }); save(updated); console.info(`[automations] ${id}: token webhooku pregenerovan, stara adresa neplati`); return toDetail(updated); @@ -1430,7 +1249,7 @@ export function regenerateWebhookToken( /** Najde automatizaci podle tokenu v adrese webhooku. */ export function findByWebhookToken(token: string): AutomationDetail | undefined { for (const stored of store.values()) { - if (stored.flow.trigger?.webhookToken === token) return toDetail(stored); + if (stored.flow.trigger?.webhookToken && timingSafeEqualString(stored.flow.trigger.webhookToken, token)) return toDetail(stored); } return undefined; } @@ -1482,6 +1301,7 @@ export function recordRun(id: string, ok = true, tenantIds?: string[]): Automati ? `Automatizace ${id} proběhla: ${updated.name}` : `Automatizace ${id} skončila chybou: ${updated.name}`, { automationId: id, ok }, + updated.tenantId, ); return toDetail(updated); } @@ -1497,7 +1317,7 @@ export function deleteAutomation(id: string, tenantIds: string[]): boolean { console.info(`[automations] smazana automatizace ${id}`); publish('automation.deleted', `Automatizace ${id} smazána: ${name ?? ''}`, { automationId: id, - }); + }, stored?.tenantId ?? null); } return existed; } diff --git a/src/data/bootstrap.ts b/src/data/bootstrap.ts index 491513f..98782eb 100644 --- a/src/data/bootstrap.ts +++ b/src/data/bootstrap.ts @@ -9,23 +9,16 @@ * co se na ne pta. */ -import { refreshActions, actionPermissions, actionStore, seedActions } from './ticketActions.js'; -import { refreshCustomWidgets, customWidgetStore, seedCustomWidgets } from './customWidgets.js'; +import { actionPermissions, actionStore, seedActions } from './ticketActions.js'; +import { customWidgetStore, seedCustomWidgets } from './customWidgets.js'; import { auditStore } from './audit.js'; -import { notificationStore, refreshNotifications } from './notifications.js'; -import { inviteStore, refreshInvites } from './invites.js'; -import { tenantScriptStore, refreshTenantScripts } from './tenantScripts.js'; +import { notificationStore } from './notifications.js'; +import { inviteStore } from './invites.js'; +import { tenantScriptStore } from './tenantScripts.js'; import { onTicket } from './ticketHooks.js'; import { onTicketEvent } from '../runtime/triggers.js'; import { initQueue } from '../runtime/queue.js'; -import { - groupStore, - personStore, - refreshGroups, - refreshPeople, - seedGroups, - seedPeople, -} from './people.js'; +import { groupStore, personStore, seedGroups, seedPeople } from './people.js'; import { refreshRoles, roleStore, @@ -34,11 +27,12 @@ import { systemRoles, invalidatePermissions, } from './permissions.js'; +import { refreshAllCaches, refreshCache } from './store/cached.js'; import { initStores, type EntityStore, type TenantEntity } from './store/index.js'; -import { featuresStore, refreshFeatures, seedFeatures } from './tenantFeatures.js'; -import { refreshTenants, seedTenants, tenantStore } from './tenants.js'; -import { refreshTicketTypes, seedTicketTypes, ticketTypeStore } from './ticketTypes.js'; -import { refreshUsers, seedUsers, userStore } from './users.js'; +import { featuresStore, seedFeatures } from './tenantFeatures.js'; +import { seedTenants, tenantStore } from './tenants.js'; +import { seedTicketTypes, ticketTypeStore } from './ticketTypes.js'; +import { seedUsers, userStore } from './users.js'; import { refreshMcpTools } from './mcpTools.js'; import { initTickets } from './ticketStore.js'; import { initAutomations } from './automationStore.js'; @@ -84,25 +78,32 @@ const runtimeData: Array<{ name: string; init: () => Promise }> = [ ]; /** - * Obnovi kopie v pameti u entit, ktere se ctou pri kazdem requestu. - * Vola se pri startu a po kazdem zapisu, ktery je muze zmenit. + * Obnovi kopie v pameti u vsech entit, ktere se ctou pri kazdem requestu. + * Vola se pri startu. Po zapisu staci `refreshEntity` jen pro to, co se zmenilo. */ export async function refreshCaches(): Promise { - await Promise.all([ - refreshTenants(), - refreshUsers(), - refreshRoles(), - refreshPeople(), - refreshGroups(), - refreshFeatures(), - refreshTicketTypes(), - refreshActions(), - refreshCustomWidgets(), - refreshNotifications(), - refreshInvites(), - refreshTenantScripts(), - ]); + // Role maji vlastni kopii mimo `withCache`, proto zvlast. + await Promise.all([refreshAllCaches(), refreshRoles()]); + afterRefresh(); +} +/** + * Obnovi kopii jednoho druhu entity (`store.kind`, napr. `person`). + * + * Zapis jedne firmy nema znamenat cteni vsech dvanacti tabulek. Neznamy druh + * vraci false a volajici si ma zvolit plnou obnovu. + */ +export async function refreshEntity(kind: string): Promise { + if (kind === roleStore.kind) { + await refreshRoles(); + } else if (!(await refreshCache(kind))) { + return false; + } + afterRefresh(); + return true; +} + +function afterRefresh(): void { // Prava k akcim vznikaji z definic akci, takze se registruji az po jejich nacteni. setDynamicPermissions(actionPermissions()); invalidatePermissions(); diff --git a/src/data/conditions.ts b/src/data/conditions.ts index fff04cd..c562cfd 100644 --- a/src/data/conditions.ts +++ b/src/data/conditions.ts @@ -1,30 +1,19 @@ /** * Typy vstupnich parametru a operatory podminek. * - * POZOR: stejne tabulky ma i frontend v apps/web/src/lib/flow.ts. - * Pri zmene je nutne upravit obe strany (viz docs/08-automatizace-builder.md). + * POZOR: stejnou tabulku `operatorsByType` ma i web ve `web/src/lib/flow.ts`. + * Pri zmene je nutne upravit obe strany (viz documentation/05-dashboard-a-builder.md). */ -/** - * `object` a `list` jsou celé struktury, ne jednotlive hodnoty. Vznikly kvuli - * transformacim: krok muze predat dal cely objekt objednavky, ne jen jeho pole. - * Do sablony se nedosazuji, predavaji se jen jako celek dalsimu kroku. - */ -export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object' | 'list'; +import type { + ConditionMatch, + ConditionOperator, + ConditionRule, + FieldType, +} from '../shared/conditions.js'; -export type ConditionOperator = - | 'eq' - | 'neq' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'contains' - | 'startsWith' - | 'isEmpty' - | 'isNotEmpty' - | 'isTrue' - | 'isFalse'; +/** Typy jsou sdilene s webem, viz src/shared/conditions.ts. Tady zustavaji tabulky. */ +export type { ConditionMatch, ConditionOperator, ConditionRule, FieldType }; export const fieldTypes: FieldType[] = [ 'string', @@ -70,30 +59,6 @@ export const operatorsByType: Record = { list: ['contains', 'isEmpty', 'isNotEmpty'], }; -/** - * Jedna otazka v podmince. - * - * Driv byla podminka **prave jedna otazka** a slozitejsi vetveni se muselo - * skladat z vnorenych podminek. U tri hodnot, ktere maji dopadnout stejne, to - * znamenalo tri urovne stromu, ve kterych se nikdo nevyzna. - */ -export interface ConditionRule { - /** ID parametru spoustece, nebo `krok.vystup`. */ - fieldId: string; - operator: ConditionOperator; - /** Chybi u operatoru, ktere hodnotu nepotrebuji (isEmpty, isTrue apod.). */ - value?: string; -} - -/** - * Jak se otazky spoji. - * - * `all` = a zaroven, `any` = nebo. Zamerne **jedna uroven**, ne strom se - * zavorkami: dve treti podminek jsou "vsechny tohle" nebo "cokoliv z tohohle" - * a zavorky by v rozhrani znamenaly editor vyrazu, ktery uz nikdo neuklika. - */ -export type ConditionMatch = 'all' | 'any'; - /** Operatory, ktere nepotrebuji hodnotu k porovnani. */ export const unaryOperators: ConditionOperator[] = [ 'isEmpty', diff --git a/src/data/connectorStore.ts b/src/data/connectorStore.ts index c6bdab3..06a27f6 100644 --- a/src/data/connectorStore.ts +++ b/src/data/connectorStore.ts @@ -1,5 +1,5 @@ /** - * Konektory: vyber uloziste a jedine misto, kde se rozhoduje o rezimu. + * Konektory: vyber uloziste podle rezimu, o kterem rozhodl `store/index.ts`. * * Tri rezimy, jedno rozhrani: * @@ -9,9 +9,9 @@ * | `file` | neni databaze, ale je datova slozka | restart, ne redeploy | * | `memory` | ani jedno, nebo se nepodarilo zapsat | nic | * - * Rozhodnuti je **jen tady**. Nikde jinde se nezjistuje, ktery rezim jede - - * kdyby se to rozlezlo po kodu, jedno misto by se zapomnelo a chovalo by se - * pak jinak nez zbytek. + * Rozhodnuti je **jedno pro vsechna uloziste** (`initStores`). Driv se tady + * rozhodovalo podruhe a s jinou podminkou, takze tickety mohly jit do databaze + * a konektory do souboru. Tady se uz jen sestavuje stav pro portal a log. * * Databaze potrebuje klic ze `SECRETS_KEY`. Klic vedle dat by u ni nemel smysl: * kdo ma zalohu tabulky, ma i klic ze stejneho stroje. @@ -20,7 +20,7 @@ import path from 'node:path'; import { config } from '../config.js'; import { isDatabaseEnabled } from '../db/pool.js'; -import { canSealSecrets, initSecrets, secretsKeySource } from '../db/secretBox.js'; +import { canSealSecrets, secretsKeySource } from '../db/secretBox.js'; import { createLocalConnectors } from './connectors/local.js'; import { postgresConnectors } from './connectors/postgres.js'; import type { @@ -31,6 +31,7 @@ import type { UpdateConnectorInput, } from './connectors/types.js'; import { fileSnapshot, memorySnapshot } from './snapshot.js'; +import { initStores } from './store/index.js'; import type { McpToolset } from '../mcp/client.js'; export type { @@ -43,21 +44,10 @@ export type { } from './connectors/types.js'; export { CHECK_HISTORY, toPublicConnector, validateConnectorValues } from './connectors/types.js'; -export type StorageMode = 'postgres' | 'file' | 'memory'; +import type { StorageMode, StorageStatus } from '../shared/connectors.js'; -export interface StorageStatus { - mode: StorageMode; - /** Kratke vysvetleni pro portal. Vzdy vyplnene, i u databaze. */ - reason: string; - /** true = restart procesu data smaze. */ - ephemeral: boolean; - /** true = redeploy containeru data smaze, i kdyz restart ne. */ - lostOnRedeploy: boolean; - /** Odkud je klic k sifrovani tajnych hodnot. */ - secrets: 'env' | 'file' | 'none'; - /** Kde data lezi. null u pameti. */ - location: string | null; -} +/** Stav uloziste je sdileny s webem, viz src/shared/connectors.ts. */ +export type { StorageMode, StorageStatus }; let repository: ConnectorRepository = createLocalConnectors({ store: memorySnapshot(), @@ -85,13 +75,12 @@ export interface InitOptions { /** Vybere uloziste. Vola se jednou pri startu, po migracich. */ export async function initConnectorStore(options: InitOptions = {}): Promise { const databaseReady = options.databaseReady ?? true; - const useDatabase = isDatabaseEnabled() && databaseReady; - - // Klic vedle dat je pripustny jen mimo databazi. U ni ma prijit ze secretu. - initSecrets({ allowKeyFile: !useDatabase, dir: config.dataDir }); + // Rezim je spolecny pro vsechna uloziste. `initStores` zaroven nastavi klic + // k sifrovani, proto se na nej pta az po nem. + const mode = initStores({ databaseReady }); const secrets = secretsKeySource(); - if (useDatabase && canSealSecrets()) { + if (mode === 'postgres') { repository = postgresConnectors; flushRepository = null; status = { @@ -111,13 +100,13 @@ export async function initConnectorStore(options: InitOptions = {}): Promise; + isDefault: boolean; +}): Connector { + const timestamp = nowIso(); + return { + id: `con_${randomUUID().slice(0, 8)}`, + tenantId: input.tenantId, + serviceId: input.serviceId, + name: input.name, + baseUrl: input.baseUrl, + values: { ...input.values }, + enabled: true, + status: 'untested', + lastCheckAt: null, + lastError: null, + checks: [], + mcp: null, + isDefault: input.isDefault, + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +/** Stejne poradi jako `ORDER BY name` v Postgresu: vychozi prvni, pak podle nazvu. */ +function byDefaultThenName(a: Connector, b: Connector): number { + return Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name, 'cs'); +} + /** * Tvar zaznamu v souboru. Proti `Connector` se lisi jen tim, ze `secrets` * je zasifrovana obalka misto ctecich hodnot. @@ -79,7 +118,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto } return { - kind: store.kind === 'file' ? 'memory' : 'memory', + kind: store.kind, async init() { const stored = await store.load(); @@ -117,11 +156,12 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto }, async defaultFor(tenantId, serviceId) { - const forService = rows.filter( - (row) => row.tenantId === tenantId && row.serviceId === serviceId && row.enabled, - ); - const chosen = forService.find((row) => row.isDefault) ?? forService[0]; - return chosen ? copy(chosen) : undefined; + // Vychozi vyhrava, jinak prvni podle nazvu. Vypnute konektory se neberou. + // Stejne pravidlo jako v Postgresu, aby se rezimy nelisily. + const forService = rows + .filter((row) => row.tenantId === tenantId && row.serviceId === serviceId && row.enabled) + .sort(byDefaultThenName); + return forService[0] ? copy(forService[0]) : undefined; }, async countsByService(tenantIds) { @@ -133,29 +173,19 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto }, async create(input: CreateConnectorInput) { - const timestamp = nowIso(); const existing = rows.filter( (row) => row.tenantId === input.tenantId && row.serviceId === input.serviceId, ); - const connector: Connector = { - id: `con_${randomUUID().slice(0, 8)}`, + const connector = newConnector({ tenantId: input.tenantId, serviceId: input.serviceId, name: input.name, baseUrl: input.baseUrl ?? null, - values: { ...(input.values ?? {}) }, - enabled: true, - status: 'untested', - lastCheckAt: null, - lastError: null, - checks: [], - mcp: null, + values: input.values ?? {}, // Prvni konektor na sluzbu je vychozi, jinak by krok bez vyberu nemel co vzit. isDefault: input.isDefault ?? existing.length === 0, - createdAt: timestamp, - updatedAt: timestamp, - }; + }); if (connector.isDefault) clearDefaults(input.tenantId, input.serviceId); rows.push(connector); @@ -172,10 +202,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto if (patch.enabled !== undefined) row.enabled = patch.enabled; if (patch.values) { - for (const [key, value] of Object.entries(patch.values)) { - if (value === '') delete row.values[key]; - else row.values[key] = value; - } + row.values = mergeValues(row.values, patch.values); // Zmena udaju znamena, ze predchozi overeni uz nic nerika. Nastroje // se ale nemazou: jina adresa muze vratit jiny seznam, jenze dokud ho // nekdo nenacte, jsou ty stare porad to jedine, co v ulozenych @@ -201,12 +228,13 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto rows.splice(rows.indexOf(row), 1); - // Kdyz zmizel vychozi, prevezme to prvni zbyly - jinak by kroky bez vyberu - // prestaly fungovat, aniz by se cokoliv jineho zmenilo. + // Kdyz zmizel vychozi, prevezme to prvni zbyly podle nazvu - jinak by kroky + // bez vyberu prestaly fungovat, aniz by se cokoliv jineho zmenilo. + // Podle nazvu, ne podle poradi vlozeni: tak to dela i Postgres. if (row.isDefault) { - const next = rows.find( - (item) => item.tenantId === row.tenantId && item.serviceId === row.serviceId, - ); + const next = rows + .filter((item) => item.tenantId === row.tenantId && item.serviceId === row.serviceId) + .sort(byDefaultThenName)[0]; if (next) next.isDefault = true; } @@ -231,10 +259,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto const row = rows.find((item) => item.id === id); if (!row || !tenantIds.includes(row.tenantId)) return undefined; - for (const [key, value] of Object.entries(values)) { - if (value === '') delete row.values[key]; - else row.values[key] = value; - } + row.values = mergeValues(row.values, values); row.updatedAt = nowIso(); persist(); return copy(row); @@ -262,24 +287,16 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto if (rows.length > 0) return; if (!findService('idoklad')) return; - const timestamp = nowIso(); - rows.push({ - id: `con_${randomUUID().slice(0, 8)}`, - tenantId: 'tnt_automia', - serviceId: 'idoklad', - name: 'iDoklad Automia', - baseUrl: null, - values: {}, - enabled: true, - status: 'untested', - lastCheckAt: null, - lastError: null, - checks: [], - mcp: null, - isDefault: true, - createdAt: timestamp, - updatedAt: timestamp, - }); + rows.push( + newConnector({ + tenantId: 'tnt_automia', + serviceId: 'idoklad', + name: 'iDoklad Automia', + baseUrl: null, + values: {}, + isDefault: true, + }), + ); persist(); } } diff --git a/src/data/connectors/postgres.ts b/src/data/connectors/postgres.ts index b4d7a53..5168492 100644 --- a/src/data/connectors/postgres.ts +++ b/src/data/connectors/postgres.ts @@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto'; import type { McpToolset } from '../../mcp/client.js'; import { query, queryOne, transaction } from '../../db/pool.js'; import { openAll, sealAll } from '../../db/secretBox.js'; -import { CHECK_HISTORY } from './types.js'; +import { CHECK_HISTORY, mergeValues } from './types.js'; import type { Connector, ConnectorCheck, @@ -169,12 +169,7 @@ export const postgresConnectors: ConnectorRepository = { let secrets = row.secrets; let statusReset = false; if (patch.values) { - const merged = openAll(row.secrets); - for (const [key, value] of Object.entries(patch.values)) { - if (value === '') delete merged[key]; - else merged[key] = value; - } - secrets = sealAll(merged); + secrets = sealAll(mergeValues(openAll(row.secrets), patch.values)); statusReset = true; } @@ -279,11 +274,7 @@ export const postgresConnectors: ConnectorRepository = { const row = current.rows[0]; if (!row) return undefined; - const merged = openAll(row.secrets); - for (const [key, value] of Object.entries(values)) { - if (value === '') delete merged[key]; - else merged[key] = value; - } + const merged = mergeValues(openAll(row.secrets), values); const updated = await client.query( `UPDATE connectors SET secrets = $2::jsonb, updated_at = now() diff --git a/src/data/connectors/types.ts b/src/data/connectors/types.ts index 647987f..690e43b 100644 --- a/src/data/connectors/types.ts +++ b/src/data/connectors/types.ts @@ -5,44 +5,25 @@ * X-ClientId a X-ClientSecret". Konektor drzi hodnoty. Kazda firma ma svoje. * * Tenhle soubor je jen model a to, co plati v obou ulozistich. Samotna - * uloziste jsou vedle: `memory.ts` a `postgres.ts`. + * uloziste jsou vedle: `local.ts` (soubor nebo pamet) a `postgres.ts`. */ import { findService, type Service, type ServiceCredentialField } from '../services.js'; import { fieldsFromSchema, outputsFromTool } from '../../mcp/schema.js'; import type { McpToolset } from '../../mcp/client.js'; +import type { + Connector as PublicConnector, + ConnectorCheck, + ConnectorStatus, + Toolset as PublicToolset, +} from '../../shared/connectors.js'; + /** - * Jeden zaznam o overeni konektoru. - * - * Drzi se **cele telo odpovedi**, ne jen kod. Prave tam sluzba pise, co ji - * vadilo, a bez toho se neda rozlisit spatny udaj od zakazane IP adresy. - * Text je zredigovany uz pri vzniku, takze pristupovy udaj v nem nikdy neni. + * Verejny tvar konektoru je sdileny s webem, viz src/shared/connectors.ts. + * Tady zustava ulozeny tvar s hodnotami a prevod na verejny. */ -export interface ConnectorCheck { - /** ISO cas overeni. */ - at: string; - ok: boolean; - /** Co se overovalo: pristupove udaje, nebo jen dostupnost sluzby. */ - checked: string; - /** HTTP kod od sluzby. null, kdyz se k volani vubec nedoslo. */ - status: number | null; - message: string; - /** Cele telo odpovedi sluzby. */ - detail: string | null; - /** Cela adresa vcetne serveru, bez query. null, kdyz se k volani nedoslo. */ - request: { method: string; path: string; url: string } | null; - /** - * Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal - aplikace, nebo - * proxy pred ni. U kodu bez tela je to jedina stopa, ktera zbyde. - */ - responseHeaders: Record | null; - /** - * Odchozi IP adresa portalu ve chvili volani. Vyplnuje se jen u odmitnuteho - * pristupu - jinde nema co rict a nestoji za volani ven. - */ - egressIp: string | null; -} +export type { ConnectorCheck, ConnectorStatus, PublicConnector, PublicToolset }; /** Kolik zaznamu o overeni se u konektoru drzi. */ export const CHECK_HISTORY = 5; @@ -59,7 +40,7 @@ export interface Connector { values: Record; enabled: boolean; /** untested = jeste se neoverovalo, ok = posledni overeni proslo. */ - status: 'untested' | 'ok' | 'error'; + status: ConnectorStatus; lastCheckAt: string | null; lastError: string | null; /** Poslednich `CHECK_HISTORY` overeni, nejnovejsi prvni. */ @@ -80,61 +61,6 @@ export interface Connector { updatedAt: string; } -/** - * Verze pro klienta. **Tajne hodnoty tady nejsou** a nikdy nesmi byt - - * secrets se nevraci z beznych endpointu (AGENTS.md). - */ -export interface PublicConnector { - id: string; - tenantId: string; - serviceId: string; - name: string; - baseUrl: string | null; - enabled: boolean; - status: Connector['status']; - lastCheckAt: string | null; - lastError: string | null; - /** - * Kolik zaznamu o overeni je k dispozici. Samotna historie se sem nedava, - * v seznamu konektoru by to byly desitky kilobajtu tel odpovedi navic. - * Cte se zvlast pres `/connectors/:id/checks`. - */ - checkCount: number; - isDefault: boolean; - createdAt: string; - updatedAt: string; - /** ID poli, ktera jsou vyplnena. Hodnoty se nevraci. */ - filled: string[]; - /** ID povinnych poli, ktera jeste chybi. */ - missing: string[]; - /** Necitliva nastaveni. Tajna pole tu nejsou vubec. */ - config: Record; - /** true = vsechna povinna pole jsou vyplnena, jde volat. */ - ready: boolean; - /** - * Nastroje MCP serveru pro portal. Schemata tu **nejsou** - klient je - * nepotrebuje, uz prevedena na pole kroku chodi v katalogu sluzeb. Tady jde - * jen o to ukazat u konektoru, co se nacetlo. - */ - mcp: PublicToolset | null; -} - -/** Nastroje jednoho serveru tak, jak je vidi portal. */ -export interface PublicToolset { - at: string; - server: string; - protocolVersion: string; - tools: Array<{ - name: string; - label: string; - description: string; - /** Nazvy parametru, ktere nastroj prijima. Povinne jsou prvni. */ - inputs: string[]; - /** Nazvy hodnot, ktere vraci. Vzdy aspon text a priznak chyby. */ - outputs: string[]; - }>; -} - export interface CreateConnectorInput { tenantId: string; serviceId: string; @@ -163,7 +89,7 @@ export interface ConnectorIssue { /** Rozhrani uloziste. Dve implementace, vyber je na jednom miste. */ export interface ConnectorRepository { - readonly kind: 'memory' | 'postgres'; + readonly kind: 'memory' | 'file' | 'postgres'; list(tenantIds: string[], options?: { serviceId?: string }): Promise; get(id: string, tenantIds: string[]): Promise; defaultFor(tenantId: string, serviceId: string): Promise; @@ -205,8 +131,25 @@ export interface ConnectorRepository { // ------------------------------------------------------------------- pomocne -export function nowIso(): string { - return new Date().toISOString(); +export { nowIso } from '../store/types.js'; + +/** + * Slouceni hodnot konektoru s patchem. + * + * Prazdny retezec hodnotu **smaze**, chybejici klic ji nechava. Diky tomu jde + * ulozit formular, ktery tajne hodnoty neposila. Jedno misto pro obe uloziste + * i pro spravovane hodnoty - ctyri kopie tehoz pravidla by se casem rozesly. + */ +export function mergeValues( + current: Record, + patch: Record, +): Record { + const merged = { ...current }; + for (const [key, value] of Object.entries(patch)) { + if (value === '') delete merged[key]; + else merged[key] = value; + } + return merged; } /** Povinna pole, ktera nejsou vyplnena. */ diff --git a/src/data/customWidgets.ts b/src/data/customWidgets.ts index dcec448..bb32d15 100644 --- a/src/data/customWidgets.ts +++ b/src/data/customWidgets.ts @@ -17,79 +17,17 @@ import { defineStore, nowIso, type TenantEntity } from './store/index.js'; import { withCache } from './store/cached.js'; -import type { TicketChannel, TicketStatus } from './ticketStore.js'; +import type { + WidgetGroupBy, + WidgetPeriod, + WidgetRender, + WidgetSize, + WidgetSource, + WidgetTicketFilter, +} from '../shared/widgets.js'; -export type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge'; - -/** Podle ceho se da seskupovat. Bez toho by byl widget na kazdeho cloveka. */ -export type WidgetGroupBy = - | 'assignee' - | 'group' - /** Stav ticketu. Volny retezec, u ticketu z cizich aplikaci jejich vlastni. */ - | 'status' - | 'type' - | 'tag' - | 'channel'; - -export type WidgetPeriod = 'today' | '7d' | '30d' | 'month' | 'all'; - -/** Filtr nad tickety. Tentyz, ktery umi `GET /api/dashboard/tickets`. */ -export interface WidgetTicketFilter { - status?: TicketStatus[]; - channel?: TicketChannel[]; - typeIds?: string[]; - tags?: string[]; - /** `me` = prihlaseny, `unassigned` = fronta, jinak ID resitelu. */ - assignee?: string[]; - groupIds?: string[]; - period?: WidgetPeriod; - /** - * Jen vyrizene, nebo jen nevyrizene. Nevyplnene = oboji. - * - * Ptat se na to pres stav nejde: stav je volny retezec a vyjmenovat - * vsechny podoby slova "hotovo" se neda. Priznak `closed` je od toho. - */ - closed?: boolean; -} - -export type WidgetSource = - | { kind: 'ticketCount'; filter: WidgetTicketFilter; groupBy?: WidgetGroupBy } - | { kind: 'ticketList'; filter: WidgetTicketFilter; limit: number } - | { kind: 'ticketSeries'; filter: WidgetTicketFilter; bucket: 'day' | 'week' } - | { kind: 'workload'; groupIds?: string[] } - /** - * Vykon resitelu: kolik kdo odbavil, jak dlouho mu to trva a kolik se mu - * toho vratilo. `period` plati jen na vyresene - fronta neni vec obdobi. - */ - | { kind: 'agentStats'; period?: WidgetPeriod } - /** - * Data z napojene sluzby. Co umi zjistit konektor, jde vytahnout do dlazdice. - * - * Vola se **tentyz skript**, ktery pouziva krok automatizace a akce na - * ticketu. Widget si nic vlastniho nevymysli, jen vybere z vysledku: - * - `path` je cesta ve vystupu skriptu, napr. `total` nebo `items`, - * - cislo se ukaze jako cislo, seznam jako radky. - * - * Vysledek se **cachuje** (`ttlSec`), aby otevreni prehledu neznamenalo - * deset volani cizi sluzby. Cizi API ma limity a plati se za ne. - */ - | { - kind: 'connector'; - serviceId: string; - operationId: string; - /** null = vychozi konektor firmy pro tuhle sluzbu. */ - connectorId: string | null; - inputs: Record; - /** Cesta ve vystupu skriptu. Prazdne = cely vystup. */ - path?: string; - /** U seznamu: co v radku ukazat jako popisek a co jako hodnotu. */ - labelPath?: string; - valuePath?: string; - /** Jak dlouho se vysledek drzi. Vychozi 300 s, nejmene 30. */ - ttlSec?: number; - }; - -export type WidgetSize = 'third' | 'half' | 'full'; +/** Tvar zdroje dat je sdileny s webem, viz src/shared/widgets.ts. Tady je uloziste. */ +export type { WidgetGroupBy, WidgetPeriod, WidgetRender, WidgetSize, WidgetSource, WidgetTicketFilter }; export interface CustomWidget extends TenantEntity { tenantId: string; @@ -159,7 +97,9 @@ export function seedCustomWidgets(): CustomWidget[] { render: 'stat', source: { kind: 'ticketCount', - filter: { typeIds: ['tt_order'], status: ['new', 'open', 'waiting'] }, + // Pres priznak vyrizeni, ne pres text stavu: stav je volny retezec + // a vyjmenovat vsechny podoby "nevyrizeno" nejde. + filter: { typeIds: ['tt_order'], closed: false }, }, size: 'third', }, @@ -171,7 +111,7 @@ export function seedCustomWidgets(): CustomWidget[] { render: 'table', source: { kind: 'ticketCount', - filter: { status: ['new', 'open', 'waiting'] }, + filter: { closed: false }, groupBy: 'assignee', }, size: 'half', @@ -186,10 +126,8 @@ export async function refreshCustomWidgets(): Promise { /** Widgety firmy: firemni plus osobni prihlaseneho. */ export function listCustomWidgets(tenantIds: string[], userId: string): CustomWidget[] { return cache - .all() - .filter((widget) => tenantIds.includes(widget.tenantId)) - .filter((widget) => widget.ownerId === null || widget.ownerId === userId) - .sort((a, b) => a.name.localeCompare(b.name, 'cs')); + .listByTenant(tenantIds, 'name') + .filter((widget) => widget.ownerId === null || widget.ownerId === userId); } export function findCustomWidget(id: string): CustomWidget | undefined { diff --git a/src/data/dashboardLayouts.ts b/src/data/dashboardLayouts.ts index 6936314..c3413ea 100644 --- a/src/data/dashboardLayouts.ts +++ b/src/data/dashboardLayouts.ts @@ -8,16 +8,13 @@ */ import type { CatalogWidget } from './widgets.js'; -import type { WidgetSize } from './widgets.js'; import { defineStore, nowIso, type TenantEntity } from './store/index.js'; import { withMirror } from './store/mirror.js'; -export interface LayoutItem { - /** Instance widgetu. Tentyz widget muze byt na dashboardu vickrat. */ - id: string; - widgetId: string; - size: WidgetSize; -} +import type { LayoutItem } from '../shared/widgets.js'; + +/** Polozka rozlozeni je sdilena s webem, viz src/shared/widgets.ts. */ +export type { LayoutItem }; /** Kolik widgetu jeste dava smysl. Nad tim je to seznam, ne prehled. */ export const MAX_WIDGETS = 12; @@ -41,7 +38,8 @@ const defaultLayout: LayoutItem[] = [ * V katalogu zustava, kdo ho chce, prida si ho. */ -const layouts = new Map(); +/** Kopie v pameti. Drzi i cas vzniku, aby ho dalsi ulozeni neprepsalo. */ +const layouts = new Map(); /** * Zaznam v ulozisti. ID je dvojice uzivatel a firma, takze zapis nepotrebuje @@ -62,11 +60,16 @@ function key(userId: string, tenantId: string): string { export async function initLayouts(): Promise { const rows = await mirror.load(); layouts.clear(); - for (const row of rows) layouts.set(key(row.userId, row.tenantId ?? ''), row.items); + for (const row of rows) { + layouts.set(key(row.userId, row.tenantId ?? ''), { + items: row.items, + createdAt: row.createdAt, + }); + } } export function getLayout(userId: string, tenantId: string): LayoutItem[] { - return layouts.get(key(userId, tenantId)) ?? defaultLayout.map((item) => ({ ...item })); + return layouts.get(key(userId, tenantId))?.items ?? defaultLayout.map((item) => ({ ...item })); } /** true = uzivatel si rozlozeni uz upravil, nekouka na vychozi. */ @@ -76,10 +79,13 @@ export function hasCustomLayout(userId: string, tenantId: string): boolean { export function saveLayout(userId: string, tenantId: string, items: LayoutItem[]): LayoutItem[] { const id = key(userId, tenantId); - layouts.set(id, items); - const timestamp = nowIso(); - mirror.save({ id, tenantId, userId, items, createdAt: timestamp, updatedAt: timestamp }); + // Cas vzniku se pri dalsim ulozeni nemeni. Postgres ho stejne drzi puvodni, + // soubor prepisoval cely zaznam - ted se chovaji stejne. + const createdAt = layouts.get(id)?.createdAt ?? timestamp; + layouts.set(id, { items, createdAt }); + + mirror.save({ id, tenantId, userId, items, createdAt, updatedAt: timestamp }); console.info(`[layout] ${userId}@${tenantId}: ulozeno ${items.length} widgetu`); return items; } diff --git a/src/data/egressIp.ts b/src/data/egressIp.ts index bd6c792..4ded868 100644 --- a/src/data/egressIp.ts +++ b/src/data/egressIp.ts @@ -14,50 +14,10 @@ import { config } from '../config.js'; -export interface EgressIp { - /** null = nepodarilo se zjistit, nebo je funkce vypnuta. */ - ip: string | null; - /** Odkud to je. Pro uzivatele, at vi, komu ta odpoved patri. */ - source: string; - checkedAt: string; - /** Vyplnene jen kdyz `ip` je null. */ - error?: string; - /** - * Jak nas vidi **nase vlastni reverse proxy**, kdyz volani vyjde z tohoto - * containeru a otoci se pres verejnou domenu zpatky. Tohle je ta adresa, - * kterou porovnava seznam povolenych IP u sluzeb za toutez proxy - a byva - * jina nez ta verejna, protoze volani na vlastni stroj se hairpinuje - * a proxy vidi adresu docker bridge. - * - * null = nezmereno, chybi `PUBLIC_ORIGIN`, nebo se to nepodarilo. - */ - viaProxy: ProxyView | null; -} +import type { EgressIp, ProxyView } from '../shared/connectors.js'; -export interface ProxyView { - /** - * **Tohle je to cislo.** Adresa po `trust proxy`, tedy z X-Forwarded-For - - * proxy tam zapsala, s jakou adresou k ni nase volani doslo. - */ - ip: string | null; - /** Surova hlavicka, at je videt i to, co proxy nepripsala. */ - forwardedFor: string | null; - /** - * Adresa druheho konce spojeni. Pozor, to je **sama proxy**, ne volajici - - * k nam uz to jde od ni. Je to tu na to, aby bylo poznat, ze se volani - * opravdu tocilo pres ni a ne primo. - */ - remoteAddress: string | null; - /** - * Rozsah, ktery tuhle adresu pokryje cely. Docker prideluje z bloku, - * takze povolit jednu adresu vydrzi do prvniho prekresleni site - patri - * tam cely rozsah. null = adresa je verejna, zadny blok se nenabizi. - */ - suggestedRange: string | null; - /** Kam se volalo. Pro uzivatele, at vi, co se vlastne merilo. */ - url: string; - error?: string; -} +/** Tvar odpovedi je sdileny s webem, viz src/shared/connectors.ts. */ +export type { EgressIp, ProxyView }; /** * Rozsah, ktery danou adresu pokryje cely. diff --git a/src/data/incidentStore.ts b/src/data/incidentStore.ts index f5a43c9..d89bb0d 100644 --- a/src/data/incidentStore.ts +++ b/src/data/incidentStore.ts @@ -9,48 +9,13 @@ import { config } from '../config.js'; import { publish } from '../events/bus.js'; -import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { defineStore, highestNumber, minutesAgo, nowIso, type TenantEntity } from './store/index.js'; import { withMirror } from './store/mirror.js'; -export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3'; -export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved'; +import type { Incident, IncidentSeverity, IncidentStatus } from '../shared/incidents.js'; -export interface Incident { - id: string; - /** - * Firma, ktere se to tyka. `null` = nas vlastni, platformni incident - * (vypadek sluzby, ktera je spolecna vsem). - */ - tenantId: string | null; - /** Srozumitelne pro klienta. Zadne stack trace, zadne ID kroku. */ - title: string; - service: string; - severity: IncidentSeverity; - status: IncidentStatus; - /** - * Co s tim ma klient delat, nebo co to pro nej znamena. - * - * "Objednávka 5001 se nepřenesla do účetnictví, zkusíme to znovu" je - * pouzitelne. "TypeError: undefined" neni. - */ - impact: string; - /** - * Vsechno, co potrebuje admin: cele hlaseni, ktery beh, ktery krok, co - * prislo na vstupu. Zamerne oddelene od `title` - klient tohle videt nema - * a admin bez toho nema z ceho vychazet. - */ - detail: string | null; - /** Odkud incident vznikl, napr. `run_ab12` nebo `rucne`. */ - source: string | null; - /** Ticket, u ktereho to prasklo. */ - ticketId: string | null; - startedAt: string; - resolvedAt: string | null; -} - -function minutesAgo(minutes: number): string { - return new Date(Date.now() - minutes * 60_000).toISOString(); -} +/** Tvar incidentu je sdileny s webem, viz src/shared/incidents.ts. */ +export type { Incident, IncidentSeverity, IncidentStatus }; /** * Ukazkove incidenty. Se `SEED_DEMO=1` se pouziji, jinak se zacina naprazdno - @@ -134,33 +99,36 @@ export async function initIncidents(): Promise { incidents.push(incident); } - for (const incident of incidents) { - const number = Number(/^INC-(\d+)$/.exec(incident.id)?.[1] ?? 0); - if (number > counter) counter = number; - } + counter = Math.max(counter, highestNumber(incidents.map((incident) => incident.id), 'INC')); +} + +/** Vidi firmy z `tenantIds` tenhle incident? Platformni vidi kazdy. */ +function visibleTo(incident: Incident, tenantIds: string[]): boolean { + return incident.tenantId === null || tenantIds.includes(incident.tenantId); } /** * Incidenty firmy plus platformni. * - * Bez omezeni na firmy vraci prazdno, ne vse. Zapomenuty filtr nesmi znamenat - * "ukaz cizi incidenty" - je to stejne pravidlo jako u ticketu. + * Filtr na firmy je **povinny argument**, ne volitelny. Zapomenuty filtr tak + * neznamena "ukaz cizi incidenty", ale nezkompiluje se - stejne pravidlo + * jako u ticketu. Prazdny seznam vraci jen platformni. */ -export function listIncidents(tenantIds?: string[]): Incident[] { - const visible = tenantIds - ? incidents.filter((item) => item.tenantId === null || tenantIds.includes(item.tenantId)) - : incidents; - - return [...visible].sort((a, b) => { - if (a.status === 'resolved' && b.status !== 'resolved') return 1; - if (b.status === 'resolved' && a.status !== 'resolved') return -1; - return b.startedAt.localeCompare(a.startedAt); - }); +export function listIncidents(tenantIds: string[]): Incident[] { + return incidents + .filter((item) => visibleTo(item, tenantIds)) + .sort((a, b) => { + if (a.status === 'resolved' && b.status !== 'resolved') return 1; + if (b.status === 'resolved' && a.status !== 'resolved') return -1; + return b.startedAt.localeCompare(a.startedAt); + }); } /** Uz je stejny problem otevreny? Aby deset stejnych chyb nedelalo deset incidentu. */ -export function findOpenIncident(source: string): Incident | undefined { - return incidents.find((item) => item.source === source && item.status !== 'resolved'); +export function findOpenIncident(source: string, tenantIds: string[]): Incident | undefined { + return incidents.find( + (item) => item.source === source && item.status !== 'resolved' && visibleTo(item, tenantIds), + ); } export function createIncident(input: { @@ -194,7 +162,7 @@ export function createIncident(input: { publish('incident.started', `Nový incident ${incident.id}: ${incident.title}`, { incidentId: incident.id, severity: incident.severity, - }); + }, incident.tenantId); return incident; } @@ -212,17 +180,17 @@ export function updateIncidentStatus(id: string, status: IncidentStatus): Incide if (status === 'resolved') { publish('incident.resolved', `Incident ${incident.id} vyřešen: ${incident.title}`, { incidentId: incident.id, - }); + }, incident.tenantId); } else { publish('incident.updated', `Incident ${incident.id}: ${status}`, { incidentId: incident.id, status, - }); + }, incident.tenantId); } return incident; } -/** Prvni bezici incident. */ -export function firstActiveIncident(): Incident | undefined { - return incidents.find((i) => i.status !== 'resolved'); +/** Prvni bezici incident viditelny pro dane firmy. */ +export function firstActiveIncident(tenantIds: string[]): Incident | undefined { + return incidents.find((i) => i.status !== 'resolved' && visibleTo(i, tenantIds)); } diff --git a/src/data/invites.ts b/src/data/invites.ts index 040ba3f..6594074 100644 --- a/src/data/invites.ts +++ b/src/data/invites.ts @@ -14,6 +14,7 @@ */ import { randomBytes, randomUUID } from 'node:crypto'; +import { timingSafeEqualString } from '../lib/secure.js'; import { defineStore, nowIso, type TenantEntity } from './store/index.js'; import { withCache } from './store/cached.js'; @@ -87,7 +88,7 @@ export function newInvite(input: CreateInviteInput): Invite { /** Pozvanka podle kodu. Nezkoumá platnost, na to je `inviteProblem`. */ export function findByCode(code: string): Invite | undefined { if (code.length < 16) return undefined; - return cache.find((invite) => invite.code === code); + return cache.find((invite) => timingSafeEqualString(invite.code, code)); } /** @@ -105,10 +106,8 @@ export function inviteProblem(invite: Invite): string | null { } export function listInvites(tenantIds: string[]): Invite[] { - return cache - .all() - .filter((invite) => tenantIds.includes(invite.tenantId)) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + // Nejnovejsi nahore. + return cache.listByTenant(tenantIds, (a, b) => b.createdAt.localeCompare(a.createdAt)); } export async function markUsed(invite: Invite, userId: string): Promise { diff --git a/src/data/mock.ts b/src/data/mock.ts index 69035a5..f83b858 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -9,6 +9,7 @@ import { listAutomations } from './automationStore.js'; import { listIncidents } from './incidentStore.js'; import { listTickets } from './ticketStore.js'; +import type { DashboardSummary } from '../shared/dashboard.js'; /** Casova rada pro graf na dashboardu - poslednich 14 dni. */ export function getRunsSeries(days = 14) { @@ -24,10 +25,10 @@ export function getRunsSeries(days = 14) { return series; } -export function getSummary(tenantIds: string[]) { +export function getSummary(tenantIds: string[]): DashboardSummary { // Ukazkova cisla nad vlastnimi daty, strop viditelnosti se sem nevztahuje. const tickets = listTickets({ tenantIds, visibility: { kind: 'all' } }); - const incidents = listIncidents(); + const incidents = listIncidents(tenantIds); const automations = listAutomations(tenantIds); const series = getRunsSeries(); diff --git a/src/data/model.ts b/src/data/model.ts index d179fb5..d435a5f 100644 --- a/src/data/model.ts +++ b/src/data/model.ts @@ -18,15 +18,10 @@ import type { FieldType } from './conditions.js'; -export interface ModelNode { - /** Cesta k hodnote, napr. `data.order.items[].name`. */ - path: string; - type: FieldType; - /** Ukazkova hodnota, zkracena. Podle ni clovek pozna, co to je. */ - sample: string | null; - /** Kolik polozek melo pole v ukazce. Jen u typu `list`. */ - count?: number; -} +import type { ModelNode } from '../shared/automations.js'; + +/** Tvar uzlu modelu je sdileny s webem, viz src/shared/automations.ts. */ +export type { ModelNode }; /** Kolik urovni zanoreni se prochazi. Hlubsi model uz nikdo necte. */ const MAX_DEPTH = 8; diff --git a/src/data/notifications.ts b/src/data/notifications.ts index f25536a..f7371d6 100644 --- a/src/data/notifications.ts +++ b/src/data/notifications.ts @@ -86,30 +86,32 @@ export function notify(input: NotifyInput): void { void notificationStore .create(notification) .then(async () => { + // Uklid nad kopii pred obnovou: nove upozorneni v ni jeste neni, takze + // se pocita s +1. Obnova je pak jedna, ne dve. + await trim(user.id, 1); await cache.refresh(); // Portal ma cislo u zalozky prekreslit hned, ne az pri obnoveni stranky. publish('notification.created', input.title, { userId: user.id, ticketId: notification.ticketId, - }); - await trim(user.id); + }, notification.tenantId); }) .catch((err: unknown) => { console.error('[upozorneni] zapis selhal:', err); }); } -async function trim(userId: string): Promise { +/** Odmaze nejstarsi nad limit. `extra` = kolik jich pribylo mimo kopii. */ +async function trim(userId: string, extra: number): Promise { const mine = cache .all() .filter((item) => item.userId === userId) .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); - const excess = mine.slice(0, Math.max(0, mine.length - MAX_PER_USER)); - for (const item of excess) { - await notificationStore.remove(item.id, { tenantIds: [item.tenantId], includeGlobal: true }); - } - if (excess.length > 0) await cache.refresh(); + const excess = mine.slice(0, Math.max(0, mine.length + extra - MAX_PER_USER)); + if (excess.length === 0) return; + // Maze se, co se nacetlo z vlastni kopie, filtr na firmu tu nema co hlidat. + await notificationStore.removeMany(excess.map((item) => item.id)); } /** Upozorneni uzivatele, nejnovejsi nahore. */ @@ -133,13 +135,15 @@ export async function markRead(userId: string, ids?: string[]): Promise .filter((item) => item.userId === userId && item.readAt === null) .filter((item) => !ids || ids.includes(item.id)); - for (const item of mine) { - await notificationStore.update( - item.id, - { readAt: timestamp }, - { tenantIds: [item.tenantId], includeGlobal: true }, - ); - } - if (mine.length > 0) await cache.refresh(); - return mine.length; + if (mine.length === 0) return 0; + + // Jeden zapis pro vsechna, ne N za sebou. Firmy se vezmou z toho, co se oznacuje. + const tenantIds = [...new Set(mine.map((item) => item.tenantId))]; + const changed = await notificationStore.updateMany( + mine.map((item) => item.id), + { readAt: timestamp }, + { tenantIds, includeGlobal: true }, + ); + await cache.refresh(); + return changed; } diff --git a/src/data/people.ts b/src/data/people.ts index 00cf248..18b6b6d 100644 --- a/src/data/people.ts +++ b/src/data/people.ts @@ -12,30 +12,10 @@ import { defineStore, nowIso, type TenantEntity } from './store/index.js'; import { withCache } from './store/cached.js'; +import type { Person } from '../shared/people.js'; -export interface Person extends TenantEntity { - /** Firma, jejiz je clenem tymu. Hranice viditelnosti, proto nikdy null. */ - tenantId: string; - name: string; - email: string; - /** Cim se v tymu zabyva. Jen popisek, nic nerozhoduje. */ - role: string; - /** Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez. */ - capacity: number; - /** Vypnuty resitel se nenabizi k prirazeni, ale stare tickety nespadnou. */ - enabled: boolean; - /** - * ID, pod kterymi cloveka znaji cizi aplikace. - * - * Voicebot posle `voicebotId`, telefonni ustredna klapku, chat svoje ID - - * a my z toho musime poznat, komu ticket patri. Bez toho by se to muselo - * mapovat v kazde automatizaci zvlast a pri zmene cloveka opravovat na - * peti mistech. - * - * Jeden clovek jich muze mit vic, protoze aplikaci je vic. - */ - externalIds: string[]; -} +/** Tvar resitele je sdileny s webem, viz src/shared/people.ts. */ +export type { Person }; export const personStore = defineStore('person'); const cache = withCache(personStore); @@ -63,18 +43,12 @@ export async function refreshPeople(): Promise { /** Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse". */ export function listPeople(tenantIds: string[]): Person[] { - return cache - .all() - .filter((person) => tenantIds.includes(person.tenantId) && person.enabled) - .sort((a, b) => a.name.localeCompare(b.name, 'cs')); + return cache.listByTenant(tenantIds, 'name').filter((person) => person.enabled); } /** Vcetne vypnutych. Pro spravu tymu. */ export function listAllPeople(tenantIds: string[]): Person[] { - return cache - .all() - .filter((person) => tenantIds.includes(person.tenantId)) - .sort((a, b) => a.name.localeCompare(b.name, 'cs')); + return cache.listByTenant(tenantIds, 'name'); } /** @@ -201,10 +175,7 @@ export async function refreshGroups(): Promise { } export function listGroups(tenantIds: string[]): PersonGroup[] { - return groupCache - .all() - .filter((group) => tenantIds.includes(group.tenantId)) - .sort((a, b) => a.name.localeCompare(b.name, 'cs')); + return groupCache.listByTenant(tenantIds, 'name'); } export function findGroup(id: string): PersonGroup | undefined { diff --git a/src/data/permissions.ts b/src/data/permissions.ts index bb40a17..5387578 100644 --- a/src/data/permissions.ts +++ b/src/data/permissions.ts @@ -12,20 +12,13 @@ * Popis modelu je v documentation/16-prava-a-role.md. */ -import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { defineStore, nowIso } from './store/index.js'; import type { User } from '../types.js'; -export type PermissionKey = string; +import type { PermissionDefinition, PermissionKey, Role } from '../shared/permissions.js'; -export interface PermissionDefinition { - key: PermissionKey; - label: string; - /** Do ktere skupiny patri v nastaveni role. */ - group: string; - /** true = pravo je nase, klientsky admin ho neprideluje. */ - platform?: boolean; - hint?: string; -} +/** Tvar prava a role je sdileny s webem, viz src/shared/permissions.ts. */ +export type { PermissionDefinition, PermissionKey, Role }; /** * Katalog prav. @@ -128,16 +121,6 @@ const catalogKeys = new Set(permissionCatalog.map((item) => item.key)); // ------------------------------------------------------------------------ role -export interface Role extends TenantEntity { - /** Stabilni klic, napr. `admin`. U vlastnich roli je stejny jako ID. */ - key: string; - name: string; - description: string; - permissions: PermissionKey[]; - /** true = systemova role, nejde smazat ani prejmenovat. */ - system: boolean; -} - export const roleStore = defineStore('role'); /** Vsechna prava mimo platformni. Pro systemovou roli spravce firmy. */ diff --git a/src/data/refresh.ts b/src/data/refresh.ts index 68d92fd..c533fd3 100644 --- a/src/data/refresh.ts +++ b/src/data/refresh.ts @@ -7,32 +7,72 @@ * * Volani se **slucuje**. Deset zapisu za sebou znamena jednu obnovu, jinak by * hromadna uprava znamenala desetkrat precteni vsech roli a lidi. + * + * Obnovuje se **jen to, co se zmenilo**: zapis do lidi nema cist znovu firmy, + * role, typy ticketu a dalsich devet tabulek. Bez udani entity se obnovi vse. */ -import { refreshCaches } from './bootstrap.js'; +import { refreshCaches, refreshEntity } from './bootstrap.js'; + +/** + * Ktera entita se meni pod kterou cestou routeru nastaveni (`/api/settings/...`). + * Prvni segment cesty -> `store.kind`. Neznama cesta znamena plnou obnovu, + * radeji precist vse nez nechat zastaralou kopii. + */ +const routeKinds: Record = { + tenants: 'tenant', + users: 'user', + roles: 'role', + people: 'person', + groups: 'personGroup', + features: 'tenantFeatures', + 'ticket-types': 'ticketType', + actions: 'ticketAction', + widgets: 'customWidget', +}; let pending: Promise | null = null; -let again = false; +const queuedKinds = new Set(); +let queuedFull = false; -export async function bootstrapDataRefresh(): Promise { - if (pending) { - // Uz se obnovuje. Poznacime, ze po dokonceni ma prijit jeste jedna - - // jinak by se zmena z posledniho zapisu neprojevila. - again = true; - return pending; - } +/** + * Obnovi kopie v pameti po zapisu. + * + * `route` je cesta v routeru nastaveni, napr. `/people/ppl_1`. Podle ni se + * pozna, co se zmenilo. Bez ni se obnovi vsechno. + */ +export function bootstrapDataRefresh(route?: string): Promise { + const kind = route ? routeKinds[route.split('/').filter(Boolean)[0] ?? ''] : undefined; + if (kind) queuedKinds.add(kind); + else queuedFull = true; - pending = refreshCaches() - .catch((err: unknown) => { - console.error('[data] obnova kopii selhala:', err); - }) - .finally(() => { + // Uz se obnovuje: pozadavek je ve fronte a smycka ho vezme po dokonceni - + // jinak by se zmena z posledniho zapisu neprojevila. + if (!pending) { + pending = drain().finally(() => { pending = null; - if (again) { - again = false; - void bootstrapDataRefresh(); - } }); - + } return pending; } + +async function drain(): Promise { + while (queuedFull || queuedKinds.size > 0) { + const full = queuedFull; + const kinds = [...queuedKinds]; + queuedFull = false; + queuedKinds.clear(); + + try { + if (full) { + await refreshCaches(); + continue; + } + for (const kind of kinds) { + if (!(await refreshEntity(kind))) await refreshCaches(); + } + } catch (err) { + console.error('[data] obnova kopii selhala:', err); + } + } +} diff --git a/src/data/services.ts b/src/data/services.ts index 14a59c6..bfe81e6 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -18,249 +18,37 @@ * Jak pridat sluzbu: documentation/12-sluzby-a-konektory.md */ -import type { FieldType } from './conditions.js'; import type { User } from '../types.js'; import { isMcpService, MCP_EASYWEB_SERVICE_ID, MCP_SERVICE_ID } from '../mcp/dialect.js'; import { tenantHasService } from './tenantFeatures.js'; -export type ServiceCategory = - /** Obecne veci, ktere ma kazdy. Nepotrebuji konektor. */ - | 'obecne' - | 'crm' - | 'ekonomika' - | 'logistika' - | 'komunikace' - | 'analytika' - | 'ai' - /** - * Prevody dat mezi kroky. Vlastni kategorie, ne polozka v Obecnem: - * je jich hodne a clovek je hleda jako skupinu, ne mezi webhookem a pauzou. - */ - | 'transformace'; +import type { + OperationField, + ProvidedField, + Service, + ServiceCategory, + ServiceCategoryEntry, + ServiceCredentialField, + ServiceOperation, + ServiceStatus, + ServiceVisibility, +} from '../shared/services.js'; /** - * available = sluzbu umime, planned = je na roadmape. - * - * "Napojeno" tady zamerne NENI. Jestli je sluzba napojena, zavisi na tom, - * jestli si firma vytvorila konektor - to je vlastnost firmy, ne sluzby. - * Pocita se v `serviceViewsFor`. + * Tvar sluzby je sdileny s webem, viz src/shared/services.ts. Tady je katalog + * samotny a to, co se z nej pocita. */ -export type ServiceStatus = 'available' | 'planned'; - -/** - * Kdo sluzbu vidi. - * - * `everyone` vsichni prihlaseni - * `restricted` jen uvedene firmy a jmenovite uvedeni lide - * `admin` jen spravce platformy - * - * Spravce platformy vidi vzdy vsechno. Obecne sluzby (`general: true`) taky - * vidi vzdy vsichni, viditelnost se u nich neresi. - */ -export interface ServiceVisibility { - mode: 'everyone' | 'restricted' | 'admin'; - /** Jen u `restricted`: firmy, ktere sluzbu vidi. */ - tenantIds: string[]; - /** Jen u `restricted`: konkretni lide bez ohledu na firmu. */ - userIds: string[]; -} - -/** - * Jeden udaj, ktery je potreba vyplnit pri zakladani konektoru. - * - * `target: 'header'` znamena, ze se hodnota posila jako hlavicka requestu - * pojmenovana v `name`. Tim je popsane, co ktera sluzba vyzaduje, a konektor - * uz jen doplni hodnoty. - * - * `secret: true` se **nikdy nevraci z API** a redaguje se v logu. - */ -export interface ServiceCredentialField { - id: string; - label: string; - target: 'header' | 'config'; - /** Jmeno hlavicky u `header`, jmeno klice v `ctx.config` u `config`. */ - name: string; - required: boolean; - secret: boolean; - hint?: string; - /** - * Text, ktery se pred hodnotu doplni az pri sestaveni hlavicky. - * - * Kvuli `Authorization: Bearer `: uzivatel do formulare vlepi klic - * tak, jak ho dostal od sluzby, a slovo `Bearer` dopise runtime. Kdyby si ho - * mel psat sam, byl by to prvni zdroj chyb, ktery nikdo nevidi - hodnota se - * z API nevraci, takze preklep v ni neni videt ani zpetne. - * - * Redakce v logu se dela na obojí, na hodnotu i na celou hlavicku. - */ - prefix?: string; - /** - * true = hodnotu **spravuje portal**, ne uzivatel. - * - * Ve formulari se nezobrazuje a nikdo ji nevyplnuje. Zapisuje ji az napojeni - * samo, kdyz si za behu neco vyrobi - klic zarizeni u MCP EasyWebu. Bez - * tohohle priznaku by uzivatel videl pole, do ktereho nema co psat, a mohl - * by ho prepsat. - */ - managed?: boolean; -} - -/** - * Parametr, ktery spoustec sam preda do stromu. Tvarem odpovida `TriggerField` - * v automationStore, ale definuje se tady - katalog je zdroj pravdy o tom, - * co ktera sluzba posila. - * - * ID musi byt stabilni, odkazuji se na nej podminky v ulozenych stromech. - * Prejmenovani ID rozbije existujici automatizace. - */ -export interface ProvidedField { - id: string; - name: string; - type: FieldType; - required: boolean; -} - -/** - * Nastavitelne pole akce. Na rozdil od `fields` to neni napoveda, ale skutecna - * konfigurace - hodnota se uklada do kroku stromu (`FlowStep.inputs`). - * - * Hodnota je sablona. `{{nazev}}` se nahradi parametrem spoustece, - * viz `src/data/templates.ts`. - */ -export interface OperationField { - id: string; - label: string; - /** - * Jak se pole vykresli v builderu. - * - * `text` jeden radek - * `longtext` vice radku - * `html` vice radku, obsah je HTML - dosazene hodnoty se escapuji - * `choice` vyber z `options` - * `lookup` ciselnik **a zaroven** volny text: bud se vybere ze seznamu, - * nebo se hodnota dosadi z dat sablonou. Pro pole, kde by jinak - * clovek musel opisovat ID, ale kde ho krok muze dostat i z toho, - * co prislo. - * `json` JSON, kontroluje se uz pri ulozeni stromu - * `mapping` pravidla transformace, klikatelny editor nad JSONem - * `object` odkaz na parametr typu objekt nebo seznam z predchoziho kroku - * - * Hodnota je vzdy retezec, i u `json` a `mapping`. Diky tomu se nemenil - * `FlowStep.inputs` a strukturu si rozparsuje az ten, kdo ji pouziva. - */ - kind: 'text' | 'longtext' | 'html' | 'choice' | 'lookup' | 'json' | 'mapping' | 'object'; - required: boolean; - options?: Array<{ value: string; label: string }>; - /** - * Odkud se doplni nabidka za behu. Katalog vznika pri importu modulu, - * ale resitele a skupiny se nacitaji z uloziste az pozdeji. - */ - optionsFrom?: 'people' | 'groups' | 'scripts' | 'ticketTypes'; - hint?: string; -} - -export interface ServiceOperation { - id: string; - name: string; - description: string; - /** - * Popis toho, co bude potreba nastavit - jen napoveda, nikam se neuklada. - * Cilovy stav je prevest vsechny akce na `inputs`, zatim je to udelane - * u ticketu a kanalu, ktere do nej ustuji. - */ - fields?: string[]; - /** Skutecne nastavitelna pole akce. Kdyz chybi, akci nejde konfigurovat. */ - inputs?: OperationField[]; - /** - * Jen u akci: co krok vrati dalsim krokum. Diky tomu jde po kroku - * "dohledej firmu v CRM" postavit podminku na to, jestli se nasla. - * - * `required: true` znamena, ze sluzba hodnotu vraci vzdy. - * ID musi zustat stabilni, odkazuji se na nej podminky v ulozenych stromech. - */ - outputFields?: ProvidedField[]; - /** - * Jen u triggeru: true = vstupni parametry si definuje uzivatel - * (webhook, formular). false/chybi = data urcuje sluzba. - */ - customPayload?: boolean; - /** - * Jen u triggeru: parametry, ktere sluzba predava sama. Uzivatel je nemeni, - * server je pri ukladani stromu dosadi misto toho, co poslal klient. - * Diky nim jde stavet podminky nad daty, ktera si nikdo nevymyslel. - */ - providedFields?: ProvidedField[]; - /** - * `script` = operaci obsluhuje skript ze slozky skriptu, tedy se opravdu - * vykona. Kdyz chybi, je to zatim jen zapis v katalogu. - * Doplnuje `src/scripts/registry.ts`, rucne se to nepise. - */ - implementation?: 'script'; - /** Ktery skript operaci obsluhuje. Vyplnene spolu s `implementation`. */ - scriptId?: string; -} - -export interface Service { - id: string; - name: string; - category: ServiceCategory; - description: string; - /** Klic ikony - frontend si ho mapuje na komponentu (lib/serviceIcons.ts). */ - icon: string; - status: ServiceStatus; - /** - * true = sluzba funguje bez konektoru. Obecne veci, ktere ma kazdy: - * webhook, planovac, tickety, transformace dat, HTTP pozadavek, pauza, log. - */ - general: boolean; - /** - * ID aplikace v services.csbot.cz/apps. Zaklad adresy se sklada z nej - * a ze `SERVICES_BASE_URL`, verejna domena se nikdy nehardcoduje (AGENTS.md). - * null = sluzba se nevola pres jednu pevnou adresu. - */ - appId: string | null; - /** - * Cim se sluzba vola. - * - * `http` (vychozi) je zbytek katalogu: skript rekne cestu a runtime doplni - * adresu a hlavicky. `smtp` je e-mail - neni to HTTP, takze operaci nevykona - * skript, ale vnitrni krok, a overeni konektoru se misto cteciho volani - * prihlasi na posmovni server. `mcp` je JSON-RPC nad HTTP, kde se pred - * kazdym volanim navazuje sezeni a operace nejsou v katalogu - rekne je - * az server. - * - * Je to priznak sluzby, ne konektoru: jak se sluzba vola, je jeji vlastnost. - */ - transport?: 'http' | 'smtp' | 'mcp'; - /** - * Absolutni adresa sluzby, ktera **nebezi u nas**. Typicky OpenAI. - * - * Je to jina vec nez `appId`: tam se adresa sklada ze `SERVICES_BASE_URL`, - * protoze aplikace je nase a muze se cela prestehovat. Tohle je cizi domena, - * ktera se ridi cizim rozhodnutim, takze patri ke sluzbe. - * - * Prepsat ji jde promennou `_BASE_URL` (napr. `OPENAI_BASE_URL`) - * nebo adresou u konektoru - kvuli branam typu Azure OpenAI a kvuli testu - * proti napodobenine. - */ - baseUrl?: string; - visibility: ServiceVisibility; - /** - * Co je potreba vyplnit pri zakladani konektoru. - * Hodnoty patri konektoru, ne prostredi - kazda firma ma svoje. - * Prazdne pole u obecnych sluzeb a u tech, ktere jeste nemame popsane. - */ - credentials: ServiceCredentialField[]; - /** - * Levne cteci volani, kterym se overi, ze konektor funguje. - * Musi to byt neco, co **nic nemeni** a co vyzaduje autorizaci - jinak by test - * prosel i se spatnymi udaji a rekl uzivateli nepravdu. - * Kdyz chybi, overi se jen dostupnost sluzby na `/health`. - */ - verifyPath?: string; - triggers: ServiceOperation[]; - actions: ServiceOperation[]; -} +export type { + OperationField, + ProvidedField, + Service, + ServiceCategory, + ServiceCategoryEntry, + ServiceCredentialField, + ServiceOperation, + ServiceStatus, + ServiceVisibility, +}; /** * Nabidka resitelu do vyberu u akci. @@ -281,14 +69,7 @@ const priorityOptions = [ { value: 'critical', label: 'Kritická' }, ]; -const statusOptions = [ - { value: 'new', label: 'Nový' }, - { value: 'open', label: 'V řešení' }, - { value: 'waiting', label: 'Čeká na klienta' }, - { value: 'resolved', label: 'Vyřešeno' }, -]; - -export const serviceCategories: Array<{ id: ServiceCategory; label: string }> = [ +export const serviceCategories: ServiceCategoryEntry[] = [ { id: 'obecne', label: 'Obecné' }, { id: 'crm', label: 'CRM' }, { id: 'ekonomika', label: 'Ekonomika a banky' }, @@ -905,21 +686,10 @@ export const services: Service[] = [ }, ], }, - { - id: 'set-status', - name: 'Změnit stav', - description: 'Posune ticket na nový, v řešení, čeká na klienta nebo vyřešeno.', - inputs: [ - { id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true }, - { - id: 'status', - label: 'Nový stav', - kind: 'choice', - required: true, - options: statusOptions, - }, - ], - }, + /* + * Druhy `set-status` s pevnym ciselnikem stavu tady byl a katalog ho + * ukazoval vedle prvniho. Stav je volny retezec, plati jen ten vyse. + */ { id: 'link-customer', name: 'Napojit na zákazníka', @@ -2783,6 +2553,35 @@ export function findService(serviceId: string): Service | undefined { return services.find((service) => service.id === serviceId); } +/** + * Kontrola katalogu pri startu: ID operace musi byt v ramci sluzby jedine. + * + * `findOperation` bere prvni shodu, takze druha operace se stejnym ID by se + * v katalogu ukazala, ale nikdy by se nespustila - a nikdo by nepoznal proc. + * Neshazuje start (AGENTS.md), ale rekne to nahlas. + */ +function checkOperationIds(): void { + for (const service of services) { + for (const [type, pool] of [ + ['spoustec', service.triggers], + ['akce', service.actions], + ] as const) { + const seen = new Set(); + for (const operation of pool) { + if (seen.has(operation.id)) { + console.error( + `[sluzby] sluzba ${service.id} ma dvakrat ${type} "${operation.id}", ` + + 'pouzije se jen prvni definice', + ); + } + seen.add(operation.id); + } + } + } +} + +checkOperationIds(); + // -------------------------------------------------------- kdo co vidi /** diff --git a/src/data/snapshot.ts b/src/data/snapshot.ts index 706e13b..c659a68 100644 --- a/src/data/snapshot.ts +++ b/src/data/snapshot.ts @@ -59,6 +59,12 @@ export function fileSnapshot(file: string): SnapshotStore { let pending: T[] | null = null; let timer: NodeJS.Timeout | null = null; let writing: Promise = Promise.resolve(); + /** + * true = soubor existuje, ale nesel precist ani odlozit stranou. Zapisovat + * se pak nesmi: prvni ulozeni by ho prepsalo prazdnymi daty a puvodni + * obsah by byl pryc. Jede se v pameti a rika se to v logu. + */ + let locked = false; async function writeNow(items: T[]): Promise { const payload: SnapshotFile = { @@ -91,8 +97,15 @@ export function fileSnapshot(file: string): SnapshotStore { let raw: string; try { raw = await fs.readFile(file, 'utf8'); - } catch { + } catch (err) { // Chybejici soubor je bezny stav, ne chyba. Prvni spusteni. + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + // Cokoliv jineho (prava, disk) je chyba a soubor se nesmi prepsat. + locked = true; + console.error( + `[snapshot] ${file} nelze precist (${err instanceof Error ? err.message : err}), ` + + 'zapisy jsou vypnute, aby se soubor neprepsal prazdnymi daty', + ); return []; } @@ -114,12 +127,26 @@ export function fileSnapshot(file: string): SnapshotStore { `[snapshot] ${file} nelze precist (${err instanceof Error ? err.message : err}), ` + `puvodni soubor je v ${broken}`, ); - await fs.rename(file, broken).catch(() => undefined); + try { + await fs.rename(file, broken); + } catch (renameErr) { + // Bez zalohy se zapisovat nesmi, jinak by prvni ulozeni rozbity + // soubor prepsalo a nebylo by z ceho data zachranit. + locked = true; + console.error( + `[snapshot] ${file} se nepodarilo odlozit do ${broken} ` + + `(${renameErr instanceof Error ? renameErr.message : renameErr}), zapisy jsou vypnute`, + ); + } return []; } }, save(items) { + if (locked) { + console.warn(`[snapshot] ${file}: zapis odmitnut, soubor se pri startu nepodarilo precist`); + return; + } // Kopie, aby pozdejsi zmeny v pameti neovlivnily to, co se prave zapisuje. pending = items.map((item) => ({ ...item })); if (timer) return; diff --git a/src/data/store/cached.ts b/src/data/store/cached.ts index f3903e8..746170c 100644 --- a/src/data/store/cached.ts +++ b/src/data/store/cached.ts @@ -18,6 +18,9 @@ import type { EntityStore, TenantEntity } from './types.js'; +/** Podle ceho se seznam radi: nazev pole (ceske razeni) nebo vlastni porovnani. */ +export type SortBy = (keyof T & string) | ((a: T, b: T) => number); + export interface CachedStore { readonly store: EntityStore; /** Nacte kopii. Vola se pri startu a po kazdem zapisu. */ @@ -26,16 +29,32 @@ export interface CachedStore { all(): T[]; find(predicate: (entity: T) => boolean): T | undefined; byId(id: string): T | undefined; + /** + * Zaznamy vybranych firem, serazene. Platformni (`tenantId: null`) tu nejsou. + * + * Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse" - + * stejne pravidlo jako u ticketu. + */ + listByTenant(tenantIds: string[], sortBy?: SortBy): T[]; } +/** + * Vsechny kopie podle druhu entity. Diky tomu je obnova po zapisu jeden + * radek (`refreshCache(kind)`) a ne seznam dvanacti funkci, ktery se pri + * pridani entity zapomene doplnit. + */ +const caches = new Map>(); + export function withCache(store: EntityStore): CachedStore { let rows: T[] = []; + let index = new Map(); - return { + const cache: CachedStore = { store, async refresh() { rows = await store.listAll(); + index = new Map(rows.map((row) => [row.id, row])); }, all() { @@ -47,7 +66,34 @@ export function withCache(store: EntityStore): Cached }, byId(id) { - return rows.find((row) => row.id === id); + return index.get(id); + }, + + listByTenant(tenantIds, sortBy) { + const selected = rows.filter( + (row) => row.tenantId !== null && tenantIds.includes(row.tenantId), + ); + if (!sortBy) return selected; + if (typeof sortBy === 'function') return selected.sort(sortBy); + return selected.sort((a, b) => + String(a[sortBy] ?? '').localeCompare(String(b[sortBy] ?? ''), 'cs'), + ); }, }; + + caches.set(store.kind, cache as CachedStore); + return cache; +} + +/** Obnovi kopii jednoho druhu entity. Neznamy druh je chyba volajiciho, ne ticho. */ +export async function refreshCache(kind: string): Promise { + const cache = caches.get(kind); + if (!cache) return false; + await cache.refresh(); + return true; +} + +/** Obnovi vsechny kopie naraz. Vola se pri startu. */ +export async function refreshAllCaches(): Promise { + await Promise.all([...caches.values()].map((cache) => cache.refresh())); } diff --git a/src/data/store/index.ts b/src/data/store/index.ts index fad3713..437d689 100644 --- a/src/data/store/index.ts +++ b/src/data/store/index.ts @@ -18,28 +18,46 @@ import path from 'node:path'; import { config } from '../../config.js'; import { isDatabaseEnabled } from '../../db/pool.js'; +import { canSealSecrets, initSecrets } from '../../db/secretBox.js'; import { createLocalStore } from './local.js'; import { createPostgresStore } from './postgres.js'; import type { EntityStore, TenantEntity } from './types.js'; export type { Entity, EntityStore, ListOptions, TenantEntity } from './types.js'; -export { isVisible, nowIso } from './types.js'; +export { highestNumber, isVisible, minutesAgo, nowIso, writableOrWarn } from './types.js'; + +export type StoresMode = 'postgres' | 'file' | 'memory'; /** Rezim se urcuje jednou pri startu, aby se stores nechovaly kazdy jinak. */ let useDatabase = false; let ready = false; const registry = new Map>(); -/** Nastavi rezim. Vola se pri startu po migracich, pred `init` jednotlivych stores. */ -export function initStores(options: { databaseReady: boolean }): 'postgres' | 'file' | 'memory' { - useDatabase = isDatabaseEnabled() && options.databaseReady; - ready = true; +/** + * Nastavi rezim. Vola se pri startu po migracich, pred `init` jednotlivych stores. + * + * **Jedine rozhodnuti pro vsechna uloziste**, konektory vcetne. Databaze se + * pouzije jen kdyz je `DATABASE_URL`, migrace prosly a je cim sifrovat + * (`SECRETS_KEY`): konektory nesou pristupove udaje a ty do tabulky citelne + * nesmi. Driv se to rozhodovalo na dvou mistech a mohlo se stat, ze tickety + * sly do databaze a konektory do souboru. + * + * Opakovane volani rezim nemeni - konektory i ostatni entity se ptaji + * kazde zvlast a odpoved musi byt stejna. + */ +export function initStores(options: { databaseReady: boolean }): StoresMode { + if (ready) return storesMode(); - if (useDatabase) return 'postgres'; - return config.dataDir === '' ? 'memory' : 'file'; + const databaseUsable = isDatabaseEnabled() && options.databaseReady; + // Klic vedle dat je pripustny jen mimo databazi. U ni ma prijit ze secretu. + initSecrets({ allowKeyFile: !databaseUsable, dir: config.dataDir }); + + useDatabase = databaseUsable && canSealSecrets(); + ready = true; + return storesMode(); } -export function storesMode(): 'postgres' | 'file' | 'memory' { +export function storesMode(): StoresMode { if (useDatabase) return 'postgres'; return config.dataDir === '' ? 'memory' : 'file'; } @@ -78,7 +96,9 @@ export function defineStore(kind: string): EntityStore resolve().create(entity), put: (entity) => resolve().put(entity), update: (id, patch, options) => resolve().update(id, patch, options), + updateMany: (ids, patch, options) => resolve().updateMany(ids, patch, options), remove: (id, options) => resolve().remove(id, options), + removeMany: (ids) => resolve().removeMany(ids), flush: () => resolve().flush(), }; diff --git a/src/data/store/local.ts b/src/data/store/local.ts index 386fa22..2126596 100644 --- a/src/data/store/local.ts +++ b/src/data/store/local.ts @@ -72,6 +72,11 @@ export function createLocalStore( }, async create(entity) { + // Stejne chovani jako primarni klic v Postgresu: druhy zaznam s tymz ID + // je chyba, ne tichy duplikat, ktery by `get` uz nikdy nenasel. + if (rows.some((item) => item.id === entity.id)) { + throw new Error(`[${options.kind}] zaznam ${entity.id} uz existuje`); + } rows.push(entity); persist(); return copy(entity); @@ -95,6 +100,20 @@ export function createLocalStore( return copy(rows[index]); }, + async updateMany(ids, patch, listOptions) { + const { id: _id, createdAt: _createdAt, ...rest } = patch as Partial; + const wanted = new Set(ids); + const timestamp = nowIso(); + let changed = 0; + rows = rows.map((row) => { + if (!wanted.has(row.id) || !isVisible(row, listOptions)) return row; + changed += 1; + return { ...row, ...(rest as Partial), updatedAt: timestamp }; + }); + if (changed > 0) persist(); + return changed; + }, + async remove(id, listOptions) { const index = rows.findIndex((item) => item.id === id); if (index === -1 || !isVisible(rows[index], listOptions)) return false; @@ -103,6 +122,15 @@ export function createLocalStore( return true; }, + async removeMany(ids) { + const wanted = new Set(ids); + const before = rows.length; + rows = rows.filter((row) => !wanted.has(row.id)); + const removed = before - rows.length; + if (removed > 0) persist(); + return removed; + }, + async flush() { await snapshot.flush(); }, diff --git a/src/data/store/mirror.ts b/src/data/store/mirror.ts index acee856..a586ab3 100644 --- a/src/data/store/mirror.ts +++ b/src/data/store/mirror.ts @@ -42,6 +42,28 @@ export function withMirror(store: EntityStore): Mirro console.error(`[${store.kind}] ${action} se nepodarilo ulozit:`, err); } + /** + * Rozepsane zapisy po zaznamech. + * + * Zapisy **tehoz zaznamu jdou za sebou**, ne soubezne. Dva `put` stejneho + * ticketu vystrelene hned po sobe by v databazi mohly dobehnout v opacnem + * poradi a vyhral by starsi stav. Ruzne zaznamy se navzajem nebrzdi. + */ + const chains = new Map>(); + + function enqueue(id: string, action: string, work: () => Promise): void { + const previous = chains.get(id) ?? Promise.resolve(); + const next = previous + .then(work) + .then(() => undefined) + .catch((err: unknown) => report(action, err)); + chains.set(id, next); + void next.finally(() => { + // Retez se uklidi, jen kdyz mezitim nepribyl dalsi zapis. + if (chains.get(id) === next) chains.delete(id); + }); + } + return { store, @@ -51,7 +73,7 @@ export function withMirror(store: EntityStore): Mirro }, save(entity) { - void store.put(entity).catch((err: unknown) => report(entity.id, err)); + enqueue(entity.id, entity.id, () => store.put(entity)); }, drop(id) { @@ -60,16 +82,14 @@ export function withMirror(store: EntityStore): Mirro * znamena "nic" (viz isVisible), takze bez dohledani by se nesmazalo nic. * Pravo si volajici overil driv, tady uz jde jen o zapis. */ - void store - .getRaw(id) - .then((entity) => { - if (!entity) return false; - return store.remove(id, { - tenantIds: entity.tenantId === null ? [] : [entity.tenantId], - includeGlobal: true, - }); - }) - .catch((err: unknown) => report(`smazani ${id}`, err)); + enqueue(id, `smazani ${id}`, async () => { + const entity = await store.getRaw(id); + if (!entity) return false; + return store.remove(id, { + tenantIds: entity.tenantId === null ? [] : [entity.tenantId], + includeGlobal: true, + }); + }); }, }; } diff --git a/src/data/store/postgres.ts b/src/data/store/postgres.ts index a0a388b..e757f8d 100644 --- a/src/data/store/postgres.ts +++ b/src/data/store/postgres.ts @@ -142,6 +142,23 @@ export function createPostgresStore(kind: string): Entit return row ? toEntity(row) : undefined; }, + async updateMany(ids, patch, options) { + if (ids.length === 0) return 0; + const { id: _id, createdAt: _createdAt, ...rest } = patch as Partial; + const { sql, params } = scope(options); + // Patch se slouci primo v SQL, at je to jeden dotaz a ne cteni a zapis + // za kazdy zaznam zvlast. Cas zmeny se pri cteni bere ze sloupce (toEntity). + const rows = await query<{ id: string }>( + `UPDATE records + SET data = data || $${params.length + 2}::jsonb, + updated_at = now() + WHERE ${sql} AND id = ANY($${params.length + 1}) + RETURNING id`, + [...params, ids, JSON.stringify(rest)], + ); + return rows.length; + }, + async remove(id, options) { const current = await this.get(id, options); if (!current) return false; @@ -149,6 +166,15 @@ export function createPostgresStore(kind: string): Entit return true; }, + async removeMany(ids) { + if (ids.length === 0) return 0; + const rows = await query<{ id: string }>( + 'DELETE FROM records WHERE kind = $1 AND id = ANY($2) RETURNING id', + [kind, ids], + ); + return rows.length; + }, + async flush() { // Databaze nic neodklada. }, diff --git a/src/data/store/types.ts b/src/data/store/types.ts index 9379f60..b1b6865 100644 --- a/src/data/store/types.ts +++ b/src/data/store/types.ts @@ -17,22 +17,10 @@ * neda, viz documentation/14-databaze.md. */ -/** Kazdy zaznam ma ID a vi, kdy vznikl. */ -export interface Entity { - id: string; - createdAt: string; - updatedAt: string; -} +import type { Entity, TenantEntity } from '../../shared/entities.js'; -/** - * Zaznam patrici firme. - * - * `tenantId: null` znamena platformni zaznam, tedy nas, ne zakaznikuv. - * Typicky systemova role nebo sluzba dostupna vsem. - */ -export interface TenantEntity extends Entity { - tenantId: string | null; -} +/** Zakladni tvar zaznamu je sdileny s webem, viz src/shared/entities.ts. */ +export type { Entity, TenantEntity }; export interface ListOptions { /** @@ -64,7 +52,21 @@ export interface EntityStore { */ put(entity: T): Promise; update(id: string, patch: Partial, options: ListOptions): Promise; + /** + * Stejny patch na vic zaznamu naraz. Vraci, kolik se jich zmenilo. + * + * Jeden zapis misto N: oznaceni dvaceti upozorneni jako prectenych by jinak + * znamenalo dvacet dotazu za sebou. + */ + updateMany(ids: string[], patch: Partial, options: ListOptions): Promise; remove(id: string, options: ListOptions): Promise; + /** + * Smaze zaznamy podle ID **bez kontroly firmy**. Vraci, kolik se smazalo. + * + * Jen pro uklid (audit, upozorneni), kde volajici maze to, co sam nacetl + * pres `listAll`. Pro zapis za uzivatele patri `remove` s filtrem. + */ + removeMany(ids: string[]): Promise; /** Dokonci rozepsany zapis. Vola se pri ukonceni procesu. */ flush(): Promise; } @@ -73,6 +75,45 @@ export function nowIso(): string { return new Date().toISOString(); } +/** Cas pred N minutami. Pouziva jen ukazkova data. */ +export function minutesAgo(minutes: number): string { + return new Date(Date.now() - minutes * 60_000).toISOString(); +} + +/** + * Nejvyssi cislo v ID tvaru `PREFIX-123`. + * + * Citace ticketu, automatizaci a incidentu musi po startu pokracovat za tim, + * co uz je v ulozisti, jinak by nove zaznamy prepisovaly stare. + */ +export function highestNumber(ids: Iterable, prefix: string): number { + const pattern = new RegExp(`^${prefix}-(\\d+)$`); + let highest = 0; + for (const id of ids) { + const number = Number(pattern.exec(id)?.[1] ?? 0); + if (number > highest) highest = number; + } + return highest; +} + +/** + * Zaznam k zapisu jen z povolenych firem. Cizi se chova jako neexistujici + * a zaloguje se - pokus o zapis mimo firmu je vec, o ktere chceme vedet. + */ +export function writableOrWarn( + entity: T | undefined, + id: string, + tenantIds: string[], + label: string, +): T | undefined { + if (!entity) return undefined; + if (!tenantIds.includes(entity.tenantId)) { + console.warn(`[${label}] pokus o zapis do ${id} mimo povolene firmy`); + return undefined; + } + return entity; +} + /** Vidi volajici tenhle zaznam? */ export function isVisible(entity: TenantEntity, options: ListOptions): boolean { if (entity.tenantId === null) return options.includeGlobal === true; diff --git a/src/data/tenantFeatures.ts b/src/data/tenantFeatures.ts index 1f63727..e3f2631 100644 --- a/src/data/tenantFeatures.ts +++ b/src/data/tenantFeatures.ts @@ -20,20 +20,10 @@ import { withCache } from './store/cached.js'; import { hasAnyPermission } from './permissions.js'; import type { User } from '../types.js'; -/** Klic zalozky. Frontend si ho mapuje na cestu a komponentu. */ -export type ModuleKey = - | 'overview' - | 'automations' - | 'actions' - | 'widgets' - | 'services' - | 'connectors' - | 'scripts' - | 'tickets' - | 'helpdesk' - | 'people' - | 'incidents' - | 'settings'; +import type { ModuleKey, NavItem } from '../shared/access.js'; + +/** Klic zalozky a polozka navigace jsou sdilene s webem, viz src/shared/access.ts. */ +export type { ModuleKey, NavItem }; export interface ModuleDefinition { key: ModuleKey; @@ -161,11 +151,6 @@ export function limitsOf(tenantId: string | null): TenantLimits { return featuresOf(tenantId).limits; } -export interface NavItem { - key: ModuleKey; - label: string; -} - /** * Zalozky, ktere ma uzivatel videt. * diff --git a/src/data/tenantScripts.ts b/src/data/tenantScripts.ts index 18e9728..94d8fe8 100644 --- a/src/data/tenantScripts.ts +++ b/src/data/tenantScripts.ts @@ -52,15 +52,12 @@ export async function refreshTenantScripts(): Promise { export const MAX_CODE_LENGTH = 20_000; export function listTenantScripts(tenantIds: string[]): TenantScript[] { - return cache - .all() - .filter((script) => tenantIds.includes(script.tenantId)) - .sort((a, b) => a.name.localeCompare(b.name, 'cs')); + return cache.listByTenant(tenantIds, 'name'); } /** Skript firmy. Cizi skript se chova jako neexistujici, ne jako zakazany. */ export function findTenantScript(id: string, tenantIds: string[]): TenantScript | undefined { - const script = cache.all().find((item) => item.id === id); + const script = cache.byId(id); if (!script || !tenantIds.includes(script.tenantId)) return undefined; return script; } @@ -93,14 +90,22 @@ export function newTenantScript(input: { * uz probehl. Je to informace do prehledu, ne soucast vysledku. */ export function noteRun(id: string, tenantId: string, ok: boolean): void { - const script = cache.all().find((item) => item.id === id); + const script = cache.byId(id); if (!script) return; - script.lastRunAt = nowIso(); - script.lastRunOk = ok; - + const lastRunAt = nowIso(); void tenantScriptStore - .update(id, { lastRunAt: script.lastRunAt, lastRunOk: ok }, { tenantIds: [tenantId], includeGlobal: false }) + .update(id, { lastRunAt, lastRunOk: ok }, { tenantIds: [tenantId], includeGlobal: false }) + .then((updated) => { + // Kopie se meni az po zapisu. Driv se prepsala hned a kdyz zapis tise + // neprosel (skript jine firmy), portal ukazoval beh, ktery v ulozisti nebyl. + if (!updated) { + console.warn(`[skripty] skript ${id} nepatri firme ${tenantId}, beh se nepoznamenal`); + return; + } + script.lastRunAt = updated.lastRunAt; + script.lastRunOk = updated.lastRunOk; + }) .catch((err: unknown) => { console.warn(`[skripty] beh skriptu ${id} se nepodarilo poznamenat:`, err); }); diff --git a/src/data/tenants.ts b/src/data/tenants.ts index 8ce7461..c0c7956 100644 --- a/src/data/tenants.ts +++ b/src/data/tenants.ts @@ -14,34 +14,13 @@ */ import { randomBytes } from 'node:crypto'; -import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { timingSafeEqualString } from '../lib/secure.js'; +import { defineStore, nowIso } from './store/index.js'; import { withCache } from './store/cached.js'; +import type { Tenant } from '../shared/tenants.js'; -export interface Tenant extends TenantEntity { - name: string; - /** Kratka poznamka pro nas, klient ji nevidi. */ - note: string; - enabled: boolean; - /** - * Token pro prijem udalosti na `/webhook/ticket/:token`. - * - * Je na firme, ne na automatizaci: prijem ticketu ma fungovat i bez toho, - * aby se kvuli nemu stavel strom. Token urcuje, ktere firme udalost patri, - * a tim i to, v jakem rozsahu je externi ID unikatni. - */ - intakeToken: string; - /** - * Firma, ktera teto firme resi pozadavky z helpdesku. - * - * `null` = helpdesk nema komu poslat, takze se pozadavek nezalozi a rekne se - * to nahlas. Nastavuje to spravce platformy, ne firma sama: kdo koho obsluhuje - * je obchodni vztah, ne volba klienta. - * - * Pozadavek pak vznikne jako ticket **teto dodavatelske firmy** a zadavatel - * zustane v `helpdeskSourceId`. - */ - helpdeskProviderId: string | null; -} +/** Tvar firmy je sdileny s webem, viz src/shared/tenants.ts. */ +export type { Tenant }; /** * Firma je platformni zaznam: `tenantId` je vzdy null, protoze firmu nevlastni @@ -95,7 +74,7 @@ export function seedTenants(): Tenant[] { */ export function findByIntakeToken(token: string): Tenant | undefined { if (token.length < 16) return undefined; - return cache.all().find((tenant) => tenant.intakeToken === token && tenant.enabled); + return cache.all().find((tenant) => tenant.enabled && timingSafeEqualString(tenant.intakeToken, token)); } export async function refreshTenants(): Promise { diff --git a/src/data/ticketActions.ts b/src/data/ticketActions.ts index 8220633..b95ff17 100644 --- a/src/data/ticketActions.ts +++ b/src/data/ticketActions.ts @@ -120,10 +120,10 @@ export async function refreshActions(): Promise { } export function listActions(tenantIds: string[]): TicketAction[] { - return cache - .all() - .filter((action) => tenantIds.includes(action.tenantId)) - .sort((a, b) => a.order - b.order || a.label.localeCompare(b.label, 'cs')); + return cache.listByTenant( + tenantIds, + (a, b) => a.order - b.order || a.label.localeCompare(b.label, 'cs'), + ); } export function findAction(id: string): TicketAction | undefined { diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 53dc2b0..b64d9c9 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -23,213 +23,49 @@ import type { Visibility } from './access.js'; import { onTicketChanged } from './ticketHooks.js'; import { notify } from './notifications.js'; import { findPerson, type Person } from './people.js'; -import { defineStore } from './store/index.js'; +import { defineStore, highestNumber, minutesAgo, writableOrWarn } from './store/index.js'; import { withMirror } from './store/mirror.js'; +import type { + AgentStatsRow, + Ticket, + TicketAssignee, + TicketChannel, + TicketCustomer, + TicketDetail, + TicketEvent, + TicketPriority, + TicketStatus, + TicketTraceEntry, + TraceKind, + TraceStatus, + Workload, + WorkloadRow, +} from '../shared/tickets.js'; + /** - * Stav ticketu. **Volny retezec**, ne ciselnik. - * - * Tickety chodi z cizich aplikaci, ktere maji svoje stavy: voicebot posila - * `ringing` a `completed`, e-shop `pripraveno k expedici`. Nutit je do nasi - * ctverice znamenalo, ze uzivatel videl "Novy" u ticketu, ktery byl podle - * jeho systemu davno hotovy. - * - * Vychozi ctverice zustava jako **doporuceni**, ne omezeni - kdo si nic - * nenastavi, dostane rozumne stavy a nemusi vymyslet vlastni. + * Tvar ticketu je sdileny s webem, viz src/shared/tickets.ts. Tady zustava + * uloziste, ukazkova data a to, co se z ticketu odvozuje. */ -export type TicketStatus = string; +export type { + AgentStatsRow, + Ticket, + TicketAssignee, + TicketChannel, + TicketCustomer, + TicketDetail, + TicketEvent, + TicketPriority, + TicketStatus, + TicketTraceEntry, + TraceKind, + TraceStatus, + Workload, + WorkloadRow, +}; /** Vychozi stavy, kdyz si typ ticketu nenadefinuje vlastni. Jen nabidka. */ export const defaultStatuses = ['Nový', 'V řešení', 'Čeká na klienta', 'Vyřešeno'] as const; -export type TicketPriority = 'low' | 'normal' | 'high' | 'critical'; - -/** Odkud pozadavek prisel. Podle toho se pozna, co ma smysl hledat v logu. */ -export type TicketChannel = - | 'whatsapp' - | 'facebook' - | 'instagram' - | 'email' - | 'voice' - | 'form' - | 'portal'; - -export interface TicketCustomer { - /** - * ID firmy v CRM. null = zakaznika se nepodarilo dohledat. - * Prave na tohle se pta podminka "mame zakaznika?" ve stromu automatizace. - */ - id: string | null; - company: string; - /** Konkretni clovek, ktery pozadavek poslal. */ - contact: string; - /** Adresa nebo cislo, odkud to prislo a kam se odpovida. */ - reply: string; -} - -/** Odkaz na resitele v odpovedi API. Uvnitr se drzi jen jeho ID. */ -export interface TicketAssignee { - id: string; - name: string; -} - -export type TraceStatus = 'ok' | 'error' | 'skipped' | 'info'; -export type TraceKind = 'trigger' | 'action' | 'condition' | 'note'; - -/** - * Jeden radek v logu ticketu. Strom se sklada pres `parentId` - - * vetev podminky visi na zaznamu te podminky. - */ -export interface TicketTraceEntry { - id: string; - /** null = zaznam v hlavni sekvenci. */ - parentId: string | null; - kind: TraceKind; - /** Ktera sluzba to byla. null u poznamek a podminek. */ - serviceId: string | null; - operationId: string | null; - label: string; - status: TraceStatus; - /** Co sluzba vratila. Tohle je duvod, proc log existuje. */ - response: string | null; - durationMs: number | null; - at: string; -} - -/** - * Jedna prichozi udalost k ticketu. - * - * Rozdil proti radku logu: log je nase stopa toho, co se delo uvnitr, udalost - * je **fakt zvenku**. Odesilatel posle "objednavka 3 vznikla" a pozdeji - * "objednavka 3 vyfakturovana" - obojí patri k jednomu ticketu a obojí musi - * zustat v puvodni podobe, vcetne cele prijate zpravy. - */ -export interface TicketEvent { - id: string; - /** Typ od odesilatele, napr. `order.created`, `email.sent`. */ - type: string; - /** Kdo to poslal: `webhook`, `portal`, ID automatizace. */ - source: string; - /** Popisek do casove osy. */ - label: string; - /** Cela prijata data. Nic se nezahazuje, at jde dohledat, co presne prislo. */ - payload: Record; - at: string; - /** - * Kolikrat prislo totež. Vetsinou 1. - * - * Odesilatele umi poslat stejnou zpravu i osmdesatkrat za minutu. Zalozit - * osmdesat radku znamena, ze v historii nikdo nic nenajde - a pritom se - * nestalo nic noveho. Proto se shodna udalost **pocita** u te predchozi. - * Zahodit ji nejde: bez pocitadla by nikdo nezjistil, ze odesilatel tluce. - */ - repeats: number; - /** Kdy prisla naposledy. Same jako `at`, dokud se neopakuje. */ - lastAt: string; -} - -export interface Ticket { - id: string; - /** Firma, ktere ticket patri. Hranice viditelnosti, viz data/access.ts. */ - tenantId: string; - /** - * Firma, ze ktere pozadavek prisel pres helpdesk. `null` u beznych ticketu. - * - * Vlastnikem ticketu (`tenantId`) je ta firma, ktera pozadavek **resi** - - * u helpdesku se dosadi sama podle toho, koho ma zadavatel nastaveneho jako - * dodavatele. Zadavatel neni vlastnik: kdyby byl, videl by ho resitel jen - * jako cizi ticket a nemel by ho ve sve fronte. - * - * Podle tohohle pole vidi zadavatel svoje pozadavky, a **jen je**. Do bezneho - * seznamu ticketu se nepromita, ten zustava vlastnikovi. - */ - helpdeskSourceId: string | null; - /** - * ID u odesilatele, napr. cislo objednavky. - * - * **Unikatni v ramci firmy**, ne globalne: dve firmy mohou mit objednavku - * cislo 3 a nesmi si o sebe zavadit. Dalsi zprava se stejnym externim ID se - * **navesi na existujici ticket** misto zalozeni druheho, viz `intakeEvent`. - */ - externalId: string | null; - /** Odkud externi ID pochazi, napr. `eshop`. Jen informace, do klice nevstupuje. */ - externalSource: string | null; - subject: string; - /** - * Cely text pozadavku - telo e-mailu, zprava z WhatsApp, prepis hovoru. - * Prazdny retezec znamena, ze krok "Zalozit ticket" obsah nenaplnil. - */ - body: string; - /** Odkaz na zdrojovou zpravu u poskytovatele. null u rucne zalozenych. */ - sourceRef: string | null; - channel: TicketChannel; - customer: TicketCustomer; - status: TicketStatus; - priority: TicketPriority; - assignee: TicketAssignee | null; - /** - * Skupina, ktera ma ticket u sebe. Prehazovat praci na jmeno nestaci - - * clovek chce rict "tohle je pro ucetni" bez toho, aby resil, kdo z nich - * ma dovolenou. Viz data/people.ts, sekce skupiny. - */ - assigneeGroupId: string | null; - /** - * Typ ticketu. Za nim stoji vlastni pole, proto se na nej vazou akce. - * null = ticket bez typu, jako driv. - */ - typeId: string | null; - /** Hodnoty vlastnich poli typu. Klic je `TicketTypeField.key`. */ - fields: Record; - /** - * Je vyrizeny? - * - * **Nastavuje se vyslovne**, neodvozuje se ze stavu. Stav je volny retezec - * a hadat z nej, jestli `completed` nebo `hotovo` znamena konec, by znamenalo - * uhodnout spatne pokazde, kdyz si nekdo pojmenuje stavy po svem. - * - * Fronta, vytizeni i statistiky se ptaji na tenhle priznak. - */ - closed: boolean; - /** - * Volne oznaceni. Na rozdil od typu jich muze byt vic a nestoji za nimi - * zadna pole - proto se hodi na filtry a widgety, ne na akce, ktere - * potrebuji data. - */ - tags: string[]; - /** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */ - automationId: string | null; - /** - * Ucet, ktery ticket zalozil rucne. null u ticketu z automatizace. - * - * Je to **ucet, ne resitel**: v helpdesku podle nej zadavatel pozna svoje - * pozadavky, a ten je nikdy nema prirazene - resi je nekdo u dodavatele. - */ - createdById: string | null; - /** - * Kdy se ticketu prvne nekdo venoval: prirazeni, komentar, zmena stavu. - * Bez toho nejde rict, jak dlouho zakaznik cekal na reakci. - */ - firstResponseAt: string | null; - /** Kdy byl vyresen. Znovuotevreni to vynuluje. */ - resolvedAt: string | null; - /** Kdo ho vyresil. Bez toho nejde rict, kdo kolik odbavil. */ - resolvedById: string | null; - /** - * Kolikrat se vratil z vyreseno zpatky do prace. - * - * Nejlepsi jednoduchy ukazatel toho, ze prace nebyla hotova. Pocet vyresenych - * sam o sobe odmenuje toho, kdo tickety zaviral predcasne. - */ - reopenCount: number; - createdAt: string; - updatedAt: string; -} - -export interface TicketDetail extends Ticket { - trace: TicketTraceEntry[]; - /** Prichozi udalosti, nejstarsi nahore. */ - events: TicketEvent[]; -} - /** * Tvar v ulozisti. * @@ -282,16 +118,30 @@ export const channelLabels: Record = { portal: 'Portál', }; -function minutesAgo(minutes: number): string { - return new Date(Date.now() - minutes * 60_000).toISOString(); -} - // ------------------------------------------------------------------- uloziste const tickets: StoredTicket[] = []; +/** + * Indexy nad polem. Ticket se hleda podle ID pri kazdem zapisu do logu + * a podle externiho ID pri kazde prichozi udalosti - linearni hledani + * v tisicich ticketu by bylo znat. Pole zustava kvuli poradi v seznamu. + */ +const ticketsById = new Map(); +const ticketsByExternal = new Map(); /** Log ticketu drzime zvlast - je to jina zivotnost i jiny objem dat. */ const traces = new Map(); +/** Klic externiho ID: unikatni je v ramci firmy, ne globalne. */ +function externalKey(tenantId: string, externalId: string): string { + return `${tenantId}:${externalId}`; +} + +/** Zaradi ticket do indexu. Vola se vsude, kde ticket pribyva do pole. */ +function index(ticket: StoredTicket): void { + ticketsById.set(ticket.id, ticket); + if (ticket.externalId) ticketsByExternal.set(externalKey(ticket.tenantId, ticket.externalId), ticket); +} + /** * Tvar v ulozisti. Log je soucasti zaznamu zamerne: v pameti se drzi zvlast * kvuli objemu, ale ukladat ho jako druhou entitu by znamenalo dva zapisy @@ -306,13 +156,33 @@ const events = new Map(); const mirror = withMirror(defineStore('ticket')); +/** + * Tickety, ktere cekaji na zapis. Zapisuje se **jednou za tik** smycky: + * jedna operace (zmena stavu, radek do logu, udalost na sbernici) volala + * `persist` dvakrat az trikrat a pokazde sla do uloziste cela kopie ticketu + * vcetne logu a az dvou set udalosti. Ted se zmeny za tik slouci a zapise se + * stav, ktery plati na jeho konci. Poradi zapisu tehoz ticketu hlida `withMirror`. + */ +const pendingPersist = new Set(); + /** Ulozi ticket vcetne logu. Necekana se, chyba se loguje. */ function persist(ticket: StoredTicket): void { - mirror.save({ - ...ticket, - trace: traces.get(ticket.id) ?? [], - events: events.get(ticket.id) ?? [], - }); + if (pendingPersist.size === 0) queueMicrotask(flushPersist); + pendingPersist.add(ticket.id); +} + +function flushPersist(): void { + const ids = [...pendingPersist]; + pendingPersist.clear(); + for (const id of ids) { + const ticket = ticketsById.get(id); + if (!ticket) continue; + mirror.save({ + ...ticket, + trace: traces.get(id) ?? [], + events: events.get(id) ?? [], + }); + } } /** @@ -341,11 +211,14 @@ export async function initTickets(): Promise { ); tickets.length = 0; + ticketsById.clear(); + ticketsByExternal.clear(); traces.clear(); events.clear(); for (const row of rows) { const { trace, events: rowEvents, ...stored } = row; tickets.push(stored); + index(stored); traces.set(row.id, trace ?? []); /* * Pocitadlo opakovani pribylo pozdeji. Ulozene udalosti ho nemaji, takze @@ -363,10 +236,7 @@ export async function initTickets(): Promise { // Citac musi pokracovat za nejvyssim ulozenym cislem, jinak by nove tickety // prepisovaly stare. - for (const ticket of tickets) { - const number = Number(/^TK-(\d+)$/.exec(ticket.id)?.[1] ?? 0); - if (number > ticketCounter) ticketCounter = number; - } + ticketCounter = Math.max(ticketCounter, highestNumber(ticketsById.keys(), 'TK')); // Log muze byt dlouhy, do citace radku se to nepocita. traceCounter = [...traces.values()].reduce((sum, list) => sum + list.length, traceCounter); @@ -440,7 +310,7 @@ export function appendTrace(ticketId: string, inputs: TraceInput[]): number { flattenTrace(inputs, currentRun()?.traceParent.id ?? null, existing); // Log je soucast ulozeneho ticketu, takze zapis do logu je zmena ticketu. - const ticket = tickets.find((item) => item.id === ticketId); + const ticket = ticketsById.get(ticketId); if (ticket) persist(ticket); return existing.length - before; @@ -450,15 +320,17 @@ export function appendTrace(ticketId: string, inputs: TraceInput[]): number { function seed(ticket: StoredTicket, trace: TraceInput[]) { tickets.push(ticket); + index(ticket); traces.set(ticket.id, flattenTrace(trace, null, [])); } /** * Ukazkove tickety. * - * Nasypou se **jen se `SEED_DEMO=1`**. Na instanci, kde uz chodi skutecny - * provoz, jsou to cizi zaznamy mezi opravdovymi a po kazdem redeployi - * se vraceji. + * Nasypou se **jen se `SEED_DEMO=1`**, vsechny vcetne tech u klientskych firem. + * Na instanci, kde uz chodi skutecny provoz, jsou to cizi zaznamy mezi + * opravdovymi a po kazdem redeployi se vraceji. Stavy jsou z `defaultStatuses`, + * tedy ty, se kterymi vznikaji i skutecne tickety. */ function seedDemoTickets(): void { seed( @@ -477,7 +349,7 @@ function seedDemoTickets(): void { contact: 'Petra Klientová', reply: '+420 601 118 224', }, - status: 'open', + status: 'V řešení', priority: 'high', assigneeId: 'ppl_novakova', automationId: 'AUT-02', @@ -591,7 +463,7 @@ function seedDemoTickets(): void { contact: 'Tomáš Beran', reply: 'tomas.beran@nordis.cz', }, - status: 'waiting', + status: 'Čeká na klienta', priority: 'normal', assigneeId: 'ppl_kriz', automationId: 'AUT-03', @@ -672,7 +544,7 @@ function seedDemoTickets(): void { contact: 'Bistro Kolektiv', reply: '+420 774 902 331', }, - status: 'new', + status: 'Nový', priority: 'normal', assigneeId: null, automationId: 'AUT-01', @@ -751,122 +623,123 @@ function seedDemoTickets(): void { }, ], ); + + seed( + { + id: 'TK-4817', + tenantId: 'tnt_logitrans', + subject: 'Rozšíření hlasového scénáře o objednávku svozu', + body: + 'Chtěli bychom, aby si zákazník mohl objednat svoz rovnou po telefonu, ' + + 'bez přepojení na dispečink. Rozpočet do 40 000. Prosím o odhad.', + sourceRef: null, + channel: 'form', + customer: { + id: 'crm_3390', + company: 'LogiTrans', + contact: 'Jana Sedláčková', + reply: 'jana.sedlackova@logitrans.cz', + }, + status: 'V řešení', + priority: 'low', + assigneeId: 'ppl_kadlec', + automationId: null, + createdAt: minutesAgo(2_600), + updatedAt: minutesAgo(420), + }, + [ + { + kind: 'trigger', + serviceId: 'form', + operationId: 'submitted', + label: 'Odeslán formulář Požadavek na úpravu', + status: 'ok', + response: '{ "company": "LogiTrans", "topic": "voicebot", "budget": "do 40 000" }', + durationMs: 45, + agoMinutes: 2_600, + }, + { + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + label: 'Založení ticketu', + status: 'ok', + response: '{ "ticketId": "TK-4817", "priority": "low" }', + durationMs: 71, + agoMinutes: 2_600, + }, + { + kind: 'note', + label: 'Ticket ručně přiřazen na Ondřeje Kadlece.', + status: 'info', + agoMinutes: 2_580, + }, + { + kind: 'note', + label: 'Ondřej Kadlec: Odhad odeslán, čeká se na objednávku.', + status: 'info', + agoMinutes: 420, + }, + ], + ); + + seed( + { + id: 'TK-4812', + tenantId: 'tnt_nordis', + subject: 'Duplicitní zápis kontaktů z webového formuláře', + body: + 'Každé odeslání formuláře zakládá nový kontakt, i když stejný e-mail už v CRM je. ' + + 'Máme tam desítky duplicit.', + sourceRef: null, + channel: 'portal', + customer: { + id: 'crm_1042', + company: 'Firma s.r.o.', + contact: 'Petra Klientová', + reply: 'petra.klientova@firma.cz', + }, + status: 'Vyřešeno', + closed: true, + priority: 'critical', + assigneeId: 'ppl_bartos', + automationId: null, + createdAt: minutesAgo(5_100), + updatedAt: minutesAgo(1_500), + }, + [ + { + kind: 'trigger', + serviceId: 'form', + operationId: 'submitted', + label: 'Nahlášeno z portálu', + status: 'ok', + response: '{ "reportedBy": "petra.klientova@firma.cz" }', + durationMs: 38, + agoMinutes: 5_100, + }, + { + kind: 'action', + serviceId: 'transform', + operationId: 'deduplicate', + label: 'Kontrola duplicit v CRM', + status: 'ok', + response: '{ "scanned": 1284, "duplicates": 37, "key": "email" }', + durationMs: 6_700, + agoMinutes: 4_900, + }, + { + kind: 'note', + label: 'Lukáš Bartoš: Do formuláře doplněna kontrola podle e-mailu, duplicity sloučeny.', + status: 'info', + agoMinutes: 1_500, + }, + ], + ); } if (config.seedDemo) seedDemoTickets(); -seed( - { - id: 'TK-4817', - tenantId: 'tnt_logitrans', - subject: 'Rozšíření hlasového scénáře o objednávku svozu', - body: - 'Chtěli bychom, aby si zákazník mohl objednat svoz rovnou po telefonu, ' + - 'bez přepojení na dispečink. Rozpočet do 40 000. Prosím o odhad.', - sourceRef: null, - channel: 'form', - customer: { - id: 'crm_3390', - company: 'LogiTrans', - contact: 'Jana Sedláčková', - reply: 'jana.sedlackova@logitrans.cz', - }, - status: 'open', - priority: 'low', - assigneeId: 'ppl_kadlec', - automationId: null, - createdAt: minutesAgo(2_600), - updatedAt: minutesAgo(420), - }, - [ - { - kind: 'trigger', - serviceId: 'form', - operationId: 'submitted', - label: 'Odeslán formulář Požadavek na úpravu', - status: 'ok', - response: '{ "company": "LogiTrans", "topic": "voicebot", "budget": "do 40 000" }', - durationMs: 45, - agoMinutes: 2_600, - }, - { - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - label: 'Založení ticketu', - status: 'ok', - response: '{ "ticketId": "TK-4817", "priority": "low" }', - durationMs: 71, - agoMinutes: 2_600, - }, - { - kind: 'note', - label: 'Ticket ručně přiřazen na Ondřeje Kadlece.', - status: 'info', - agoMinutes: 2_580, - }, - { - kind: 'note', - label: 'Ondřej Kadlec: Odhad odeslán, čeká se na objednávku.', - status: 'info', - agoMinutes: 420, - }, - ], -); - -seed( - { - id: 'TK-4812', - tenantId: 'tnt_nordis', - subject: 'Duplicitní zápis kontaktů z webového formuláře', - body: - 'Každé odeslání formuláře zakládá nový kontakt, i když stejný e-mail už v CRM je. ' + - 'Máme tam desítky duplicit.', - sourceRef: null, - channel: 'portal', - customer: { - id: 'crm_1042', - company: 'Firma s.r.o.', - contact: 'Petra Klientová', - reply: 'petra.klientova@firma.cz', - }, - status: 'resolved', - priority: 'critical', - assigneeId: 'ppl_bartos', - automationId: null, - createdAt: minutesAgo(5_100), - updatedAt: minutesAgo(1_500), - }, - [ - { - kind: 'trigger', - serviceId: 'form', - operationId: 'submitted', - label: 'Nahlášeno z portálu', - status: 'ok', - response: '{ "reportedBy": "petra.klientova@firma.cz" }', - durationMs: 38, - agoMinutes: 5_100, - }, - { - kind: 'action', - serviceId: 'transform', - operationId: 'deduplicate', - label: 'Kontrola duplicit v CRM', - status: 'ok', - response: '{ "scanned": 1284, "duplicates": 37, "key": "email" }', - durationMs: 6_700, - agoMinutes: 4_900, - }, - { - kind: 'note', - label: 'Lukáš Bartoš: Do formuláře doplněna kontrola podle e-mailu, duplicity sloučeny.', - status: 'info', - agoMinutes: 1_500, - }, - ], -); - // ------------------------------------------------------------------ prevody function toTicket(stored: StoredTicket): Ticket { @@ -1024,7 +897,7 @@ export function getTicket( helpdeskSourceIds: string[] = [], visibility: Visibility = { kind: 'all' }, ): TicketDetail | undefined { - const stored = tickets.find((t) => t.id === id); + const stored = ticketsById.get(id); if (!stored) return undefined; const owns = tenantIds.includes(stored.tenantId); @@ -1064,7 +937,7 @@ export function getTicket( * za firmu ticketu, ne za prave prepnutou. */ export function ticketWithinVisibility(id: string, visibility: Visibility): boolean { - const stored = tickets.find((t) => t.id === id); + const stored = ticketsById.get(id); return stored ? withinVisibility(stored, visibility) : false; } @@ -1076,29 +949,6 @@ export function firstOpenTicket(tenantIds: string[]): Ticket | undefined { return stored ? toTicket(stored) : undefined; } -/** - * Kdo co ma u sebe. Prehled pres cely tym, ne jen pres prihlaseneho cloveka - - * bez nej nejde poznat, ze jeden clovek utahuje polovinu servicedesku. - */ -export interface WorkloadRow { - person: Person; - /** Nevyresene tickety. Tohle je to cislo, na ktere se lidi divaji. */ - open: number; - /** Vcetne vyresenych, aby bylo videt, kolik uz ma za sebou. */ - total: number; - critical: number; - /** Nejstarsi nevyreseny ticket. null = nic nevisi. */ - oldestOpenAt: string | null; - /** true = ma vic nevyrizenych, nez je jeho kapacita. */ - overloaded: boolean; -} - -export interface Workload { - rows: WorkloadRow[]; - unassigned: number; - openTotal: number; -} - export function getWorkload( everyone: Person[], tenantIds: string[], @@ -1108,20 +958,19 @@ export function getWorkload( // a vytizeni tymu tak obchazelo kazde omezeni viditelnosti. const visible = listTickets({ tenantIds, visibility }); + // Jeden pruchod pres tickety, ne jeden filtr za kazdeho cloveka. + const byAssignee = groupByAssignee(visible); + const rows = everyone.map((person) => { - const mine = visible.filter((t) => t.assignee?.id === person.id); + const mine = byAssignee.get(person.id) ?? []; const open = mine.filter((t) => !t.closed); - const oldest = open.reduce( - (acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc), - null, - ); return { person, open: open.length, total: mine.length, critical: open.filter((t) => t.priority === 'critical').length, - oldestOpenAt: oldest, + oldestOpenAt: oldestCreatedAt(open), overloaded: open.length > person.capacity, }; }); @@ -1136,6 +985,26 @@ export function getWorkload( }; } +/** Tickety podle resitele. Nezarazene tu nejsou, ty maji vlastni pocitadlo. */ +function groupByAssignee(list: Ticket[]): Map { + const groups = new Map(); + for (const ticket of list) { + if (!ticket.assignee) continue; + const mine = groups.get(ticket.assignee.id); + if (mine) mine.push(ticket); + else groups.set(ticket.assignee.id, [ticket]); + } + return groups; +} + +/** Nejstarsi cas vzniku. null = prazdny seznam. */ +function oldestCreatedAt(list: Ticket[]): string | null { + return list.reduce( + (acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc), + null, + ); +} + /** * Prvni reakce se zapisuje jednou a uz se neprepisuje. * @@ -1148,16 +1017,14 @@ function markResponded(ticket: StoredTicket): void { /** Ticket podle naseho ID, jen z povolenych firem. */ export function findTicket(id: string, tenantIds: string[]): Ticket | undefined { - const stored = tickets.find((ticket) => ticket.id === id); + const stored = ticketsById.get(id); if (!stored || !tenantIds.includes(stored.tenantId)) return undefined; return toTicket(stored); } /** Ticket firmy podle externiho ID. Klic je dvojice firma a ID, ne ID samotne. */ export function findByExternalId(tenantId: string, externalId: string): Ticket | undefined { - const stored = tickets.find( - (ticket) => ticket.tenantId === tenantId && ticket.externalId === externalId, - ); + const stored = ticketsByExternal.get(externalKey(tenantId, externalId)); return stored ? toTicket(stored) : undefined; } @@ -1260,9 +1127,7 @@ export function intakeEvent(input: IntakeInput): IntakeResult { }; const existing = input.externalId - ? tickets.find( - (ticket) => ticket.tenantId === input.tenantId && ticket.externalId === input.externalId, - ) + ? ticketsByExternal.get(externalKey(input.tenantId, input.externalId)) : undefined; if (existing) { @@ -1309,7 +1174,8 @@ export function intakeEvent(input: IntakeInput): IntakeResult { publish('ticket.updated', `Ticket ${existing.id}: ${event.label}`, { ticketId: existing.id, externalId: existing.externalId, - }); + ticket: toTicket(existing), + }, existing.tenantId); return { ticket: toTicket(existing), created: false, event, repeated: false }; } @@ -1357,7 +1223,7 @@ export function intakeEvent(input: IntakeInput): IntakeResult { ], }); - const stored = tickets.find((ticket) => ticket.id === created.id); + const stored = ticketsById.get(created.id); if (stored) { appendEvent(stored, event); persist(stored); @@ -1501,19 +1367,6 @@ function describePayload(payload: Record): string | null { // ---------------------------------------------------------------- statistiky -export interface AgentStatsRow { - personId: string; - name: string; - resolved: number; - open: number; - critical: number; - medianResolveMs: number | null; - medianResponseMs: number | null; - reopened: number; - oldestOpenAt: string | null; - overloaded: boolean; -} - /** Median, ne prumer: jeden ticket zapomenuty pres dovolenou jinak prebije vsechno. */ function median(values: number[]): number | null { if (values.length === 0) return null; @@ -1539,39 +1392,43 @@ export function getAgentStats( ): AgentStatsRow[] { const visible = listTickets({ tenantIds, visibility }); + /** + * Cas z nepovinneho pole. `undefined` znamena starsi ticket zalozeny driv, + * nez se to zacalo evidovat - takovy se do statistiky nepocita. + */ + function at(value: string | null | undefined): number | null { + if (!value) return null; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : null; + } + + // Jeden pruchod: tickety podle resitele a vyresene podle toho, kdo je vyresil. + const byAssignee = groupByAssignee(visible); + const byResolver = new Map(); + for (const ticket of visible) { + if (!ticket.resolvedById) continue; + const time = at(ticket.resolvedAt); + if (time === null || (since !== null && time < since)) continue; + const mine = byResolver.get(ticket.resolvedById); + if (mine) mine.push(ticket); + else byResolver.set(ticket.resolvedById, [ticket]); + } + const rows = everyone.map((person) => { - const open = visible.filter((t) => t.assignee?.id === person.id && !t.closed); - - /** - * Cas z nepovinneho pole. `undefined` znamena starsi ticket zalozeny driv, - * nez se to zacalo evidovat - takovy se do statistiky nepocita. - */ - function at(value: string | null | undefined): number | null { - if (!value) return null; - const time = new Date(value).getTime(); - return Number.isFinite(time) ? time : null; - } - - const resolved = visible.filter((t) => { - if (t.resolvedById !== person.id) return false; - const time = at(t.resolvedAt); - if (time === null) return false; - return since === null || time >= since; - }); + const mine = byAssignee.get(person.id) ?? []; + const open = mine.filter((t) => !t.closed); + const resolved = byResolver.get(person.id) ?? []; const resolveTimes = resolved .map((t) => (at(t.resolvedAt) ?? 0) - (at(t.createdAt) ?? 0)) .filter((value) => value >= 0); - const responseTimes = visible - .filter((t) => t.assignee?.id === person.id && at(t.firstResponseAt) !== null) + const responseTimes = mine + .filter((t) => at(t.firstResponseAt) !== null) .map((t) => (at(t.firstResponseAt) ?? 0) - (at(t.createdAt) ?? 0)) .filter((value) => value >= 0); - const oldest = open.reduce( - (acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc), - null, - ); + const oldest = oldestCreatedAt(open); return { personId: person.id, @@ -1671,6 +1528,7 @@ export function createTicket(input: CreateTicketInput): Ticket { updatedAt: now, }; tickets.unshift(stored); + index(stored); traces.set(stored.id, flattenTrace(input.trace ?? [], null, [])); events.set(stored.id, []); persist(stored); @@ -1682,19 +1540,13 @@ export function createTicket(input: CreateTicketInput): Ticket { channel: stored.channel, priority: stored.priority, knownCustomer: stored.customer.id !== null, - }); + }, stored.tenantId); return toTicket(stored); } /** Ticket z povolenych firem. Cizi se chova jako neexistujici. */ function findWritable(id: string, tenantIds: string[]): StoredTicket | undefined { - const ticket = tickets.find((t) => t.id === id); - if (!ticket) return undefined; - if (!tenantIds.includes(ticket.tenantId)) { - console.warn(`[tickets] pokus o zapis do ticketu ${id} mimo povolene firmy`); - return undefined; - } - return ticket; + return writableOrWarn(ticketsById.get(id), id, tenantIds, 'tickets'); } /** @@ -1755,12 +1607,14 @@ export function updateTicketStatus( if (ticket.closed) { publish('ticket.resolved', `Ticket ${ticket.id} vyřešen: ${ticket.subject}`, { ticketId: ticket.id, - }); + ticket: toTicket(ticket), + }, ticket.tenantId); } else { publish('ticket.updated', `Ticket ${ticket.id} má nový stav`, { ticketId: ticket.id, status, - }); + ticket: toTicket(ticket), + }, ticket.tenantId); } return toTicket(ticket); } @@ -1820,7 +1674,8 @@ export function assignTicket( person ? `Ticket ${ticket.id} přiřazen: ${person.name}` : `Ticket ${ticket.id} vrácen do fronty`, - { ticketId: ticket.id, assigneeId: ticket.assigneeId }, + { ticketId: ticket.id, assigneeId: ticket.assigneeId, ticket: toTicket(ticket) }, + ticket.tenantId, ); return toTicket(ticket); } @@ -1848,7 +1703,7 @@ export function setTicketType( appendTrace(id, [ { kind: 'note', status: 'info', label: `Typ ticketu nastaven na ${typeId ?? 'bez typu'}` }, ]); - publish('ticket.updated', `Ticket ${ticket.id} má nový typ`, { ticketId: ticket.id }); + publish('ticket.updated', `Ticket ${ticket.id} má nový typ`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId); return toTicket(ticket); } @@ -1859,7 +1714,7 @@ export function setTicketTags(id: string, tags: string[], tenantIds: string[]): ticket.tags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))]; touch(ticket); - publish('ticket.updated', `Ticket ${ticket.id} má upravené tagy`, { ticketId: ticket.id }); + publish('ticket.updated', `Ticket ${ticket.id} má upravené tagy`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId); return toTicket(ticket); } @@ -1890,7 +1745,7 @@ export function assignTicketGroup( label: groupId ? `Přehozeno na skupinu ${groupId}` : 'Odebráno ze skupiny', }, ]); - publish('ticket.assigned', `Ticket ${ticket.id} přehozen na skupinu`, { ticketId: ticket.id }); + publish('ticket.assigned', `Ticket ${ticket.id} přehozen na skupinu`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId); return toTicket(ticket); } @@ -1924,13 +1779,14 @@ export function claimTicket( publish('ticket.assigned', `${person.name} si vzal ticket ${ticket.id}`, { ticketId: ticket.id, assigneeId: person.id, - }); + ticket: toTicket(ticket), + }, ticket.tenantId); return toTicket(ticket); } /** Ma ticket uz nekoho? Pro rozhodnuti, jestli jde prevzit. */ export function ticketAssignee(id: string, tenantIds: string[]): string | null | undefined { - const ticket = tickets.find((item) => item.id === id); + const ticket = ticketsById.get(id); if (!ticket || !tenantIds.includes(ticket.tenantId)) return undefined; return ticket.assigneeId; } @@ -1950,7 +1806,7 @@ export function addComment( tenantIds: string[], helpdeskSourceIds: string[] = [], ): Ticket | undefined { - const found = tickets.find((item) => item.id === id); + const found = ticketsById.get(id); const asked = found?.helpdeskSourceId !== null && found?.helpdeskSourceId !== undefined && @@ -1966,6 +1822,6 @@ export function addComment( touch(ticket); appendTrace(id, [{ kind: 'note', label: `${author}: ${text}`, status: 'info' }]); - publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id }); + publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId); return toTicket(ticket); } diff --git a/src/data/ticketTypes.ts b/src/data/ticketTypes.ts index 984234a..53d713f 100644 --- a/src/data/ticketTypes.ts +++ b/src/data/ticketTypes.ts @@ -14,39 +14,14 @@ * "Typ nebo tag". */ -import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { defineStore, nowIso } from './store/index.js'; import { withCache } from './store/cached.js'; import type { FieldType } from './conditions.js'; -export interface TicketTypeField { - /** Stabilni, odkazuji se na nej podminky. Prejmenovani je nerozbije. */ - id: string; - /** Pouziva se v sablonach jako `{{orderNumber}}`. */ - key: string; - label: string; - type: FieldType; - required: boolean; - options?: Array<{ value: string; label: string }>; - hint?: string; -} +import type { TicketType, TicketTypeField } from '../shared/tickets.js'; -export interface TicketType extends TenantEntity { - tenantId: string; - /** Kratky klic, napr. `order`. Pouziva se ve filtrech a podminkach. */ - key: string; - name: string; - /** Klic ikony, mapuje se na klientovi. */ - icon: string; - /** - * Vlastni stavy ticketu. Prazdne = vychozi doporucena ctverice. - * - * Stav je volny retezec, tohle je jen nabidka v rozhrani - ticket muze mit - * i stav, ktery poslala cizi aplikace a v seznamu neni. - */ - statuses: string[]; - - fields: TicketTypeField[]; -} +/** Tvar typu ticketu je sdileny s webem, viz src/shared/tickets.ts. */ +export type { TicketType, TicketTypeField }; export const ticketTypeStore = defineStore('ticketType'); const cache = withCache(ticketTypeStore); @@ -96,10 +71,7 @@ export async function refreshTicketTypes(): Promise { } export function listTicketTypes(tenantIds: string[]): TicketType[] { - return cache - .all() - .filter((type) => tenantIds.includes(type.tenantId)) - .sort((a, b) => a.name.localeCompare(b.name, 'cs')); + return cache.listByTenant(tenantIds, 'name'); } export function findTicketType(id: string): TicketType | undefined { diff --git a/src/data/users.ts b/src/data/users.ts index 28030d0..56042e9 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -115,8 +115,17 @@ export function findUserById(id: string): User | undefined { return found && found.enabled ? toUser(found) : undefined; } -export function hashPassword(plain: string): string { - return bcrypt.hashSync(plain, 10); +/** + * Asynchronne zamerne. `hashSync` blokuje event loop na desitky milisekund + * a po tu dobu nikdo jiny nedostane odpoved - u hesla se to opakuje pri + * kazdem prihlaseni. + */ +export function hashPassword(plain: string): Promise { + return bcrypt.hash(plain, 10); +} + +export function verifyPassword(plain: string, hash: string): Promise { + return bcrypt.compare(plain, hash); } /** Uzivatele, kteri patri do dane firmy. */ diff --git a/src/data/widgets.ts b/src/data/widgets.ts index 22c667c..64441d2 100644 --- a/src/data/widgets.ts +++ b/src/data/widgets.ts @@ -6,35 +6,18 @@ * i novou komponentu na webu, proto jich je zamerne malo a jsou obecne. */ -import { listCustomWidgets, sizesFor, type WidgetRender, type WidgetSource } from './customWidgets.js'; +import { listCustomWidgets, sizesFor, type WidgetSource } from './customWidgets.js'; -export type WidgetSize = 'third' | 'half' | 'full'; +import type { + CatalogWidget, + WidgetDefinition, + WidgetKind, + WidgetMetric, + WidgetSize, +} from '../shared/widgets.js'; -export type WidgetKind = - | 'stat' - | 'chart' - | 'ticketList' - | 'incidentList' - | 'workload' - /** Vykon resitelu. Data chodi z `/widget-data`, stejne jako u vlastnich. */ - | 'agents' - /** Vlastni widget: kresleni si urcuje jeho `render`. */ - | WidgetRender; - -export interface WidgetDefinition { - id: string; - name: string; - description: string; - kind: WidgetKind; - /** Ktere sirky ma smysl nabizet. Graf v tretine sloupce necteme. */ - sizes: WidgetSize[]; - defaultSize: WidgetSize; - /** - * Jen u `stat`: ktere cislo ze souhrnu ukazat. - * Klic odpovida poli v odpovedi /api/dashboard/summary. - */ - metric?: 'openTickets' | 'activeIncidents' | 'activeAutomations' | 'runsToday' | 'savedHoursMonth' | 'uptime'; -} +/** Tvar widgetu je sdileny s webem, viz src/shared/widgets.ts. Tady je katalog. */ +export type { CatalogWidget, WidgetDefinition, WidgetKind, WidgetMetric, WidgetSize }; export const widgets: WidgetDefinition[] = [ { @@ -189,15 +172,6 @@ export function findWidget(id: string): WidgetDefinition | undefined { return widgets.find((widget) => widget.id === id); } -/** Polozka katalogu tak, jak ji vidi klient. Vlastni widget nese i zdroj dat. */ -export interface CatalogWidget extends WidgetDefinition { - /** true = widget si firma nadefinovala sama. */ - custom: boolean; - render?: WidgetRender; - source?: WidgetSource; - target?: number; -} - /** * Cely katalog: pevne widgety plus vlastni te firmy. * diff --git a/src/events/bus.ts b/src/events/bus.ts index eb87679..4ef61a8 100644 --- a/src/events/bus.ts +++ b/src/events/bus.ts @@ -9,31 +9,15 @@ import { randomUUID } from 'node:crypto'; * nahradit Redis pub/sub nebo jina sdilena fronta (viz dokumentace). */ -export type DashboardEventType = - | 'ticket.created' - | 'ticket.updated' - | 'ticket.assigned' - | 'ticket.resolved' - | 'incident.started' - | 'incident.updated' - | 'incident.resolved' - | 'automation.created' - | 'automation.updated' - | 'automation.deleted' - | 'automation.run' - | 'webhook.received' - /** Nekomu prislo upozorneni. Portal podle toho prekresli cislo u zalozky. */ - | 'notification.created'; +import type { + DashboardEvent, + DashboardEventType, + EntityEventKind, + EntityEventType, +} from '../shared/events.js'; -export interface DashboardEvent { - id: string; - type: DashboardEventType; - at: string; - /** Kratka veta pro uzivatele, zobrazuje se v notifikaci. */ - message: string; - /** Doplnkova data, napr. id ticketu. */ - payload?: Record; -} +/** Typy udalosti jsou sdilene s webem, viz src/shared/events.ts. */ +export type { DashboardEvent, DashboardEventType, EntityEventKind, EntityEventType }; const CHANNEL = 'dashboard'; const MAX_RECENT = 50; @@ -44,10 +28,15 @@ emitter.setMaxListeners(200); const recent: DashboardEvent[] = []; +/** + * `tenantId` je povinny a posledni zamerne: kdyby byl nepovinny, prvni + * zapomenute volani by udalost poslalo vsem. `null` jen u platformnich udalosti. + */ export function publish( type: DashboardEventType, message: string, - payload?: Record, + payload: Record | undefined, + tenantId: string | null, ): DashboardEvent { const event: DashboardEvent = { id: randomUUID(), @@ -55,6 +44,7 @@ export function publish( at: new Date().toISOString(), message, payload, + tenantId, }; recent.unshift(event); @@ -73,9 +63,17 @@ export function subscribe(listener: (event: DashboardEvent) => void): () => void }; } -/** Poslednich par udalosti - klient je dostane hned po pripojeni. */ -export function recentEvents(limit = 10): DashboardEvent[] { - return recent.slice(0, limit); +/** + * Poslednich par udalosti - klient je dostane hned po pripojeni. + * + * `visible` filtruje pred orezem, jinak by clovek z male firmy dostal prazdno + * vzdycky, kdyz posledni udalosti patrily jinym. + */ +export function recentEvents( + limit = 10, + visible: (event: DashboardEvent) => boolean = () => true, +): DashboardEvent[] { + return recent.filter(visible).slice(0, limit); } export function listenerCount(): number { diff --git a/src/index.ts b/src/index.ts index ca15246..567f0bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,5 @@ import cors from 'cors'; -import express, { - Router, - type NextFunction, - type Request, - type Response, -} from 'express'; +import express, { type NextFunction, type Request, type Response } from 'express'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -20,6 +15,7 @@ import { bootstrapData } from './data/bootstrap.js'; import { flushConnectorStore, initConnectorStore, storageStatus } from './data/connectorStore.js'; import { flushStores } from './data/store/index.js'; import { adminRouter } from './routes/admin.js'; +import { safeRouter } from './middleware/asyncHandler.js'; import { runMigrations } from './db/migrate.js'; import { closeDatabase, databaseHealth, isDatabaseEnabled } from './db/pool.js'; import { ensureLoaded, scriptsDir } from './scripts/registry.js'; @@ -31,8 +27,13 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const webRoot = path.join(here, 'public'); const app = express(); -// Aplikace bezi za reverse proxy, jinak by req.ip a protokol byly containeru. -app.set('trust proxy', true); +/* + * Aplikace bezi za reverse proxy (Caddy), jinak by req.ip a protokol byly + * containeru. Duveruje se **jednomu** skoku, ne vsem: pri `true` by si kazdy + * volajici mohl do X-Forwarded-For vepsat cizi adresu a obejit tak limit + * poctu pokusu, ktery je na adresu navazany. + */ +app.set('trust proxy', 1); app.use( cors({ @@ -47,9 +48,30 @@ app.use( ); app.use(express.json({ limit: '256kb' })); +/* + * Bezpecnostni hlavicky. Rucne a stridme: zadne CSP, ktere by rozbilo SPA + * nebo Swagger UI. Ramovani jen ze stejne domeny, zadne hadani typu obsahu, + * referer bez cesty pri odchodu jinam a vypnute senzory, ktere portal nepouziva. + */ +app.use((_req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'SAMEORIGIN'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + next(); +}); + +/** + * Token v adrese je pristupovy udaj. Do logu jde jen jeho zacatek, aby slo + * volani dohledat, ale ne zopakovat. + */ +function maskSecretsInUrl(url: string): string { + return url.replace(/(\/webhook\/(?:ticket\/)?|\/invites\/)([^/?#]{6})[^/?#]*/g, '$1$2...'); +} + app.use((req, _res, next) => { // Loguje se jen metoda a cesta, nikdy hlavicky ani telo - obsahuji secrets. - console.info(`[req] ${req.method} ${req.originalUrl}`); + console.info(`[req] ${req.method} ${maskSecretsInUrl(req.originalUrl)}`); next(); }); @@ -67,8 +89,11 @@ function isOriginAllowed(origin: string): boolean { /** * strict: true je nutne. Bez nej by se cesta /docs shodovala i s /docs/ * a presmerovani nize by se zacyklilo. + * + * `safeRouter`: async handler, ktery spadne, dojde do error handleru nize + * misto toho, aby request visel a chyba skoncila jako unhandledRejection. */ -const api = Router({ strict: true }); +const api = safeRouter({ strict: true }); /** * Liveness. Zamerne **nezavisi na databazi**: kratky vypadek DB by jinak vedl @@ -151,8 +176,12 @@ app.use(api); if (config.rootPath) app.use(config.rootPath, api); // Neexistujici API cesta musi vratit JSON, ne HTML aplikace. +// Prefix se bere z ROOT_PATH, ne z tvaru `/apps/` napsaneho natvrdo. +const apiPathPattern = new RegExp( + `^(${config.rootPath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')})?/(api|webhook)/`, +); app.use((req, res, next) => { - if (/^(\/apps\/[^/]+)?\/(api|webhook)\//.test(req.path)) { + if (apiPathPattern.test(req.path)) { console.warn(`[404] ${req.method} ${req.originalUrl}`); return res.status(404).json({ error: 'not_found', message: 'Endpoint neexistuje.' }); } @@ -315,6 +344,24 @@ for (const signal of ['SIGTERM', 'SIGINT'] as const) { }); } +/* + * Posledni zachytna sit. Bez ni Node odmitnuty promise jen vypise a jede dal + * (u starsich verzi), nebo proces shodi bez slova o tom, kde to bylo. + * + * Odmitnuty promise: zalogovat i se stackem a bezet dal - byva to jedna + * rozbita uloha na pozadi, ne stav celeho procesu. Nechycena vyjimka: proces + * uz nemusi byt konzistentni, proto se po zapisu do logu ukonci a AppFactory + * ho nastartuje znovu. + */ +process.on('unhandledRejection', (reason: unknown) => { + console.error('[fatal] neosetreny odmitnuty promise:', reason instanceof Error ? reason.stack : reason); +}); + +process.on('uncaughtException', (err: Error) => { + console.error('[fatal] nechycena vyjimka, proces se ukonci:', err.stack ?? err); + process.exit(1); +}); + server.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { console.error(`[start] Port ${config.port} je obsazeny.`); diff --git a/src/lib/secure.ts b/src/lib/secure.ts new file mode 100644 index 0000000..4dfa7f8 --- /dev/null +++ b/src/lib/secure.ts @@ -0,0 +1,16 @@ +/** + * Porovnani tajnych retezcu v konstantnim case. + * + * `a === b` skonci u prvniho jineho znaku, takze se z doby odpovedi da po + * kouskach uhadnout token. U webhooku a pozvanek je token jedina ochrana, + * proto se porovnava takhle. + */ + +import { createHash, timingSafeEqual } from 'node:crypto'; + +export function timingSafeEqualString(a: string, b: string): boolean { + // Hash srovna delky, jinak by uz rozdilna delka prozradila, ze se netrefil. + const left = createHash('sha256').update(a).digest(); + const right = createHash('sha256').update(b).digest(); + return timingSafeEqual(left, right); +} diff --git a/src/mail/smtp.ts b/src/mail/smtp.ts index d71a616..e44ffe3 100644 --- a/src/mail/smtp.ts +++ b/src/mail/smtp.ts @@ -17,7 +17,7 @@ import nodemailer from 'nodemailer'; import { config } from '../config.js'; import type { ResolvedTarget } from '../scripts/connections.js'; -import { isPrivateHost } from '../scripts/http.js'; +import { hostProblem } from '../net/guard.js'; import { createRedactor, truncate } from '../scripts/util.js'; export interface SmtpSettings { @@ -81,13 +81,8 @@ export function smtpSettings(target: ResolvedTarget): { settings: SmtpSettings } if (host === '') return { error: 'Konektor nemá vyplněný SMTP server.' }; // Stejne pravidlo jako u HTTP: adresu vyplnuje firma, takze tohle je jedina // zabrana proti navazani spojeni dovnitr nasi site. - if (!config.allowPrivateTargets && isPrivateHost(host)) { - return { - error: - `Server ${host} míří do vnitřní sítě. ` + - 'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.', - }; - } + const problem = hostProblem(host); + if (problem !== null) return { error: problem }; if (user === '') return { error: 'Konektor nemá vyplněného uživatele.' }; if (password === '') return { error: 'Konektor nemá vyplněné heslo.' }; if (from === '') return { error: 'Konektor nemá vyplněnou adresu odesílatele.' }; diff --git a/src/mcp/auth.ts b/src/mcp/auth.ts index 0ea524f..ebee66c 100644 --- a/src/mcp/auth.ts +++ b/src/mcp/auth.ts @@ -22,10 +22,15 @@ */ import { config } from '../config.js'; +import { describeFetchError, readJsonLimited } from '../net/guard.js'; import type { ResolvedTarget } from '../scripts/connections.js'; -import { truncate } from '../scripts/util.js'; +import { jwtExpiry, pickText, truncate } from '../scripts/util.js'; import { dialectFor, normalizeServerUrl } from './dialect.js'; import { ensureAccess, forgetSession, type EasyWebCredentials } from './easyweb/session.js'; +import { AuthFailure } from './errors.js'; + +// Trida bydli v `errors.ts` kvuli kruhu s EasyWebem, ale patri k prihlaseni. +export { AuthFailure } from './errors.js'; /** * O kolik driv nez vyprsi se token vymeni. @@ -75,16 +80,14 @@ interface Session { const cache = new Map(); -/** Chyba prihlaseni. Nese vetu pro uzivatele, ne stack. */ -export class AuthFailure extends Error { - constructor( - message: string, - readonly detail: string | null = null, - ) { - super(message); - this.name = 'AuthFailure'; - } -} +/** + * Rozdelane prihlaseni podle konektoru. + * + * Dva soubezne behy nad tim samym konektorem nesmi spustit dve prihlaseni: + * je to zbytecne a autorizacni servery to pocitaji do limitu. Druhy volajici + * pocka na prvni, stejne jako u EasyWebu. + */ +const inflight = new Map>(); interface Credentials { serviceId: string; @@ -157,17 +160,6 @@ function bearer(token: string, method: AuthMethod): Authorization { // ------------------------------------------------------- cteni odpovedi -/** Hodnota z JSONu bez ohledu na velikost pismen. Servery se v tom lisi. */ -function stringField(source: Record | null, ...names: string[]): string | null { - if (!source) return null; - const wanted = names.map((name) => name.toLowerCase()); - for (const [key, value] of Object.entries(source)) { - if (!wanted.includes(key.toLowerCase())) continue; - if (typeof value === 'string' && value.trim() !== '') return value.trim(); - } - return null; -} - /** * Kdy token vyprsi. * @@ -180,48 +172,31 @@ function expiryFrom(body: Record, token: string): number { const seconds = body.expires_in ?? body.expiresIn ?? body.ExpiresIn; if (typeof seconds === 'number' && seconds > 0) return Date.now() + seconds * 1000; - const stamp = stringField(body, 'expiresAt', 'expiration', 'expires', 'expiresUtc', 'expirationUtc'); + const stamp = pickText(body, 'expiresAt', 'expiration', 'expires', 'expiresUtc', 'expirationUtc'); if (stamp) { const parsed = Date.parse(stamp); if (!Number.isNaN(parsed)) return parsed; } - const claim = expiryFromJwt(token); + const claim = jwtExpiry(token); if (claim !== null) return claim; return Date.now() + DEFAULT_LIFETIME_MS; } -/** `exp` z prostredni casti JWT. null, kdyz to JWT neni. */ -function expiryFromJwt(token: string): number | null { - const parts = token.split('.'); - if (parts.length < 2) return null; - try { - const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/'); - const json = Buffer.from(padded, 'base64').toString('utf8'); - const payload = JSON.parse(json) as { exp?: unknown }; - return typeof payload.exp === 'number' ? payload.exp * 1000 : null; - } catch { - return null; - } -} - /** Telo odpovedi jako objekt. Vyhazuje, kdyz to JSON neni. */ async function readJson(response: Response, where: string): Promise> { - const raw = await response.text(); - if (raw.length > config.scriptMaxResponseBytes) { + const body = await readJsonLimited(response, config.scriptMaxResponseBytes); + if (body.tooLarge) { throw new AuthFailure(`Odpověď z ${where} je nad povoleným limitem.`); } - try { - const parsed: unknown = JSON.parse(raw); - if (parsed === null || typeof parsed !== 'object') throw new Error('neni objekt'); - return parsed as Record; - } catch { + if (body.json === undefined || body.json === null || typeof body.json !== 'object') { throw new AuthFailure( `Odpověď z ${where} není platný JSON. Míří adresa opravdu na přihlášení?`, - truncate(raw, config.errorDetailBytes), + truncate(body.text, config.errorDetailBytes), ); } + return body.json as Record; } // ---------------------------------------------------------------- OAuth @@ -234,10 +209,11 @@ async function readMetadata( try { const response = await fetch(url, { method: 'GET', signal, headers: { Accept: 'application/json' } }); if (!response.ok) return null; - const raw = await response.text(); - if (raw.length > config.scriptMaxResponseBytes) return null; - const parsed: unknown = JSON.parse(raw); - return parsed !== null && typeof parsed === 'object' ? (parsed as Record) : null; + const body = await readJsonLimited(response, config.scriptMaxResponseBytes); + const parsed = body.json; + return parsed !== undefined && parsed !== null && typeof parsed === 'object' + ? (parsed as Record) + : null; } catch { return null; } @@ -322,7 +298,7 @@ async function discoverTokenUrl(serverUrl: string, signal: AbortSignal): Promise for (const suffix of ['oauth-authorization-server', 'openid-configuration']) { for (const url of wellKnown(issuerUrl, suffix)) { - const endpoint = stringField(await readMetadata(url, signal), 'token_endpoint'); + const endpoint = pickText(await readMetadata(url, signal), 'token_endpoint'); if (endpoint) return endpoint; } } @@ -347,9 +323,9 @@ async function tokenRequest( body: new URLSearchParams(body).toString(), }); } catch (err) { - throw new AuthFailure( - `Nepodařilo se spojit s ${tokenUrl}: ${err instanceof Error ? err.message : String(err)}`, - ); + const info = describeFetchError(err, tokenUrl); + // Nenavazane spojeni pomine, odmitnute udaje ne. + throw new AuthFailure(info.message, info.detail, null, !info.timedOut); } if (!response.ok) { @@ -384,7 +360,7 @@ async function oauthLogin( signal, ); - const accessToken = body ? stringField(body, 'access_token', 'accessToken') : null; + const accessToken = pickText(body, 'access_token', 'accessToken'); if (!body || !accessToken) { throw new AuthFailure( `Server ${tokenUrl} přihlášení aplikace nepřijal. Ověřte ID a tajemství, ` + @@ -395,7 +371,7 @@ async function oauthLogin( return { accessToken, expiresAt: expiryFrom(body, accessToken), - refreshToken: stringField(body, 'refresh_token', 'refreshToken'), + refreshToken: pickText(body, 'refresh_token', 'refreshToken'), method: 'OAuth jako aplikace', renewUrl: tokenUrl, fingerprint: fingerprintOf(credentials), @@ -422,14 +398,14 @@ async function oauthRenew( signal, ); - const accessToken = body ? stringField(body, 'access_token', 'accessToken') : null; + const accessToken = pickText(body, 'access_token', 'accessToken'); if (!body || !accessToken) return null; return { ...session, accessToken, expiresAt: expiryFrom(body, accessToken), - refreshToken: stringField(body, 'refresh_token', 'refreshToken') ?? session.refreshToken, + refreshToken: pickText(body, 'refresh_token', 'refreshToken') ?? session.refreshToken, fingerprint: fingerprintOf(credentials), }; } @@ -488,17 +464,36 @@ export async function authorize( const fingerprint = fingerprintOf(credentials); if (force) cache.delete(key); const cached = cache.get(key); + const usable = cached && cached.fingerprint === fingerprint ? cached : null; - if (cached && cached.fingerprint === fingerprint) { - if (Date.now() < cached.expiresAt - EXPIRY_MARGIN_MS) { - return bearer(cached.accessToken, cached.method); - } - // Vyprsel. Obnova je levnejsi nez cele prihlaseni znovu. - const renewed = await oauthRenew(cached, credentials, signal); - if (renewed) { - cache.set(key, renewed); - return bearer(renewed.accessToken, renewed.method); - } + if (usable && Date.now() < usable.expiresAt - EXPIRY_MARGIN_MS) { + return bearer(usable.accessToken, usable.method); + } + + // Jedno prihlaseni naraz na konektor. Kdo prijde druhy, pocka na prvni. + let pending = inflight.get(key); + if (!pending) { + pending = obtainSession(usable, credentials, signal).finally(() => { + if (inflight.get(key) === pending) inflight.delete(key); + }); + inflight.set(key, pending); + } + + const session = await pending; + cache.set(key, session); + return bearer(session.accessToken, session.method); +} + +/** Obnovi prosly token, nebo se prihlasi znovu. Vzdy vraci platne sezeni. */ +async function obtainSession( + expired: Session | null, + credentials: Credentials, + signal: AbortSignal, +): Promise { + if (expired) { + // Obnova je levnejsi nez cele prihlaseni znovu. + const renewed = await oauthRenew(expired, credentials, signal); + if (renewed) return renewed; } const tokenUrl = credentials.tokenUrl || (await discoverTokenUrl(credentials.serverUrl, signal)); @@ -509,9 +504,7 @@ export async function authorize( ); } - const session = await oauthLogin(tokenUrl, credentials, signal); - cache.set(key, session); - return bearer(session.accessToken, session.method); + return oauthLogin(tokenUrl, credentials, signal); } /** diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 9a327ed..a292080 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -22,9 +22,15 @@ */ import { config } from '../config.js'; +import { + assertAllowedUrl, + describeFetchError, + readBodyLimited, + readJsonLimited, + tooLargeMessage, +} from '../net/guard.js'; import { targetSecrets, type ResolvedTarget } from '../scripts/connections.js'; -import { isPrivateHost } from '../scripts/http.js'; -import { createRedactor, truncate } from '../scripts/util.js'; +import { createRedactor, pick, truncate } from '../scripts/util.js'; import { AuthFailure, authorize, type AuthMethod } from './auth.js'; import { dialectFor, normalizeServerUrl, type McpDialect } from './dialect.js'; @@ -51,6 +57,15 @@ const SESSION_LOST_CODE = -32008; /** Kod, kterym server rika, ze nastroj se musi spustit jako uloha. */ const TASK_REQUIRED_CODE = -32601; +/** + * HTTP kody, po kterych ma smysl zkusit to znovu. + * + * Server volani odmitl driv, nez nastroj spustil (pretizeni, limit, proxy + * bez backendu). Bez 500: to uz mohl nastroj bezet a MCP nema idempotencni + * klic, ktery by druhe provedeni zastavil. + */ +const retryableStatuses = new Set([408, 425, 429, 502, 503, 504]); + /** Schema podle JSON Schema. Tvar se prochazi az v `schema.ts`. */ export type JsonSchema = Record; @@ -106,6 +121,11 @@ export interface McpOutcome { status: number | null; request: { method: string; path: string; url: string } | null; value: T | null; + /** + * true = k nastroji se nedoslo (spojeni, odmitnuti pred spustenim) a za + * chvili to muze projit. Timeout beziciho nastroje ne, viz `McpFailure`. + */ + retryable: boolean; } /** Chyba uvnitr tohoto souboru. Ven se nedostane, prevede se na `McpOutcome`. */ @@ -118,6 +138,17 @@ class McpFailure extends Error { readonly sessionLost = false, /** Kod chyby JSON-RPC, kdyz ji server poslal. */ readonly code: number | null = null, + /** + * true = opakovani ma smysl: spojeni se nenavazalo, nebo server odmitl + * driv, nez cokoliv spustil. Timeout uz odeslaneho volani ne - nastroj + * mohl bezet a druhy pokus by ho provedl podruhe. + */ + readonly retryable = false, + /** + * true = server nezna sezeni, ktere jsme meli za platne (400/404 po + * preskocenem handshaku). Staci handshake zopakovat, token plati. + */ + readonly handshakeLost = false, ) { super(message); this.name = 'McpFailure'; @@ -141,15 +172,7 @@ function serverUrl(target: ResolvedTarget): URL { } catch { throw new McpFailure(`Adresa ${raw} není platná URL.`); } - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - throw new McpFailure(`Adresa ${url.protocol} není povolená, jen http a https.`); - } - if (!config.allowPrivateTargets && isPrivateHost(url.hostname)) { - throw new McpFailure( - `Adresa ${url.hostname} míří do vnitřní sítě. ` + - 'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.', - ); - } + assertAllowedUrl(url, (message) => new McpFailure(message)); return url; } @@ -160,6 +183,8 @@ interface Session { authHeader: string; /** ID sezeni z hlavicky odpovedi. Server ho mit nemusi. */ sessionId: string | null; + /** true = handshake se preskocil, protoze uz probehl driv s timto tokenem. */ + reused: boolean; protocolVersion: string; redact: (value: string) => string; /** Jak se portal prihlasil. Jde to do hlasky u konektoru. */ @@ -172,13 +197,19 @@ interface Session { } /** - * Pro ktery token uz probehl handshake. + * Pro ktery token uz probehl handshake a jake sezeni pri nem server zalozil. * - * Klic je konektor, hodnota je hlavicka `Authorization`. Jina hlavicka znamena - * jiny token, tedy jine sezeni, a handshake se musi zopakovat. Servery, ktere - * si sezeni drzi u tokenu, jinak odpovi, ze relace neni inicializovana. + * Klic je konektor. Jina hlavicka `Authorization` znamena jiny token, tedy + * jine sezeni, a handshake se musi zopakovat. Servery, ktere si sezeni drzi + * u tokenu, jinak odpovi, ze relace neni inicializovana. + * + * `sessionId` se drzi u handshaku, ne u jednoho volani: server ho vydá pri + * `initialize` a pak ho vyzaduje u kazdeho dalsiho volani. Bez toho by druhe + * `tools/call` slo bez hlavicky a spec-compliant server odpovi 400 nebo 404. + * Plati i pro server bez prihlaseni - i ten ma sezeni a handshake stoji dve + * volani navic pri kazdem kroku. */ -const initialized = new Map(); +const initialized = new Map(); let nextId = 1; @@ -303,12 +334,10 @@ async function rpc( body: JSON.stringify(body), }); } catch (err) { - const name = err instanceof Error ? err.name : ''; - if (name === 'AbortError' || name === 'TimeoutError') { - throw new McpFailure(`Server ${session.url.host} neodpověděl v limitu.`); - } - const reason = err instanceof Error ? err.message : String(err); - throw new McpFailure(`Nepodařilo se spojit se serverem ${session.url.host}: ${reason}`); + const info = describeFetchError(err, session.url.host); + // Nenavazane spojeni se smi opakovat. Timeout ne: volani uz odeslo + // a nastroj mohl bezet, druhy pokus by ho provedl podruhe. + throw new McpFailure(info.message, null, null, false, null, !info.timedOut); } // Sezeni zaklada server pri prvnim volani a pak ho vyzaduje u dalsich. @@ -320,8 +349,9 @@ async function rpc( const isStream = response.headers.get('content-type')?.includes('event-stream') ?? false; if (!response.ok) { - // Telo chyby byva male, u nej se po kouscich cist nemusi. - const raw = await response.text().catch(() => ''); + const raw = await readBodyLimited(response, config.scriptMaxResponseBytes) + .then((body) => body.text) + .catch(() => ''); const detail = session.redact(truncate(raw, config.errorDetailBytes)); throw new McpFailure( `${method} vrátilo HTTP ${response.status}.` + @@ -329,6 +359,10 @@ async function rpc( response.status, detail === '' ? null : detail, response.status === 401, + null, + retryableStatuses.has(response.status), + // Preskoceny handshake a server sezeni nezna: 400/404 podle specifikace. + session.reused && (response.status === 400 || response.status === 404), ); } @@ -344,23 +378,20 @@ async function rpc( // Stream skoncil bez odpovedi. Server to obcas udela, neni to chyba. if (!envelope) return null; } else { - const raw = await response.text(); - if (raw.length > config.scriptMaxResponseBytes) { - throw new McpFailure( - `Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`, - response.status, - ); + const body = await readJsonLimited(response, config.scriptMaxResponseBytes); + if (body.tooLarge) { + throw new McpFailure(tooLargeMessage(config.scriptMaxResponseBytes), response.status); } - if (raw.trim() === '') return null; - try { - envelope = JSON.parse(raw) as Record; - } catch { + // Prazdne telo neni chyba, server to obcas udela. + if (body.json === null) return null; + if (body.json === undefined || typeof body.json !== 'object') { throw new McpFailure( `${method} nevrátilo platný JSON. Míří adresa opravdu na MCP server?`, response.status, - session.redact(truncate(raw, config.errorDetailBytes)), + session.redact(truncate(body.text, config.errorDetailBytes)), ); } + envelope = body.json as Record; } const error = envelope.error as { code?: number; message?: string } | undefined; @@ -394,6 +425,7 @@ async function buildSession( headers: { ...target.headers, ...auth.headers }, authHeader: auth.headers.Authorization ?? '', sessionId: null, + reused: false, protocolVersion: dialect.protocolVersion, redact: createRedactor([...targetSecrets(target), ...auth.secrets]), authMethod: auth.method, @@ -403,6 +435,10 @@ async function buildSession( }; } +function handshakeKey(session: Session): string { + return session.connectorId ?? session.url.toString(); +} + /** * Handshake, jednou na token. * @@ -413,8 +449,13 @@ async function buildSession( * probehl driv a jmeno tedy znovu neprislo. */ async function ensureInitialized(session: Session, signal: AbortSignal): Promise { - const key = session.connectorId ?? session.url.toString(); - if (session.authHeader !== '' && initialized.get(key) === session.authHeader) return ''; + const known = initialized.get(handshakeKey(session)); + if (known && known.authHeader === session.authHeader) { + // Sezeni z handshaku plati dal, server ho chce u kazdeho volani. + session.sessionId = known.sessionId; + session.reused = true; + return ''; + } const result = (await rpc( session, @@ -429,16 +470,19 @@ async function ensureInitialized(session: Session, signal: AbortSignal): Promise } await rpc(session, 'notifications/initialized', undefined, signal, false); - if (session.authHeader !== '') initialized.set(key, session.authHeader); + initialized.set(handshakeKey(session), { + authHeader: session.authHeader, + sessionId: session.sessionId, + }); const name = result?.serverInfo?.name ?? 'neznámý server'; const version = result?.serverInfo?.version; return version ? `${name} ${version}` : name; } -/** Zapomene handshake. Po odmitnutem tokenu se musi udelat znovu. */ +/** Zapomene handshake. Po odmitnutem tokenu nebo ztracenem sezeni se musi udelat znovu. */ function forgetHandshake(session: Session): void { - initialized.delete(session.connectorId ?? session.url.toString()); + initialized.delete(handshakeKey(session)); } // ------------------------------------------------------------ rozbaleni @@ -510,8 +554,10 @@ function toTool(value: unknown): McpTool | null { * Obal, ktery z vyjimky udela vysledek. Ven z tohoto souboru nic nevyhazuje. * * Resi jednu vec navic: **kdyz server odmitne token nebo sezeni, ktere jsme - * povazovali za platne, prihlasi se znovu a zkusi to jeste jednou**. Stava se - * to, kdyz nekdo na druhe strane odebral opravneni driv, nez token vyprsel. + * povazovali za platne, zopakuje handshake a zkusi to jeste jednou**. Odmitnuty + * token (401, -32008) se pri tom vymeni za novy - stava se to, kdyz nekdo na + * druhe strane odebral opravneni driv, nez token vyprsel. Ztracene sezeni + * (400/404 po preskocenem handshaku) staci navazat znovu s tymz tokenem. * Podruhe uz se to neopakuje - to uz nejsou udaje, ktere by sedely. */ async function attempt( @@ -539,10 +585,13 @@ async function attempt( timer = setTimeout(() => controller.abort(), ms); }; const signal = controller.signal; + // Posledni sestavene sezeni. Jeho redakce zna i token, ne jen udaje konektoru. + let session: Session | null = null; try { + let force = false; for (let round = 0; ; round += 1) { - const session = await buildSession(target, signal, round > 0, extend); + session = await buildSession(target, signal, force, extend); try { const value = await run(session, signal); return { @@ -552,11 +601,15 @@ async function attempt( status: 200, request, value, + retryable: false, }; } catch (err) { - const lost = err instanceof McpFailure && err.sessionLost; - if (lost && round === 0 && session.authMethod !== 'bez přihlášení') { + const failure = err instanceof McpFailure ? err : null; + const stale = failure !== null && (failure.sessionLost || failure.handshakeLost); + if (stale && round === 0) { forgetHandshake(session); + // Odmitnuty token se vymeni. Bez prihlaseni neni co menit, staci handshake. + force = failure.sessionLost && session.authMethod !== 'bez přihlášení'; continue; } throw err; @@ -565,16 +618,19 @@ async function attempt( } catch (err) { const failure = err instanceof McpFailure ? err : null; const auth = err instanceof AuthFailure ? err : null; - const redact = createRedactor(targetSecrets(target)); + const redact = session?.redact ?? createRedactor(targetSecrets(target)); const reported = failure ?? auth; const message = reported ? reported.message : err instanceof Error ? err.message : String(err); + const detail = reported?.detail ?? null; return { ok: false, message: redact(message), - detail: failure?.detail ?? auth?.detail ?? null, - status: failure?.status ?? null, + // Vzdy pres redakci: i chyba prihlaseni nese telo odpovedi serveru. + detail: detail === null ? null : redact(detail), + status: failure?.status ?? auth?.status ?? null, request, value: null, + retryable: reported?.retryable ?? false, }; } finally { clearTimeout(timer); @@ -663,27 +719,20 @@ const TASK_WAIT_MS = 600_000; /** Jak casto se server ptame na stav ulohy, kdyz svuj interval nerekl. */ const TASK_POLL_MS = 2_000; -/** Hodnota z odpovedi .NETu. Klice pise velkymi i malymi pismeny. */ -function pick(source: unknown, ...names: string[]): unknown { - if (source === null || typeof source !== 'object') return undefined; - const wanted = names.map((name) => name.toLowerCase()); - for (const [key, value] of Object.entries(source as Record)) { - if (wanted.includes(key.toLowerCase())) return value; - } - return undefined; -} - +/** Cekani, ktere skonci driv, kdyz se volani zrusi. */ function delay(ms: number, signal: AbortSignal): Promise { return new Promise((resolve) => { - const timer = setTimeout(resolve, ms); - signal.addEventListener( - 'abort', - () => { - clearTimeout(timer); - resolve(); - }, - { once: true }, - ); + const onAbort = (): void => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + // Posluchac se musi odebrat i kdyz cekani dobehlo samo. Uloha se + // dotazuje stokrat a kazde kolo by jinak nechalo jeden posluchac viset. + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal.addEventListener('abort', onAbort, { once: true }); }); } diff --git a/src/mcp/easyweb/session.ts b/src/mcp/easyweb/session.ts index a40a0f3..c0be31b 100644 --- a/src/mcp/easyweb/session.ts +++ b/src/mcp/easyweb/session.ts @@ -25,7 +25,9 @@ */ import { config } from '../../config.js'; -import { truncate } from '../../scripts/util.js'; +import { describeFetchError, readBodyLimited } from '../../net/guard.js'; +import { jwtExpiry, pickText, truncate } from '../../scripts/util.js'; +import { AuthFailure } from '../errors.js'; import { KEY_REGISTRATION_CONTEXT, POP_CONTEXT, @@ -48,14 +50,20 @@ const RENEW_MARGIN_MS = 90_000; /** Jak dlouho token plati, kdyz to z nej nejde vycíst. Odhad smerem dolu. */ const FALLBACK_LIFETIME_MS = 300_000; -/** Chyba prihlaseni. Vetu cte uzivatel, proto zadny stack. */ -export class EasyWebAuthError extends Error { +/** + * Chyba prihlaseni. Vetu cte uzivatel, proto zadny stack. + * + * Dedi z `AuthFailure`, aby ji klient MCP poznal jako chybu prihlaseni + * a vzal si z ni `detail` i `status` - driv se oboji cestou ztratilo. + */ +export class EasyWebAuthError extends AuthFailure { constructor( message: string, - readonly status: number | null = null, - readonly detail: string | null = null, + status: number | null = null, + detail: string | null = null, + retryable = false, ) { - super(message); + super(message, detail, status, retryable); this.name = 'EasyWebAuthError'; } } @@ -101,16 +109,6 @@ function entryFor(connectorId: string): Entry { // ------------------------------------------------------------------ volani -/** Odpoved serveru jako objekt. Klice cte necitlive, .NET je pise velkymi. */ -function fieldOf(body: Record, ...names: string[]): string | null { - const wanted = names.map((name) => name.toLowerCase()); - for (const [key, value] of Object.entries(body)) { - if (!wanted.includes(key.toLowerCase())) continue; - if (typeof value === 'string' && value.trim() !== '') return value.trim(); - } - return null; -} - /** * Kdy vyprsi pristupovy token. * @@ -119,18 +117,8 @@ function fieldOf(body: Record, ...names: string[]): string | nu * navic u kazdeho kroku. */ function accessExpiry(token: string): number { - const parts = token.split('.'); - if (parts.length >= 2) { - try { - const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as { - exp?: unknown; - }; - if (typeof payload.exp === 'number') return payload.exp * 1000; - } catch { - // Neni to JWT. Plati odhad nize. - } - } - return Date.now() + FALLBACK_LIFETIME_MS; + // Neni to JWT: plati odhad smerem dolu. + return jwtExpiry(token) ?? Date.now() + FALLBACK_LIFETIME_MS; } function endpoint(serverUrl: string, path: string): string { @@ -159,16 +147,12 @@ async function post( body: JSON.stringify(body), }); } catch (err) { - const name = err instanceof Error ? err.name : ''; - if (name === 'AbortError' || name === 'TimeoutError') { - throw new EasyWebAuthError(`Server ${url} neodpověděl v limitu.`); - } - throw new EasyWebAuthError( - `Nepodařilo se spojit s ${url}: ${err instanceof Error ? err.message : String(err)}`, - ); + const info = describeFetchError(err, url); + // Nenavazane spojeni pomine, odmitnute udaje ne. + throw new EasyWebAuthError(info.message, null, info.detail, !info.timedOut); } - const raw = await response.text(); + const raw = (await readBodyLimited(response, config.scriptMaxResponseBytes)).text; const detail = truncate(raw, config.errorDetailBytes); let parsed: Record | null = null; @@ -190,7 +174,7 @@ function tokensFrom( previous: Tokens | null, where: string, ): Tokens { - const accessToken = body ? fieldOf(body, 'AccessToken') : null; + const accessToken = body ? pickText(body, 'AccessToken') : null; if (!accessToken) { throw new EasyWebAuthError(`Odpověď z ${where} neobsahuje přístupový token.`); } @@ -203,10 +187,10 @@ function tokensFrom( return { accessToken, accessExpiresAt: accessExpiry(accessToken), - refreshToken: (body ? fieldOf(body, 'RefreshToken') : null) ?? previous?.refreshToken ?? '', - refreshNonce: (body ? fieldOf(body, 'RefreshTokenNonce') : null) ?? previous?.refreshNonce ?? '', - deviceToken: (body ? fieldOf(body, 'DeviceToken') : null) ?? previous?.deviceToken ?? '', - deviceNonce: (body ? fieldOf(body, 'DeviceTokenNonce') : null) ?? previous?.deviceNonce ?? '', + refreshToken: (body ? pickText(body, 'RefreshToken') : null) ?? previous?.refreshToken ?? '', + refreshNonce: (body ? pickText(body, 'RefreshTokenNonce') : null) ?? previous?.refreshNonce ?? '', + deviceToken: (body ? pickText(body, 'DeviceToken') : null) ?? previous?.deviceToken ?? '', + deviceNonce: (body ? pickText(body, 'DeviceTokenNonce') : null) ?? previous?.deviceNonce ?? '', }; } diff --git a/src/mcp/errors.ts b/src/mcp/errors.ts new file mode 100644 index 0000000..700e1d1 --- /dev/null +++ b/src/mcp/errors.ts @@ -0,0 +1,23 @@ +/** + * Chyba prihlaseni k MCP serveru. + * + * Ve vlastnim souboru, protoze ji dedi i chyba EasyWebu v `easyweb/session.ts` + * a ten soubor importuje `auth.ts`. Kdyby trida bydlela v `auth.ts`, vznikl by + * kruh, ve kterem se trida pouzije driv, nez existuje. + * + * Nese vetu pro uzivatele, ne stack. `detail` je cela odpoved serveru + * a redakci tajemstvi dostane az v klientovi, kde jsou tajemstvi znama. + */ +export class AuthFailure extends Error { + constructor( + message: string, + readonly detail: string | null = null, + /** HTTP kod, kdyz se k odpovedi serveru vubec doslo. */ + readonly status: number | null = null, + /** true = spojeni se nenavazalo, za chvili to muze projit. Odmitnute udaje ne. */ + readonly retryable = false, + ) { + super(message); + this.name = 'AuthFailure'; + } +} diff --git a/src/mcp/schema.ts b/src/mcp/schema.ts index c413e50..85c371c 100644 --- a/src/mcp/schema.ts +++ b/src/mcp/schema.ts @@ -21,6 +21,7 @@ import type { ProvidedField, OperationField } from '../data/services.js'; import type { FieldType } from '../data/conditions.js'; import type { JsonSchema, McpTool } from './client.js'; +import { parseBool } from '../scripts/util.js'; /** * Vystupy, ktere ma **kazdy** nastroj bez ohledu na schema. @@ -247,10 +248,7 @@ export interface ArgumentsResult { /** Zaskrtnuto, nebo ne? Ve strome se vsechno predava jako text. */ function toBoolean(value: string): boolean | null { - const text = value.trim().toLowerCase(); - if (['true', '1', 'ano', 'yes'].includes(text)) return true; - if (['false', '0', 'ne', 'no'].includes(text)) return false; - return null; + return parseBool(value); } /** diff --git a/src/middleware/asyncHandler.ts b/src/middleware/asyncHandler.ts new file mode 100644 index 0000000..f58a092 --- /dev/null +++ b/src/middleware/asyncHandler.ts @@ -0,0 +1,45 @@ +/** + * Async handlery v Expressu 4. + * + * Express 4 odmitnuty promise z handleru nechyti: request zustane viset bez + * odpovedi a chyba skonci jako unhandledRejection. Tenhle soubor to resi na + * jednom miste, aby se na `.catch(next)` nemuselo myslet u kazde routy. + */ + +import { Router, type NextFunction, type Request, type RequestHandler, type Response, type RouterOptions } from 'express'; + +type AnyHandler = (req: Request, res: Response, next: NextFunction) => unknown; + +/** Obali handler tak, aby odmitnuty promise dosel do centralniho error handleru. */ +export function wrap(fn: AnyHandler): RequestHandler { + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} + +const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'all'] as const; + +/** Error handler ma ctyri parametry, ten se obalovat nesmi - zmenil by se mu tvar. */ +function wrapArgument(value: unknown): unknown { + if (typeof value === 'function' && value.length < 4) return wrap(value as AnyHandler); + if (Array.isArray(value)) return value.map(wrapArgument); + return value; +} + +/** + * Router, ktery kazdy handler obali sam. + * + * Misto `router.get(path, wrap(async ...))` u kazde routy staci jednou + * `safeRouter()` misto `Router()`. Cesta a middleware se predavaji beze zmeny. + */ +export function safeRouter(options?: RouterOptions): Router { + const router = Router(options); + const target = router as unknown as Record unknown>; + + for (const method of METHODS) { + const original = target[method]; + target[method] = (...args: unknown[]) => original.apply(router, args.map(wrapArgument)); + } + + return router; +} diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts new file mode 100644 index 0000000..73d3f06 --- /dev/null +++ b/src/middleware/rateLimit.ts @@ -0,0 +1,58 @@ +/** + * Omezeni poctu pokusu z jedne adresy. + * + * Bez toho jde prihlaseni, prijeti pozvanky nebo kontaktni formular zkouset + * donekonecna. Zamerne v pameti procesu a bez knihovny: klic je adresa a routa, + * okno je klouzave a mapa ma strop, aby ji nikdo nenafoukl. + */ + +import type { RequestHandler } from 'express'; + +export interface RateLimitOptions { + /** Nazev do klice a do logu, napr. `login`. */ + name: string; + /** Delka okna v milisekundach. */ + windowMs: number; + /** Kolik pokusu se v okne pusti. */ + max: number; +} + +/** Kolik klicu se drzi nejvys. Pri prekroceni se nejdriv uklidi stare. */ +const MAX_KEYS = 10_000; + +export function rateLimit(options: RateLimitOptions): RequestHandler { + const hits = new Map(); + let lastCleanup = Date.now(); + + function cleanup(now: number): void { + for (const [key, stamps] of hits) { + const fresh = stamps.filter((time) => now - time < options.windowMs); + if (fresh.length === 0) hits.delete(key); + else hits.set(key, fresh); + } + lastCleanup = now; + } + + return (req, res, next) => { + const now = Date.now(); + // Uklid jednou za okno, ne pri kazdem requestu - mapa je mala, ale ne nulova. + if (now - lastCleanup > options.windowMs || hits.size > MAX_KEYS) cleanup(now); + + const key = `${options.name}:${req.ip ?? 'unknown'}`; + const stamps = (hits.get(key) ?? []).filter((time) => now - time < options.windowMs); + + if (stamps.length >= options.max) { + const retryAfterSec = Math.ceil((stamps[0] + options.windowMs - now) / 1000); + console.warn(`[limit] ${options.name}: ${req.ip} prekrocil ${options.max} pokusu`); + res.setHeader('Retry-After', String(Math.max(retryAfterSec, 1))); + return res.status(429).json({ + error: 'too_many_requests', + message: 'Příliš mnoho pokusů. Zkuste to prosím za chvíli.', + }); + } + + stamps.push(now); + hits.set(key, stamps); + return next(); + }; +} diff --git a/src/middleware/tenant.ts b/src/middleware/tenant.ts new file mode 100644 index 0000000..622d47d --- /dev/null +++ b/src/middleware/tenant.ts @@ -0,0 +1,125 @@ +/** + * Firma a prava za jeden request. + * + * `accessFor` se driv pocitalo nekolikrat za request (pohled, firma, prava, + * resitel...). Tady se spocita jednou do `req.access` a vsechno ostatni + * z toho cte. Zaroven je tu jedno misto pro "firma z query plus kontrola + * clenstvi", ktere bylo rozepsane v peti souborech a v sesti dalsich chybelo. + */ + +import type { NextFunction, Request, Response } from 'express'; +import { + accessFor, + isDenied, + resolveScope, + type Access, + type ResolvedScope, +} from '../data/access.js'; + +/** Firma z query, kdyz ji klient poslal. */ +export function requestedTenantId(req: Request): string | undefined { + return typeof req.query.tenantId === 'string' && req.query.tenantId !== '' + ? req.query.tenantId + : undefined; +} + +/** + * Prava za request. Kdyz middleware nebezel, spocita se a ulozi ted - + * routa tak funguje i mimo dashboard, jen bez usetreneho vypoctu. + */ +export function accessOf(req: Request): Access { + if (!req.access) req.access = accessFor(req.user!, requestedTenantId(req)); + return req.access; +} + +/** Spocita prava jednou za request. Musi bezet az po `requireAuth`. */ +export function attachAccess(req: Request, res: Response, next: NextFunction) { + if (!req.user) { + console.error('[access] attachAccess pouzito bez requireAuth'); + return res.status(401).json({ error: 'unauthorized' }); + } + accessOf(req); + return next(); +} + +/** + * Firma, za kterou request jedna, bez odpovedi. + * + * Vraci `null`, kdyz uzivatel do zadane firmy nepatri nebo zadnou nema. + * Pro routy, ktere si chteji rict, co se stalo, samy. + */ +export function requestTenant(req: Request): string | null { + const access = accessOf(req); + const tenantId = requestedTenantId(req) ?? access.defaultTenantId; + if (!tenantId) return null; + if (!access.tenants.some((tenant) => tenant.id === tenantId)) { + console.warn(`[access] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`); + return null; + } + return tenantId; +} + +/** + * Firma z query s kontrolou clenstvi. Pri odepreni rovnou odpovi a vrati null, + * takze volajici jen zkontroluje `if (!tenantId) return;`. + * + * Cizi firma je 404, ne 403: z odpovedi nema jit poznat, ze firma existuje. + */ +export function tenantOrDeny(req: Request, res: Response): string | null { + const access = accessOf(req); + const tenantId = requestedTenantId(req) ?? access.defaultTenantId; + + if (!tenantId) { + res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' }); + return null; + } + if (!access.tenants.some((tenant) => tenant.id === tenantId)) { + console.warn(`[access] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`); + res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' }); + return null; + } + return tenantId; +} + +/** + * Totez, ale bez firmy se nezlobi. + * + * Katalog sluzeb nebo skriptu dava smysl i uctu bez clenstvi, jen bez + * firemnich udaju. Cizi firma je porad 404. Vraci `null`, kdyz uz odpovedel. + */ +export function optionalTenantOrDeny( + req: Request, + res: Response, +): { tenantId: string | null } | null { + const access = accessOf(req); + const tenantId = requestedTenantId(req) ?? access.defaultTenantId; + if (!tenantId) return { tenantId: null }; + + if (!access.tenants.some((tenant) => tenant.id === tenantId)) { + console.warn(`[access] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`); + res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' }); + return null; + } + return { tenantId }; +} + +/** + * Prevede query (`scope`, `tenantId`) na povolene firmy a strop viditelnosti. + * Pri odepreni rovnou odpovi a vrati null. + */ +export function scopeOrDeny(req: Request, res: Response): ResolvedScope | null { + const resolved = resolveScope( + req.user!, + { + scope: typeof req.query.scope === 'string' ? req.query.scope : undefined, + tenantId: requestedTenantId(req), + }, + accessOf(req), + ); + + if (isDenied(resolved)) { + res.status(resolved.status).json({ error: resolved.error, message: resolved.message }); + return null; + } + return resolved; +} diff --git a/src/middleware/validation.ts b/src/middleware/validation.ts new file mode 100644 index 0000000..04fd754 --- /dev/null +++ b/src/middleware/validation.ts @@ -0,0 +1,27 @@ +/** + * Jednotny tvar chyby validace. + * + * Driv se to psalo u kazde routy zvlast a tvar se rozesel: nekde `issues[].path`, + * nekde `issues[].field`, nekde nic. Klient cte `field` (viz web/src/lib/api.ts), + * proto je tady jen ten. + */ + +import type { Response } from 'express'; +import type { ZodError } from 'zod'; + +/** + * Odpovi 400 s prvni hlaskou a seznamem vsech problemu. + * + * `message` prebije hlasku ze schematu. Hodi se tam, kde zod vraci anglicky + * text ("String must contain...") a routa ma vlastni, srozumitelnou vetu. + */ +export function validationError(res: Response, error: ZodError, message?: string): Response { + return res.status(400).json({ + error: 'validation_error', + message: message ?? error.issues[0]?.message ?? 'Neplatný vstup.', + issues: error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })), + }); +} diff --git a/src/net/guard.ts b/src/net/guard.ts new file mode 100644 index 0000000..d0d7315 --- /dev/null +++ b/src/net/guard.ts @@ -0,0 +1,152 @@ +/** + * Spolecna pravidla pro volani ven. + * + * Adresu vyplnuje firma (napojeni, MCP server, SMTP), takze tohle je jedina + * zabrana proti navazani spojeni dovnitr nasi site. Pravidlo ma byt jedno: + * driv bylo trikrat (HTTP klient, MCP klient, SMTP) a casem by se rozesla. + * + * Totez plati pro popis chyby spojeni a pro cteni tela s limitem - kazdy + * klient to resil po svem a jeden z nich cetl cele telo do pameti, nez zjistil, + * ze je moc velke. + */ + +import { config } from '../config.js'; + +const privateHostPattern = + /^(localhost|127\.|0\.0\.0\.0$|10\.|192\.168\.|169\.254\.|::1$|\[::1\]$|172\.(1[6-9]|2\d|3[01])\.)/i; + +/** Miri tohle jmeno do vnitrni site? */ +export function isPrivateHost(hostname: string): boolean { + return privateHostPattern.test(hostname); +} + +/** Duvod, proc se na tento server nesmi. `null` = smi. */ +export function hostProblem(hostname: string): string | null { + if (!config.allowPrivateTargets && isPrivateHost(hostname)) { + return ( + `Adresa ${hostname} míří do vnitřní sítě. ` + + 'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.' + ); + } + return null; +} + +/** Duvod, proc se na tuto adresu nesmi: jen http(s) a ne do vnitrni site. */ +export function urlProblem(url: URL): string | null { + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return `Adresa ${url.protocol} není povolená, jen http a https.`; + } + return hostProblem(url.hostname); +} + +/** + * Overi adresu a pri problemu vyhodi chybu, kterou si volajici sam vyrobi. + * Kazdy klient ma vlastni typ chyby (ScriptError, McpFailure), proto se + * nevyhazuje primo. + */ +export function assertAllowedUrl(url: URL, fail: (message: string) => Error): void { + const problem = urlProblem(url); + if (problem !== null) throw fail(problem); +} + +export interface FetchErrorInfo { + /** true = vyprsel limit (AbortError), ne chyba site. */ + timedOut: boolean; + /** Kod od Node (`ECONNREFUSED`), prazdny kdyz zadny neni. */ + code: string; + /** Hlaska pro uzivatele. */ + message: string; + /** Puvodni chyba vcetne priciny, do `detail`. */ + detail: string; +} + +/** + * Popis chyby, kterou vyhodil `fetch`. + * + * `where` je server nebo adresa do hlasky. `cause` nese u fetch to podstatne, + * proto se pripojuje - bez nej je v hlasce jen "fetch failed". + */ +export function describeFetchError(err: unknown, where: string): FetchErrorInfo { + const code = + err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).code) : ''; + const name = err instanceof Error ? err.name : ''; + const reason = err instanceof Error ? err.message : String(err); + const cause = err instanceof Error && err.cause instanceof Error ? `\nPříčina: ${err.cause.message}` : ''; + const timedOut = name === 'AbortError' || name === 'TimeoutError'; + + return { + timedOut, + code, + message: timedOut + ? `Server ${where} neodpověděl v limitu.` + : `Nepodařilo se spojit s ${where}: ${reason}`, + detail: `${name}: ${reason}${cause}`, + }; +} + +/** Hlaska pro telo nad limitem. Jedna, aby byla vsude stejna. */ +export function tooLargeMessage(limit: number): string { + return `Odpověď je větší než povolený limit ${limit} bajtů.`; +} + +export interface LimitedBody { + text: string; + /** true = telo presahlo limit. Cteni se zastavilo, `text` je neuplny. */ + tooLarge: boolean; +} + +/** + * Precte telo odpovedi, ale nejvys `limit` bajtu. + * + * Cte se po kouscich a pri prekroceni se spojeni zavre. Precist cele telo + * a az pak merit by znamenalo, ze limit chrani jen log, ne pamet procesu. + */ +export async function readBodyLimited(response: Response, limit: number): Promise { + const declared = Number(response.headers.get('content-length') ?? 0); + if (declared > limit) { + await response.body?.cancel().catch(() => undefined); + return { text: '', tooLarge: true }; + } + + const body = response.body; + if (!body) return { text: '', tooLarge: false }; + + const reader = body.getReader(); + const decoder = new TextDecoder(); + let text = ''; + let size = 0; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + await reader.cancel().catch(() => undefined); + return { text, tooLarge: true }; + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return { text, tooLarge: false }; + } finally { + reader.releaseLock(); + } +} + +export type LimitedJson = + | { tooLarge: true; text: string; json: undefined } + /** `json` je `undefined`, kdyz telo neni platny JSON. Prazdne telo je `null`. */ + | { tooLarge: false; text: string; json: unknown }; + +/** Telo jako JSON s limitem. Nevyhazuje, volajici si vybere vlastni chybu. */ +export async function readJsonLimited(response: Response, limit: number): Promise { + const body = await readBodyLimited(response, limit); + if (body.tooLarge) return { tooLarge: true, text: body.text, json: undefined }; + if (body.text.trim() === '') return { tooLarge: false, text: body.text, json: null }; + try { + return { tooLarge: false, text: body.text, json: JSON.parse(body.text) as unknown }; + } catch { + return { tooLarge: false, text: body.text, json: undefined }; + } +} diff --git a/src/openapi.ts b/src/openapi.ts index 4164682..98ccc9f 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -102,9 +102,572 @@ const settingsEntities = [ { path: 'ticket-types', label: 'typy ticketu', permission: 'ticketType.manage' }, { path: 'actions', label: 'akce na ticketu', permission: 'action.manage' }, { path: 'widgets', label: 'vlastni widgety', permission: 'widget.manage' }, - { path: 'features', label: 'zalozky firmy', permission: 'tenant.manage' }, + // `features` tu neni: zalozky firmy maji jen GET a PUT, viz nize. ]; +const bearer = [{ bearerAuth: [] }]; +const idParam = { name: 'id', in: 'path', required: true, schema: { type: 'string' } }; +const tenantParam = { + name: 'tenantId', + in: 'query', + schema: { type: 'string' }, + description: 'Firma. Bez ni prvni, do ktere volajici patri. Cizi firma vraci 404.', +}; +/** Strankovani. Odpoved zustava seznam, pocet pred orezem je v hlavicce X-Total-Count. */ +const pagingParams = [ + { + name: 'limit', + in: 'query', + schema: { type: 'integer', minimum: 1, maximum: 500 }, + description: 'Kolik polozek nejvys. Bez hodnoty vsechny (u behu poslednich 50).', + }, + { + name: 'offset', + in: 'query', + schema: { type: 'integer', minimum: 0, default: 0 }, + description: 'Kolik polozek preskocit.', + }, +]; +const totalCountHeader = { + 'X-Total-Count': { + schema: { type: 'integer' }, + description: 'Pocet polozek pred strankovanim.', + }, +}; +const jsonBody = (schema: Record, required = true) => ({ + required, + content: { 'application/json': { schema } }, +}); +const jsonResponse = (description: string, schema: Record = { type: 'object' }) => ({ + description, + content: { 'application/json': { schema } }, +}); +const tooMany = { '429': { description: 'Prilis mnoho pokusu z jedne adresy, viz Retry-After' } }; + +/** + * Cesty, ktere nejsou ani CRUD z fabriky, ani v hlavnim seznamu nize. + * Pridane pri kontrole uplnosti: kazda registrovana routa musi byt tady. + */ +const additionalPaths: Record = { + '/api/dashboard/people/{id}': { + get: { + tags: ['Tickety'], + summary: 'Detail resitele', + description: + 'Kdo to je, statistika za 30 dni, skupiny, co ma u sebe a co naposledy vyresil. ' + + 'Jednim requestem, protoze se to vsechno pocita z tehoz seznamu. Cizi resitel je 404.', + security: bearer, + parameters: [idParam, tenantParam], + responses: { + '200': jsonResponse('Detail resitele', { + type: 'object', + properties: { + person: { $ref: '#/components/schemas/Person' }, + stats: { type: 'object', nullable: true }, + groups: { type: 'array', items: { type: 'object' } }, + open: { type: 'array', items: { $ref: '#/components/schemas/Ticket' } }, + resolved: { type: 'array', items: { $ref: '#/components/schemas/Ticket' } }, + }, + }), + '404': { description: 'Resitel neexistuje' }, + }, + }, + }, + '/api/dashboard/intake': { + get: { + tags: ['Webhook'], + summary: 'Adresa pro prijem udalosti do ticketu', + description: + 'Token je pristupovy udaj, proto ho vidi jen kdo ma connector.manage. ' + + 'Vraci i typy ticketu firmy, aby odesilatel vedel, jaka pole muze poslat.', + security: bearer, + parameters: [tenantParam], + responses: { + '200': jsonResponse('Adresa a typy ticketu'), + '403': { description: 'Chybi pravo connector.manage' }, + }, + }, + }, + '/api/dashboard/intake/regenerate': { + post: { + tags: ['Webhook'], + summary: 'Nova adresa prijmu', + description: 'Stara okamzite prestane fungovat.', + security: bearer, + parameters: [tenantParam], + responses: { + '200': jsonResponse('Nova adresa', { + type: 'object', + properties: { url: { type: 'string' } }, + }), + '403': { description: 'Chybi pravo connector.manage' }, + }, + }, + }, + '/api/dashboard/tickets/statuses': { + get: { + tags: ['Tickety'], + summary: 'Stavy, ktere firma opravdu pouziva', + description: + 'Stav je volny retezec, ne ciselnik. Tohle je jen naseptavac z toho, co v datech je.', + security: bearer, + parameters: [tenantParam], + responses: { + '200': jsonResponse('Stavy', { + type: 'object', + properties: { items: { type: 'array', items: { type: 'string' } } }, + }), + }, + }, + }, + '/api/dashboard/tickets/{id}/claim': { + post: { + tags: ['Tickety'], + summary: 'Prevzit ticket', + description: + 'Clovek si vezme praci sam. Jde to u ticketu bez resitele nebo ze sve skupiny; ' + + 'vzit cizi rozdelanou praci chce ticket.assign.others. Bez tela.', + security: bearer, + parameters: [idParam, tenantParam], + responses: { + '200': jsonResponse('Prevzato', { $ref: '#/components/schemas/Ticket' }), + '400': { description: 'Volajici neni veden jako resitel' }, + '403': { description: 'Chybi pravo ticket.assign.self, nebo ticket neni ve skupine volajiciho' }, + '404': { description: 'Ticket neexistuje nebo na nej volajici nevidi' }, + '409': { description: 'Ticket uz nekdo resi' }, + }, + }, + }, + '/api/dashboard/helpdesk': { + get: { + tags: ['Helpdesk'], + summary: 'Pozadavky, ktere firma poslala svemu dodavateli', + description: + 'Pohled zadavatele, ne resitele. Kdo nevidi celou firmu, vidi jen to, co sam poslal.', + security: bearer, + parameters: [tenantParam], + responses: { + '200': jsonResponse('Pozadavky, dodavatel a jestli lze zakladat'), + '403': { description: 'Chybi pravo helpdesk.view' }, + }, + }, + post: { + tags: ['Helpdesk'], + summary: 'Poslat pozadavek dodavateli', + description: 'Vlastnikem ticketu je dodavatel firmy, zadavatel ho vidi pres helpdesk.', + security: bearer, + parameters: [tenantParam], + requestBody: jsonBody({ + type: 'object', + required: ['subject'], + properties: { + subject: { type: 'string' }, + body: { type: 'string' }, + priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] }, + }, + }), + responses: { + '201': jsonResponse('Zalozeno', { $ref: '#/components/schemas/Ticket' }), + '400': { description: 'Neplatny vstup, nebo firma nema dodavatele helpdesku' }, + '403': { description: 'Chybi pravo helpdesk.create' }, + }, + }, + }, + '/api/dashboard/helpdesk/{id}': { + get: { + tags: ['Helpdesk'], + summary: 'Detail vlastniho pozadavku', + security: bearer, + parameters: [idParam, tenantParam], + responses: { + '200': jsonResponse('Pozadavek', { $ref: '#/components/schemas/TicketDetail' }), + '404': { description: 'Pozadavek neexistuje nebo ho neposlala tato firma' }, + }, + }, + }, + '/api/dashboard/helpdesk/{id}/comment': { + post: { + tags: ['Helpdesk'], + summary: 'Komentar zadavatele', + description: 'Jedina zmena, kterou zadavatel nad pozadavkem smi.', + security: bearer, + parameters: [idParam, tenantParam], + requestBody: jsonBody({ + type: 'object', + required: ['text'], + properties: { text: { type: 'string', minLength: 1 } }, + }), + responses: { + '200': jsonResponse('Zapsano', { $ref: '#/components/schemas/Ticket' }), + '404': { description: 'Pozadavek neexistuje' }, + }, + }, + }, + '/api/dashboard/invites': { + get: { + tags: ['Pozvanky'], + summary: 'Pozvanky firmy', + description: 'Vcetne cele adresy k odeslani a roli, ktere jde pridelit. Chce user.manage.', + security: bearer, + parameters: [tenantParam], + responses: { + '200': jsonResponse('Pozvanky a role'), + '403': { description: 'Chybi pravo user.manage' }, + }, + }, + post: { + tags: ['Pozvanky'], + summary: 'Vytvorit pozvanku', + description: + 'Odkaz s neodhadnutelnym kodem, plati tyden. Role musi byt teto firmy nebo systemove. ' + + 'Pozvanka nikdy nedela spravce platformy.', + security: bearer, + parameters: [tenantParam], + requestBody: jsonBody({ + type: 'object', + required: ['roleIds'], + properties: { + email: { type: 'string', description: 'Prazdne = komukoliv s odkazem.' }, + note: { type: 'string' }, + roleIds: { type: 'array', items: { type: 'string' } }, + asPerson: { type: 'boolean', default: true }, + }, + }), + responses: { + '201': jsonResponse('Pozvanka vcetne url'), + '400': { description: 'Neplatny vstup nebo neznama role' }, + '403': { description: 'Chybi pravo user.manage' }, + }, + }, + }, + '/api/dashboard/invites/{id}': { + delete: { + tags: ['Pozvanky'], + summary: 'Zrusit pozvanku', + security: bearer, + parameters: [idParam, tenantParam], + responses: { + '204': { description: 'Zruseno' }, + '403': { description: 'Chybi pravo user.manage' }, + '404': { description: 'Pozvanka neexistuje' }, + }, + }, + }, + '/api/invites/{code}': { + get: { + tags: ['Pozvanky'], + summary: 'Co je za odkazem pozvanky', + description: 'VEREJNE. Vraci jen nazev firmy, pripadny e-mail a jestli jde prijmout.', + parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': jsonResponse('Firma a platnost', { + type: 'object', + properties: { + tenant: { type: 'string' }, + email: { type: 'string', nullable: true }, + knownUser: { type: 'boolean' }, + valid: { type: 'boolean' }, + problem: { type: 'string', nullable: true }, + }, + }), + '404': { description: 'Pozvanka neexistuje' }, + }, + }, + }, + '/api/invites/{code}/accept': { + post: { + tags: ['Pozvanky'], + summary: 'Prijmout pozvanku', + description: + 'VEREJNE. Bez uctu ho zalozi, s uctem ho po overeni hesla pripoji k firme. ' + + 'Pet pokusu za ctvrt hodiny z jedne adresy.', + parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: jsonBody({ + type: 'object', + required: ['name', 'email', 'password'], + properties: { + name: { type: 'string' }, + email: { type: 'string', format: 'email' }, + password: { type: 'string', format: 'password', minLength: 8 }, + }, + }), + responses: { + '201': jsonResponse('Prijato'), + '400': { description: 'Neplatne udaje' }, + '401': { description: 'Ucet existuje a heslo nesedi' }, + '403': { description: 'Pozvanka je pro jinou adresu' }, + '404': { description: 'Pozvanka neexistuje' }, + '409': { description: 'Pozvanka uz byla pouzita nebo vyprsela' }, + ...tooMany, + }, + }, + }, + '/api/dashboard/tenant-scripts': { + get: { + tags: ['Skripty'], + summary: 'Skripty firmy', + description: 'Prevod dat v JS uvnitr firmy. Nevolaji ven. Vraci i vzor pro novy skript.', + security: bearer, + parameters: [tenantParam], + responses: { '200': jsonResponse('Skripty, vzor a limit delky') }, + }, + post: { + tags: ['Skripty'], + summary: 'Zalozit skript firmy', + security: bearer, + parameters: [tenantParam], + requestBody: jsonBody({ + type: 'object', + required: ['name', 'code'], + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + code: { type: 'string' }, + enabled: { type: 'boolean' }, + }, + }), + responses: { + '201': jsonResponse('Zalozeno'), + '400': { description: 'Neplatny vstup' }, + '403': { description: 'Chybi pravo action.manage' }, + }, + }, + }, + '/api/dashboard/tenant-scripts/{id}': { + put: { + tags: ['Skripty'], + summary: 'Upravit skript firmy', + security: bearer, + parameters: [idParam, tenantParam], + requestBody: jsonBody({ type: 'object' }), + responses: { + '200': jsonResponse('Ulozeno'), + '403': { description: 'Chybi pravo action.manage' }, + '404': { description: 'Skript neexistuje' }, + }, + }, + delete: { + tags: ['Skripty'], + summary: 'Smazat skript firmy', + security: bearer, + parameters: [idParam, tenantParam], + responses: { + '204': { description: 'Smazano' }, + '403': { description: 'Chybi pravo action.manage' }, + '404': { description: 'Skript neexistuje' }, + }, + }, + }, + '/api/dashboard/tenant-scripts/test': { + post: { + tags: ['Skripty'], + summary: 'Zkusit skript firmy bez ulozeni', + description: 'Vraci 200 i kdyz skript spadl. Chyba ve skriptu neni chyba API.', + security: bearer, + parameters: [tenantParam], + requestBody: jsonBody({ + type: 'object', + required: ['code'], + properties: { code: { type: 'string' }, input: {} }, + }), + responses: { + '200': jsonResponse('Vysledek behu'), + '403': { description: 'Chybi pravo action.manage' }, + }, + }, + }, + '/api/dashboard/settings/features': { + get: { + tags: ['Nastaveni'], + summary: 'Zalozky a limity firmy', + description: 'Vraci i vychozi, kdyz firma vlastni nastaveni jeste nema.', + security: bearer, + parameters: [tenantParam], + responses: { '200': jsonResponse('Nastaveni firmy') }, + }, + put: { + tags: ['Nastaveni'], + summary: 'Nastavit zalozky a limity firmy', + description: 'Jen spravce platformy. Povinne moduly se doplni vzdy.', + security: bearer, + parameters: [tenantParam], + requestBody: jsonBody({ + type: 'object', + properties: { + modules: { type: 'array', items: { type: 'string' } }, + limits: { type: 'object' }, + serviceIds: { type: 'array', items: { type: 'string' } }, + }, + }), + responses: { + '200': jsonResponse('Ulozeno'), + '400': { description: 'Neznamy modul nebo neplatny vstup' }, + '403': { description: 'Jen spravce platformy' }, + }, + }, + }, + '/api/dashboard/settings/ares/companies': { + get: { + tags: ['Nastaveni'], + summary: 'Firmy z registru ARES', + description: + 'Jen spravce platformy. Query je bud IC (1 az 8 cislic, presna shoda), nebo cast nazvu. ' + + 'U kazde firmy je `existingTenantId`, kdyz uz v portalu je.', + security: bearer, + parameters: [{ name: 'query', in: 'query', required: true, schema: { type: 'string' } }], + responses: { + '200': jsonResponse('Firmy', { + type: 'object', + properties: { companies: { type: 'array', items: { $ref: '#/components/schemas/AresCompany' } } }, + }), + '400': { description: 'Prazdny dotaz nebo neplatne IC' }, + '403': { description: 'Jen spravce platformy' }, + '502': { description: 'ARES neodpovedel' }, + }, + }, + }, + '/api/dashboard/settings/ares/companies/{ico}/persons': { + get: { + tags: ['Nastaveni'], + summary: 'Osoby, ktere za firmu jednaji', + description: + 'Soucasni clenove statutarnich organu a prokura z verejneho rejstriku. ' + + 'Kazda osoba ma navrzeny nahradni e-mail IC-poradi@placeholder.cz, ARES e-maily nevede.', + security: bearer, + parameters: [{ name: 'ico', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': jsonResponse('Osoby', { + type: 'object', + properties: { + persons: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', example: 'Jan Novák' }, + firstName: { type: 'string' }, + lastName: { type: 'string' }, + roles: { type: 'array', items: { type: 'string' }, example: ['jednatel'] }, + email: { type: 'string', example: '12345678-1@placeholder.cz' }, + }, + }, + }, + }, + }), + '403': { description: 'Jen spravce platformy' }, + '502': { description: 'ARES neodpovedel' }, + }, + }, + }, + '/api/dashboard/settings/ares/tenants': { + post: { + tags: ['Nastaveni'], + summary: 'Zalozit firmu z ARES vcetne uctu', + description: + 'Udaje firmy se berou znovu z ARES podle IC. Vybrane osoby dostanou ucet se roli spravce firmy ' + + 'a nahodnym heslem; bez e-mailu dostanou nahradni IC-poradi@placeholder.cz.', + security: bearer, + requestBody: jsonBody({ + type: 'object', + required: ['ico'], + properties: { + ico: { type: 'string', example: '27074358' }, + name: { type: 'string', description: 'Prepis nazvu z ARES.' }, + note: { type: 'string' }, + persons: { + type: 'array', + items: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + email: { type: 'string', description: 'Prazdne = nahradni e-mail.' }, + }, + }, + }, + }, + }), + responses: { + '201': jsonResponse('Firma a ucty', { + type: 'object', + properties: { + tenant: { $ref: '#/components/schemas/Tenant' }, + users: { type: 'array', items: { type: 'object' } }, + }, + }), + '400': { description: 'Neplatny vstup' }, + '403': { description: 'Jen spravce platformy' }, + '404': { description: 'ARES firmu nezna' }, + '409': { description: 'Firma nebo e-mail uz existuje' }, + '502': { description: 'ARES neodpovedel' }, + }, + }, + }, + '/api/dashboard/settings/users-overview': { + get: { + tags: ['Nastaveni'], + summary: 'Uzivatele vcetne vypnutych', + description: 'Spravce platformy vidi vsechny, spravce firmy (user.manage) jen lidi sve firmy.', + security: bearer, + parameters: [tenantParam], + responses: { + '200': jsonResponse('Uzivatele'), + '403': { description: 'Chybi pravo user.manage' }, + }, + }, + }, + '/api/dashboard/settings/roles-available': { + get: { + tags: ['Nastaveni'], + summary: 'Role dostupne firme', + description: 'Vlastni role firmy plus systemove. Pro nabidku u clenstvi.', + security: bearer, + parameters: [tenantParam], + responses: { '200': jsonResponse('Role') }, + }, + }, + '/api/dashboard/settings/people-overview': { + get: { + tags: ['Nastaveni'], + summary: 'Resitele vcetne vypnutych', + security: bearer, + parameters: [tenantParam], + responses: { '200': jsonResponse('Resitele') }, + }, + }, + '/api/dashboard/settings/actions-overview': { + get: { + tags: ['Nastaveni'], + summary: 'Akce firmy vcetne vypnutych', + security: bearer, + parameters: [tenantParam], + responses: { '200': jsonResponse('Akce') }, + }, + }, + '/api/dashboard/settings/actions/{id}/scope': { + get: { + tags: ['Nastaveni'], + summary: 'Na co se da ve stromu akce odkazovat', + description: 'Udaje ticketu plus vlastni pole jeho typu a doptavaci pole akce.', + security: bearer, + parameters: [idParam, tenantParam], + responses: { + '200': jsonResponse('Parametry'), + '404': { description: 'Akce neexistuje' }, + }, + }, + }, + '/api/dashboard/settings/widgets-overview': { + get: { + tags: ['Nastaveni'], + summary: 'Widgety firmy plus osobni prihlaseneho', + security: bearer, + parameters: [tenantParam], + responses: { '200': jsonResponse('Widgety') }, + }, + }, +}; + /** * OpenAPI popis API. * @@ -136,7 +699,10 @@ export function buildOpenApiDocument() { { name: 'Skripty', description: 'Vykonna cast sluzby: manifest, kod a zkusebni beh' }, { name: 'Nastaveni', description: 'Firmy, lide, role a prava, typy ticketu, akce, widgety' }, { name: 'Sprava platformy', description: 'Audit a prepnuti na jiny ucet' }, - { name: 'Webhook', description: 'Verejny prijem dat do automatizace' }, + { name: 'Webhook', description: 'Verejny prijem dat do automatizace a do ticketu' }, + { name: 'Helpdesk', description: 'Pozadavky, ktere firma posila svemu dodavateli' }, + { name: 'Pozvanky', description: 'Pozvanky do firmy a jejich prijeti' }, + { name: 'Portal', description: 'Pomocne endpointy klienta' }, { name: 'Kontakt', description: 'Poptavkovy formular z webu' }, ], components: { @@ -149,11 +715,56 @@ export function buildOpenApiDocument() { }, }, 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: { @@ -205,6 +816,29 @@ export function buildOpenApiDocument() { 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: { @@ -675,6 +1309,7 @@ export function buildOpenApiDocument() { (all, entity) => ({ ...all, ...crudPaths(entity) }), {} as Record, ), + ...additionalPaths, '/health': { get: { tags: ['Provoz'], @@ -719,6 +1354,7 @@ export function buildOpenApiDocument() { description: 'Nespravny e-mail nebo heslo', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, + ...tooMany, }, }, }, @@ -839,8 +1475,10 @@ export function buildOpenApiDocument() { tags: ['Dashboard'], summary: 'Co uzivatel smi videt', description: - 'Povolene pohledy, firmy k prepinani a prava. Klient si to nesmi dovozovat sam.', + 'Povolene pohledy, firmy k prepinani, prava a nazvy roli za vybranou firmu. ' + + 'Klient si to nesmi dovozovat sam.', security: [{ bearerAuth: [] }], + parameters: [tenantParam], responses: { '200': { description: 'Opravneni', @@ -914,19 +1552,35 @@ export function buildOpenApiDocument() { { name: 'channel', in: 'query', - schema: { type: 'string', enum: ['whatsapp', 'email', 'voice', 'form', 'portal'] }, + schema: { + type: 'string', + enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'], + }, }, + { name: 'typeId', in: 'query', schema: { type: 'string' }, description: '`none` = bez typu.' }, + { name: 'tag', in: 'query', schema: { type: 'string' }, description: '`none` = bez tagu.' }, + { name: 'groupId', in: 'query', schema: { type: 'string' }, description: '`none` = bez skupiny.' }, + ...pagingParams, ], responses: { '200': { description: 'Tickety', + headers: totalCountHeader, content: { 'application/json': { schema: { type: 'object', properties: { items: { type: 'array', items: { $ref: '#/components/schemas/Ticket' } }, + total: { type: 'integer', description: 'Pocet pred strankovanim.' }, + statuses: { + type: 'array', + items: { type: 'string' }, + description: 'Stavy, ktere firma pouziva. Z celeho rozsahu, ne z filtru.', + }, meId: { type: 'string', nullable: true }, + scope: { type: 'string', enum: ['all', 'tenant', 'mine'] }, + tenantId: { type: 'string', nullable: true }, }, }, }, @@ -934,6 +1588,34 @@ export function buildOpenApiDocument() { }, }, }, + post: { + tags: ['Tickety'], + summary: 'Zalozit ticket rucne', + description: + 'Zaklada se do prave prepnute firmy. Zakaznik je nepovinny - rucne zalozeny ' + + 'ticket je casto ukol, ne pozadavek zvenku. Chce pravo ticket.create.', + security: [{ bearerAuth: [] }], + parameters: [tenantParam], + requestBody: jsonBody({ + type: 'object', + required: ['subject'], + properties: { + subject: { type: 'string' }, + body: { type: 'string' }, + priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] }, + typeId: { type: 'string', nullable: true }, + assigneeId: { type: 'string', nullable: true }, + assigneeGroupId: { type: 'string', nullable: true }, + tags: { type: 'array', items: { type: 'string' }, maxItems: 20 }, + customer: { $ref: '#/components/schemas/TicketCustomer' }, + }, + }), + responses: { + '201': jsonResponse('Zalozeno', { $ref: '#/components/schemas/Ticket' }), + '400': { description: 'Neplatny vstup nebo neni vybrana firma' }, + '403': { description: 'Chybi pravo ticket.create' }, + }, + }, }, '/api/dashboard/tickets/workload': { get: { @@ -981,7 +1663,9 @@ export function buildOpenApiDocument() { post: { tags: ['Tickety'], summary: 'Priradit resitele', - description: 'Poslete null pro vraceni ticketu do fronty.', + description: + 'Poslete null pro vraceni ticketu do fronty. Vzit si ticket na sebe chce ' + + 'ticket.assign.self, cokoliv jineho ticket.assign.others.', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], requestBody: { @@ -1006,6 +1690,7 @@ export function buildOpenApiDocument() { }, }, '400': { description: 'Chybi assigneeId' }, + '403': { description: 'Chybi pravo ticket.assign.self nebo ticket.assign.others' }, '404': { description: 'Ticket nebo resitel neexistuje' }, }, }, @@ -1014,6 +1699,9 @@ export function buildOpenApiDocument() { post: { tags: ['Tickety'], summary: 'Zmenit stav ticketu', + description: + 'Stav je volny retezec, ne ciselnik - tickety chodi z cizich aplikaci. ' + + '`closed` rika, jestli je vyrizeny; bez nej priznak zustava. Chce ticket.status.change.', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], requestBody: { @@ -1024,7 +1712,8 @@ export function buildOpenApiDocument() { type: 'object', required: ['status'], properties: { - status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] }, + status: { type: 'string', maxLength: 60, example: 'open' }, + closed: { type: 'boolean' }, }, }, }, @@ -1038,6 +1727,7 @@ export function buildOpenApiDocument() { }, }, '400': { description: 'Neplatny stav' }, + '403': { description: 'Chybi pravo ticket.status.change' }, '404': { description: 'Neexistuje' }, }, }, @@ -1069,6 +1759,7 @@ export function buildOpenApiDocument() { }, }, '400': { description: 'Prazdny komentar' }, + '403': { description: 'Chybi pravo ticket.comment' }, '404': { description: 'Neexistuje' }, }, }, @@ -1523,7 +2214,11 @@ export function buildOpenApiDocument() { 'vubec, ne se stavem "nemate pravo". Operace, ktere obsluhuje skript, nesou ' + 'implementation: script a maji skutecne inputs a outputFields.', security: [{ bearerAuth: [] }], - responses: { '200': { description: 'Sluzby, kategorie a operatory podminek' } }, + parameters: [tenantParam], + responses: { + '200': { description: 'Sluzby, kategorie a operatory podminek' }, + '404': { description: 'Firma neexistuje, nebo do ni volajici nepatri' }, + }, }, }, '/api/dashboard/connectors/services': { @@ -1606,6 +2301,7 @@ export function buildOpenApiDocument() { }, }, '400': { description: 'Obecna sluzba konektor nepotrebuje, nebo nezname pole' }, + '403': { description: 'Chybi pravo connector.manage' }, '404': { description: 'Sluzba neexistuje nebo ji uzivatel nevidi' }, }, }, @@ -1644,22 +2340,30 @@ export function buildOpenApiDocument() { }, }, }, - responses: { '200': { description: 'Upraveno' }, '404': { description: 'Neexistuje' } }, + responses: { + '200': { description: 'Upraveno' }, + '403': { description: 'Chybi pravo connector.manage' }, + '404': { description: 'Neexistuje' }, + }, }, delete: { tags: ['Konektory'], summary: 'Smazat konektor', description: 'Kdyz zmizel vychozi konektor, prevezme to prvni zbyly - jinak by kroky bez ' + - 'vybraneho konektoru prestaly fungovat.', + 'vybraneho konektoru prestaly fungovat. Chce pravo connector.manage.', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - responses: { '204': { description: 'Smazano' }, '404': { description: 'Neexistuje' } }, + responses: { + '204': { description: 'Smazano' }, + '403': { description: 'Chybi pravo connector.manage' }, + '404': { description: 'Neexistuje' }, + }, }, }, - '/api/whoami': { + '/whoami': { get: { - tags: ['Systém'], + tags: ['Provoz'], summary: 'Jak nas vidi ten, kdo nam vola', description: 'Vraci volajicimu jeho vlastni adresu tak, jak dorazila k serveru. Zni to ' + @@ -1781,6 +2485,7 @@ export function buildOpenApiDocument() { }, }, }, + '403': { description: 'Chybi pravo connector.manage' }, '404': { description: 'Konektor neexistuje' }, }, }, @@ -1789,6 +2494,7 @@ export function buildOpenApiDocument() { post: { tags: ['Portal'], summary: 'Nahlasit pad vykreslovani', + parameters: [tenantParam], description: 'Zaklada incident z padu portalu v prohlizeci. Bez toho je jedina stopa v konzoli ' + 'uzivatele, kam se nikdo nedostane, takze bychom o padu vedeli jen tehdy, kdyby ho ' + @@ -1833,6 +2539,7 @@ export function buildOpenApiDocument() { }, '202': { description: 'Stejny pad uz je hlaseny, incident se nezaklada' }, '400': { description: 'Neplatny vstup' }, + '404': { description: 'Firma neexistuje, nebo do ni volajici nepatri' }, }, }, }, @@ -1890,6 +2597,7 @@ export function buildOpenApiDocument() { }, }, '400': { description: 'Sluzba neni MCP server' }, + '403': { description: 'Chybi pravo connector.manage' }, '404': { description: 'Konektor neexistuje' }, }, }, @@ -1977,6 +2685,7 @@ export function buildOpenApiDocument() { 'a stav napojeni v `connections`. Pristupove udaje se nikdy nevraci, ' + 'jen jmena chybejicich environment variables.', security: [{ bearerAuth: [] }], + parameters: [tenantParam], responses: { '200': { description: 'Skripty, problemy a stav napojeni', @@ -1997,7 +2706,12 @@ export function buildOpenApiDocument() { type: 'array', items: { $ref: '#/components/schemas/ConnectionStatus' }, }, - directory: { type: 'string', example: '/app/scripts' }, + directory: { + type: 'string', + nullable: true, + example: '/app/scripts', + description: 'Jen pro spravce platformy, ostatnim null.', + }, }, }, }, @@ -2155,7 +2869,9 @@ export function buildOpenApiDocument() { post: { tags: ['Automatizace'], summary: 'Zalozit automatizaci', + description: 'Do prave prepnute firmy. Chce pravo automation.edit.', security: [{ bearerAuth: [] }], + parameters: [tenantParam], requestBody: { required: true, content: { @@ -2176,6 +2892,7 @@ export function buildOpenApiDocument() { }, }, '400': { description: 'Neplatny nazev' }, + '403': { description: 'Chybi pravo automation.edit' }, }, }, }, @@ -2227,6 +2944,7 @@ export function buildOpenApiDocument() { }, }, '400': { description: 'Neplatny strom' }, + '403': { description: 'Chybi pravo automation.edit' }, '404': { description: 'Neexistuje' }, }, }, @@ -2234,14 +2952,18 @@ export function buildOpenApiDocument() { tags: ['Automatizace'], summary: 'Smazat automatizaci', security: [{ bearerAuth: [] }], - responses: { '204': { description: 'Smazano' }, '404': { description: 'Neexistuje' } }, + responses: { + '204': { description: 'Smazano' }, + '403': { description: 'Chybi pravo automation.edit' }, + '404': { description: 'Neexistuje' }, + }, }, }, '/api/dashboard/automations/{id}/webhook/regenerate': { post: { tags: ['Automatizace'], summary: 'Nova adresa webhooku', - description: 'Stara adresa okamzite prestane fungovat.', + description: 'Stara adresa okamzite prestane fungovat. Chce pravo automation.edit.', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { @@ -2251,6 +2973,7 @@ export function buildOpenApiDocument() { 'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } }, }, }, + '403': { description: 'Chybi pravo automation.edit' }, '404': { description: 'Neexistuje nebo spoustecem neni webhook' }, }, }, @@ -2313,11 +3036,14 @@ export function buildOpenApiDocument() { summary: 'Stav fronty behu', description: 'Kdyz neco nefunguje, tohle je prvni misto, kam se clovek podiva: ceka fronta, ' + - 'nebo uz to nekolikrat selhalo? U kazdeho behu je cele chybove hlaseni.', + 'nebo uz to nekolikrat selhalo? U kazdeho behu je cele chybove hlaseni. ' + + 'Bez `limit` poslednich 50.', security: [{ bearerAuth: [] }], + parameters: [tenantParam, ...pagingParams], responses: { '200': { description: 'Fronta a posledni behy', + headers: totalCountHeader, content: { 'application/json': { schema: { @@ -2377,6 +3103,16 @@ export function buildOpenApiDocument() { '404': { description: 'Neznamy token' }, }, }, + get: { + tags: ['Webhook'], + summary: 'Napoveda k prijmu udalosti', + description: 'Jak se ma volat a jake typy ticketu firma ma. Nic nemeni.', + parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { description: 'Navod a typy ticketu' }, + '404': { description: 'Neznamy token' }, + }, + }, }, '/webhook/{token}': { post: { @@ -2463,6 +3199,7 @@ export function buildOpenApiDocument() { responses: { '202': { description: 'Prijato' }, '400': { description: 'Neplatny vstup' }, + ...tooMany, }, }, }, diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 631c039..f9788d2 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -10,17 +10,39 @@ * a smaze neco cizim jmenem. */ -import { Router } from 'express'; +import type { NextFunction, Request, Response } from 'express'; import jwt from 'jsonwebtoken'; import { z } from 'zod'; import { config } from '../config.js'; import { listAudit, recordAudit } from '../data/audit.js'; +import { hasPermission } from '../data/permissions.js'; import { listAllUsers, findUserById } from '../data/users.js'; +import { safeRouter } from '../middleware/asyncHandler.js'; import { requireAuth, requirePlatformAdmin } from '../middleware/auth.js'; +import { attachAccess } from '../middleware/tenant.js'; +import { validationError } from '../middleware/validation.js'; import { readScope } from './crud.js'; -export const adminRouter = Router(); -adminRouter.use(requireAuth); +export const adminRouter = safeRouter(); +adminRouter.use(requireAuth, attachAccess); + +/** + * Platformni pravo z katalogu (`impersonate`, `audit.view`). + * + * Spravce platformy ma vsechna prava, takze je to dnes totez co + * `requirePlatformAdmin`. Kontroluje se presto: pravo je v katalogu a bez + * teto kontroly by tam bylo jen na okrasu. Az se platformni prava zacnou + * delit mezi vic lidi, tohle je misto, kde se to projevi. + */ +function requirePlatformPermission(permission: string) { + return (req: Request, res: Response, next: NextFunction) => { + if (!hasPermission(req.user!, permission, null)) { + console.warn(`[admin] ${req.user!.email}: chybi pravo ${permission}`); + return res.status(403).json({ error: 'forbidden', message: 'Nedostatečná oprávnění.' }); + } + return next(); + }; +} /** Kratka platnost je zamer. Impersonace neni rezim, ve kterem se pracuje. */ const IMPERSONATION_MINUTES = 30; @@ -35,11 +57,9 @@ const startSchema = z.object({ reason: z.string().trim().max(300).optional(), }); -adminRouter.post('/impersonate', requirePlatformAdmin, (req, res) => { +adminRouter.post('/impersonate', requirePlatformAdmin, requirePlatformPermission('impersonate'), (req, res) => { const parsed = startSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: 'validation_error', message: 'Vyberte uživatele.' }); - } + if (!parsed.success) return validationError(res, parsed.error, 'Vyberte uživatele.'); const target = findUserById(parsed.data.userId); if (!target) { @@ -114,7 +134,7 @@ adminRouter.post('/impersonate/stop', (req, res) => { }); /** Koho lze prepnout. Spravci platformy se nenabizeji. */ -adminRouter.get('/impersonate/candidates', requirePlatformAdmin, (_req, res) => { +adminRouter.get('/impersonate/candidates', requirePlatformAdmin, requirePlatformPermission('impersonate'), (_req, res) => { res.json({ items: listAllUsers() .filter((user) => !user.platformAdmin && user.enabled) @@ -124,12 +144,8 @@ adminRouter.get('/impersonate/candidates', requirePlatformAdmin, (_req, res) => // ------------------------------------------------------------------- audit -adminRouter.get('/audit', (req, res) => { - // Audit vidi jen kdo ma pravo. Je to zaznam o lidech, ne provozni log. - if (!req.user!.platformAdmin) { - return res.status(403).json({ error: 'forbidden', message: 'Audit vidí správce platformy.' }); - } - +// Audit vidi jen kdo ma pravo. Je to zaznam o lidech, ne provozni log. +adminRouter.get('/audit', requirePlatformAdmin, requirePlatformPermission('audit.view'), (req, res) => { const limit = Number(req.query.limit ?? 200); return void listAudit({ ...readScope(req), diff --git a/src/routes/ares.ts b/src/routes/ares.ts new file mode 100644 index 0000000..06ed737 --- /dev/null +++ b/src/routes/ares.ts @@ -0,0 +1,188 @@ +/** + * Zalozeni firmy z registru ARES. + * + * Spravce platformy napise IC nebo nazev, portal dotahne udaje firmy a osoby, + * ktere za ni jednaji, a spravce si vybere, komu zalozit ucet. ARES e-maily + * nevede, takze osoba bez zadaneho e-mailu dostane nahradni ve tvaru + * `IC-poradi@placeholder.cz`, ktery se ma v Nastaveni nahradit skutecnym. + * + * Jen spravce platformy: firmy zaklada jen on (rozhodnuti v 07-firmy-a-prava). + * Router je namontovany pod /settings, takze `requireAuth` uz probehl. + */ + +import { randomBytes, randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { + AresError, + listCompanyPersons, + lookupCompany, + normalizeIco, + placeholderEmail, + searchCompanies, +} from '../ares/client.js'; +import { recordAudit } from '../data/audit.js'; +import { nowIso } from '../data/store/index.js'; +import { generateIntakeToken, listTenants, tenantStore, type Tenant } from '../data/tenants.js'; +import { hashPassword, listAllUsers, userStore, type StoredUser } from '../data/users.js'; +import { safeRouter } from '../middleware/asyncHandler.js'; +import { requirePlatformAdmin } from '../middleware/auth.js'; +import { validationError } from '../middleware/validation.js'; + +export const aresRouter = safeRouter(); +aresRouter.use(requirePlatformAdmin); + +/** Chyba ARES neni chyba naseho API: 400 je vstup, 404 neznama firma, jinak 502. */ +function aresStatus(err: AresError): number { + if (err.status === 400 || err.status === 404) return err.status; + return 502; +} + +/** Firmy podle IC (presne) nebo casti nazvu. */ +aresRouter.get('/companies', async (req, res) => { + const query = String(req.query.query ?? '').trim(); + if (query === '') { + return res.status(400).json({ error: 'validation_error', message: 'Zadejte IČ nebo název.' }); + } + try { + const ico = normalizeIco(query); + const companies = ico !== null ? [await lookupCompany(ico)].filter((c) => c !== null) : await searchCompanies(query, 10); + const known = new Map(listTenants().filter((t) => t.ico).map((t) => [t.ico!, t.id])); + return res.json({ + // `existingTenantId` rika, ze firma uz v portalu je - druhe zalozeni nema smysl. + companies: companies.map((c) => ({ ...c, existingTenantId: known.get(c.ico) ?? null })), + }); + } catch (err) { + if (err instanceof AresError) return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message }); + throw err; + } +}); + +/** Osoby, ktere za firmu dnes jednaji, kazda s navrzenym nahradnim e-mailem. */ +aresRouter.get('/companies/:ico/persons', async (req, res) => { + const ico = normalizeIco(req.params.ico); + if (ico === null) return res.status(400).json({ error: 'validation_error', message: 'IČ má 1 až 8 číslic.' }); + try { + const persons = await listCompanyPersons(ico); + return res.json({ + persons: persons.map((person, index) => ({ ...person, email: placeholderEmail(ico, index + 1) })), + }); + } catch (err) { + if (err instanceof AresError) return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message }); + throw err; + } +}); + +const createSchema = z.object({ + ico: z.string().trim().regex(/^\d{1,8}$/, 'IČ má 1 až 8 číslic.'), + /** Nazev jde prepsat, ARES vraci i tvary jako "FIRMA, s.r.o." velkymi pismeny. */ + name: z.string().trim().min(2).max(80).optional(), + note: z.string().trim().max(500).optional(), + persons: z + .array( + z.object({ + name: z.string().trim().min(2, 'Jméno je moc krátké.').max(80), + /** Prazdne = nahradni e-mail podle poradi. */ + email: z.string().trim().email('Zadejte platný e-mail.').optional().or(z.literal('')), + }), + ) + .max(50) + .default([]), +}); + +/** + * Zalozi firmu a ucty vybranych osob, vsechny jako spravce firmy. + * + * Udaje firmy se berou znovu z ARES, ne z klienta: co se ulozi k firme, ma + * odpovidat registru, ne tomu, co prislo v tele. Heslo je nahodne a nikam se + * neposila; clovek si ho nastavi pres zmenu hesla v Nastaveni nebo pozvanku. + */ +aresRouter.post('/tenants', async (req, res) => { + const parsed = createSchema.safeParse(req.body); + if (!parsed.success) return validationError(res, parsed.error); + const ico = normalizeIco(parsed.data.ico)!; + + let company; + try { + company = await lookupCompany(ico); + } catch (err) { + if (err instanceof AresError) return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message }); + throw err; + } + if (company === null) { + return res.status(404).json({ error: 'not_found', message: `ARES nezná firmu s IČ ${ico}.` }); + } + + const tenants = listTenants(); + const name = parsed.data.name ?? company.name; + if (tenants.some((t) => t.ico === ico)) { + return res.status(409).json({ error: 'conflict', message: `Firma s IČ ${ico} už existuje.` }); + } + if (tenants.some((t) => t.name.toLowerCase() === name.toLowerCase())) { + return res.status(409).json({ error: 'conflict', message: `Firma ${name} už existuje.` }); + } + + // E-maily se kontroluji pred prvnim zapisem: firma bez lidi by jinak vznikla + // a druhy pokus by skoncil na "uz existuje". + const taken = new Set(listAllUsers().map((u) => u.email.toLowerCase())); + const emails = parsed.data.persons.map((person, index) => + (person.email && person.email !== '' ? person.email : placeholderEmail(ico, index + 1)).toLowerCase(), + ); + for (const email of emails) { + if (taken.has(email)) { + return res.status(409).json({ error: 'conflict', message: `E-mail ${email} už někdo má.` }); + } + taken.delete(email); + } + if (new Set(emails).size !== emails.length) { + return res.status(400).json({ error: 'validation_error', message: 'Dva lidé mají stejný e-mail.' }); + } + + const timestamp = nowIso(); + const tenant: Tenant = { + id: `tnt_${randomUUID().slice(0, 8)}`, + tenantId: null, + name, + note: parsed.data.note ?? '', + enabled: true, + intakeToken: generateIntakeToken(), + helpdeskProviderId: null, + ico: company.ico, + dic: company.dic, + address: company.address || null, + legalForm: company.legalForm || null, + createdAt: timestamp, + updatedAt: timestamp, + }; + const created = await tenantStore.create(tenant); + + const users: StoredUser[] = []; + for (const [index, person] of parsed.data.persons.entries()) { + const user: StoredUser = { + id: `usr_${randomUUID().slice(0, 8)}`, + tenantId: null, + email: emails[index], + name: person.name, + passwordHash: await hashPassword(randomBytes(18).toString('base64url')), + platformAdmin: false, + memberships: [{ tenantId: created.id, roleIds: ['role_admin'] }], + enabled: true, + createdAt: timestamp, + updatedAt: timestamp, + }; + users.push(await userStore.create(user)); + } + + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId: created.id, + action: 'tenant.create.ares', + target: created.id, + detail: { ico, users: users.map((u) => u.email) }, + }); + + return res.status(201).json({ + tenant: created, + users: users.map(({ passwordHash: _hash, ...rest }) => rest), + }); +}); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 7fef4d1..297ea16 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,27 +1,29 @@ -import bcrypt from 'bcryptjs'; -import { Router } from 'express'; import jwt from 'jsonwebtoken'; import { z } from 'zod'; import { config } from '../config.js'; -import { findUserByEmail } from '../data/users.js'; +import { findUserByEmail, verifyPassword } from '../data/users.js'; +import { safeRouter } from '../middleware/asyncHandler.js'; import { requireAuth } from '../middleware/auth.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import { validationError } from '../middleware/validation.js'; import { toPublicUser, type JwtPayload } from '../types.js'; -export const authRouter = Router(); +export const authRouter = safeRouter(); const loginSchema = z.object({ email: z.string().email('Zadejte platný e-mail.'), password: z.string().min(1, 'Zadejte heslo.'), }); -authRouter.post('/login', async (req, res) => { +/** + * Dvacet pokusu za ctvrt hodiny z jedne adresy. Dost na preklepy cele + * kancelare za jednou NAT adresou, malo na hadani hesla. + */ +const loginLimiter = rateLimit({ name: 'login', windowMs: 15 * 60_000, max: 20 }); + +authRouter.post('/login', loginLimiter, async (req, res) => { const parsed = loginSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); const { email, password } = parsed.data; const user = findUserByEmail(email); @@ -35,7 +37,7 @@ authRouter.post('/login', async (req, res) => { return invalid(); } - const passwordOk = await bcrypt.compare(password, user.passwordHash); + const passwordOk = await verifyPassword(password, user.passwordHash); if (!passwordOk) { console.info(`[auth] neuspesne prihlaseni - spatne heslo: ${user.email}`); return invalid(); diff --git a/src/routes/connectors.ts b/src/routes/connectors.ts index b064957..439f722 100644 --- a/src/routes/connectors.ts +++ b/src/routes/connectors.ts @@ -15,9 +15,9 @@ * udaje do prohlizece. */ -import { Router, type Request, type Response } from 'express'; +import { type Request, type Response } from 'express'; import { z } from 'zod'; -import { accessFor } from '../data/access.js'; +import { hasPermission } from '../data/permissions.js'; import { CHECK_HISTORY, connectorCountsByService, @@ -42,6 +42,7 @@ import { } from '../data/services.js'; import { config } from '../config.js'; import { egressIp } from '../data/egressIp.js'; +import { publish } from '../events/bus.js'; import { smtpSettings, smtpTargetUrl, verifySmtp } from '../mail/smtp.js'; import { listTools } from '../mcp/client.js'; import { forgetMcpTools, rememberMcpTools } from '../data/mcpTools.js'; @@ -50,25 +51,25 @@ import { resolveTarget, serviceBaseUrl, targetSecrets } from '../scripts/connect import { createHttp } from '../scripts/http.js'; import { ScriptError } from '../scripts/types.js'; import { createRedactor, describe, truncate } from '../scripts/util.js'; +import { safeRouter } from '../middleware/asyncHandler.js'; +import { optionalTenantOrDeny, tenantOrDeny } from '../middleware/tenant.js'; +import { validationError } from '../middleware/validation.js'; -export const connectorsRouter = Router(); +export const connectorsRouter = safeRouter(); /** - * Firma, do ktere se zapisuje. Konektor je vzdy jedne firmy, i kdyz uzivatel - * kouka na pohled "vse" - jinak by nebylo kam ho zaloz1t. + * Firma, do ktere se zapisuje, plus pravo konektory spravovat. + * + * Konektor je vzdy jedne firmy, i kdyz uzivatel kouka na pohled "vse". Cteni + * staci clenstvi, zapis a overeni (to vola cizi sluzbu pod udaji firmy) chce + * `connector.manage` - driv stacilo clenstvi i na to. */ -function tenantOrDeny(req: Request, res: Response): string | null { - const access = accessFor(req.user!); - const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined; - const tenantId = requested ?? access.defaultTenantId; - - if (!tenantId) { - res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' }); - return null; - } - if (!access.tenants.some((tenant) => tenant.id === tenantId)) { - console.warn(`[connectors] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`); - res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' }); +function managedTenantOrDeny(req: Request, res: Response): string | null { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return null; + if (!hasPermission(req.user!, 'connector.manage', tenantId)) { + console.warn(`[connectors] ${req.user!.email}: chybi pravo connector.manage ve firme ${tenantId}`); + res.status(403).json({ error: 'forbidden', message: 'Konektory spravuje ten, kdo na to má právo.' }); return null; } return tenantId; @@ -100,9 +101,10 @@ function serviceOrDeny( * ale te firmy, proto se to pocita tady a ne v katalogu. */ connectorsRouter.get('/services', async (req, res) => { - const access = accessFor(req.user!); - const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined; - const tenantId = requested ?? access.defaultTenantId; + // Cizi firma je 404. Bez firmy jen katalog bez poctu konektoru. + const resolved = optionalTenantOrDeny(req, res); + if (!resolved) return; + const { tenantId } = resolved; const visible = visibleServices(req.user!, tenantId); // Firma se predava kvuli MCP: nastroje jsou vlastnost jejiho napojeni. @@ -183,16 +185,11 @@ const createSchema = z.object({ }); connectorsRouter.post('/', async (req, res) => { - const tenantId = tenantOrDeny(req, res); + const tenantId = managedTenantOrDeny(req, res); if (!tenantId) return; const parsed = createSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); const service = serviceOrDeny(req, res, parsed.data.serviceId, tenantId); if (!service) return; @@ -217,6 +214,13 @@ connectorsRouter.post('/', async (req, res) => { values: parsed.data.values, }); + // Portal si konektory drzi v pameti, bez udalosti by novy videl az po obnoveni. + publish( + 'connector.created', + `Nový konektor ${connector.name}`, + { id: connector.id, connector: toPublicConnector(connector) }, + connector.tenantId, + ); return res.status(201).json(toPublicConnector(connector)); }); @@ -230,7 +234,7 @@ const updateSchema = z.object({ }); connectorsRouter.patch('/:id', async (req, res) => { - const tenantId = tenantOrDeny(req, res); + const tenantId = managedTenantOrDeny(req, res); if (!tenantId) return; const existing = await getConnector(req.params.id, [tenantId]); @@ -242,12 +246,7 @@ connectorsRouter.patch('/:id', async (req, res) => { if (!service) return; const parsed = updateSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); if (parsed.data.values) { const issues = validateConnectorValues(service, parsed.data.values); @@ -263,11 +262,17 @@ connectorsRouter.patch('/:id', async (req, res) => { // Nazev konektoru je v nazvu kazdeho jeho nastroje ve vyberu kroku. Bez // tohohle by tam po prejmenovani zustal stary az do restartu. if (isMcpService(updated.serviceId)) rememberMcpTools(updated); + publish( + 'connector.updated', + `Konektor ${updated.name} upraven`, + { id: updated.id, connector: toPublicConnector(updated) }, + updated.tenantId, + ); return res.json(toPublicConnector(updated)); }); connectorsRouter.delete('/:id', async (req, res) => { - const tenantId = tenantOrDeny(req, res); + const tenantId = managedTenantOrDeny(req, res); if (!tenantId) return; if (!(await deleteConnector(req.params.id, [tenantId]))) { @@ -276,6 +281,7 @@ connectorsRouter.delete('/:id', async (req, res) => { // Smazanym konektorem zmizi i jeho nastroje z katalogu, jinak by v builderu // zustaly kroky, ktere uz nemaji kam volat. forgetMcpTools(req.params.id); + publish('connector.deleted', `Konektor ${req.params.id} smazán`, { id: req.params.id }, tenantId); return res.status(204).end(); }); @@ -321,6 +327,10 @@ async function loadMcpTools(connectorId: string, tenantId: string) { } const outcome = await listTools(target); + // Telo chybove odpovedi serveru nesmi nest token. Hlasku uz klient MCP + // rediguje, telo ne vzdy - proto jeste jednou tady, pred zapisem i odpovedi. + const redact = createRedactor(targetSecrets(target)); + const detail = outcome.detail ? redact(outcome.detail) : outcome.detail; await setConnectorStatus( connector.id, @@ -332,7 +342,7 @@ async function loadMcpTools(connectorId: string, tenantId: string) { checked, status: outcome.status, message: outcome.message, - detail: outcome.detail, + detail, request: outcome.request, responseHeaders: null, // Server si zaklada firma, seznamy povolenych IP na nem nemame v ruce. @@ -348,7 +358,7 @@ async function loadMcpTools(connectorId: string, tenantId: string) { checked, message: outcome.message, ...(outcome.status !== null ? { status: outcome.status } : {}), - ...(outcome.detail ? { detail: outcome.detail } : {}), + ...(detail ? { detail } : {}), request: outcome.request, tools: [], }; @@ -359,6 +369,14 @@ async function loadMcpTools(connectorId: string, tenantId: string) { const publicView = saved ? toPublicConnector(saved) : null; console.info(`[mcp] ${connector.id}: ${outcome.message}`); + if (saved) { + publish( + 'connector.updated', + `Konektor ${saved.name} má nové nástroje`, + { id: saved.id, connector: publicView }, + saved.tenantId, + ); + } return { ok: true, @@ -386,7 +404,7 @@ async function loadMcpTools(connectorId: string, tenantId: string) { * na spravny stroj. Hadat to podle toho, kde je nasazeny portal, nejde. */ connectorsRouter.post('/:id/test', async (req, res) => { - const tenantId = tenantOrDeny(req, res); + const tenantId = managedTenantOrDeny(req, res); if (!tenantId) return; const connector = await getConnector(req.params.id, [tenantId]); @@ -598,7 +616,7 @@ connectorsRouter.post('/:id/test', async (req, res) => { * da se spustit kdykoliv. */ connectorsRouter.post('/:id/mcp/tools', async (req, res) => { - const tenantId = tenantOrDeny(req, res); + const tenantId = managedTenantOrDeny(req, res); if (!tenantId) return; const connector = await getConnector(req.params.id, [tenantId]); diff --git a/src/routes/contact.ts b/src/routes/contact.ts index 5eafcab..4769e51 100644 --- a/src/routes/contact.ts +++ b/src/routes/contact.ts @@ -1,8 +1,13 @@ import { Router } from 'express'; import { z } from 'zod'; +import { rateLimit } from '../middleware/rateLimit.js'; +import { validationError } from '../middleware/validation.js'; export const contactRouter = Router(); +/** Verejny formular bez prihlaseni. Pet poptavek za hodinu z jedne adresy staci. */ +const contactLimiter = rateLimit({ name: 'contact', windowMs: 60 * 60_000, max: 5 }); + const contactSchema = z.object({ name: z.string().min(2, 'Zadejte jméno.'), email: z.string().email('Zadejte platný e-mail.'), @@ -16,15 +21,9 @@ const contactSchema = z.object({ * PROTOTYP: zpravu jen zalogujeme. Realne odeslani (SMTP / ticket system) * pribude pozdeji - viz docs/04-backend-api.md. */ -contactRouter.post('/', (req, res) => { +contactRouter.post('/', contactLimiter, (req, res) => { const parsed = contactSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', - issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), - }); - } + if (!parsed.success) return validationError(res, parsed.error); const data = parsed.data; console.info( diff --git a/src/routes/crud.ts b/src/routes/crud.ts index c9edc97..8a7c4ff 100644 --- a/src/routes/crud.ts +++ b/src/routes/crud.ts @@ -21,43 +21,14 @@ */ import { randomUUID } from 'node:crypto'; -import { Router, type Request, type Response } from 'express'; +import { type Request, type Response, type Router } from 'express'; import type { z } from 'zod'; -import { accessFor, actingTenant } from '../data/access.js'; import { hasPermission } from '../data/permissions.js'; +import { publish as publishEvent, type EntityEventKind } from '../events/bus.js'; import { nowIso, type EntityStore, type ListOptions, type TenantEntity } from '../data/store/index.js'; - -/** Firma, ve ktere se prave pracuje. Zapis je vzdy do jedne. */ -export function currentTenant(req: Request, res: Response): string | null { - const access = accessFor(req.user!); - const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined; - const tenantId = requested ?? access.defaultTenantId; - - if (!tenantId) { - res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' }); - return null; - } - if (!access.tenants.some((tenant) => tenant.id === tenantId)) { - console.warn(`[crud] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`); - res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' }); - return null; - } - return tenantId; -} - -/** Rozsah pro cteni: firmy uzivatele plus volitelne platformni zaznamy. */ -/** - * Firma, za kterou request jedna. Podle ni se pocitaji prava. - * - * Na rozdil od `currentTenant` neodpovida - vraci jen hodnotu, protoze - * kontrola prava chce sama rict, co se stalo. - */ -export function requestTenant(req: Request): string | null { - return actingTenant( - req.user!, - typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, - ); -} +import { safeRouter } from '../middleware/asyncHandler.js'; +import { requestTenant, tenantOrDeny } from '../middleware/tenant.js'; +import { validationError } from '../middleware/validation.js'; /** * Ze ktere firmy smi request cist. @@ -88,20 +59,95 @@ export interface CrudOptions { updateSchema: z.ZodType; /** Pravo potrebne k zapisu. Cteni staci prihlaseni. */ writePermission: string; - /** Sestavi novy zaznam z overeneho vstupu. */ - build: (input: C, tenantId: string) => Omit & Partial; + /** Sestavi novy zaznam z overeneho vstupu. Smi byt asynchronni (hash hesla). */ + build: ( + input: C, + tenantId: string, + ) => BuiltEntity | Promise>; /** Vlastni kontrola nad ulozenym zaznamem. Vraci popisy problemu. */ validate?: (entity: T, all: T[]) => string[]; /** Uprava pred odeslanim klientovi, napr. schovani citlivych poli. */ toPublic?: (entity: T) => unknown; + /** + * Druh udalosti pro zivy stream (`person.created` apod.). Bez nej se zmena + * neohlasi a portal ji uvidi az po novem nacteni. Payload je verejna podoba + * zaznamu (`toPublic`), takze tudy neutece nic, co neutece ani z GET. + */ + event?: EntityEventKind; /** true = zaznamy vidi jen spravce platformy. */ platformOnly?: boolean; + /** + * Zaznamy bez firmy (`tenantId: null`), ktere se presto spravuji po firmach. + * + * Uzivatel neni majetkem firmy, muze byt ve vic firmach naraz. Fabrika ho + * proto nesmi brat jako systemovy zaznam (to by ho smel menit jen spravce + * platformy), ale pta se tady: patri do firmy, ve ktere se prave pracuje, + * a smi tenhle vstup projit? + */ + scopeBy?: { + /** Patri zaznam do firmy? Cizi se chova jako neexistujici. */ + belongsTo: (entity: T, tenantId: string) => boolean; + /** + * Overi a pripadne upravi vstup za firmu. `input` je `undefined` u mazani. + * Vraci upraveny vstup, nebo hlasku a stav, se kterym se odmitne. + */ + prepare: ( + req: Request, + tenantId: string, + input: C | U | undefined, + existing?: T, + ) => { ok: true; input: C | U | undefined } | { ok: false; status: number; message: string }; + }; } +type BuiltEntity = Omit & Partial; + export function crudRouter(options: CrudOptions): Router { - const router = Router(); + const router = safeRouter(); const publish = options.toPublic ?? ((entity: T) => entity); + /** Ohlasi zmenu do streamu. Firma zaznamu, u platformnich (null) ji dostanou vsichni. */ + const announce = (verb: 'created' | 'updated' | 'deleted', entity: T) => { + if (!options.event) return; + const label = (entity as { name?: unknown }).name; + const message = `${options.event} ${verb}: ${typeof label === 'string' ? label : entity.id}`; + const payload = verb === 'deleted' ? { id: entity.id } : { id: entity.id, [options.event]: publish(entity) }; + publishEvent(`${options.event}.${verb}`, message, payload, entity.tenantId); + }; + + /** + * Zaznam v dosahu firmy. Platformni admin vidi vse, ostatni jen to, co do + * jejich firmy patri - u obycejnych entit to zaridi uloziste, u zaznamu + * bez firmy (`scopeBy`) se to musi rict tady. + */ + function reachable(req: Request, entity: T): boolean { + if (!options.scopeBy || req.user!.platformAdmin) return true; + const tenantId = requestTenant(req); + return tenantId !== null && options.scopeBy.belongsTo(entity, tenantId); + } + + /** Vstup po kontrole za firmu. Pri odmitnuti odpovi a vrati `null`. */ + function prepared( + req: Request, + res: Response, + input: C | U | undefined, + existing?: T, + ): { input: C | U | undefined } | null { + if (!options.scopeBy) return { input }; + const tenantId = requestTenant(req); + if (!tenantId) { + res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' }); + return null; + } + const outcome = options.scopeBy.prepare(req, tenantId, input, existing); + if (!outcome.ok) { + console.warn(`[crud] ${req.user!.email}: ${options.store.kind} odmitnuto - ${outcome.message}`); + res.status(outcome.status).json({ error: 'forbidden', message: outcome.message }); + return null; + } + return { input: outcome.input }; + } + /** Smi uzivatel zapisovat? Jedno misto, aby se to nekontrolovalo jen nekde. */ function canWrite(req: Request, res: Response, entity?: T): boolean { if (options.platformOnly && !req.user!.platformAdmin) { @@ -109,7 +155,8 @@ export function crudRouter(options: CrudOptions(options: CrudOptions reachable(req, item)); return res.json({ items: items.map(publish) }); }); router.get('/:id', async (req, res) => { const entity = await options.store.get(req.params.id, readScope(req)); - if (!entity) { + if (!entity || !reachable(req, entity)) { return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); } return res.json(publish(entity)); @@ -149,26 +196,23 @@ export function crudRouter(options: CrudOptions { if (!canWrite(req, res)) return; - const tenantId = currentTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; const parsed = options.createSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', - issues: parsed.error.issues.map((issue) => ({ - field: issue.path.join('.'), - message: issue.message, - })), - }); - } + if (!parsed.success) return validationError(res, parsed.error); + + const checked = prepared(req, res, parsed.data); + if (!checked) return; const timestamp = nowIso(); + const built = await options.build(checked.input as C, tenantId); const entity = { - ...options.build(parsed.data, tenantId), + ...built, id: `${options.idPrefix}_${randomUUID().slice(0, 8)}`, - tenantId, + // Kdo si firmu urcil sam (`tenantId: null` u uzivatele a firmy), tomu + // se neprepisuje. Driv se prepsala vzdy a "bez firmy" nikdy nenastalo. + tenantId: built.tenantId === undefined ? tenantId : built.tenantId, createdAt: timestamp, updatedAt: timestamp, } as T; @@ -181,26 +225,26 @@ export function crudRouter(options: CrudOptions { const existing = await options.store.get(req.params.id, readScope(req)); - if (!existing) { + if (!existing || !reachable(req, existing)) { return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); } if (!canWrite(req, res, existing)) return; const parsed = options.updateSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); + + const checked = prepared(req, res, parsed.data, existing); + if (!checked) return; + const patch = checked.input as Partial; if (options.validate) { - const merged = { ...existing, ...(parsed.data as Partial) } as T; + const merged = { ...existing, ...patch } as T; const others = (await options.store.list(readScope(req))).filter( (item) => item.id !== existing.id, ); @@ -210,25 +254,24 @@ export function crudRouter(options: CrudOptions, - readScope(req), - ); + const updated = await options.store.update(req.params.id, patch, readScope(req)); if (!updated) { return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); } + announce('updated', updated); return res.json(publish(updated)); }); router.delete('/:id', async (req, res) => { const existing = await options.store.get(req.params.id, readScope(req)); - if (!existing) { + if (!existing || !reachable(req, existing)) { return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); } if (!canWrite(req, res, existing)) return; + if (!prepared(req, res, undefined, existing)) return; const removed = await options.store.remove(req.params.id, readScope(req)); + if (removed) announce('deleted', existing); return removed ? res.status(204).end() : res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 9cd3f62..f5a5a59 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -1,13 +1,8 @@ -import { Router, type Request, type Response } from 'express'; +import { createHash } from 'node:crypto'; +import { type Request, type Response } from 'express'; import { z } from 'zod'; import { publicBaseUrl } from '../config.js'; -import { - accessFor, - isDenied, - resolveScope, - visibilityFor, - type ResolvedScope, -} from '../data/access.js'; +import { type ResolvedScope } from '../data/access.js'; import { createAutomation, deleteAutomation, @@ -46,13 +41,7 @@ import { collectScopes } from '../data/flowScope.js'; import { widgetCatalog } from '../data/widgets.js'; import { createIncident, listIncidents } from '../data/incidentStore.js'; import { getSummary } from '../data/mock.js'; -import { - findPersonByEmail, - groupsOfPerson, - isMember, - listGroups, - listPeople, -} from '../data/people.js'; +import { isMember, listGroups, listPeople } from '../data/people.js'; import { hasPermission } from '../data/permissions.js'; import { listNotifications, markRead, unreadCount } from '../data/notifications.js'; import { inviteRouter } from './invites.js'; @@ -61,56 +50,61 @@ import { recordAudit } from '../data/audit.js'; import { findTenant, generateIntakeToken, refreshTenants, tenantStore } from '../data/tenants.js'; import { listTicketTypes } from '../data/ticketTypes.js'; import { - addComment, - assignTicket, - claimTicket, createTicket, - ticketAssignee, getAgentStats, - getTicket, - ticketWithinVisibility, getWorkload, listTickets, - updateTicketStatus, type TicketChannel, type TicketFilter, } from '../data/ticketStore.js'; +import { safeRouter } from '../middleware/asyncHandler.js'; import { requireAuth } from '../middleware/auth.js'; +import { + accessOf, + attachAccess, + optionalTenantOrDeny, + scopeOrDeny, + tenantOrDeny, +} from '../middleware/tenant.js'; +import { validationError } from '../middleware/validation.js'; import { validateRules } from '../scripts/mapping.js'; import { connectorsRouter } from './connectors.js'; import { helpdeskRouter } from './helpdesk.js'; import { settingsRouter } from './settings.js'; -import { ticketActionsRouter } from './ticketActions.js'; +import { ticketActionsRouter, visibleTicketOrDeny } from './ticketActions.js'; import { widgetDataRouter } from './widgetData.js'; import { scriptsRouter } from './scripts.js'; import { tenantScriptRouter } from './tenantScripts.js'; import { streamRouter } from './stream.js'; -export const dashboardRouter = Router(); +export const dashboardRouter = safeRouter(); -// Cely dashboard je jen pro prihlasene. -dashboardRouter.use(requireAuth); +// Cely dashboard je jen pro prihlasene. Prava se spocitaji jednou za request. +dashboardRouter.use(requireAuth, attachAccess); /** - * Prevede query na povolene firmy. Pri odepreni rovnou odpovi a vrati null, - * takze volajici jen zkontroluje `if (!scope) return;`. + * Strankovani ze query. `limit` bez hodnoty = vsechno, jako driv. + * + * Odpoved zustava seznam, jen se k ni prida hlavicka `X-Total-Count` + * s poctem pred orezem - klient, ktery strankovani nezna, nic nepozna. */ -function scopeOrDeny(req: Request, res: Response): ResolvedScope | null { - const resolved = resolveScope(req.user!, { - scope: typeof req.query.scope === 'string' ? req.query.scope : undefined, - tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, - }); +function pageFrom(query: Record, maxLimit = 500): { limit: number | null; offset: number } { + const limit = Number(query.limit); + const offset = Number(query.offset); + return { + limit: Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLimit) : null, + offset: Number.isInteger(offset) && offset > 0 ? offset : 0, + }; +} - if (isDenied(resolved)) { - res.status(resolved.status).json({ error: resolved.error, message: resolved.message }); - return null; - } - return resolved; +function paginate(items: T[], page: { limit: number | null; offset: number }): T[] { + if (page.limit === null && page.offset === 0) return items; + return items.slice(page.offset, page.limit === null ? undefined : page.offset + page.limit); } /** Co uzivatel smi, aby klient nemusel hadat, ktere prepinace kreslit. */ dashboardRouter.get('/access', (req, res) => { - res.json(accessFor(req.user!)); + res.json(accessOf(req)); }); dashboardRouter.get('/summary', (req, res) => { @@ -125,8 +119,10 @@ dashboardRouter.get('/summary', (req, res) => { * Portal to musi umet rict nahlas. Bez toho se clovek divi, kam se podely * jeho konektory, a hleda chybu v aplikaci - presne to se stalo. */ -dashboardRouter.get('/storage', (_req, res) => { - res.json(storageStatus()); +dashboardRouter.get('/storage', (req, res) => { + // Cesta na disku serveru je nase provozni informace, ne zakaznikova. + const { location, ...status } = storageStatus(); + res.json(req.user!.platformAdmin ? { ...status, location } : { ...status, location: null }); }); /** @@ -182,9 +178,29 @@ const FIELD_LIMIT = 4_000; */ const DEDUPE_MS = 600_000; +/** Kolik otisku se drzi. Pri prekroceni se uklidi prosle, pak nejstarsi. */ +const RECENT_LIMIT = 1_000; + /** Otisk padu a kdy naposled zalozil incident. */ const recent = new Map(); +/** + * Uklid mapy otisku. Klic je z hlasky od klienta, takze bez stropu by ji + * kdokoliv prihlaseny mohl nafouknout do nekonecna. + */ +function forgetOldCrashes(now: number): void { + if (recent.size < RECENT_LIMIT) return; + for (const [key, at] of recent) { + if (now - at >= DEDUPE_MS) recent.delete(key); + } + // Same cerstve? Mapa drzi poradi vkladani, nejstarsi je prvni. + while (recent.size >= RECENT_LIMIT) { + const oldest = recent.keys().next().value; + if (oldest === undefined) break; + recent.delete(oldest); + } +} + const crashSchema = z.object({ message: z.string().trim().min(1).max(FIELD_LIMIT), stack: z.string().max(FIELD_LIMIT).optional(), @@ -196,17 +212,12 @@ const crashSchema = z.object({ }); dashboardRouter.post('/client-crash', (req, res) => { - const access = accessFor(req.user!); - const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined; - const tenantId = requested ?? access.defaultTenantId; + // Incident se zaklada firme, do ktere clovek patri. Cizi firma je 404. + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; const parsed = crashSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); const crash = parsed.data; const where = crash.path ?? 'neznámá stránka'; @@ -214,13 +225,19 @@ dashboardRouter.post('/client-crash', (req, res) => { /* * Otisk je hlaska a misto, ne cas. Tatáz chyba na tomtez miste je porad * jeden problem, i kdyz na ni clovek narazi desetkrat za sebou. + * + * Hash, ne surovy text: klic v mape by jinak nesl az 4 kB od klienta. + * Zacatek hlasky staci, konec byva promenlivy (ID, cisla radku). */ - const fingerprint = `${crash.message}|${where}`; + const fingerprint = createHash('sha1') + .update(`${tenantId}|${crash.message.slice(0, 500)}|${where.slice(0, 500)}`) + .digest('hex'); const last = recent.get(fingerprint) ?? 0; const now = Date.now(); if (now - last < DEDUPE_MS) { return res.status(202).json({ created: false, reason: 'stejný pád už je hlášený' }); } + forgetOldCrashes(now); recent.set(fingerprint, now); const detail = [ @@ -264,37 +281,20 @@ dashboardRouter.get('/widgets', (req, res) => { * vsech firem uzivatele, sla by polozit dlazdice Automie na dashboard * Nordisu - v nabidce by byla, ale data by k ni nikdy neprisla. */ - const tenantId = layoutTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; return res.json({ - items: widgetCatalog([tenantId], req.user!.id, accessFor(req.user!, tenantId).personId !== null), + items: widgetCatalog([tenantId], req.user!.id, accessOf(req).personId !== null), }); }); -/** +/* * Rozlozeni je vzdy za konkretni firmu, i kdyz uzivatel kouka na pohled "vse". * Jinak by clovek ve dvou firmach nemel kam ulozit dve ruzna nastaveni. */ -function layoutTenant(req: Request, res: Response): string | null { - const access = accessFor(req.user!); - const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined; - const tenantId = requested ?? access.defaultTenantId; - - if (!tenantId) { - res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' }); - return null; - } - if (!access.tenants.some((tenant) => tenant.id === tenantId)) { - console.warn(`[layout] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`); - res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' }); - return null; - } - return tenantId; -} - dashboardRouter.get('/layout', (req, res) => { - const tenantId = layoutTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; return res.json({ @@ -315,20 +315,15 @@ const layoutSchema = z.object({ }); dashboardRouter.put('/layout', (req, res) => { - const tenantId = layoutTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; const parsed = layoutSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatné rozložení.', - }); - } + if (!parsed.success) return validationError(res, parsed.error, 'Neplatné rozložení.'); const problems = validateLayout( parsed.data.items, - widgetCatalog([tenantId], req.user!.id, accessFor(req.user!, tenantId).personId !== null), + widgetCatalog([tenantId], req.user!.id, accessOf(req).personId !== null), ); if (problems.length > 0) { console.warn(`[layout] ${req.user!.email}: neplatne rozlozeni - ${problems.join(' ')}`); @@ -341,7 +336,7 @@ dashboardRouter.put('/layout', (req, res) => { /** Vraceni na vychozi. Zamerne DELETE - je to smazani ulozeneho nastaveni. */ dashboardRouter.delete('/layout', (req, res) => { - const tenantId = layoutTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; return res.json({ @@ -367,17 +362,8 @@ dashboardRouter.get('/people/:id', (req, res) => { return res.status(404).json({ error: 'not_found', message: 'Řešitel neexistuje.' }); } - const tickets = listTickets({ - tenantIds: scope.tenantIds, - visibility: scope.visibility, - assignee: person.id, - }); - const resolvedRecently = listTickets({ - tenantIds: scope.tenantIds, - visibility: scope.visibility, - }).filter( - (ticket) => ticket.resolvedById === person.id, - ); + // Jeden pruchod seznamem: "ma u sebe" i "vyresil" se pozna z tehoz ticketu. + const tickets = listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }); // Obdobi drzime stejne jako u widgetu vykonu, aby cisla sedela na obou mistech. const since = Date.now() - 30 * 86_400_000; @@ -390,10 +376,10 @@ dashboardRouter.get('/people/:id', (req, res) => { .filter((group) => isMember(group, person.id)) .map((group) => ({ id: group.id, name: group.name })), /** Co ma prave ted u sebe. */ - open: tickets.filter((ticket) => !ticket.closed), + open: tickets.filter((ticket) => !ticket.closed && ticket.assignee?.id === person.id), /** Co za posledni dobu vyresil, nejnovejsi nahore. */ - resolved: resolvedRecently - .filter((ticket) => ticket.closed) + resolved: tickets + .filter((ticket) => ticket.closed && ticket.resolvedById === person.id) .sort((a, b) => (b.resolvedAt ?? '').localeCompare(a.resolvedAt ?? '')) .slice(0, 20), }); @@ -518,7 +504,7 @@ dashboardRouter.post('/notifications/read', (req, res) => { /** Kolik nevyrizenych ma prihlaseny u sebe. */ function myOpenTickets(req: Request): number { - const access = accessFor(req.user!); + const access = accessOf(req); if (!access.personId) return 0; // Vlastni tickety jsou ve stropu vzdycky, ale posila se vyslovne - filtr @@ -542,9 +528,17 @@ dashboardRouter.get('/runs', (req, res) => { const scope = scopeOrDeny(req, res); if (!scope) return; + const stats = queueStats(scope.tenantIds); + const page = pageFrom(req.query as Record); + // Bez `limit` poslednich 50, jako driv. Celkovy pocet je soucet stavu fronty. + const runs = page.limit === null && page.offset === 0 + ? recentRuns(scope.tenantIds) + : recentRuns(scope.tenantIds, page.offset + (page.limit ?? 50)).slice(page.offset); + res.setHeader('X-Total-Count', String(stats.pending + stats.running + stats.failed + stats.done)); + return res.json({ - stats: queueStats(scope.tenantIds), - items: recentRuns(scope.tenantIds).map((run) => ({ + stats, + items: runs.map((run) => ({ id: run.id, automationId: run.automationId, trigger: run.trigger, @@ -573,11 +567,10 @@ dashboardRouter.get('/people', (req, res) => { const scope = scopeOrDeny(req, res); if (!scope) return; - const me = findPersonByEmail(req.user!.email); return res.json({ items: listPeople(scope.tenantIds), groups: listGroups(scope.tenantIds).map((group) => ({ id: group.id, name: group.name })), - meId: me?.id ?? null, + meId: accessOf(req).personId, }); }); @@ -645,31 +638,32 @@ dashboardRouter.get('/tickets', (req, res) => { const scope = scopeOrDeny(req, res); if (!scope) return; - const me = findPersonByEmail(req.user!.email); const filter = ticketFilterFrom(req.query as Record, scope); - const items = listTickets(filter); - /* * Stavy, ktere firma opravdu pouziva. Stav je volny retezec, takze pevny * seznam v rozhrani by u ticketu z cizich aplikaci nikdy nesedel - filtr * musi nabidnout to, co v datech je. * * Bere se z celeho rozsahu, ne z vyfiltrovaneho vysledku: jinak by po - * kliknuti na jeden stav zmizely ostatni a nesel by prepnout. + * kliknuti na jeden stav zmizely ostatni a nesel by prepnout. Kdyz zadny + * filtr neni, je cely rozsah zaroven vysledek a seznam se cte jen jednou. */ - const statuses = [ - ...new Set( - listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }).map( - (ticket) => ticket.status, - ), - ), - ].sort((a, b) => a.localeCompare(b, 'cs')); + const all = listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }); + const filtered = + Object.keys(filter).length === 2 ? all : listTickets(filter); + const statuses = [...new Set(all.map((ticket) => ticket.status))].sort((a, b) => + a.localeCompare(b, 'cs'), + ); + + const page = pageFrom(req.query as Record); + res.setHeader('X-Total-Count', String(filtered.length)); return res.json({ - items, + items: paginate(filtered, page), + total: filtered.length, statuses, - meId: me?.id ?? null, + meId: accessOf(req).personId, scope: scope.scope, tenantId: scope.tenantId, }); @@ -715,16 +709,7 @@ dashboardRouter.post('/tickets', (req, res) => { } const parsed = createTicketSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Ticket se nepodařilo založit.', - issues: parsed.error.issues.map((issue) => ({ - field: issue.path.join('.'), - message: issue.message, - })), - }); - } + if (!parsed.success) return validationError(res, parsed.error); // Zaklada se vzdy do firmy, ve ktere clovek prave je. Vybirat ji ve formulari // by znamenalo, ze se ticket omylem zalozi jinam. @@ -778,79 +763,10 @@ dashboardRouter.post('/tickets', (req, res) => { return res.status(201).json(ticket); }); -/** - * Prevzeti ticketu. - * - * Clovek si vezme praci sam, misto aby cekal, az mu ji nekdo prideli. Tohle je - * bezny zpusob prace v tymu: ticket lezi ve fronte skupiny a kdo ma cas, si ho - * vezme. - * - * Smi to u ticketu **ze sve skupiny** nebo u takoveho, ktery nema nikoho. - * Vzit nekomu rozdelanou praci nejde - to uz je prehozeni a na to je pravo - * `ticket.assign.others`. +/* + * Prevzeti, prirazeni, stav a komentar jsou vestavene akce na ticketu, + * viz `routes/ticketActions.ts` - kazda ma sve pravo a projde auditem. */ -dashboardRouter.post('/tickets/:id/claim', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - - const access = accessFor(req.user!); - if (!access.personId) { - return res.status(400).json({ - error: 'not_a_person', - message: 'Nejste vedený jako řešitel, takže si ticket nemůžete převzít.', - }); - } - - const tenantIds = writableTenants(req); - const current = ticketAssignee(req.params.id, tenantIds); - if (current === undefined) { - return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); - } - - const ticket = getTicket(req.params.id, tenantIds); - if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); - - // Prevzit jde jen to, na co clovek vidi. - if (!ticketWithinVisibility(ticket.id, visibilityFor(req.user!, ticket.tenantId))) { - return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); - } - - if (current !== null && current !== access.personId) { - // Uz to nekdo resi. Prehodit praci nekomu jinemu je jine rozhodnuti. - // Pravo se pta za firmu ticketu, ne za tu prave prepnutou. - if (!hasPermission(req.user!, 'ticket.assign.others', ticket.tenantId)) { - return res.status(409).json({ - error: 'already_assigned', - message: 'Ticket už někdo řeší. Přehodit ho může jen ten, kdo na to má právo.', - }); - } - } - - const mine = groupsOfPerson(access.personId, tenantIds).map((group) => group.id); - const inMyGroup = ticket.assigneeGroupId !== null && mine.includes(ticket.assigneeGroupId); - - if (!inMyGroup && current !== null && current !== access.personId) { - return res.status(403).json({ - error: 'forbidden', - message: 'Tenhle ticket není ve vaší skupině.', - }); - } - - const updated = claimTicket(req.params.id, access.personId, tenantIds); - if (!updated) { - return res.status(400).json({ error: 'failed', message: 'Převzetí se nepodařilo.' }); - } - - recordAudit({ - userId: req.user!.id, - userEmail: req.user!.email, - tenantId: updated.tenantId, - action: 'ticket.claim', - target: updated.id, - }); - - return res.json(updated); -}); /** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */ /** @@ -886,116 +802,18 @@ dashboardRouter.get('/tickets/workload', (req, res) => { }); dashboardRouter.get('/tickets/:id', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - - // Detail hledame pres vsechny firmy uzivatele, ne jen pres tu prave zvolenou. - // Jinak by odkaz z pohledu "vse" na ticket jine firmy vratil 404. - const reachable = accessFor(req.user!).tenants.map((tenant) => tenant.id); - const ticket = getTicket(req.params.id, reachable); - if (!ticket) { - return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); - } - - // Strop se pocita za firmu ticketu, ne za prave prepnutou. Bez teto kontroly - // by seznam ticket schoval, ale adresa detailu by ho vydala. - if (!ticketWithinVisibility(ticket.id, visibilityFor(req.user!, ticket.tenantId))) { - return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); - } + // Detail se hleda pres vsechny firmy uzivatele a strop se pocita za firmu + // ticketu - stejne jako u akci, viz `visibleTicketOrDeny`. + const ticket = visibleTicketOrDeny(req, res); + if (!ticket) return; return res.json(ticket); }); /** Firmy, na ktere ma uzivatel dosah pri zapisu. */ function writableTenants(req: Request): string[] { - return accessFor(req.user!).tenants.map((tenant) => tenant.id); + return accessOf(req).tenants.map((tenant) => tenant.id); } -const assignSchema = z.object({ - /** null = vratit do fronty. */ - assigneeId: z.string().min(1).nullable(), -}); - -dashboardRouter.post('/tickets/:id/assign', (req, res) => { - const parsed = assignSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: 'Chybí ID řešitele, nebo poslete null pro vrácení do fronty.', - }); - } - - // Prehazovat praci mezi lidmi smi jen admin. Agent si smi vzit ticket na sebe. - const access = accessFor(req.user!); - if (!access.canAssignOthers && parsed.data.assigneeId !== access.personId) { - return res.status(403).json({ - error: 'forbidden', - message: 'Přiřazovat ostatním může jen správce firmy. Ticket si můžete vzít na sebe.', - }); - } - - const ticket = assignTicket(req.params.id, parsed.data.assigneeId, writableTenants(req)); - if (!ticket) { - return res.status(404).json({ - error: 'not_found', - message: 'Ticket nebo řešitel neexistuje, nebo je řešitel z jiné firmy.', - }); - } - return res.json(ticket); -}); - -const statusSchema = z.object({ - /* - * Stav je volny retezec, ne ciselnik. Tickety chodi z cizich aplikaci, ktere - * maji svoje stavy, a nutit je do nasi ctverice znamenalo, ze uzivatel videl - * nesmysl. Omezena je jen delka, aby se do pole nedal cely dokument. - */ - status: z.string().trim().min(1).max(60), - /** - * Je vyrizeny? Kdyz se neposle, priznak zustava - zmena textu stavu sama - * o sobe neznamena, ze je hotovo. - */ - closed: z.boolean().optional(), -}); - -dashboardRouter.post('/tickets/:id/status', (req, res) => { - const parsed = statusSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: 'validation_error', message: 'Neplatný stav ticketu.' }); - } - - const ticket = updateTicketStatus( - req.params.id, - parsed.data.status, - writableTenants(req), - parsed.data.closed, - ); - if (!ticket) { - return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); - } - return res.json(ticket); -}); - -const commentSchema = z.object({ - text: z.string().trim().min(2, 'Komentář musí mít alespoň 2 znaky.'), -}); - -dashboardRouter.post('/tickets/:id/comment', (req, res) => { - const parsed = commentSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný komentář.', - }); - } - - const author = req.user?.name ?? 'Portál'; - const ticket = addComment(req.params.id, author, parsed.data.text, writableTenants(req)); - if (!ticket) { - return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); - } - return res.json(ticket); -}); - // Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo. dashboardRouter.use('/stream', streamRouter); @@ -1035,9 +853,10 @@ dashboardRouter.use('/helpdesk', helpdeskRouter); * neni vubec, ne se stavem "nemate pravo". */ dashboardRouter.get('/services', async (req, res) => { - const access = accessFor(req.user!); - const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined; - const tenantId = requested ?? access.defaultTenantId; + // Cizi firma je 404. Bez firmy se vrati jen katalog bez firemnich nabidek. + const resolved = optionalTenantOrDeny(req, res); + if (!resolved) return; + const { tenantId } = resolved; const visible = new Set(visibleServices(req.user!, tenantId).map((service) => service.id)); const tenantIds = tenantId ? [tenantId] : []; @@ -1368,14 +1187,33 @@ dashboardRouter.get('/automations/:id', (req, res) => { return res.json(automation); }); +/** + * Smi volajici menit automatizace teto firmy? Pri odepreni odpovi. + * + * Driv stacilo clenstvi: kdokoliv z firmy mohl prepsat strom, ktery posila + * data ven. Pravo je `automation.edit` a pta se za firmu automatizace. + */ +function mayEditAutomations(req: Request, res: Response, tenantId: string): boolean { + if (hasPermission(req.user!, 'automation.edit', tenantId)) return true; + console.warn(`[automations] ${req.user!.email}: chybi pravo automation.edit ve firme ${tenantId}`); + res.status(403).json({ error: 'forbidden', message: 'Automatizace upravuje ten, kdo na to má právo.' }); + return false; +} + +/** Automatizace v dosahu uzivatele, nebo 404. Kdo ji smi menit, se pta zvlast. */ +function editableAutomationOrDeny(req: Request, res: Response) { + const automation = getAutomation(req.params.id, writableTenants(req)); + if (!automation) { + res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); + return null; + } + if (!mayEditAutomations(req, res, automation.tenantId)) return null; + return automation; +} + dashboardRouter.post('/automations', (req, res) => { const parsed = createSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); // Zakladat se musi do konkretni firmy, pohled "vse" na to nestaci. const scope = scopeOrDeny(req, res); @@ -1386,6 +1224,7 @@ dashboardRouter.post('/automations', (req, res) => { message: 'Vyberte firmu, do které se má automatizace založit.', }); } + if (!mayEditAutomations(req, res, scope.tenantId)) return; const automation = createAutomation(parsed.data.name, scope.tenantId); return res.status(201).json(automation); @@ -1393,32 +1232,30 @@ dashboardRouter.post('/automations', (req, res) => { dashboardRouter.put('/automations/:id', async (req, res) => { const parsed = updateSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', - issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), - }); - } + if (!parsed.success) return validationError(res, parsed.error); + + const existing = editableAutomationOrDeny(req, res); + if (!existing) return; const flow = parsed.data.flow ? normalizeTriggerFields(parsed.data.flow) : undefined; let connectorIssues: string[] = []; if (flow) { - const { problems, issues } = await validateFlowReferences(flow, writableTenants(req)); + // Konektory se hledaji jen ve firme automatizace, cizi je rozbity strom. + const { problems, issues } = await validateFlowReferences(flow, [existing.tenantId]); if (problems.length > 0) { console.warn(`[automations] ${req.params.id}: neplatny strom - ${problems.join(' ')}`); return res.status(400).json({ error: 'validation_error', message: problems[0], - issues: problems.map((message) => ({ path: 'flow', message })), + issues: problems.map((message) => ({ field: 'flow', message })), }); } connectorIssues = issues; } - const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, writableTenants(req)); + const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, [existing.tenantId]); if (!updated) { return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); } @@ -1429,7 +1266,10 @@ dashboardRouter.put('/automations/:id', async (req, res) => { /** Nova adresa webhooku. Stara okamzite prestane fungovat - zamer, ne chyba. */ dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => { - const updated = regenerateWebhookToken(req.params.id, writableTenants(req)); + const existing = editableAutomationOrDeny(req, res); + if (!existing) return; + + const updated = regenerateWebhookToken(req.params.id, [existing.tenantId]); if (!updated) { return res.status(404).json({ error: 'not_found', @@ -1440,7 +1280,10 @@ dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => { }); dashboardRouter.delete('/automations/:id', (req, res) => { - if (!deleteAutomation(req.params.id, writableTenants(req))) { + const existing = editableAutomationOrDeny(req, res); + if (!existing) return; + + if (!deleteAutomation(req.params.id, [existing.tenantId])) { return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); } return res.status(204).end(); diff --git a/src/routes/helpdesk.ts b/src/routes/helpdesk.ts index 7f3c0ad..749d4f1 100644 --- a/src/routes/helpdesk.ts +++ b/src/routes/helpdesk.ts @@ -23,16 +23,19 @@ * klienta. */ -import { Router, type Request, type Response } from 'express'; +import { type Request, type Response } from 'express'; import { z } from 'zod'; -import { actingTenant, seesWholeTenant } from '../data/access.js'; +import { seesWholeTenant } from '../data/access.js'; import { recordAudit } from '../data/audit.js'; import { hasPermission } from '../data/permissions.js'; import { findPersonByEmail } from '../data/people.js'; import { findTenant } from '../data/tenants.js'; import { addComment, createTicket, getTicket, listTickets } from '../data/ticketStore.js'; +import { safeRouter } from '../middleware/asyncHandler.js'; +import { requestTenant } from '../middleware/tenant.js'; +import { validationError } from '../middleware/validation.js'; -export const helpdeskRouter = Router(); +export const helpdeskRouter = safeRouter(); /** * Firma, za kterou clovek prave jedna, plus kontrola prava. @@ -47,10 +50,7 @@ function asking(req: Request, res: Response, permission: string): string | null * bez ni neni ceho se ptat - a kdyz clovek patri do vic firem, rozhoduje * ta, kterou ma prepnutou. */ - const tenantId = actingTenant( - req.user!, - typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, - ); + const tenantId = requestTenant(req); if (!tenantId) { res.status(403).json({ @@ -150,12 +150,7 @@ helpdeskRouter.post('/', (req, res) => { if (!provider) return; const parsed = createSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Požadavek se nepodařilo poslat.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); const tenant = findTenant(tenantId); const person = findPersonByEmail(req.user!.email); @@ -227,12 +222,7 @@ helpdeskRouter.post('/:id/comment', (req, res) => { if (!tenantId) return; const parsed = commentSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Komentář se nepodařilo uložit.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); const person = findPersonByEmail(req.user!.email); const author = person?.name ?? req.user!.email; diff --git a/src/routes/invites.ts b/src/routes/invites.ts index ac00e9e..c8c8b0d 100644 --- a/src/routes/invites.ts +++ b/src/routes/invites.ts @@ -11,7 +11,7 @@ * ochrana a odkaz se snadno dostane, kam nema. */ -import { Router } from 'express'; +import type { Request, Response } from 'express'; import { z } from 'zod'; import { recordAudit } from '../data/audit.js'; import { publicBaseUrl } from '../config.js'; @@ -24,26 +24,36 @@ import { newInvite, refreshInvites, } from '../data/invites.js'; -import { rolesFor, type Role } from '../data/permissions.js'; +import { hasPermission, rolesFor, type Role } from '../data/permissions.js'; import { personStore, refreshPeople, listPeople } from '../data/people.js'; import { findTenant } from '../data/tenants.js'; -import bcrypt from 'bcryptjs'; import { findUserByEmail, hashPassword, listAllUsers, refreshUsers, userStore, + verifyPassword, } from '../data/users.js'; import { nowIso } from '../data/store/index.js'; +import { safeRouter } from '../middleware/asyncHandler.js'; import { requireAuth } from '../middleware/auth.js'; -import { currentTenant } from './crud.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import { tenantOrDeny } from '../middleware/tenant.js'; +import { validationError } from '../middleware/validation.js'; /** Verejne cesty. Bez prihlaseni, autorizuje kod v adrese. */ -export const publicInviteRouter = Router(); +export const publicInviteRouter = safeRouter(); /** Cesty za prihlasenim: sprava pozvanek. */ -export const inviteRouter = Router(); +export const inviteRouter = safeRouter(); + +/** + * Prijeti pozvanky s existujicim uctem overuje heslo, takze je to druhe misto + * vedle prihlaseni, kde jde heslo zkouset. Prisny limit: pet pokusu za ctvrt + * hodiny z jedne adresy. Kdo pozvanku prijima, potrebuje jeden. + */ +const acceptLimiter = rateLimit({ name: 'invite-accept', windowMs: 15 * 60_000, max: 5 }); // ------------------------------------------------------------------ verejne @@ -88,7 +98,7 @@ const acceptSchema = z.object({ * * Heslo se **nikdy neposila** - clovek si ho nastavi sam prave tady. */ -publicInviteRouter.post('/:code/accept', (req, res) => { +publicInviteRouter.post('/:code/accept', acceptLimiter, (req, res) => { const invite = findByCode(req.params.code); if (!invite) { return res.status(404).json({ error: 'not_found', message: 'Pozvánka neexistuje.' }); @@ -98,12 +108,7 @@ publicInviteRouter.post('/:code/accept', (req, res) => { if (problem) return res.status(409).json({ error: 'invalid_invite', message: problem }); const parsed = acceptSchema.safeParse(req.body ?? {}); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); const email = parsed.data.email.toLowerCase(); @@ -131,7 +136,7 @@ publicInviteRouter.post('/:code/accept', (req, res) => { */ const stored = listAllUsers().find((item) => item.id === existing.id); const passwordOk = - stored !== undefined && (await bcrypt.compare(parsed.data.password, stored.passwordHash)); + stored !== undefined && (await verifyPassword(parsed.data.password, stored.passwordHash)); if (!passwordOk) { return res.status(401).json({ @@ -163,7 +168,8 @@ publicInviteRouter.post('/:code/accept', (req, res) => { tenantId: null, email, name: parsed.data.name, - passwordHash: hashPassword(parsed.data.password), + passwordHash: await hashPassword(parsed.data.password), + // Pozvanka nikdy nedela spravce platformy, at ji vytvoril kdokoliv. platformAdmin: false, enabled: true, memberships: [{ tenantId: invite.tenantId, roleIds: invite.roleIds }], @@ -229,8 +235,25 @@ inviteRouter.use(requireAuth); /** Kdo smi zvat. Zvat lidi do firmy je totez co spravovat uzivatele. */ const PERMISSION = 'user.manage'; +/** + * Firma plus pravo zvat do ni. Pri odepreni odpovi a vrati null. + * + * Driv se pravo nekontrolovalo vubec: kdokoliv z firmy mohl vytvorit pozvanku + * s roli spravce a pozvat sam sebe podruhe. + */ +function managedTenant(req: Request, res: Response): string | null { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return null; + if (!hasPermission(req.user!, PERMISSION, tenantId)) { + console.warn(`[pozvanky] ${req.user!.email}: chybi pravo ${PERMISSION} ve firme ${tenantId}`); + res.status(403).json({ error: 'forbidden', message: 'Pozvánky spravuje ten, kdo spravuje uživatele.' }); + return null; + } + return tenantId; +} + inviteRouter.get('/', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = managedTenant(req, res); if (!tenantId) return; const base = `${publicBaseUrl()}/pozvanka/`; @@ -257,14 +280,22 @@ const createSchema = z.object({ }); inviteRouter.post('/', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = managedTenant(req, res); if (!tenantId) return; const parsed = createSchema.safeParse(req.body ?? {}); - if (!parsed.success) { + if (!parsed.success) return validationError(res, parsed.error); + + // Jen role teto firmy nebo systemove. Cizi role by se stejne nepouzila, + // ale pozvanka s ni by vypadala, ze nekomu neco dava. + const known = rolesFor(tenantId); + const unknown = parsed.data.roleIds.filter( + (ref) => !known.some((role) => role.id === ref || role.key === ref), + ); + if (unknown.length > 0) { return res.status(400).json({ error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.', + message: `Role ${unknown.join(', ')} v této firmě neexistuje.`, }); } @@ -304,7 +335,7 @@ inviteRouter.post('/', (req, res) => { /** Zruseni pozvanky. Odkaz okamzite prestane platit. */ inviteRouter.delete('/:id', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = managedTenant(req, res); if (!tenantId) return; return void inviteStore diff --git a/src/routes/scripts.ts b/src/routes/scripts.ts index 5619dee..d1a805d 100644 --- a/src/routes/scripts.ts +++ b/src/routes/scripts.ts @@ -7,12 +7,13 @@ * zakladat tickety (viz documentation/09-navrh-rozsireni.md, bod 9). */ -import { Router } from 'express'; import { z } from 'zod'; -import { accessFor } from '../data/access.js'; import { defaultConnectorFor, getConnector, toPublicConnector } from '../data/connectorStore.js'; import { findService } from '../data/services.js'; +import { safeRouter } from '../middleware/asyncHandler.js'; import { requirePlatformAdmin } from '../middleware/auth.js'; +import { optionalTenantOrDeny, tenantOrDeny } from '../middleware/tenant.js'; +import { validationError } from '../middleware/validation.js'; import { operationIdOf, serviceIdOf } from '../scripts/manifest.js'; import { ensureLoaded, @@ -26,7 +27,7 @@ import { } from '../scripts/registry.js'; import { runScript } from '../scripts/runner.js'; -export const scriptsRouter = Router(); +export const scriptsRouter = safeRouter(); /** Manifest plus to, co si klient nema dopocitavat sam. */ async function scriptSummaries() { @@ -43,11 +44,12 @@ async function scriptSummaries() { } scriptsRouter.get('/', async (req, res) => { + // Cizi firma je 404. Bez firmy jen seznam skriptu bez konektoru. + const resolved = optionalTenantOrDeny(req, res); + if (!resolved) return; + const { tenantId } = resolved; + const [items, problems] = await Promise.all([scriptSummaries(), scriptProblems()]); - const access = accessFor(req.user!); - const tenantId = - (typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ?? - access.defaultTenantId; // Ke kazde sluzbe vychozi konektor firmy, aby portal poznal, jestli je cim volat. const pairs = await Promise.all( @@ -63,8 +65,11 @@ scriptsRouter.get('/', async (req, res) => { problems, connectors, tenantId: tenantId ?? null, - /** Kam se soubory ukladaji. Kdo ma na server pristup, upravi je i rucne. */ - directory: scriptsDir(), + /** + * Kam se soubory ukladaji. Kdo ma na server pristup, upravi je i rucne. + * Cesta na disku je nase provozni informace, zakaznik ji nevidi. + */ + directory: req.user!.platformAdmin ? scriptsDir() : null, }); }); @@ -88,10 +93,9 @@ scriptsRouter.get('/:id', async (req, res) => { // Manifest muze chybet, kdyz je soubor rozbity. Kod se vrati vzdy, aby slo opravit. const problems = await scriptProblems(); const serviceId = serviceIdOf(id); - const access = accessFor(req.user!); - const tenantId = - (typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ?? - access.defaultTenantId; + const resolved = optionalTenantOrDeny(req, res); + if (!resolved) return; + const { tenantId } = resolved; const connector = tenantId ? await defaultConnectorFor(tenantId, serviceId) : undefined; return res.json({ @@ -121,12 +125,7 @@ scriptsRouter.put('/:id', requirePlatformAdmin, async (req, res) => { } const parsed = saveSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); const result = await saveSource(id, parsed.data.code); if (!result.ok) { @@ -162,19 +161,11 @@ scriptsRouter.post('/:id/test', requirePlatformAdmin, async (req, res) => { const parsed = testSchema.safeParse(req.body); if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: 'Vstupy musí být objekt s hodnotami parametrů.', - }); + return validationError(res, parsed.error, 'Vstupy musí být objekt s hodnotami parametrů.'); } - const access = accessFor(req.user!); - const tenantId = - (typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined) ?? - access.defaultTenantId; - if (!tenantId) { - return res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' }); - } + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; const serviceId = serviceIdOf(id); const service = findService(serviceId); diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 8b31b8a..11e44d5 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -10,7 +10,7 @@ * uzivatel ulozil roli a prava by se zmenila az po restartu. */ -import { Router } from 'express'; +import type { Request } from 'express'; import { z } from 'zod'; import { recordAudit } from '../data/audit.js'; import { bootstrapDataRefresh } from '../data/refresh.js'; @@ -54,18 +54,24 @@ import { validateAction, type TicketAction, } from '../data/ticketActions.js'; -import { hashPassword, listAllUsers, userStore, type StoredUser } from '../data/users.js'; +import { hashPassword, listAllUsers, userStore, usersOfTenant, type StoredUser } from '../data/users.js'; +import { hasPermission } from '../data/permissions.js'; +import { publish } from '../events/bus.js'; +import { safeRouter } from '../middleware/asyncHandler.js'; import { requireAuth } from '../middleware/auth.js'; -import { crudRouter, currentTenant, readScope } from './crud.js'; +import { requestTenant, tenantOrDeny } from '../middleware/tenant.js'; +import { validationError } from '../middleware/validation.js'; +import { aresRouter } from './ares.js'; +import { crudRouter, readScope } from './crud.js'; -export const settingsRouter = Router(); +export const settingsRouter = safeRouter(); settingsRouter.use(requireAuth); /** Zapis do jakekoliv entity muze zmenit prava nebo nabidky, proto obnova. */ settingsRouter.use((req, res, next) => { if (req.method === 'GET') return next(); res.on('finish', () => { - if (res.statusCode < 400) void bootstrapDataRefresh(); + if (res.statusCode < 400) void bootstrapDataRefresh(req.path); }); return next(); }); @@ -88,16 +94,34 @@ settingsRouter.get('/catalog', (req, res) => { // -------------------------------------------------------------------- firmy +/** Udaje z ARES jsou nepovinne, rucne zalozena firma je mit nemusi. */ +const tenantRegistryFields = { + ico: z.string().trim().regex(/^\d{8}$/, 'IČ má 8 číslic.').nullable().optional(), + dic: z.string().trim().max(20).nullable().optional(), + address: z.string().trim().max(300).nullable().optional(), + legalForm: z.string().trim().max(120).nullable().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, }); +/** Dve firmy se stejnym IC jsou jedna firma zalozena dvakrat. */ +function duplicateIco(tenant: Tenant, all: Tenant[]): string[] { + if (!tenant.ico) return []; + return all.some((other) => other.ico === tenant.ico) + ? [`Firma s IČ ${tenant.ico} už existuje.`] + : []; +} + settingsRouter.use( '/tenants', crudRouter, Partial>({ store: tenantStore, idPrefix: 'tnt', + event: 'tenant', createSchema: tenantCreate, updateSchema: z.object({ name: z.string().trim().min(2).max(80).optional(), @@ -105,6 +129,7 @@ settingsRouter.use( enabled: z.boolean().optional(), /** Kdo teto firme resi helpdesk. null = nikdo, pozadavek nepujde poslat. */ helpdeskProviderId: z.string().trim().min(1).nullable().optional(), + ...tenantRegistryFields, }), writePermission: 'tenant.manage', platformOnly: true, @@ -119,14 +144,23 @@ settingsRouter.use( intakeToken: generateIntakeToken(), // Dodavatele helpdesku doplni spravce az pri nastaveni vztahu. helpdeskProviderId: null, + ico: input.ico ?? null, + dic: input.dic ?? null, + address: input.address ?? null, + legalForm: input.legalForm ?? null, }), - validate: (tenant, all) => - all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase()) + validate: (tenant, all) => [ + ...(all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase()) ? [`Firma ${tenant.name} už existuje.`] - : [], + : []), + ...duplicateIco(tenant, all), + ], }), ); +// Zalozeni firmy z registru ARES vcetne lidi, kteri za ni jednaji. +settingsRouter.use('/ares', aresRouter); + // ---------------------------------------------------------------- uzivatele const membershipSchema = z.object({ @@ -142,31 +176,102 @@ const userCreate = z.object({ memberships: z.array(membershipSchema).default([]), }); +const userUpdate = z.object({ + name: z.string().trim().min(2).max(80).optional(), + email: z.string().trim().email().optional(), + platformAdmin: z.boolean().optional(), + memberships: z.array(membershipSchema).optional(), + enabled: z.boolean().optional(), +}); + +type UserInput = z.infer | z.infer; + /** Heslo se z API nikdy nevraci, ani jako hash. */ function publicUser(user: StoredUser) { const { passwordHash: _passwordHash, ...rest } = user; return rest; } +/** Je uzivatel clenem firmy? Spravce firmy vidi jen sve lidi. */ +function memberOf(user: StoredUser, tenantId: string): boolean { + return user.memberships.some((membership) => membership.tenantId === tenantId); +} + +/** + * Co smi se zaznamem uzivatele spravce firmy. + * + * Spravce platformy muze vsechno. Spravce firmy (pravo `user.manage`) smi + * zakladat a menit jen lidi sve firmy, a to jen v ni: clenstvi v jinych + * firmach se mu nechavaji, jak jsou, priznak spravce platformy nenastavi + * a na spravce platformy vubec nesahne. Bez toho by si kazdy spravce firmy + * mohl pridat clenstvi kamkoliv a udelat ze sebe spravce platformy. + */ +function prepareUserInput( + req: Request, + tenantId: string, + input: UserInput | undefined, + existing?: StoredUser, +): { ok: true; input: UserInput | undefined } | { ok: false; status: number; message: string } { + if (req.user!.platformAdmin) return { ok: true, input }; + + if (existing?.platformAdmin) { + return { ok: false, status: 403, message: 'Správce platformy upravuje jen správce platformy.' }; + } + + // Mazani: clovek z vic firem se nemaze, jen se mu vezme clenstvi tady. + if (input === undefined) { + const elsewhere = (existing?.memberships ?? []).some((m) => m.tenantId !== tenantId); + if (elsewhere) { + return { + ok: false, + status: 403, + message: 'Uživatel patří i do jiné firmy. Odeberte mu členství ve vaší, nemažte ho.', + }; + } + return { ok: true, input }; + } + + if (input.platformAdmin !== undefined) { + return { + ok: false, + status: 403, + message: 'Příznak správce platformy nastavuje jen správce platformy.', + }; + } + + if (input.memberships !== undefined) { + if (input.memberships.some((membership) => membership.tenantId !== tenantId)) { + return { + ok: false, + status: 403, + message: 'Členství můžete nastavit jen ve firmě, ve které právě jste.', + }; + } + if (!existing && input.memberships.length === 0) { + return { ok: false, status: 403, message: 'Nový uživatel musí mít členství ve vaší firmě.' }; + } + // Clenstvi jinde zustavaji, ta spravce firmy nevidi a nesmi je smazat. + const others = (existing?.memberships ?? []).filter((m) => m.tenantId !== tenantId); + return { ok: true, input: { ...input, memberships: [...others, ...input.memberships] } }; + } + + return { ok: true, input }; +} + settingsRouter.use( '/users', - crudRouter, Partial>({ + crudRouter, z.infer>({ store: userStore, idPrefix: 'usr', + event: 'user', createSchema: userCreate, - updateSchema: z.object({ - name: z.string().trim().min(2).max(80).optional(), - email: z.string().trim().email().optional(), - password: z.string().min(8).optional(), - platformAdmin: z.boolean().optional(), - memberships: z.array(membershipSchema).optional(), - enabled: z.boolean().optional(), - }), + updateSchema: userUpdate, writePermission: 'user.manage', - build: (input) => ({ + scopeBy: { belongsTo: memberOf, prepare: prepareUserInput }, + build: async (input) => ({ email: input.email.toLowerCase(), name: input.name, - passwordHash: hashPassword(input.password), + passwordHash: await hashPassword(input.password), platformAdmin: input.platformAdmin ?? false, memberships: input.memberships, enabled: true, @@ -182,6 +287,15 @@ settingsRouter.use( for (const membership of user.memberships) { if (!listTenants().some((tenant) => tenant.id === membership.tenantId)) { problems.push(`Firma ${membership.tenantId} neexistuje.`); + continue; + } + // Role musi byt te firmy nebo systemova. Cizi role by se stejne + // nepouzila (viz permissionsOf), ale ulozit ji je matouci. + const known = rolesFor(membership.tenantId); + for (const ref of membership.roleIds) { + if (!known.some((role) => role.id === ref || role.key === ref)) { + problems.push(`Role ${ref} v této firmě neexistuje.`); + } } } return problems; @@ -196,22 +310,29 @@ settingsRouter.use( */ settingsRouter.patch('/users/:id/password', async (req, res) => { const parsed = z.object({ password: z.string().min(8) }).safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: 'validation_error', message: 'Heslo musí mít aspoň 8 znaků.' }); - } + if (!parsed.success) return validationError(res, parsed.error, 'Heslo musí mít aspoň 8 znaků.'); const target = await userStore.get(req.params.id, readScope(req)); if (!target) return res.status(404).json({ error: 'not_found', message: 'Uživatel neexistuje.' }); - // Svoje heslo si smi zmenit kazdy, cizi jen kdo spravuje uzivatele. + /* + * Svoje heslo si smi zmenit kazdy. Cizi spravce platformy, nebo spravce + * firmy svym lidem - ne ale spravci platformy, ten je mimo jeho dosah. + */ const own = target.id === req.user!.id; - if (!own && !req.user!.platformAdmin) { + const tenantId = requestTenant(req); + const managesTarget = + tenantId !== null && + !target.platformAdmin && + memberOf(target, tenantId) && + hasPermission(req.user!, 'user.manage', tenantId); + if (!own && !req.user!.platformAdmin && !managesTarget) { return res.status(403).json({ error: 'forbidden', message: 'Cizí heslo měnit nemůžete.' }); } await userStore.update( req.params.id, - { passwordHash: hashPassword(parsed.data.password) } as Partial, + { passwordHash: await hashPassword(parsed.data.password) } as Partial, readScope(req), ); recordAudit({ @@ -224,12 +345,22 @@ settingsRouter.patch('/users/:id/password', async (req, res) => { return res.status(204).end(); }); -/** Uzivatele vcetne vypnutych. Seznam pro spravu, ne pro nabidky. */ +/** + * Uzivatele vcetne vypnutych. Seznam pro spravu, ne pro nabidky. + * + * Spravce platformy vidi vsechny, spravce firmy jen lidi sve firmy. + */ settingsRouter.get('/users-overview', (req, res) => { - if (!req.user!.platformAdmin) { - return res.status(403).json({ error: 'forbidden', message: 'Jen pro správce platformy.' }); + if (req.user!.platformAdmin) { + return res.json({ items: listAllUsers().map(publicUser) }); } - return res.json({ items: listAllUsers().map(publicUser) }); + + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + if (!hasPermission(req.user!, 'user.manage', tenantId)) { + return res.status(403).json({ error: 'forbidden', message: 'Uživatele vidí ten, kdo je spravuje.' }); + } + return res.json({ items: usersOfTenant(tenantId).map(publicUser) }); }); // ---------------------------------------------------------------------- role @@ -245,6 +376,7 @@ settingsRouter.use( crudRouter, Partial>({ store: roleStore, idPrefix: 'role', + event: 'role', createSchema: roleCreate, updateSchema: z.object({ name: z.string().trim().min(2).max(60).optional(), @@ -270,7 +402,7 @@ settingsRouter.use( /** Role dostupne firme vcetne systemovych. Pro nabidku u clenstvi. */ settingsRouter.get('/roles-available', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; return res.json({ items: rolesFor(tenantId) }); }); @@ -291,6 +423,7 @@ settingsRouter.use( crudRouter, Partial>({ store: personStore, idPrefix: 'ppl', + event: 'person', createSchema: personCreate, updateSchema: z.object({ name: z.string().trim().min(2).max(80).optional(), @@ -318,7 +451,7 @@ settingsRouter.use( /** Vcetne vypnutych. Pro spravu tymu. */ settingsRouter.get('/people-overview', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; return res.json({ items: listAllPeople([tenantId]) }); }); @@ -347,6 +480,7 @@ settingsRouter.use( crudRouter({ store: groupStore, idPrefix: 'grp', + event: 'group', createSchema: groupCreate, updateSchema: z.object({ name: z.string().trim().min(2).max(60).optional(), @@ -377,7 +511,7 @@ const featuresUpdate = z.object({ /** Nastaveni firmy. GET vraci i vychozi, kdyz firma vlastni jeste nema. */ settingsRouter.get('/features', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; return res.json({ tenantId, features: featuresOf(tenantId) }); }); @@ -386,16 +520,11 @@ settingsRouter.put('/features', async (req, res) => { if (!req.user!.platformAdmin) { return res.status(403).json({ error: 'forbidden', message: 'Záložky nastavuje správce platformy.' }); } - const tenantId = currentTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; const parsed = featuresUpdate.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); const known = new Set(moduleCatalog.map((module) => module.key)); const unknown = (parsed.data.modules ?? []).filter((key) => !known.has(key as never)); @@ -441,6 +570,8 @@ settingsRouter.put('/features', async (req, res) => { detail: { modules }, }); + // Zalozky a limity meni navigaci portalu; ten si je drzi s pravy v pameti. + publish('feature.updated', 'Záložky firmy upraveny', { id: tenantId, feature: saved }, tenantId); return res.json({ tenantId, features: saved }); }); @@ -488,6 +619,7 @@ settingsRouter.use( crudRouter, Partial>({ store: ticketTypeStore, idPrefix: 'tt', + event: 'ticketType', createSchema: typeCreate, // Transformace dogeneruje ID poli. Bez ni by pole prislo bez `id` // a podminky v akcich by se nemely na co odkazat. @@ -589,6 +721,7 @@ settingsRouter.use( crudRouter, Partial>({ store: actionStore, idPrefix: 'tka', + event: 'action', createSchema: actionCreate, updateSchema: z.object({ label: z.string().trim().min(2).max(60).optional(), @@ -622,7 +755,7 @@ settingsRouter.use( /** Akce firmy vcetne vypnutych. Pro spravu. */ settingsRouter.get('/actions-overview', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; return res.json({ items: listActions([tenantId]) }); }); @@ -635,7 +768,7 @@ settingsRouter.get('/actions-overview', (req, res) => { * server, aby si klient nedelal druhy seznam, ktery se casem rozejde. */ settingsRouter.get('/actions/:id/scope', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; const action = listActions([tenantId]).find((item) => item.id === req.params.id); @@ -732,6 +865,7 @@ settingsRouter.use( crudRouter, Partial>({ store: customWidgetStore, idPrefix: 'cw', + event: 'widget', createSchema: widgetCreate, updateSchema: z.object({ name: z.string().trim().min(2).max(60).optional(), @@ -758,7 +892,7 @@ settingsRouter.use( /** Widgety firmy plus osobni prihlaseneho. */ settingsRouter.get('/widgets-overview', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; return res.json({ items: listCustomWidgets([tenantId], req.user!.id) }); }); diff --git a/src/routes/stream.ts b/src/routes/stream.ts index ce2061d..07720ef 100644 --- a/src/routes/stream.ts +++ b/src/routes/stream.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { listenerCount, recentEvents, subscribe, type DashboardEvent } from '../events/bus.js'; import { requireAuth } from '../middleware/auth.js'; +import { accessOf } from '../middleware/tenant.js'; export const streamRouter = Router(); @@ -26,10 +27,26 @@ streamRouter.get('/', requireAuth, (req, res) => { res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders(); - const email = req.user?.email ?? 'neznamy'; + const user = req.user!; + const email = user.email; console.info(`[stream] pripojen ${email} (celkem posluchacu: ${listenerCount() + 1})`); + /* + * Co tenhle clovek smi videt. Firmy se berou jednou pri pripojeni: kdo + * clenstvi ztrati, dostava udalosti do dalsiho pripojeni - token mu stejne + * vyprsi driv, nez by to vadilo. + */ + const allowed = new Set(accessOf(req).tenants.map((tenant) => tenant.id)); + const visible = (event: DashboardEvent): boolean => { + // Upozorneni patri jednomu cloveku, ne cele firme. + const forUser = event.payload?.userId; + if (typeof forUser === 'string' && forUser !== user.id) return false; + if (event.tenantId === null || user.platformAdmin) return true; + return allowed.has(event.tenantId); + }; + const send = (event: DashboardEvent) => { + if (!visible(event)) return; res.write(`event: ${event.type}\n`); res.write(`id: ${event.id}\n`); res.write(`data: ${JSON.stringify(event)}\n\n`); @@ -39,7 +56,7 @@ streamRouter.get('/', requireAuth, (req, res) => { res.write(`event: connected\ndata: ${JSON.stringify({ at: new Date().toISOString() })}\n\n`); // Kratka historie, aby klient nepresel o to, co se stalo tesne pred pripojenim. - for (const event of recentEvents(5).reverse()) send(event); + for (const event of recentEvents(5, visible).reverse()) send(event); const unsubscribe = subscribe(send); diff --git a/src/routes/tenantScripts.ts b/src/routes/tenantScripts.ts index b06c2b5..c5aad2b 100644 --- a/src/routes/tenantScripts.ts +++ b/src/routes/tenantScripts.ts @@ -10,7 +10,7 @@ * co aplikace ve firme umi - ne bezna prace s tickety. */ -import { Router } from 'express'; +import type { Request, Response } from 'express'; import { z } from 'zod'; import { recordAudit } from '../data/audit.js'; import { @@ -23,11 +23,13 @@ import { SCRIPT_TEMPLATE, } from '../data/tenantScripts.js'; import { hasPermission } from '../data/permissions.js'; +import { safeRouter } from '../middleware/asyncHandler.js'; import { requireAuth } from '../middleware/auth.js'; +import { tenantOrDeny } from '../middleware/tenant.js'; +import { validationError } from '../middleware/validation.js'; import { runSandbox } from '../runtime/sandbox.js'; -import { currentTenant, requestTenant } from './crud.js'; -export const tenantScriptRouter = Router(); +export const tenantScriptRouter = safeRouter(); tenantScriptRouter.use(requireAuth); @@ -35,9 +37,9 @@ tenantScriptRouter.use(requireAuth); const PERMISSION = 'action.manage'; /** Vrati false a odpovi, kdyz volajici na zmenu nema pravo. */ -function mayWrite(req: Parameters[0], res: Parameters[1]): boolean { +function mayWrite(req: Request, res: Response, tenantId: string): boolean { // Pravo za firmu, kterou ma clovek prepnutou. Skript patri jedne firme. - if (hasPermission(req.user!, PERMISSION, requestTenant(req))) return true; + if (hasPermission(req.user!, PERMISSION, tenantId)) return true; console.warn(`[skripty] ${req.user!.email}: chybi pravo ${PERMISSION}`); res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' }); return false; @@ -54,7 +56,7 @@ const bodySchema = z.object({ }); tenantScriptRouter.get('/', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; return res.json({ @@ -66,17 +68,12 @@ tenantScriptRouter.get('/', (req, res) => { }); tenantScriptRouter.post('/', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; - if (!mayWrite(req, res)) return; + if (!mayWrite(req, res, tenantId)) return; const parsed = bodySchema.safeParse(req.body ?? {}); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); const script = newTenantScript({ tenantId, ...parsed.data }); @@ -103,21 +100,16 @@ tenantScriptRouter.post('/', (req, res) => { }); tenantScriptRouter.put('/:id', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; - if (!mayWrite(req, res)) return; + if (!mayWrite(req, res, tenantId)) return; if (!findTenantScript(req.params.id, [tenantId])) { return res.status(404).json({ error: 'not_found', message: 'Skript neexistuje.' }); } const parsed = bodySchema.safeParse(req.body ?? {}); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); return void tenantScriptStore .update(req.params.id, parsed.data, { tenantIds: [tenantId], includeGlobal: false }) @@ -145,9 +137,9 @@ tenantScriptRouter.put('/:id', (req, res) => { }); tenantScriptRouter.delete('/:id', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; - if (!mayWrite(req, res)) return; + if (!mayWrite(req, res, tenantId)) return; return void tenantScriptStore .remove(req.params.id, { tenantIds: [tenantId], includeGlobal: false }) @@ -187,17 +179,12 @@ const testSchema = z.object({ * az z padleho behu o hodinu pozdeji. */ tenantScriptRouter.post('/test', (req, res) => { - const tenantId = currentTenant(req, res); + const tenantId = tenantOrDeny(req, res); if (!tenantId) return; - if (!mayWrite(req, res)) return; + if (!mayWrite(req, res, tenantId)) return; const parsed = testSchema.safeParse(req.body ?? {}); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.', - }); - } + if (!parsed.success) return validationError(res, parsed.error); const result = runSandbox(parsed.data.code, parsed.data.input ?? {}); diff --git a/src/routes/ticketActions.ts b/src/routes/ticketActions.ts index 8ad525f..731fa46 100644 --- a/src/routes/ticketActions.ts +++ b/src/routes/ticketActions.ts @@ -10,22 +10,31 @@ * zmackl" by znamenala dve casove osy a hledani ve dvou mistech. */ -import { Router } from 'express'; +import type { Request, Response } from 'express'; import { z } from 'zod'; -import { isDenied, resolveScope } from '../data/access.js'; +import { visibilityFor } from '../data/access.js'; import { recordAudit } from '../data/audit.js'; import { defaultConnectorFor, getConnector } from '../data/connectorStore.js'; -import { findGroup } from '../data/people.js'; +import { findGroup, groupsOfPerson } from '../data/people.js'; import { hasPermission } from '../data/permissions.js'; import { renderTemplate } from '../data/templates.js'; import { + addComment, appendTrace, + assignTicket, assignTicketGroup, + claimTicket, getTicket, setTicketTags, setTicketType, + ticketWithinVisibility, + updateTicketStatus, type Ticket, + type TicketDetail, } from '../data/ticketStore.js'; +import { safeRouter } from '../middleware/asyncHandler.js'; +import { accessOf } from '../middleware/tenant.js'; +import { validationError } from '../middleware/validation.js'; import { actionPermission, actionsForTicket, @@ -39,7 +48,25 @@ import { runScript } from '../scripts/runner.js'; import { scriptIdFor } from '../scripts/lookup.js'; import { runFlow } from '../runtime/executor.js'; -export const ticketActionsRouter = Router(); +export const ticketActionsRouter = safeRouter(); + +/** + * Ticket, na ktery volajici vidi. Cizi nebo neviditelny je 404, ne 403. + * + * Hleda se pres **vsechny firmy uzivatele**, ne jen prepnutou - odkaz + * z pohledu "vse" vede i na ticket jine firmy. Strop viditelnosti se pak + * pocita za firmu ticketu. Stejne pravidlo jako u detailu, proto jedno misto: + * kdyby si to kazda akce resila sama, jednou nekde strop chybi. + */ +export function visibleTicketOrDeny(req: Request, res: Response): TicketDetail | null { + const reachable = accessOf(req).tenants.map((tenant) => tenant.id); + const ticket = getTicket(req.params.id, reachable); + if (!ticket || !ticketWithinVisibility(ticket.id, visibilityFor(req.user!, ticket.tenantId))) { + res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); + return null; + } + return ticket; +} /** * Hodnoty, proti kterym se vyhodnocuji podminky a sablony. @@ -103,16 +130,8 @@ function publicAction(action: TicketAction) { /** Akce, ktere na ticket sedi. Uz vyfiltrovane podle typu, tagu, podminek a prav. */ ticketActionsRouter.get('/:id/actions', async (req, res) => { - const scope = resolveScope(req.user!, { - scope: typeof req.query.scope === 'string' ? req.query.scope : undefined, - tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, - }); - if (isDenied(scope)) { - return res.status(scope.status).json({ error: scope.error, message: scope.message }); - } - - const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility); - if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); + const ticket = visibleTicketOrDeny(req, res); + if (!ticket) return; const actions = actionsForTicket( [ticket.tenantId], @@ -136,15 +155,8 @@ const runSchema = z.object({ * otevrenou starou stranku, nebo tlacitko vubec nezobrazit a poslat request sam. */ ticketActionsRouter.post('/:id/actions/:actionId', async (req, res) => { - const scope = resolveScope(req.user!, { - tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, - }); - if (isDenied(scope)) { - return res.status(scope.status).json({ error: scope.error, message: scope.message }); - } - - const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility); - if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); + const ticket = visibleTicketOrDeny(req, res); + if (!ticket) return; const action = findAction(req.params.actionId); if (!action || action.tenantId !== ticket.tenantId || !action.enabled) { @@ -173,9 +185,7 @@ ticketActionsRouter.post('/:id/actions/:actionId', async (req, res) => { } const parsed = runSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: 'validation_error', message: 'Neplatný formulář.' }); - } + if (!parsed.success) return validationError(res, parsed.error, 'Neplatný formulář.'); for (const field of action.form) { if (field.required && (parsed.data.form[field.id] ?? '').trim() === '') { @@ -340,32 +350,63 @@ async function runAction( // ------------------------------------------------- vestavene akce na ticketu +/** + * Odmitnuti z `apply`, kdyz duvod neni "ticket neexistuje". Ma vlastni tvar, + * aby se nepletl s ticketem - ten ma taky pole `status`. + */ +interface ActionRefusal { + refused: true; + status: number; + error: string; + message: string; +} + +function refuse(status: number, error: string, message: string): ActionRefusal { + return { refused: true, status, error, message }; +} + +function isRefusal(value: unknown): value is ActionRefusal { + return typeof value === 'object' && value !== null && (value as ActionRefusal).refused === true; +} + /** * Vestavene akce jsou ve stejnem rezimu jako vlastni: kazda ma svoje pravo * a projde auditem. Diky tomu jde vestavenou akci nekomu vypnout bez zmeny kodu. * - * Fabrika, ne tri skoro stejne handlery. Kazdy by jinak resil znovu firmu, - * pravo, 404, audit a udalost. + * Fabrika, ne sedm skoro stejnych handleru. Kazdy by jinak resil znovu firmu, + * strop viditelnosti, pravo, 404, audit a udalost - a prirazeni, stav + * a komentar to driv opravdu resily kazdy jinak a pravo nekontrolovaly vubec. */ function builtinAction(options: { path: string; - permission: string; + /** Pravo, nebo funkce, kdyz zalezi na vstupu (vzit si vs. prehodit). */ + permission: string | ((input: T, req: Request) => string); + /** Hlaska pri odepreni. Bez ni obecna. */ + deniedMessage?: string; auditAction: string; schema: z.ZodType; - apply: (ticket: Ticket, input: T, tenantIds: string[]) => Ticket | undefined; + /** Hlaska pri neplatnem vstupu. Bez ni ta ze schematu. */ + invalidMessage?: string; + apply: ( + ticket: TicketDetail, + input: T, + tenantIds: string[], + req: Request, + ) => Ticket | undefined | ActionRefusal; }): void { ticketActionsRouter.post(`/:id/${options.path}`, (req, res) => { - const scope = resolveScope(req.user!, { - tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, - }); - if (isDenied(scope)) { - return res.status(scope.status).json({ error: scope.error, message: scope.message }); - } + const ticket = visibleTicketOrDeny(req, res); + if (!ticket) return; - const ticket = getTicket(req.params.id, scope.tenantIds, [], scope.visibility); - if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); + const parsed = options.schema.safeParse(req.body); + if (!parsed.success) return validationError(res, parsed.error, options.invalidMessage); - if (!hasPermission(req.user!, options.permission, ticket.tenantId)) { + // Pravo za firmu ticketu, ne za prave prepnutou. + const permission = + typeof options.permission === 'string' + ? options.permission + : options.permission(parsed.data, req); + if (!hasPermission(req.user!, permission, ticket.tenantId)) { recordAudit({ userId: req.user!.id, userEmail: req.user!.email, @@ -375,18 +416,16 @@ function builtinAction(options: { target: ticket.id, result: 'denied', }); - return res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' }); - } - - const parsed = options.schema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', + return res.status(403).json({ + error: 'forbidden', + message: options.deniedMessage ?? 'K této změně nemáte oprávnění.', }); } - const updated = options.apply(ticket, parsed.data, scope.tenantIds); + const updated = options.apply(ticket, parsed.data, [ticket.tenantId], req); + if (isRefusal(updated)) { + return res.status(updated.status).json({ error: updated.error, message: updated.message }); + } if (!updated) { return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); } @@ -447,3 +486,107 @@ builtinAction({ return assignTicketGroup(ticket.id, input.groupId, tenantIds); }, }); + +/** + * Prirazeni resitele. `null` = vratit do fronty. + * + * Vzit si ticket na sebe je jine pravo nez prehodit ho nekomu jinemu + * (nebo vratit do fronty). Ktere se pta, rozhoduje vstup. + */ +builtinAction({ + path: 'assign', + permission: (input: { assigneeId: string | null }, req) => + input.assigneeId !== null && input.assigneeId === accessOf(req).personId + ? 'ticket.assign.self' + : 'ticket.assign.others', + deniedMessage: 'Přiřazovat ostatním může jen ten, kdo na to má právo. Ticket si můžete vzít na sebe.', + auditAction: 'ticket.assign', + schema: z.object({ + /** null = vratit do fronty. */ + assigneeId: z.string().min(1).nullable(), + }), + invalidMessage: 'Chybí ID řešitele, nebo pošlete null pro vrácení do fronty.', + apply: (ticket, input, tenantIds) => + assignTicket(ticket.id, input.assigneeId, tenantIds) ?? + refuse(404, 'not_found', 'Ticket nebo řešitel neexistuje, nebo je řešitel z jiné firmy.'), +}); + +builtinAction({ + path: 'status', + permission: 'ticket.status.change', + auditAction: 'ticket.status', + schema: z.object({ + /* + * Stav je volny retezec, ne ciselnik. Tickety chodi z cizich aplikaci, ktere + * maji svoje stavy, a nutit je do nasi ctverice znamenalo, ze uzivatel videl + * nesmysl. Omezena je jen delka, aby se do pole nedal cely dokument. + */ + status: z.string().trim().min(1).max(60), + /** + * Je vyrizeny? Kdyz se neposle, priznak zustava - zmena textu stavu sama + * o sobe neznamena, ze je hotovo. + */ + closed: z.boolean().optional(), + }), + invalidMessage: 'Neplatný stav ticketu.', + apply: (ticket, input, tenantIds) => + updateTicketStatus(ticket.id, input.status, tenantIds, input.closed), +}); + +builtinAction({ + path: 'comment', + permission: 'ticket.comment', + auditAction: 'ticket.comment', + schema: z.object({ + text: z.string().trim().min(2, 'Komentář musí mít alespoň 2 znaky.'), + }), + apply: (ticket, input, tenantIds, req) => + addComment(ticket.id, req.user?.name ?? 'Portál', input.text, tenantIds), +}); + +/** + * Prevzeti ticketu. + * + * Clovek si vezme praci sam, misto aby cekal, az mu ji nekdo prideli. Tohle je + * bezny zpusob prace v tymu: ticket lezi ve fronte skupiny a kdo ma cas, si ho + * vezme. + * + * Smi to u ticketu **ze sve skupiny** nebo u takoveho, ktery nema nikoho. + * Vzit nekomu rozdelanou praci nejde - to uz je prehozeni a na to je pravo + * `ticket.assign.others`. + */ +builtinAction({ + path: 'claim', + permission: 'ticket.assign.self', + auditAction: 'ticket.claim', + // Prevzeti nema telo. Cokoliv prijde, se ignoruje. + schema: z.unknown(), + apply: (ticket, _input, tenantIds, req) => { + const personId = accessOf(req).personId; + if (!personId) { + return refuse(400, 'not_a_person', 'Nejste vedený jako řešitel, takže si ticket nemůžete převzít.'); + } + + const current = ticket.assignee?.id ?? null; + if (current !== null && current !== personId) { + // Uz to nekdo resi. Prehodit praci nekomu jinemu je jine rozhodnuti. + // Pravo se pta za firmu ticketu, ne za tu prave prepnutou. + if (!hasPermission(req.user!, 'ticket.assign.others', ticket.tenantId)) { + return refuse( + 409, + 'already_assigned', + 'Ticket už někdo řeší. Přehodit ho může jen ten, kdo na to má právo.', + ); + } + + const mine = groupsOfPerson(personId, tenantIds).map((group) => group.id); + const inMyGroup = ticket.assigneeGroupId !== null && mine.includes(ticket.assigneeGroupId); + if (!inMyGroup) return refuse(403, 'forbidden', 'Tenhle ticket není ve vaší skupině.'); + } + + return ( + claimTicket(ticket.id, personId, tenantIds) ?? + refuse(400, 'failed', 'Převzetí se nepodařilo.') + ); + }, +}); diff --git a/src/routes/webhook.ts b/src/routes/webhook.ts index c4cb4de..7e237a0 100644 --- a/src/routes/webhook.ts +++ b/src/routes/webhook.ts @@ -222,7 +222,7 @@ webhookRouter.post('/:token', (req, res) => { publish('webhook.received', `Webhook přijal data pro ${automation.id}`, { automationId: automation.id, fields: Object.keys(values), - }); + }, automation.tenantId); return void enqueue({ tenantId: automation.tenantId, diff --git a/src/routes/widgetData.ts b/src/routes/widgetData.ts index c01e145..70015c6 100644 --- a/src/routes/widgetData.ts +++ b/src/routes/widgetData.ts @@ -12,9 +12,9 @@ * nejde poslat nic, co by server polozilo. */ -import { Router } from 'express'; import { z } from 'zod'; -import { isDenied, resolveScope, type ResolvedScope } from '../data/access.js'; +import type { WidgetResult, WidgetValue } from '../shared/widgets.js'; +import { type ResolvedScope } from '../data/access.js'; import { findCustomWidget, type CustomWidget, @@ -29,27 +29,45 @@ import { runScript } from '../scripts/runner.js'; import { scriptIdFor } from '../scripts/lookup.js'; import { channelLabels, + defaultStatuses, getAgentStats, getWorkload, listTickets, - type AgentStatsRow, type Ticket, } from '../data/ticketStore.js'; import { findTicketType, listTicketTypes } from '../data/ticketTypes.js'; import { builtinSources } from '../data/widgets.js'; +import { safeRouter } from '../middleware/asyncHandler.js'; +import { scopeOrDeny } from '../middleware/tenant.js'; +import { validationError } from '../middleware/validation.js'; -export const widgetDataRouter = Router(); +export const widgetDataRouter = safeRouter(); + +/** + * Tickety v dosahu, nactene **jednou za request**. + * + * Prehled ma az 24 dlazdic a skoro kazda filtruje tentyz seznam. Driv si ho + * kazda cetla znovu, coz bylo 24 pruchodu ulozistem kvuli jednomu prekresleni. + */ +function ticketSourceFor(scope: ResolvedScope): () => Ticket[] { + let loaded: Ticket[] | null = null; + return () => { + if (loaded === null) { + loaded = listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }); + } + return loaded; + }; +} /** * Popisky stavu. `ticketStore` je neexportuje, a duplikovat je sem je mensi * zlo nez rozsirovat verejne rozhrani uloziste kvuli jednomu widgetu. */ -const statusLabels: Record = { - new: 'Nový', - open: 'V řešení', - waiting: 'Čeká na klienta', - resolved: 'Vyřešeno', -}; +// Stav je volny retezec a tickety vznikaji s hodnotami z `defaultStatuses`, +// takze nabidka ve widgetu je tataz - jiny seznam by nikdy nic nenapocital. +const statusLabels: Record = Object.fromEntries( + defaultStatuses.map((status) => [status, status]), +); const requestSchema = z.object({ widgetIds: z.array(z.string().min(1)).max(24), @@ -62,55 +80,6 @@ const requestSchema = z.object({ force: z.boolean().default(false), }); -/** - * Jedna hodnota, nebo seskupeny vysledek. Klient podle `render` vi, co s tim. - * - * U cisel a radku chodi `href`: kam se proklikne ten, kdo chce videt, co je - * za tim. Sklada ho **server**, protoze on jediny zna filtr widgetu - klient - * by si musel odvozovat, co ktery zdroj znamena, a jednou by se to rozeslo. - */ -type WidgetValue = - | { kind: 'number'; value: number; href?: string } - | { - kind: 'groups'; - rows: Array<{ key: string; label: string; value: number; href?: string }>; - } - | { kind: 'series'; points: Array<{ date: string; value: number }>; href?: string } - | { - kind: 'tickets'; - items: Array<{ id: string; subject: string; status: string; assignee: string | null }>; - href?: string; - } - | { - kind: 'workload'; - rows: Array<{ personId: string; name: string; open: number; overCapacity: boolean; href: string }>; - href?: string; - } - | { kind: 'agents'; rows: AgentStatsRow[] } - /** Vysledek z konektoru. `stale` rika, ze je to z cache, ne cerstve zavolane. */ - | { - kind: 'external'; - value: number | null; - text: string | null; - rows: Array<{ key: string; label: string; value: number }>; - at: string; - stale: boolean; - /** - * Kdy se data zase natáhnou. Klient podle toho pozna, jestli ma smysl - * cekat, nebo si vyzadat obnovu rucne. - */ - nextAt: string; - /** Jak dlouho se drzi, v sekundach. Nastavuje se u widgetu. */ - ttlSec: number; - }; - -interface WidgetResult { - id: string; - ok: boolean; - value?: WidgetValue; - error?: string; -} - /** Od kdy se pocita. `all` znamena bez omezeni. */ function since(period: WidgetTicketFilter['period']): number | null { const now = Date.now(); @@ -391,7 +360,11 @@ function ticketsHref( return query ? `/dashboard/tickety?${query}` : '/dashboard/tickety'; } -function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue { +function computeSource( + source: WidgetSource, + scope: ResolvedScope, + allTickets: () => Ticket[], +): WidgetValue { if (source.kind === 'agentStats') { return { kind: 'agents', @@ -426,10 +399,7 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue throw new Error('Zdroj Konektor se počítá zvlášť.'); } - const tickets = listTickets({ - tenantIds: scope.tenantIds, - visibility: scope.visibility, - }).filter((ticket) => matches(ticket, source.filter, scope.personId)); + const tickets = allTickets().filter((ticket) => matches(ticket, source.filter, scope.personId)); if (source.kind === 'ticketCount') { if (!source.groupBy) { @@ -488,34 +458,32 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue } /** Zdroj vlastniho widgetu. Vestavene maji svuj v `builtinSources`. */ -function computeWidget(widget: CustomWidget, scope: ResolvedScope): WidgetValue { - return computeSource(widget.source, scope); +function computeWidget( + widget: CustomWidget, + scope: ResolvedScope, + allTickets: () => Ticket[], +): WidgetValue { + return computeSource(widget.source, scope, allTickets); } widgetDataRouter.post('/', async (req, res) => { - const scope = resolveScope(req.user!, { - scope: typeof req.query.scope === 'string' ? req.query.scope : undefined, - tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, - }); - if (isDenied(scope)) { - return res.status(scope.status).json({ error: scope.error, message: scope.message }); - } + const scope = scopeOrDeny(req, res); + if (!scope) return; const parsed = requestSchema.safeParse(req.body); if (!parsed.success) { - return res.status(400).json({ - error: 'validation_error', - message: 'Pošlete seznam ID widgetů, nejvýš 24.', - }); + return validationError(res, parsed.error, 'Pošlete seznam ID widgetů, nejvýš 24.'); } + const allTickets = ticketSourceFor(scope); + const results: WidgetResult[] = await Promise.all( parsed.data.widgetIds.map(async (id): Promise => { // Vestaveny widget s vypoctem. Nema definici v ulozisti, jen zdroj dat. const builtin = builtinSources[id]; if (builtin) { try { - return { id, ok: true, value: computeSource(builtin, scope) }; + return { id, ok: true, value: computeSource(builtin, scope, allTickets) }; } catch (err) { console.error(`[widgets] ${id} selhal:`, err); return { id, ok: false, error: message(err) }; @@ -535,7 +503,7 @@ widgetDataRouter.post('/', async (req, res) => { const value = widget.source.kind === 'connector' ? await computeConnector(widget.id, widget.source, scope, parsed.data.force) - : computeWidget(widget, scope); + : computeWidget(widget, scope, allTickets); return { id, ok: true, value }; } catch (err) { // Jeden rozbity zdroj nesmi zhasnout cely prehled. @@ -555,12 +523,8 @@ function message(err: unknown): string { /** Co lze do filtru vybrat. Klient si nesklada vlastni seznam. */ widgetDataRouter.get('/options', (req, res) => { - const scope = resolveScope(req.user!, { - tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, - }); - if (isDenied(scope)) { - return res.status(scope.status).json({ error: scope.error, message: scope.message }); - } + const scope = scopeOrDeny(req, res); + if (!scope) return; return res.json({ types: listTicketTypes(scope.tenantIds).map((type) => ({ id: type.id, name: type.name })), diff --git a/src/runtime/builtinSteps.ts b/src/runtime/builtinSteps.ts index 0617901..21d5ad5 100644 --- a/src/runtime/builtinSteps.ts +++ b/src/runtime/builtinSteps.ts @@ -14,7 +14,7 @@ import { ALL_PAGES_INPUT, findMcpTool, MAX_TOOL_PAGES, parseOperationId } from ' import { isMcpService } from '../mcp/dialect.js'; import { callTool, type McpCallResult } from '../mcp/client.js'; import { argumentsFrom, cursorFieldOf, nextCursorFrom, rowsFrom } from '../mcp/schema.js'; -import { truncate } from '../scripts/util.js'; +import { DETAIL_BYTES, parseBool, truncate } from '../scripts/util.js'; import { createIncident } from '../data/incidentStore.js'; import { sendMail } from '../mail/smtp.js'; import { resolveTarget } from '../scripts/connections.js'; @@ -125,11 +125,8 @@ const channels: TicketChannel[] = [ * ktery meni stav, mimochodem otevrel vyrizeny ticket. */ function boolInput(value: string | undefined): boolean | undefined { - const text = value?.trim().toLowerCase(); - if (!text) return undefined; - if (['true', '1', 'ano', 'yes'].includes(text)) return true; - if (['false', '0', 'ne', 'no'].includes(text)) return false; - return undefined; + if (!value?.trim()) return undefined; + return parseBool(value) ?? undefined; } /** @@ -604,11 +601,13 @@ const handlers: Record = { * Incident je neco jineho nez ticket: ticket je pozadavek jednoho zakaznika, * incident je "nefunguje to a tyka se to vic lidi". Proto vlastni krok. */ - 'incident/create': (inputs) => { + 'incident/create': (inputs, context) => { const title = inputs.title?.trim(); if (!title) return missing('název'); const incident = createIncident({ + // Bez firmy by byl incident globalni a videly by ho vsechny firmy. + tenantId: context.tenantId, title, service: inputs.service?.trim() || 'Neurčeno', severity: (['sev1', 'sev2', 'sev3'] as const).includes(inputs.severity as 'sev1') @@ -741,9 +740,11 @@ function safeJson(text: string): Record { * operace (`tool::`) - jinak by musel existovat kus kodu na * kazdy nastroj, ktery si firma zalozi, a to je presne to, co MCP resi. * - * Krok se **neopakuje**. MCP nema idempotencni klic, takze druhy pokus po - * timeoutu by nastroj provedl podruhe - a jestli to znamena druhou objednavku, - * vi jen server, ktery neni nas. + * Krok se opakuje jen kdyz se **k nastroji vubec nedoslo**: spojeni se + * nenavazalo, nebo server volani odmitl (503, 429) driv, nez nastroj spustil. + * Timeout uz beziciho nastroje se neopakuje: MCP nema idempotencni klic, takze + * druhy pokus by nastroj provedl podruhe - a jestli to znamena druhou + * objednavku, vi jen server, ktery neni nas. Rozhoduje o tom `McpFailure.retryable`. */ async function runMcpTool( inputs: Record, @@ -830,7 +831,15 @@ async function runMcpTool( const outcome = await callTool(target, parsed.toolName, pageArgs); if (!outcome.ok || !outcome.value) { - return { ok: false, summary: outcome.message, detail: outcome.detail, outputs: {} }; + return { + ok: false, + summary: outcome.message, + detail: outcome.detail, + // Opakuje se jen to, co klient oznacil: nenavazane spojeni a odmitnuti + // pred spustenim nastroje. Timeout uz beziciho nastroje ne, viz vyse. + retryable: outcome.retryable, + outputs: {}, + }; } last = outcome.value; @@ -867,7 +876,9 @@ async function runMcpTool( return { ok: !last.isError, summary: truncated ? `${summary}, strop stránek vyčerpán` : summary, - detail: text === '' ? null : truncate(text, 600), + // Stejny strop jako u ostatnich kroku. Sest set znaku bylo malo: prave + // v textu odpovedi nastroj pise, proc se mu neco nepovedlo. + detail: text === '' ? null : truncate(text, DETAIL_BYTES), /* * Strukturovana odpoved se rozbaluje do vystupu, aby na ni sla postavit * podminka bez psani cesty. Spolecne hodnoty se pisou az po ni: `text` diff --git a/src/runtime/executor.ts b/src/runtime/executor.ts index 8cfaaa1..3f097b1 100644 --- a/src/runtime/executor.ts +++ b/src/runtime/executor.ts @@ -23,13 +23,19 @@ import { defaultConnectorFor, getConnector, type Connector } from '../data/connectorStore.js'; import { matchOf, rulesOf, type FlowStep } from '../data/automationStore.js'; -import { isUnary, type ConditionOperator, type ConditionRule } from '../data/conditions.js'; +import { + isUnary, + type ConditionOperator, + type ConditionRule, + type FieldType, +} from '../data/conditions.js'; import { escapeHtml, renderTemplate } from '../data/templates.js'; import { actionInputsFor, type OperationField } from '../data/services.js'; import { getPath } from '../scripts/mapping.js'; import { appendTrace, type TraceInput } from '../data/ticketStore.js'; import { scriptIdFor } from '../scripts/lookup.js'; import { runScript } from '../scripts/runner.js'; +import { DETAIL_BYTES, truncate } from '../scripts/util.js'; import { findBuiltinStep } from './builtinSteps.js'; /** @@ -65,8 +71,22 @@ export interface RunOptions { * teto tabulky se podminka nema o co oprit a tise vychazi jako prazdna. */ fieldNames?: Record; + /** + * Typy parametru podle jejich ID. Podminka nad datem se musi porovnavat + * jako cas, ne jako cislo - ISO retezec by pres `Number()` vysel jako NaN. + */ + fieldTypes?: Record; } +/** + * Pravidlo opakovani, stejne ve vsech vrstvach (skript, vnitrni krok, MCP, + * fronta): **opakuje se jen to, co muze pominout samo** - nenavazane spojeni, + * timeout, 5xx a 429 od cizi sluzby, nedostupne uloziste. Vsechno ostatni + * (401, 403, 404, spatny vstup, chybejici skript, `ctx.fail`, chyba v kodu) + * konci hned a zaklada incident. Krok, ktery nerekne nic, se **neopakuje**: + * vychozi je "ne", protoze marne opakovani petkrat za hodinu jen zopakuje + * tutez hlasku a pri tom muze cizi sluzbu zavolat podruhe. + */ export interface StepResult { stepId: string; kind: 'action' | 'condition' | 'foreach'; @@ -74,12 +94,7 @@ export interface StepResult { ok: boolean; /** true = do logu ticketu se nezapisuje. Nic se nestalo, viz StepOutcome. */ quiet?: boolean; - /** - * false = opakovat nema smysl. - * - * Chybejici skript nebo neznama skupina za minutu existovat nezacne. - * Opakovat se ma jen to, co muze pominout: nedostupna sluzba, timeout. - */ + /** true = docasna prekazka, opakovani ma smysl. Chybi = ne. Viz pravidlo vyse. */ retryable?: boolean; /** U podminky, kterou vetvi se slo. */ branch?: 'yes' | 'no'; @@ -102,7 +117,7 @@ export interface RunResult { durationMs: number; } -/** Vychozi strop kroku. Vic uz neni strom, ale program. */ +/** Vychozi strop kroku **ve strome**. Vic uz neni strom, ale program. */ const MAX_STEPS = 50; /** @@ -114,6 +129,26 @@ const MAX_STEPS = 50; */ const MAX_LOOP_ITEMS = 200; +/** + * Kolik kroku se v jednom behu nejvys **vykona**, vcetne pruchodu smyckami. + * + * Jiny strop nez `MAX_STEPS`: strom o dvou krocich ve smycce nad sto polozkami + * je maly strom, ale dve ste vykonanych kroku. Driv se pocitalo obojí jednou + * hodnotou a smycka spadla na 26. polozce, i kdyz slibovala 200. + */ +const MAX_ACTIONS = 1_000; + +/** Kolik kroku strom ma, vcetne vetvi podminek a tel smycek. */ +function countSteps(list: FlowStep[]): number { + let total = 0; + for (const step of list) { + total += 1; + if (step.kind === 'condition') total += countSteps(step.yes) + countSteps(step.no); + else if (step.kind === 'foreach') total += countSteps(step.steps); + } + return total; +} + /** * Vykona strom. * @@ -135,10 +170,25 @@ export async function runFlow( let error: string | null = null; + // Velikost stromu se meri predem a staticky. Smycka ji nemeni. + const size = countSteps(steps); + if (size > limit) { + return { + ok: false, + retryable: false, + steps: [], + context: working, + error: `Strom má ${size} kroků, strop je ${limit}. Běh se nespustil.`, + durationMs: Date.now() - startedAt, + }; + } + async function walk(list: FlowStep[]): Promise { for (const step of list) { - if (results.length >= limit) { - error = `Strom má víc než ${limit} kroků, běh se zastavil.`; + if (results.length >= MAX_ACTIONS) { + error = + `Běh vykonal ${MAX_ACTIONS} kroků včetně průchodů smyčkami a zastavil se. ` + + 'Tolik práce v jednom běhu nikdo nečeká, rozdělte data nebo strom.'; return false; } @@ -298,9 +348,8 @@ export async function runFlow( return { ok: error === null, - // Kdyz krok nerekl jinak, opakovat se smi: vypadek cizi sluzby je - // nejcastejsi duvod chyby a ten pomine. - retryable: failed?.retryable !== false, + // Opakuje se jen to, co krok vyslovne oznacil. Viz pravidlo u StepResult. + retryable: failed?.retryable === true, steps: results, context: working, error, @@ -348,14 +397,7 @@ async function runAction( options.ticketId = outcome.ticketId; } - context[step.id] = outcome.outputs; - for (const [key, value] of Object.entries(outcome.outputs)) { - context[`${step.id}.${key}`] = value; - // Vystup je i pod holym jmenem, aby se v sablone dalo psat - // `{{ticketId}}` misto `{{krok1.ticketId}}`. Pozdejsi krok prepise - // starsi, coz je to, co clovek ceka. - context[key] = value; - } + publishOutputs(context, step.id, outcome.outputs); return { stepId: step.id, @@ -383,6 +425,8 @@ async function runAction( ok: false, summary: 'krok skončil chybou', detail: err instanceof Error ? err.message : String(err), + // Vyjimka z vnitrniho kroku je chyba v kodu nebo v datech, ne vypadek. + retryable: false, durationMs: Date.now() - startedAt, }; } @@ -419,6 +463,8 @@ async function runAction( ok: false, summary: 'napojení se nepodařilo načíst', detail: err instanceof Error ? err.message : String(err), + // Vypadek uloziste pomine, na rozdil od spatne nastaveneho kroku. + retryable: true, durationMs: Date.now() - startedAt, }; } @@ -438,15 +484,14 @@ async function runAction( ok: false, summary: result.error?.message ?? 'krok selhal', detail: result.error?.detail ?? null, + // Runner uz rozhodl: timeout a vypadek ano, 401/404/validace/ctx.fail ne. + // Driv se priznak cestou ztratil a kazda chyba se opakovala petkrat. + retryable: result.error?.retryable === true, durationMs: Date.now() - startedAt, }; } - // Vystupy pod jmenem kroku, aby si dva stejne kroky neprepsaly hodnoty. - context[step.id] = result.outputs; - for (const [key, value] of Object.entries(result.outputs)) { - context[`${step.id}.${key}`] = value; - } + publishOutputs(context, step.id, result.outputs); return { stepId: step.id, @@ -459,6 +504,23 @@ async function runAction( }; } +/** + * Zapise vystupy kroku do kontextu. Stejne pro vnitrni kroky i skripty. + * + * Pod jmenem kroku (`krok1.ticketId`), aby si dva stejne kroky neprepsaly + * hodnoty. Pod holym jmenem (`{{ticketId}}`) jen kdyz tam jeste nic neni: + * driv vnitrni krok prepisoval i data spoustece, takze nastroj MCP, ktery + * vratil `status` nebo `subject`, tise zmenil to, s cim beh zacal. Existujici + * stromy pisou `{{ticketId}}` za krokem zalozeni ticketu a to dal funguje. + */ +function publishOutputs(context: RunContext, stepId: string, outputs: Record): void { + context[stepId] = outputs; + for (const [key, value] of Object.entries(outputs)) { + context[`${stepId}.${key}`] = value; + if (context[key] === undefined) context[key] = value; + } +} + /** Dosadi `{{jmeno}}` z kontextu do vsech poli kroku. */ function fillTemplates( inputs: Record, @@ -570,7 +632,7 @@ function conditionDetail( function matchRule(rule: ConditionRule, context: RunContext, options: RunOptions): boolean { const raw = conditionValue(rule.fieldId, context, options); const value = raw === undefined || raw === null ? '' : stringify(raw); - return compare(rule.operator, value, rule.value ?? ''); + return compare(rule.operator, value, rule.value ?? '', options.fieldTypes?.[rule.fieldId] === 'date'); } /** @@ -604,9 +666,38 @@ function nameOf(fieldId: string): string { return dot === -1 ? fieldId : fieldId.slice(dot + 1); } -function compare(operator: ConditionOperator, value: string, expected: string): boolean { - const number = Number(value); - const expectedNumber = Number(expected); +/** Vypada to jako datum (`2026-08-20`, s casem nebo bez)? */ +function isoLike(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}/.test(value.trim()); +} + +/** + * Obe strany jako cisla k porovnani, nebo `null`, kdyz to nejde. + * + * Datum se porovnava jako cas: `Number('2026-08-20')` je NaN a podminka + * "vetsi nez" nad datem by tise nikdy neplatila. Plati to pro parametr typu + * datum i pro hodnoty, ktere jako datum vypadaji - cislo se hleda jen tam, + * kde to cislo opravdu je. + */ +function ordered(value: string, expected: string, dateTyped: boolean): [number, number] | null { + if (!dateTyped) { + const numbers: [number, number] = [Number(value), Number(expected)]; + if (numbers.every(Number.isFinite)) return numbers; + } + if (dateTyped || (isoLike(value) && isoLike(expected))) { + const dates: [number, number] = [Date.parse(value), Date.parse(expected)]; + if (dates.every(Number.isFinite)) return dates; + } + return null; +} + +function compare( + operator: ConditionOperator, + value: string, + expected: string, + dateTyped = false, +): boolean { + const pair = ordered(value, expected, dateTyped); switch (operator) { case 'eq': @@ -618,13 +709,13 @@ function compare(operator: ConditionOperator, value: string, expected: string): case 'startsWith': return value.toLowerCase().startsWith(expected.toLowerCase()); case 'gt': - return Number.isFinite(number) && Number.isFinite(expectedNumber) && number > expectedNumber; + return pair !== null && pair[0] > pair[1]; case 'gte': - return Number.isFinite(number) && Number.isFinite(expectedNumber) && number >= expectedNumber; + return pair !== null && pair[0] >= pair[1]; case 'lt': - return Number.isFinite(number) && Number.isFinite(expectedNumber) && number < expectedNumber; + return pair !== null && pair[0] < pair[1]; case 'lte': - return Number.isFinite(number) && Number.isFinite(expectedNumber) && number <= expectedNumber; + return pair !== null && pair[0] <= pair[1]; case 'isEmpty': return value.trim() === ''; case 'isNotEmpty': @@ -659,12 +750,11 @@ function describeCondition( return `Podmínka: ${parts.join(spojka)}`; } -/** Co krok vratil, do logu. Delsi vystup se orizne, cely je ve vysledku. */ +/** Co krok vratil, do logu. Stejny strop jako u chyb, cely vystup je v kontextu. */ function describe(outputs: Record): string | null { const keys = Object.keys(outputs); if (keys.length === 0) return null; - const text = JSON.stringify(outputs); - return text.length > 4_000 ? `${text.slice(0, 4_000)}...` : text; + return truncate(JSON.stringify(outputs), DETAIL_BYTES); } function toTrace(step: StepResult): TraceInput { diff --git a/src/runtime/queue.ts b/src/runtime/queue.ts index 354dc6c..08ebdd9 100644 --- a/src/runtime/queue.ts +++ b/src/runtime/queue.ts @@ -35,7 +35,9 @@ export type TriggerKind = /** Ticket se zmenil. */ | 'ticket.updated' /** Rucni spusteni z portalu. */ - | 'manual'; + | 'manual' + /** Planovac: je cas se zeptat sluzby, ktera webhooky nema. */ + | 'poll'; export interface QueueItem extends TenantEntity { tenantId: string; @@ -72,8 +74,15 @@ export const queueStore = defineStore('runQueue'); const BACKOFF_MS = [30_000, 2 * 60_000, 10 * 60_000, 60 * 60_000]; export const MAX_ATTEMPTS = BACKOFF_MS.length + 1; -/** Po jake dobe se bezici beh povazuje za zaseknuty a vrati se do fronty. */ -const STUCK_AFTER_MS = 10 * 60_000; +/** + * Po jake dobe se bezici beh povazuje za zaseknuty a vrati se do fronty. + * + * Meri se od posledniho tlukotu (`touchClaim`), ne od vzeti z fronty. Worker + * tluce kazdou minutu, takze tohle plati jen pro beh, jehoz proces spadl. + * Zamerne vyrazne vic nez nejdelsi legitimni cekani (uloha MCP, 10 minut) - + * driv bylo obojí deset minut a bezici uloha se rozjela podruhe. + */ +const STUCK_AFTER_MS = 30 * 60_000; /** Kopie fronty v pameti. Cte se pri kazdem kole workeru, meni se zridka. */ let items: QueueItem[] = []; @@ -163,12 +172,16 @@ export async function enqueue(input: EnqueueInput): Promise { * LOCKED`), jinak si dva workeri vezmou tentyz beh. Tady je to v pameti * jednoho procesu, coz pro jednu instanci staci a je to videt na jednom miste. */ -export async function claimBatch(limit: number): Promise { +export async function claimBatch( + limit: number, + active: ReadonlySet = new Set(), +): Promise { const now = Date.now(); // Zaseknuty beh se vraci do fronty, aby se neztratil kvuli spadlemu workeru. + // Beh, ktery tenhle proces prave vykonava, zaseknuty neni, at trva jakkoliv. for (const item of items) { - if (item.status !== 'running' || !item.claimedAt) continue; + if (item.status !== 'running' || !item.claimedAt || active.has(item.id)) continue; if (now - new Date(item.claimedAt).getTime() < STUCK_AFTER_MS) continue; console.warn(`[fronta] ${item.id} visel v behu moc dlouho, vraci se do fronty`); item.status = 'pending'; @@ -199,6 +212,13 @@ export async function claimBatch(limit: number): Promise { return claimed; } +/** Tlukot beziciho behu. Posune cas, od ktereho se meri zaseknuti. */ +export async function touchClaim(item: QueueItem): Promise { + if (item.status !== 'running') return; + item.claimedAt = nowIso(); + await save(item); +} + export async function markDone(item: QueueItem): Promise { item.status = 'done'; item.finishedAt = nowIso(); diff --git a/src/runtime/sandbox.ts b/src/runtime/sandbox.ts index b1dbe8c..28b2881 100644 --- a/src/runtime/sandbox.ts +++ b/src/runtime/sandbox.ts @@ -18,7 +18,10 @@ * pravy, ne do teto funkce. */ +import { createHash } from 'node:crypto'; import { createContext, Script } from 'node:vm'; +import { getPath } from '../scripts/mapping.js'; +import { parseNumber } from '../scripts/util.js'; /** Kolik ms smi skript bezet. Delsi vypocet blokuje workera ostatnim firmam. */ const TIMEOUT_MS = 2_000; @@ -26,6 +29,14 @@ const TIMEOUT_MS = 2_000; /** Strop na velikost vysledku. Vetsi objekt uz nikdo dal nezpracuje. */ const MAX_RESULT_BYTES = 256 * 1024; +/** + * Kolik zkompilovanych skriptu se drzi v pameti. + * + * Kompilace stoji vic nez samotny beh kratkeho prevodu a tentyz skript bezi + * pri kazde udalosti znovu. Strop, aby firma s tisicem verzi neprotahla pamet. + */ +const MAX_CACHED_SCRIPTS = 100; + export interface SandboxResult { ok: boolean; /** Vysledek skriptu. Vzdy objekt, i kdyz skript vrati neco jineho. */ @@ -40,29 +51,65 @@ export interface SandboxResult { * * Zamerne jich je malo. Kazda dalsi je vec, kterou musi ctenar skriptu znat - * a `input.data.order.items.map(...)` umi kazdy, kdo umi JS. + * + * Sestavuji se **uvnitr kontextu skriptu** ze zdrojoveho textu, ne predanim + * hotovych funkci. Funkce hostitele by skriptu dala jeho `Function`, a pres + * `util.num.constructor('return process')()` by se dostal ven. Zdrojovy text + * je tentyz kod jako `getPath` a `parseNumber` jinde v systemu, takze cesty + * i ceska carka funguji stejne jako v transformaci dat. */ -const util = { - /** Hodnota na ceste, `undefined` kdyz tam nic neni. `get(o, 'a.b.0.c')`. */ - get(source: unknown, path: string): unknown { - let current: unknown = source; - for (const part of path.split('.')) { - if (current === null || current === undefined) return undefined; - current = (current as Record)[part]; - } - return current; - }, - /** Cislo z cehokoliv. Ceska desetinna carka je bezna, nema smysl na ni padat. */ - num(value: unknown): number | null { - if (typeof value === 'number') return Number.isFinite(value) ? value : null; - const parsed = Number(String(value ?? '').trim().replace(',', '.')); - return Number.isFinite(parsed) ? parsed : null; - }, - /** Datum jako `2026-08-20`. Prazdne, kdyz to datum neni. */ - day(value: unknown): string { +const UTIL_SOURCE = `(function () { + 'use strict'; + const get = (${getPath.toString()}); + const num = (${parseNumber.toString()}); + /* Datum jako 2026-08-20. Prazdne, kdyz to datum neni. */ + const day = (value) => { const parsed = Date.parse(String(value ?? '')); return Number.isNaN(parsed) ? '' : new Date(parsed).toISOString().slice(0, 10); - }, -}; + }; + return Object.freeze({ get, num, day }); +})()`; + +const utilScript = new Script(UTIL_SOURCE, { filename: 'pomucky.js' }); + +/** Zkompilovane skripty podle otisku kodu. Mapa drzi poradi, takze slouzi jako LRU. */ +const compiled = new Map(); + +function compile(code: string): Script { + const key = createHash('sha256').update(code).digest('base64url'); + const cached = compiled.get(key); + if (cached) { + // Posledni pouzity jde na konec, aby se pri uklidu zahodil ten nejstarsi. + compiled.delete(key); + compiled.set(key, cached); + return cached; + } + + /* + * Kod se zabali do funkce, ktera se **rovnou zavola**. Dve veci najednou: + * - v kodu jde psat `return`, coz je to, co clovek ceka, + * - beh je uvnitr `runInContext`, takze na nej plati casovy limit. + * + * Zavolat funkci az potom zvenku by limit obeslo: `runInContext` by jen + * vyrobil funkci a `while (true) {}` uvnitr ni by zablokovalo workera + * navzdy. Presne to se pri prvni zkousce stalo. + * + * Vstup prichazi jako JSON text a vysledek odchazi jako JSON text. Objekt + * hostitele by skriptu dal jeho prototyp a s nim cestu ven; text nic + * takoveho nenese. + */ + const wrapped = + `JSON.stringify((function (input, util) {\n'use strict';\n${code}\n})` + + '(JSON.parse(__input), __util));'; + const script = new Script(wrapped, { filename: 'skript-firmy.js' }); + + compiled.set(key, script); + if (compiled.size > MAX_CACHED_SCRIPTS) { + const oldest = compiled.keys().next().value; + if (oldest !== undefined) compiled.delete(oldest); + } + return script; +} /** * Spusti telo skriptu nad vstupem. @@ -74,28 +121,17 @@ const util = { export function runSandbox(code: string, input: unknown): SandboxResult { const startedAt = Date.now(); - /* - * Kod se zabali do funkce, ktera se **rovnou zavola**. Dve veci najednou: - * - v kodu jde psat `return`, coz je to, co clovek ceka, - * - beh je uvnitr `runInContext`, takze na nej plati casovy limit. - * - * Zavolat funkci az potom zvenku by limit obeslo: `runInContext` by jen - * vyrobil funkci a `while (true) {}` uvnitr ni by zablokovalo workera - * navzdy. Presne to se pri prvni zkousce stalo. - */ - const wrapped = `(function (input, util) {\n'use strict';\n${code}\n})(__input, __util);`; - try { - const script = new Script(wrapped, { filename: 'skript-firmy.js' }); + const script = compile(code); /* - * Kontext nese jen vstup a pomucky. Skript nevidi ani `console`, ani - * globalni objekty hostitele, takze nema kudy sahnout ven. + * Kontext nese jen vstup jako text a pomucky sestavene uvnitr nej. Nic + * z hostitele - zadny objekt ani funkce, jejichz prototyp by vedl ven. */ const context = createContext( { - __input: structuredClone(input), - __util: util, + __input: JSON.stringify(input ?? null), + __util: undefined, /* * `console` v novem kontextu existuje samo od sebe a psalo by do logu * serveru. Prevod dat nema co vypisovat - kdyz je potreba videt, co se @@ -105,10 +141,11 @@ export function runSandbox(code: string, input: unknown): SandboxResult { }, { codeGeneration: { strings: false, wasm: false } }, ); + context.__util = utilScript.runInContext(context, { timeout: TIMEOUT_MS }) as unknown; - const value = script.runInContext(context, { timeout: TIMEOUT_MS }) as unknown; + const json = script.runInContext(context, { timeout: TIMEOUT_MS }) as unknown; - if (value === undefined || value === null) { + if (typeof json !== 'string' || json === 'null') { return { ok: false, value: null, @@ -117,24 +154,19 @@ export function runSandbox(code: string, input: unknown): SandboxResult { }; } - const size = JSON.stringify(value)?.length ?? 0; - if (size > MAX_RESULT_BYTES) { + if (json.length > MAX_RESULT_BYTES) { return { ok: false, value: null, - error: `Výsledek má ${Math.round(size / 1024)} kB, vejde se ${MAX_RESULT_BYTES / 1024} kB.`, + error: `Výsledek má ${Math.round(json.length / 1024)} kB, vejde se ${MAX_RESULT_BYTES / 1024} kB.`, durationMs: Date.now() - startedAt, }; } - /* - * Vysledek vznikl uvnitr kontextu. Prevedeme ho na prosta data, aby se dal - * ulozit i porovnat - objekt z ciziho kontextu neprojde kontrolou - * `instanceof` a dal by se choval nepredvidatelne. - */ + // Vysledek prisel jako text, takze uz je to prosta data bez ciziho prototypu. return { ok: true, - value: JSON.parse(JSON.stringify(value)) as unknown, + value: JSON.parse(json) as unknown, error: null, durationMs: Date.now() - startedAt, }; diff --git a/src/runtime/triggers.ts b/src/runtime/triggers.ts index da92c6f..cde53d1 100644 --- a/src/runtime/triggers.ts +++ b/src/runtime/triggers.ts @@ -218,7 +218,9 @@ export async function planPolled(): Promise { const item = await enqueue({ tenantId: tenant.id, automationId: automation.id, - trigger: 'manual', + // Vlastni druh, ne `manual`: v prehledu behu ma byt videt, ze to + // spustil planovac, ne clovek tlacitkem. + trigger: 'poll', payload: { plannedAt: new Date(now).toISOString() }, /* * Klic drzi jeden cekajici dotaz na automatizaci. Kdyz predchozi jeste diff --git a/src/runtime/worker.ts b/src/runtime/worker.ts index 3c8d88a..b6c7d90 100644 --- a/src/runtime/worker.ts +++ b/src/runtime/worker.ts @@ -19,10 +19,12 @@ import { withRun } from './context.js'; import { runFlow, type RunResult } from './executor.js'; import { collectScopes } from '../data/flowScope.js'; import type { AutomationFlow } from '../data/automationStore.js'; +import type { FieldType } from '../data/conditions.js'; import { claimBatch, markDone, markFailed, + touchClaim, trimQueue, MAX_ATTEMPTS, type QueueItem, @@ -37,10 +39,22 @@ const IDLE_MS = 1_000; /** Jak casto se uklizi hotove behy. */ const CLEANUP_EVERY = 200; +/** + * Jak casto bezici beh hlasi fronte, ze zije. + * + * Fronta vraci behy, ktere visi dlouho, zpatky k opakovani. Beh s pauzou nebo + * s ulohou MCP na deset minut ale nevisi, pracuje - a bez tlukotu by ho fronta + * rozjela podruhe, zatimco prvni jeste bezi. + */ +const HEARTBEAT_MS = 60_000; + let running = false; let stopping = false; let rounds = 0; +/** Behy, ktere prave bezi v tomto procesu. Fronta je nesmi povazovat za zaseknute. */ +const active = new Set(); + /** * Spusti workera. * @@ -61,12 +75,20 @@ export async function stopWorker(): Promise { async function loop(): Promise { while (!stopping) { + /* + * Bazen, ne davky. Volne misto se doplni hned, jak se uvolni - kdyby se + * cekalo na celou davku, jeden beh s minutovou pauzou nebo desetiminutovou + * ulohou MCP by drzel vsechna ctyri mista a fronta by stala. + */ + const free = CONCURRENCY - active.size; let claimed: QueueItem[] = []; - try { - claimed = await claimBatch(CONCURRENCY); - } catch (err) { - // Nedostupne uloziste nesmi workera zabit. Za chvili to zkusi znovu. - console.error('[worker] frontu se nepodarilo precist:', err); + if (free > 0) { + try { + claimed = await claimBatch(free, active); + } catch (err) { + // Nedostupne uloziste nesmi workera zabit. Za chvili to zkusi znovu. + console.error('[worker] frontu se nepodarilo precist:', err); + } } if (claimed.length === 0) { @@ -74,8 +96,12 @@ async function loop(): Promise { continue; } - // Behy jdou soubezne, cekaji stejne na cizi sluzby. - await Promise.all(claimed.map((item) => runOne(item))); + for (const item of claimed) { + active.add(item.id); + void runOne(item) + .catch((err: unknown) => console.error(`[worker] beh ${item.id} spadl mimo pojistku:`, err)) + .finally(() => active.delete(item.id)); + } rounds += 1; if (rounds % CLEANUP_EVERY === 0) { @@ -87,17 +113,41 @@ async function loop(): Promise { console.info('[worker] zastaven'); } +/** Jeden beh vcetne tlukotu, ktery fronte rika, ze beh jeste zije. */ async function runOne(item: QueueItem): Promise { + const heartbeat = setInterval(() => { + void touchClaim(item).catch((err: unknown) => { + console.error(`[worker] ${item.id}: tlukot se nezapsal:`, err); + }); + }, HEARTBEAT_MS); + heartbeat.unref?.(); + + try { + await execute(item); + } finally { + clearInterval(heartbeat); + } +} + +async function execute(item: QueueItem): Promise { const automation = getAutomation(item.automationId, [item.tenantId]); + /* + * Automatizace mezitim zmizela nebo je pozastavena. Opakovat to nema smysl + * a klient se to ma dozvedet stejne jako u kazde jine marne chyby - jinak + * beh tise skonci a nikdo nevi, ze se udalost nezpracovala. + */ if (!automation) { - // Automatizace mezitim zmizela. Neni co delat a opakovat to nema smysl. - await markFailed(item, `Automatizace ${item.automationId} neexistuje.`); + const message = `Automatizace ${item.automationId} neexistuje.`; + await markFailed(item, message, false); + reportIncident(item, item.automationId, message, null); return; } if (!automation.enabled) { - await markFailed(item, `Automatizace ${automation.name} je pozastavená.`); + const message = `Automatizace ${automation.name} je pozastavená.`; + await markFailed(item, message, false); + reportIncident(item, automation.name, message, null); return; } @@ -122,6 +172,7 @@ async function runOne(item: QueueItem): Promise { // Podminka si drzi ID parametru, data chodi pod jmenem. Bez teto // tabulky by se nemela o co oprit, viz RunOptions.fieldNames. fieldNames: fieldNamesOf(automation.flow), + fieldTypes: fieldTypesOf(automation.flow), // Klic je stabilni na beh, takze opakovany pokus nevystavi druhou fakturu. idempotencyKey: `run:${item.id}`, }), @@ -134,7 +185,7 @@ async function runOne(item: QueueItem): Promise { publish('automation.run', `Automatizace ${automation.name} proběhla`, { automationId: automation.id, ok: true, - }); + }, item.tenantId); return; } @@ -157,17 +208,20 @@ async function runOne(item: QueueItem): Promise { * klient incident u kazdeho vypadku site, ktery se sam za minutu spravi. */ if (!result.retryable || item.attempts >= MAX_ATTEMPTS) { - reportIncident(item, automation.name, result, failed, message); + reportIncident(item, automation.name, message, result); } publish('automation.run', `Automatizace ${automation.name} skončila chybou`, { automationId: automation.id, ok: false, - }); + }, item.tenantId); } catch (err) { - // `runFlow` chyby nevyhazuje, tohle je posledni pojistka. + // `runFlow` chyby nevyhazuje, tohle je posledni pojistka. Chyba v kodu se + // opakovanim nespravi, tak se rovnou zalozi incident. console.error(`[worker] neocekavana chyba behu ${item.id}:`, err); - await markFailed(item, err instanceof Error ? err.message : String(err)); + const message = err instanceof Error ? err.message : String(err); + await markFailed(item, message, false); + reportIncident(item, automation.name, message, null); } } @@ -177,16 +231,19 @@ async function runOne(item: QueueItem): Promise { * Stejna pricina nezaklada druhy incident, dokud je ten prvni otevreny - * jinak by deset stejnych chyb znamenalo deset incidentu a nikdo by se v tom * nevyznal. + * + * `result` chybi, kdyz se k behu vubec nedoslo (automatizace zmizela, nebo + * spadl sam worker). I to je chyba, o ktere se klient ma dozvedet. */ function reportIncident( item: QueueItem, automationName: string, - result: RunResult, - failed: RunResult['steps'][number] | undefined, message: string, + result: RunResult | null, ): void { + const failed = result?.steps.find((step) => !step.ok); const source = `automation:${item.automationId}:${failed?.stepId ?? 'flow'}`; - if (findOpenIncident(source)) return; + if (findOpenIncident(source, [item.tenantId])) return; const detail = [ `Automatizace: ${automationName} (${item.automationId})`, @@ -196,11 +253,15 @@ function reportIncident( '', 'Hlášení:', message || '(bez hlášení)', - '', - 'Kroky běhu:', - ...result.steps.map( - (step) => ` ${step.ok ? 'ok ' : 'CHYBA'} ${step.label}: ${step.summary}`, - ), + ...(result + ? [ + '', + 'Kroky běhu:', + ...result.steps.map( + (step) => ` ${step.ok ? 'ok ' : 'CHYBA'} ${step.label}: ${step.summary}`, + ), + ] + : []), '', 'Data na vstupu:', JSON.stringify(item.payload, null, 2), @@ -255,3 +316,10 @@ function fieldNamesOf(flow: AutomationFlow): Record { for (const [id, field] of collectScopes(flow).all) names[id] = field.name; return names; } + +/** Typy parametru podle jejich ID. Podminka nad datem se porovnava jako cas. */ +function fieldTypesOf(flow: AutomationFlow): Record { + const types: Record = {}; + for (const [id, field] of collectScopes(flow).all) types[id] = field.type; + return types; +} diff --git a/src/scripts/connections.ts b/src/scripts/connections.ts index 257b7b3..dae3485 100644 --- a/src/scripts/connections.ts +++ b/src/scripts/connections.ts @@ -39,7 +39,11 @@ export interface ResolvedTarget { * sluzby rady vraceji v chybove hlasce. */ secretValues: string[]; - /** Necitliva cast, skript ji vidi jako `ctx.config`. */ + /** + * Pole s cilem `config`, **vcetne tajnych** (heslo SMTP, tajemstvi OAuth). + * Cte je runtime (SMTP, MCP). Skript dostava jen necitlivou cast pres + * `scriptConfig`, nikdy tohle cele. + */ serviceConfig: Record; /** false = chybi povinne udaje, volat nema smysl. */ ready: boolean; @@ -159,3 +163,16 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R export function targetSecrets(target: ResolvedTarget): string[] { return [...Object.values(target.headers), ...target.secretValues]; } + +/** + * Nastaveni napojeni tak, jak ho smi videt skript (`ctx.config`). + * + * Bez tajnych hodnot. Skript pise spravce firmy a jeho kod i log jsou videt + * v portalu - heslo, ktere by si z `ctx.config` vypsal, by tam skoncilo taky. + */ +export function scriptConfig(target: ResolvedTarget): Record { + const secret = new Set(target.secretValues); + return Object.fromEntries( + Object.entries(target.serviceConfig).filter(([, value]) => !secret.has(value)), + ); +} diff --git a/src/scripts/http.ts b/src/scripts/http.ts index cad1f73..406b8ec 100644 --- a/src/scripts/http.ts +++ b/src/scripts/http.ts @@ -9,6 +9,7 @@ */ import { config } from '../config.js'; +import { assertAllowedUrl, describeFetchError, readBodyLimited, tooLargeMessage } from '../net/guard.js'; import type { ResolvedTarget } from './connections.js'; import { ScriptError, @@ -18,7 +19,7 @@ import { type ScriptHttpResponse, type ScriptRequestInfo, } from './types.js'; -import { truncate } from './util.js'; +import { DETAIL_BYTES, truncate } from './util.js'; /** Kody, u kterych ma smysl opakovat. Zbytek je koncova chyba. */ const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]); @@ -35,42 +36,12 @@ const retryableCodes = new Set([ 'UND_ERR_CONNECT_TIMEOUT', ]); -const privateHostPattern = - /^(localhost|127\.|0\.0\.0\.0$|10\.|192\.168\.|169\.254\.|::1$|\[::1\]$|172\.(1[6-9]|2\d|3[01])\.)/i; - function joinUrl(baseUrl: string, path: string): string { const base = baseUrl.replace(/\/+$/, ''); const suffix = path.startsWith('/') ? path : `/${path}`; return `${base}${suffix}`; } -/** - * Miri tohle jmeno do vnitrni site? - * - * Exportovane, protoze totez potrebuje odesilani e-mailu: adresu SMTP serveru - * vyplnuje firma a bez teto kontroly by si mohla nechat navazat spojeni na - * cokoliv uvnitr. Pravidlo ma byt jedno, ne dve, ktera se casem rozejdou. - */ -export function isPrivateHost(hostname: string): boolean { - return privateHostPattern.test(hostname); -} - -/** - * Adresu skladame my z napojeni, ale az budou napojeni nastavovat klienti, - * je tohle to jedine, co brani volani na vnitrni sit. Proto tady, ne pozdeji. - */ -function assertAllowedUrl(url: URL): void { - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - throw new ScriptError('config', `Adresa ${url.protocol} není povolená, jen http a https.`); - } - if (!config.allowPrivateTargets && isPrivateHost(url.hostname)) { - throw new ScriptError( - 'config', - `Adresa ${url.hostname} míří do vnitřní sítě. Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.`, - ); - } -} - function buildUrl(target: ResolvedTarget, path: string, options?: ScriptHttpOptions): URL { // Obecna sluzba nema pevnou adresu, skript pak musi poslat absolutni. const absolute = target.baseUrl === ''; @@ -94,7 +65,9 @@ function buildUrl(target: ResolvedTarget, path: string, options?: ScriptHttpOpti url.searchParams.set(key, String(value)); } - assertAllowedUrl(url); + // Adresu skladame my z napojeni, ale nastavuji ji klienti - tohle je to + // jedine, co brani volani na vnitrni sit. Pravidlo je v net/guard.ts. + assertAllowedUrl(url, (message) => new ScriptError('config', message)); return url; } @@ -263,30 +236,24 @@ function statusError( function transportError(err: unknown, request: ScriptRequestInfo): ScriptError { if (err instanceof ScriptError) return err; - const code = - err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).code) : ''; - const name = err instanceof Error ? err.name : ''; - const message = err instanceof Error ? err.message : String(err); - // Puvodni chyba vcetne pricin. `cause` nese u fetch to podstatne. - const cause = - err instanceof Error && err.cause instanceof Error ? ` -Příčina: ${err.cause.message}` : ''; - const detail = truncate(`${name}: ${message}${cause}`, config.errorDetailBytes); + const info = describeFetchError(err, request.url); + const detail = truncate(info.detail, DETAIL_BYTES); - if (name === 'AbortError' || name === 'TimeoutError') { + if (info.timedOut) { return new ScriptError('timeout', `Volání ${request.url} nedoběhlo v limitu.`, { request, detail, cause: err, }); } - if (retryableCodes.has(code)) { - return new ScriptError('retryable', `Nepodařilo se spojit se službou (${code}).`, { + if (retryableCodes.has(info.code)) { + return new ScriptError('retryable', `Nepodařilo se spojit se službou (${info.code}).`, { request, detail, cause: err, }); } + const message = err instanceof Error ? err.message : String(err); return new ScriptError('retryable', `Volání ${request.url} selhalo: ${message}`, { request, detail, @@ -384,23 +351,15 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp { throw transportError(err, request); } - const declaredSize = Number(response.headers.get('content-length') ?? 0); - if (declaredSize > config.scriptMaxResponseBytes) { - throw new ScriptError( - 'terminal', - `Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`, - { status: response.status }, - ); - } - - const raw = await response.text(); - if (raw.length > config.scriptMaxResponseBytes) { - throw new ScriptError( - 'terminal', - `Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`, - { status: response.status }, - ); + // Limit plati uz pri cteni. Bez `Content-Length` by se jinak cele telo + // nacetlo do pameti a teprve pak by se zjistilo, ze je moc velke. + const received = await readBodyLimited(response, config.scriptMaxResponseBytes); + if (received.tooLarge) { + throw new ScriptError('terminal', tooLargeMessage(config.scriptMaxResponseBytes), { + status: response.status, + }); } + const raw = received.text; const isJson = response.headers.get('content-type')?.includes('json') ?? false; let parsed: unknown = raw; @@ -411,7 +370,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp { throw new ScriptError('terminal', `Odpověď ${request.url} není platný JSON.`, { status: response.status, request, - detail: redact(truncate(raw, config.errorDetailBytes)), + detail: redact(truncate(raw, DETAIL_BYTES)), }); } } @@ -425,7 +384,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp { throw statusError( response.status, request, - redact(truncate(raw, config.errorDetailBytes)), + redact(truncate(raw, DETAIL_BYTES)), pickResponseHeaders(response), ); } diff --git a/src/scripts/mapping.ts b/src/scripts/mapping.ts index c08404c..0c321ed 100644 --- a/src/scripts/mapping.ts +++ b/src/scripts/mapping.ts @@ -16,6 +16,7 @@ */ import { ScriptError, type ScriptJson } from './types.js'; +import { parseBool, parseNumber } from './util.js'; /** Kolik urovni zanoreni se jeste prochazi. Chrani proti cyklickym datum. */ const MAX_DEPTH = 20; @@ -122,10 +123,9 @@ function isEmpty(value: unknown): boolean { } function toNumber(value: unknown, label: string): number { - if (typeof value === 'number') return value; - // Ceska desetinna carka je bezna, nema smysl na ni padat. - const parsed = Number(String(value ?? '').trim().replace(',', '.')); - if (!Number.isFinite(parsed)) { + // Prazdno je tu nula, ne chyba: `sum` nad radky bez ceny ma vyjit 0. + const parsed = parseNumber(isEmpty(value) ? 0 : value); + if (parsed === null) { throw new ScriptError('terminal', `${label}: "${String(value)}" není číslo.`); } return parsed; @@ -143,11 +143,8 @@ function applyOne(value: unknown, operation: TransformOp, label: string, depth: return value === undefined || value === null ? '' : String(value); case 'number': return toNumber(value, label); - case 'boolean': { - if (typeof value === 'boolean') return value; - const text = String(value ?? '').trim().toLowerCase(); - return text === 'true' || text === '1' || text === 'yes' || text === 'ano'; - } + case 'boolean': + return parseBool(value) ?? false; case 'date': { const parsed = Date.parse(value instanceof Date ? value.toISOString() : String(value ?? '')); if (Number.isNaN(parsed)) { diff --git a/src/scripts/registry.ts b/src/scripts/registry.ts index 0cf5fe5..14b098f 100644 --- a/src/scripts/registry.ts +++ b/src/scripts/registry.ts @@ -20,6 +20,7 @@ import { config } from '../config.js'; import { services, setScriptActions, type ServiceOperation } from '../data/services.js'; import { parseManifest, serviceIdOf, toServiceOperation } from './manifest.js'; import type { FieldIssue, ScriptContext, ScriptManifest, ScriptValues } from './types.js'; +import type { ScriptProblem } from '../shared/scripts.js'; export type ScriptRunFn = ( inputs: ScriptValues, @@ -33,13 +34,8 @@ export interface LoadedScript { mtimeMs: number; } -export interface ScriptProblem { - /** Nazev souboru, ne cela cesta - cesta na serveru nikomu nic nerekne. */ - file: string; - scriptId: string | null; - message: string; - issues?: FieldIssue[]; -} +/** Tvar problemu je sdileny s webem, viz src/shared/scripts.ts. */ +export type { ScriptProblem }; /** ID smi byt jen tohle. Chrani zapis i cteni proti vyskoku z adresare. */ const idPattern = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/; diff --git a/src/scripts/runner.ts b/src/scripts/runner.ts index e5c7b3d..d492252 100644 --- a/src/scripts/runner.ts +++ b/src/scripts/runner.ts @@ -13,7 +13,7 @@ import { createHash } from 'node:crypto'; import { config } from '../config.js'; import type { Connector } from '../data/connectorStore.js'; -import { resolveTarget, targetSecrets } from './connections.js'; +import { resolveTarget, scriptConfig, targetSecrets } from './connections.js'; import { createHttp } from './http.js'; import { serviceIdOf } from './manifest.js'; import { getScript } from './registry.js'; @@ -176,7 +176,8 @@ export async function runScript( }), util: scriptUtil, log, - config: Object.freeze({ ...target.serviceConfig }), + // Bez tajemstvi. Skript je nikdy nevidi, do requestu je dosazuje runtime. + config: Object.freeze(scriptConfig(target)), idempotencyKey, fail(message, detail) { throw new ScriptError('terminal', message, { diff --git a/src/scripts/types.ts b/src/scripts/types.ts index de4a31c..c54968d 100644 --- a/src/scripts/types.ts +++ b/src/scripts/types.ts @@ -17,23 +17,37 @@ import type { MappingRule } from './mapping.js'; // ------------------------------------------------------------------- hodnoty -/** - * Hodnota, se kterou skript pracuje. Cokoliv, co jde vyjadrit JSONem. - * - * Objekty a seznamy tu jsou kvuli transformacim: krok muze predat dal celou - * objednavku, ne jen jeji jednotliva pole. Do sablon se ale nedosazuji, - * predavaji se jen jako celek dalsimu kroku. - */ -export type ScriptJson = - | string - | number - | boolean - | null - | ScriptJson[] - | { [key: string]: ScriptJson }; +import type { + FieldIssue, + ScriptErrorKind, + ScriptField, + ScriptJson, + ScriptLogEntry, + ScriptManifest, + ScriptRequestInfo, + ScriptRunError, + ScriptRunResult, + ScriptValue, + ScriptValues, +} from '../shared/scripts.js'; -export type ScriptValue = ScriptJson; -export type ScriptValues = Record; +/** + * Manifest, hodnoty a vysledek behu jsou sdilene s webem, viz src/shared/scripts.ts. + * Zod schema nize musi jejich tvar dodrzet, hlida to `satisfies`. + */ +export type { + FieldIssue, + ScriptErrorKind, + ScriptField, + ScriptJson, + ScriptLogEntry, + ScriptManifest, + ScriptRequestInfo, + ScriptRunError, + ScriptRunResult, + ScriptValue, + ScriptValues, +}; // -------------------------------------------------------------------- schema @@ -87,9 +101,7 @@ export const scriptFieldSchema = z */ maxBytes: z.number().int().min(256).max(4_000_000).optional(), }) - .strict(); - -export type ScriptField = z.infer; + .strict() satisfies z.ZodType; /** * Manifest skriptu. @@ -131,49 +143,16 @@ export const scriptManifestSchema = z seen.add(field.id); } } - }); - -export type ScriptManifest = z.infer; + }) satisfies z.ZodType; // --------------------------------------------------------------------- chyby -/** - * Druh selhani. Rozdeleni na `retryable` a `terminal` je to podstatne: - * timeout nebo 503 ma smysl zkusit znovu, chyba ve vstupu nebo 403 ne. - * Opakovat koncovou chybu jen vypali kvotu u cizi sluzby. - */ -export type ScriptErrorKind = - | 'not_found' - | 'config' - | 'validation' - | 'output' - | 'retryable' - | 'terminal' - | 'timeout' - | 'internal'; - const retryableKinds: ScriptErrorKind[] = ['retryable', 'timeout']; export function isRetryableKind(kind: ScriptErrorKind): boolean { return retryableKinds.includes(kind); } -/** Ktere volani spadlo. Bez toho je chybova zprava jen pulka informace. */ -export interface ScriptRequestInfo { - method: string; - /** Cesta bez domeny. Kvuli zpetne kompatibilite, hlasky pouzivaji `url`. */ - path: string; - /** - * Cela adresa **vcetne serveru**, ale bez query - v query muze byt tajemstvi. - * - * Bez serveru se hlaska neda pouzit: "/apps/idoklad/account/agenda vratilo - * 403" nerekne, jestli se to vubec trefilo na spravny stroj, nebo to zaridla - * cizi proxy cestou. A protoze zaklad adresy je z konfigurace a konektor ho - * smi prepsat, nesmi se hadat podle toho, kde je nasazeny portal. - */ - url: string; -} - export class ScriptError extends Error { readonly kind: ScriptErrorKind; /** HTTP kod cizi sluzby, kdyz chyba prisla z volani. */ @@ -299,6 +278,18 @@ export interface ScriptUtil { bool(value: unknown): boolean; /** Datum jako ISO retezec, nebo null. */ date(value: unknown): string | null; + /** Datum jako `2026-08-20`. Prazdny retezec, kdyz to datum neni. */ + day(value: unknown): string; + /** + * Seznam z odpovedi bez ohledu na obalku: pole rovnou, nebo pod jednim + * z uvedenych klicu (plus `data`, `content`, `items`, `results`). + * `null`, kdyz seznam neni nikde - to je informace, ne prazdny vysledek. + */ + list(body: unknown, ...names: string[]): unknown[] | null; + /** Adresy z textu oddelene carkou nebo strednikem, bez prazdnych. */ + addresses(value: unknown): string[]; + /** Hodnota v jednoduchych uvozovkach pro OData/SQL filtr, apostrof zdvojeny. */ + quote(value: unknown): string; /** Zaokrouhli na dane desetinne misto. Uctuje se v halerich. */ round(value: number, decimals?: number): number; /** @@ -350,39 +341,3 @@ export interface ScriptModule { // -------------------------------------------------------------------- vysledek -export interface ScriptLogEntry { - at: string; - message: string; - detail?: string; -} - -export interface ScriptRunError { - kind: ScriptErrorKind; - message: string; - retryable: boolean; - status?: number; - /** Cela odpoved sluzby, uz zredigovana. Tady je napsane, co ji vadilo. */ - detail?: string; - /** Ktere volani spadlo. */ - request?: ScriptRequestInfo; - /** Zasobnik volani. Jen mimo produkci, stejne jako u error handleru. */ - stack?: string; - /** Vyplnene jen u chyb ve vstupu nebo vystupu. */ - issues?: FieldIssue[]; -} - -export interface FieldIssue { - field: string; - message: string; -} - -export interface ScriptRunResult { - ok: boolean; - scriptId: string; - /** Prazdne, kdyz beh selhal. */ - outputs: ScriptValues; - logs: ScriptLogEntry[]; - durationMs: number; - httpCalls: number; - error: ScriptRunError | null; -} diff --git a/src/scripts/util.ts b/src/scripts/util.ts index 38861b2..3ca3434 100644 --- a/src/scripts/util.ts +++ b/src/scripts/util.ts @@ -7,9 +7,74 @@ * z nich by to resil spatne. */ +import { config } from '../config.js'; import { applyRules, fillJson, getPath } from './mapping.js'; import { ScriptError, type ScriptUtil } from './types.js'; +/** + * Strop na text, ktery jde do logu nebo do `detail` chyby. + * + * Skutecne pravidlo: detail se **nezkracuje na vetu**, drzi se cela odpoved + * sluzby az do tohoto stropu. Ten je zamerne velky (viz `errorDetailBytes`), + * protoze prave v tele odpovedi je napsane, co sluzbe vadilo. Jedna konstanta + * pro vsechny vrstvy - driv mel executor 4000, MCP 600 a HTTP 8000 a nikdo + * nevedel, ktere plati. + */ +export const DETAIL_BYTES = config.errorDetailBytes; + +// -------------------------------------------------------------- prevody + +/** Retezce, ktere lidi i sluzby pouzivaji pro ano a ne. Sjednoceno ze vsech mist. */ +const truthy = new Set(['true', '1', 'yes', 'y', 'ano', 'on']); +const falsy = new Set(['false', '0', 'no', 'n', 'ne', 'off']); + +/** + * Ano, ne, nebo nejde poznat. + * + * `null` znamena "neni to ani jedno" - volajici si rozhodne, jestli je to + * chyba (kontrola parametru), nebo "nemenit" (zaskrtavatko ve strome). + */ +export function parseBool(value: unknown): boolean | null { + if (typeof value === 'boolean') return value; + const lowered = String(value ?? '').trim().toLowerCase(); + if (truthy.has(lowered)) return true; + if (falsy.has(lowered)) return false; + return null; +} + +/** + * Cislo z textu i s ceskou desetinnou carkou. `null`, kdyz to cislo neni. + * + * Prazdny text a ano/ne nejsou nula - tise prevest prazdno na 0 je presne + * ten druh chyby, ktera se najde az na faktuře. + */ +export function parseNumber(value: unknown): number | null { + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value !== 'string') return null; + const text = value.trim().replace(',', '.'); + if (text === '') return null; + const parsed = Number(text); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * `exp` z prostredni casti JWT v ms. `null`, kdyz to JWT neni. + * + * Pro servery, ktere zivotnost tokenu nikam nepisou, ale vydavaji JWT - + * OAuth server bez `expires_in` i EasyWeb. + */ +export function jwtExpiry(token: string): number | null { + const parts = token.split('.'); + if (parts.length < 2) return null; + try { + const json = Buffer.from(parts[1], 'base64url').toString('utf8'); + const payload = JSON.parse(json) as { exp?: unknown }; + return typeof payload.exp === 'number' ? payload.exp * 1000 : null; + } catch { + return null; + } +} + /** Rozbali obalku odpovedi. `{ Data: x }` i `{ data: x }` vrati `x`. */ function unwrap(body: unknown): T { if (body === null || typeof body !== 'object') return body as T; @@ -22,8 +87,11 @@ function unwrap(body: unknown): T { /** * Prvni existujici pole bez ohledu na velikost pismen. * `pick(invoice, 'documentNumber')` najde `DocumentNumber` i `documentNumber`. + * + * Exportovane, protoze totez potrebuje MCP klient a prihlaseni: .NET pise + * klice velkymi, jine servery malymi. Jedna funkce, ne ctyri. */ -function pick(source: unknown, ...names: string[]): unknown { +export function pick(source: unknown, ...names: string[]): unknown { if (source === null || typeof source !== 'object') return undefined; const record = source as Record; @@ -40,6 +108,14 @@ function pick(source: unknown, ...names: string[]): unknown { return undefined; } +/** Jako `pick`, ale jen neprazdny text. Pro tokeny a adresy z odpovedi. */ +export function pickText(source: unknown, ...names: string[]): string | null { + const value = pick(source, ...names); + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +} + function first(value: unknown): T | null { if (Array.isArray(value)) return (value[0] as T) ?? null; const unwrapped = unwrap(value); @@ -47,6 +123,34 @@ function first(value: unknown): T | null { return null; } +/** + * Seznam z odpovedi, at ho sluzba obalila jakkoliv. + * + * Banka ho da pod `accounts`, jiny standard pod `content` nebo `items`, nekdo + * posle rovnou pole. Hadat jeden tvar by znamenalo, ze pri zmene obalky krok + * tise vrati nula zaznamu misto chyby - proto `null`, kdyz seznam neni nikde. + */ +function list(body: unknown, ...names: string[]): unknown[] | null { + if (Array.isArray(body)) return body; + const found = pick(body, ...names, 'data', 'content', 'items', 'results'); + if (Array.isArray(found)) return found; + const unwrapped = unwrap(body); + return Array.isArray(unwrapped) ? unwrapped : null; +} + +/** Adresy z pole "Prijemci": oddelene carkou nebo strednikem, bez prazdnych. */ +function addresses(value: unknown): string[] { + return String(value ?? '') + .split(/[,;]/) + .map((item) => item.trim()) + .filter((item) => item !== ''); +} + +/** Hodnota v jednoduchych uvozovkach pro OData/SQL filtr, apostrof zdvojeny. */ +function quote(value: unknown): string { + return `'${String(value ?? '').replace(/'/g, "''")}'`; +} + function text(value: unknown, fallback: string | null = null): string | null { if (value === undefined || value === null) return fallback; if (typeof value === 'object') return fallback; @@ -55,15 +159,11 @@ function text(value: unknown, fallback: string | null = null): string | null { } function num(value: unknown, fallback: number | null = null): number | null { - if (value === undefined || value === null || value === '') return fallback; - const parsed = typeof value === 'number' ? value : Number(String(value).replace(',', '.')); - return Number.isFinite(parsed) ? parsed : fallback; + return parseNumber(value) ?? fallback; } function bool(value: unknown): boolean { - if (typeof value === 'boolean') return value; - const lowered = String(value ?? '').trim().toLowerCase(); - return lowered === 'true' || lowered === '1' || lowered === 'yes' || lowered === 'ano'; + return parseBool(value) ?? false; } function date(value: unknown): string | null { @@ -72,6 +172,11 @@ function date(value: unknown): string | null { return Number.isNaN(parsed) ? null : new Date(parsed).toISOString(); } +/** Datum jako `2026-08-20`. Prazdne, kdyz to datum neni. */ +function day(value: unknown): string { + return date(value)?.slice(0, 10) ?? ''; +} + function round(value: number, decimals = 2): number { const factor = 10 ** decimals; return Math.round(value * factor) / factor; @@ -94,6 +199,10 @@ export const scriptUtil: ScriptUtil = { date, round, need, + day, + list, + addresses, + quote, get: getPath, applyRules: (source, rules) => applyRules(source, rules), fillJson: (template, source) => fillJson(template, source), @@ -110,9 +219,24 @@ export const scriptUtil: ScriptUtil = { */ export function createRedactor(secrets: Array): (value: string) => string { // Kratke hodnoty se neredigují - nahradit "1" hvezdickami by rozbilo cely text. - const values = secrets - .filter((value): value is string => typeof value === 'string' && value.length >= 6) - .sort((a, b) => b.length - a.length); + const plain = secrets.filter( + (value): value is string => typeof value === 'string' && value.length >= 6, + ); + + /* + * I zakodovane podoby. Tajemstvi se do odpovedi vraci v query (procentove + * kodovani) nebo uvnitr JSONu (escapovane uvozovky a lomitka) a v takove + * podobe by presna shoda neprosla. + */ + const values = [ + ...new Set( + plain.flatMap((secret) => [ + secret, + encodeURIComponent(secret), + JSON.stringify(secret).slice(1, -1), + ]), + ), + ].sort((a, b) => b.length - a.length); if (values.length === 0) return (value) => value; @@ -124,7 +248,7 @@ export function createRedactor(secrets: Array): (value: stri } /** Zkrati text na danou delku, aby jeden log nezabral megabajt. */ -export function truncate(value: string, max = 600): string { +export function truncate(value: string, max = DETAIL_BYTES): string { if (value.length <= max) return value; return `${value.slice(0, max)} (zkráceno, celkem ${value.length} znaků)`; } diff --git a/src/scripts/values.ts b/src/scripts/values.ts index 433c890..d8c3566 100644 --- a/src/scripts/values.ts +++ b/src/scripts/values.ts @@ -13,15 +13,12 @@ import { config } from '../config.js'; import type { FieldIssue, ScriptField, ScriptJson, ScriptValue, ScriptValues } from './types.js'; +import { parseBool, parseNumber } from './util.js'; export type ValidationResult = | { ok: true; values: ScriptValues } | { ok: false; issues: FieldIssue[] }; -/** Retezce, ktere lidi i sluzby pouzivaji pro ano a ne. */ -const truthy = new Set(['true', '1', 'yes', 'y', 'ano', 'on']); -const falsy = new Set(['false', '0', 'no', 'n', 'ne', 'off']); - function isMissing(value: unknown): boolean { return value === undefined || value === null || (typeof value === 'string' && value.trim() === ''); } @@ -86,19 +83,15 @@ function coerce(field: ScriptField, raw: unknown): { value: ScriptValue } | { er case 'number': { if (typeof raw === 'boolean') return { error: 'Očekává se číslo, přišlo ano/ne.' }; - // Ceska desetinna carka je bezna, nema smysl na ni padat. - const text = typeof raw === 'string' ? raw.trim().replace(',', '.') : raw; - const num = typeof text === 'number' ? text : Number(text); - if (!Number.isFinite(num)) return { error: `"${String(raw)}" není číslo.` }; + const num = parseNumber(raw); + if (num === null) return { error: `"${String(raw)}" není číslo.` }; return { value: num }; } case 'boolean': { - if (typeof raw === 'boolean') return { value: raw }; - const text = String(raw).trim().toLowerCase(); - if (truthy.has(text)) return { value: true }; - if (falsy.has(text)) return { value: false }; - return { error: `"${String(raw)}" není ano ani ne.` }; + const parsed = parseBool(raw); + if (parsed === null) return { error: `"${String(raw)}" není ano ani ne.` }; + return { value: parsed }; } case 'date': { diff --git a/src/shared/access.ts b/src/shared/access.ts new file mode 100644 index 0000000..94c5b7f --- /dev/null +++ b/src/shared/access.ts @@ -0,0 +1,55 @@ +/** + * Co uzivatel smi. Pocita server, klient si nic nedovozuje sam. + */ + +import type { GroupRef } from './people.js'; +import type { TenantRef } from './tenants.js'; + +/** Pohled na tickety. Server rozhoduje, ktery smi kdo pouzit. */ +export type TicketScope = 'all' | 'tenant' | 'mine'; + +/** Klic zalozky. Frontend si ho mapuje na cestu a komponentu. */ +export type ModuleKey = + | 'overview' + | 'automations' + | 'actions' + | 'widgets' + | 'services' + | 'connectors' + | 'scripts' + | 'tickets' + | 'helpdesk' + | 'people' + | 'incidents' + | 'settings'; + +/** Zalozka, kterou ma uzivatel videt. */ +export interface NavItem { + key: ModuleKey; + label: string; +} + +export interface Access { + /** Pohledy, ktere smi uzivatel pouzit. Klient podle toho kresli prepinac. */ + scopes: TicketScope[]; + /** Firmy, mezi kterymi muze prepinat. */ + tenants: TenantRef[]; + /** Vychozi firma, kdyz zadnou nezvoli. null = zadna, nema kam. */ + defaultTenantId: string | null; + /** Smi prehazovat tickety mezi lidmi, ne jen brat na sebe. */ + canAssignOthers: boolean; + /** ID resitele odpovidajiciho uzivateli, nebo null. */ + personId: string | null; + /** Efektivni prava. Klient podle nich kresli tlacitka. */ + permissions: string[]; + /** Nazvy roli uzivatele ve vybrane firme. Jmeno role je to, co se ma ukazat. */ + roleNames: string[]; + /** Zalozky, ktere ma videt. Prunik toho, co firma ma, a toho, na co ma pravo. */ + nav: NavItem[]; + /** true = vidi napric firmami a smi platformni nastaveni. */ + platformAdmin: boolean; + /** Vidi i cizi tickety, ne jen svoje a frontu? Podle toho se nabizi zalozka Vsechny. */ + seesOthers: boolean; + /** Sekce, na ktere clovek vidi. Filtr v zalozce Vsechny. */ + visibleGroups: GroupRef[]; +} diff --git a/src/shared/automations.ts b/src/shared/automations.ts new file mode 100644 index 0000000..447ef28 --- /dev/null +++ b/src/shared/automations.ts @@ -0,0 +1,163 @@ +/** + * Automatizace a jeji strom akci (flow), jak je vraci API. + */ + +import type { ConditionMatch, ConditionOperator, ConditionRule, FieldType } from './conditions.js'; + +export type AutomationKind = 'workflow' | 'voicebot' | 'integrace' | 'report'; + +/** Jeden vstupni parametr, ktery spoustec preda dal do stromu. */ +export interface TriggerField { + id: string; + /** Klic v prichozich datech, napr. "orderTotal". Musi byt unikatni. */ + name: string; + type: FieldType; + required: boolean; + /** + * Kde ta hodnota v tele je, kdyz to neni primo `name`. + * Priklady: `customer.id`, `errors.0.message`, `data.items`. + */ + path?: string; +} + +export interface FlowTrigger { + serviceId: string; + operationId: string; + /** Deklarovane vstupni parametry. Podminky se odkazuji na jejich `id`. */ + fields: TriggerField[]; + /** + * Ukazka toho, co odesilatel opravdu posila. Z ni se odvodi model, tedy + * strom cest, ze ktereho se klikanim vklada odkaz do sablony. + */ + sample?: unknown; + /** Jak casto se ma sluzba obvolavat (v sekundach). Jen u spoustecu, ktere se musi ptat. */ + intervalSec?: number; + /** Neodhadnutelny token v adrese webhooku. Generuje vyhradne server. */ + webhookToken?: string; +} + +/** Jedna cesta v modelu prichozich dat. Pocita ji server z ukazky. */ +export interface ModelNode { + /** Cesta k hodnote, napr. `data.order.items[].name`. */ + path: string; + type: FieldType; + /** Ukazkova hodnota, zkracena. */ + sample: string | null; + /** Kolik polozek melo pole v ukazce. Jen u typu `list`. */ + count?: number; +} + +/** Krok stromu: akce nad sluzbou, podminka se dvema vetvemi, nebo smycka. */ +export type FlowStep = + | { + id: string; + kind: 'action'; + serviceId: string; + operationId: string; + /** + * Ktery konektor firmy se pouzije. `null` = vychozi konektor firmy pro + * tuhle sluzbu, u obecnych sluzeb vzdy null. + */ + connectorId?: string | null; + /** Klic je `OperationField.id`, hodnota sablona s `{{parametry}}`. */ + inputs?: Record; + } + | { + id: string; + kind: 'condition'; + /** Otazky, ktere se ptaji naraz. Cte se vzdycky pres `rulesOf`. */ + rules?: ConditionRule[]; + /** `all` = a zaroven, `any` = nebo. */ + match?: ConditionMatch; + /** Stara podoba: jedna otazka primo na kroku. Prevadi ji `rulesOf`. */ + fieldId?: string; + operator?: ConditionOperator; + value?: string; + yes: FlowStep[]; + no: FlowStep[]; + } + | { + id: string; + /** + * Podstrom nad kazdou polozkou seznamu. Uvnitr je `item` a `index`, + * po skonceni `krok.results` se seznamem vysledku. + */ + kind: 'foreach'; + /** Cesta k seznamu, napr. `data.order.items`. */ + path: string; + steps: FlowStep[]; + }; + +export interface AutomationFlow { + trigger: FlowTrigger | null; + steps: FlowStep[]; +} + +/** Polozka v seznamu automatizaci, bez celeho stromu. */ +export interface Automation { + id: string; + /** Firma, ktere automatizace patri. Hranice viditelnosti. */ + tenantId: string; + name: string; + kind: AutomationKind; + enabled: boolean; + /** Behy za dnesni den. Po pulnoci zacina od nuly. */ + runsToday: number; + /** Behy za vcerejsi den, at je s cim srovnat. */ + runsYesterday: number; + /** Behy od zalozeni. */ + runsTotal: number; + /** Uspesnost dnesnich behu. Kdyz dnes zadny nebyl, posledniho dne, kdy byly. */ + successRate: number; + avgDurationMs: number; + lastRunAt: string; + /** Pocet vsech kroku vcetne vnorenych vetvi. */ + stepCount: number; + /** false = automatizace jeste nema spoustec, je to koncept. */ + configured: boolean; + /** Co chybi k tomu, aby se dala zapnout. Prazdne = je hotova. */ + issues: string[]; +} + +/** Jak dopadl jeden deklarovany parametr v jednom volani. */ +export interface WebhookCallField { + name: string; + /** Cesta v tele. Shodna se jmenem, kdyz se nezadala vlastni. */ + path: string; + required: boolean; + /** Deklarovany typ z kontraktu. */ + type: string; + /** Co na te ceste opravdu bylo: text, cislo, objekt, seznam, nepreslo. */ + got: string; + /** Kratky nahled prijate hodnoty. */ + preview: string; + /** false = tenhle parametr je duvod, proc se volani odmitlo. */ + ok: boolean; +} + +/** Jedno volani webhooku, jak dopadlo, vcetne prijateho tela. */ +export interface WebhookCall { + at: string; + /** true = prevzato do fronty, false = odmitnuto pro neplatna data. */ + ok: boolean; + /** Duvody odmitnuti. Prazdne u prevzatych volani. */ + problems: string[]; + /** Beh, ktery z volani vznikl. null u odmitnutych a u duplicit. */ + runId: string | null; + /** Kazdy deklarovany parametr a jak dopadl. */ + fields: WebhookCallField[]; + /** Cele prijate telo jako JSON. Delsi se usekne. */ + body: string; + /** true = telo bylo delsi a je useknute. */ + truncated: boolean; +} + +export interface AutomationDetail extends Automation { + flow: AutomationFlow; + /** Poslednich par volani webhooku, nejnovejsi prvni. Drzi se v pameti. */ + recentCalls: WebhookCall[]; + /** Model prichozich dat z ukazky u spoustece. Pocita server pri cteni. */ + model: ModelNode[]; + createdAt: string; + updatedAt: string; +} diff --git a/src/shared/conditions.ts b/src/shared/conditions.ts new file mode 100644 index 0000000..a0e4baf --- /dev/null +++ b/src/shared/conditions.ts @@ -0,0 +1,37 @@ +/** + * Typy vstupnich parametru a operatory podminek. Tabulky (ktery operator + * ke kteremu typu) zustavaji na serveru v src/data/conditions.ts a web je + * dostava v katalogu sluzeb jako `operatorsByType`. + */ + +/** + * `object` a `list` jsou cele struktury, ne jednotlive hodnoty. Do sablony se + * nedosazuji, predavaji se jen jako celek dalsimu kroku. + */ +export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object' | 'list'; + +export type ConditionOperator = + | 'eq' + | 'neq' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'contains' + | 'startsWith' + | 'isEmpty' + | 'isNotEmpty' + | 'isTrue' + | 'isFalse'; + +/** Jedna otazka v podmince. */ +export interface ConditionRule { + /** ID parametru spoustece, nebo `krok.vystup`. */ + fieldId: string; + operator: ConditionOperator; + /** Chybi u operatoru, ktere hodnotu nepotrebuji (isEmpty, isTrue apod.). */ + value?: string; +} + +/** Jak se otazky spoji. `all` = a zaroven, `any` = nebo. Zamerne jedna uroven. */ +export type ConditionMatch = 'all' | 'any'; diff --git a/src/shared/connectors.ts b/src/shared/connectors.ts new file mode 100644 index 0000000..f28d4ca --- /dev/null +++ b/src/shared/connectors.ts @@ -0,0 +1,167 @@ +/** + * Konektor = napojeni jedne firmy na jednu sluzbu, jak ho vidi klient. + * + * Tohle je verejny tvar (na serveru `PublicConnector`). Hodnoty pristupovych + * udaju tu nejsou a nikdy nesmi byt, server posila jen `filled` a `missing`. + * Ulozeny tvar s hodnotami je server-only, viz src/data/connectors/types.ts. + */ + +/** untested = jeste se neoverovalo, ok = posledni overeni proslo. */ +export type ConnectorStatus = 'untested' | 'ok' | 'error'; + +/** + * Jeden zaznam v historii overeni konektoru. + * Nese cele telo odpovedi sluzby, protoze prave tam je napsane, co ji vadilo. + */ +export interface ConnectorCheck { + /** ISO cas overeni. */ + at: string; + ok: boolean; + /** Co se overovalo: pristupove udaje, nebo jen dostupnost sluzby. */ + checked: string; + /** HTTP kod od sluzby. null, kdyz se k volani vubec nedoslo. */ + status: number | null; + message: string; + /** Cele telo odpovedi sluzby, zredigovane. */ + detail: string | null; + /** Cela adresa vcetne serveru, bez query. null, kdyz se k volani nedoslo. */ + request: { method: string; path: string; url: string } | null; + /** Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal. */ + responseHeaders: Record | null; + /** Odchozi IP portalu ve chvili volani. Jen u odmitnuteho pristupu. */ + egressIp: string | null; +} + +export interface Connector { + id: string; + tenantId: string; + serviceId: string; + /** Firma muze mit dva ucty teze sluzby, proto vlastni nazev. */ + name: string; + /** Prepis adresy sluzby. null = vychozi podle `appId` a SERVICES_BASE_URL. */ + baseUrl: string | null; + enabled: boolean; + status: ConnectorStatus; + lastCheckAt: string | null; + lastError: string | null; + /** Kolik zaznamu o overeni je v historii. Samotna historie se cte zvlast. */ + checkCount: number; + /** Krok stromu bez vybraneho konektoru pouzije vychozi. */ + isDefault: boolean; + createdAt: string; + updatedAt: string; + /** ID poli, ktera jsou vyplnena. Hodnoty se nevraci. */ + filled: string[]; + /** ID povinnych poli, ktera jeste chybi. */ + missing: string[]; + /** Necitliva nastaveni. Tajna pole tu nejsou vubec. */ + config: Record; + /** true = vsechna povinna pole jsou vyplnena, jde volat. */ + ready: boolean; + /** Nastroje MCP serveru. `null` u vsech ostatnich sluzeb. */ + mcp: Toolset | null; +} + +export interface ToolSummary { + name: string; + label: string; + description: string; + /** Nazvy parametru, ktere nastroj prijima. Povinne maji na konci hvezdicku. */ + inputs: string[]; + /** Nazvy hodnot, ktere nastroj vraci. */ + outputs: string[]; +} + +/** Nastroje jednoho MCP serveru tak, jak je vidi portal. Bez schemat. */ +export interface Toolset { + /** ISO cas nacteni. Rika, jak stary seznam clovek vidi. */ + at: string; + server: string; + protocolVersion: string; + tools: ToolSummary[]; +} + +/** Odpoved POST /connectors/:id/mcp/tools. Neuspech je vysledek, ne chyba API. */ +export interface ToolsLoadResult { + ok: boolean; + checked: string; + message: string; + status?: number; + detail?: string; + request?: { method: string; path: string; url: string } | null; + server?: string; + protocolVersion?: string; + tools: ToolSummary[]; +} + +/** Odpoved POST /connectors/:id/test. */ +export interface ConnectorTestResult { + ok: boolean; + /** Co se vlastne overilo: pristupove udaje, nebo jen dostupnost sluzby. */ + checked: string; + status?: number; + message: string; + /** Ktere volani to bylo, vcetne serveru. */ + request?: { method: string; path: string; url: string }; + /** Cela odpoved sluzby. Tady je napsane, co ji vadilo. */ + detail?: string; + /** Zaklad adresy, na kterou konektor miri. Vraci se i kdyz overeni projde. */ + baseUrl?: string; + /** Vybrane hlavicky odpovedi. Rikaji, kdo odpoved vydal. */ + responseHeaders?: Record; + /** Odchozi IP portalu. Jen u odmitnuteho pristupu. */ + egressIp?: string; +} + +// ------------------------------------------------------------- uloziste + +export type StorageMode = 'postgres' | 'file' | 'memory'; + +/** Kam se uklada a co to prezije. */ +export interface StorageStatus { + mode: StorageMode; + /** Kratke vysvetleni pro uzivatele. Vzdy vyplnene, i u databaze. */ + reason: string; + /** true = restart procesu data smaze. */ + ephemeral: boolean; + /** true = redeploy containeru data smaze, i kdyz restart ne. */ + lostOnRedeploy: boolean; + /** Odkud je klic k sifrovani tajnych hodnot. */ + secrets: 'env' | 'file' | 'none'; + /** Kde data lezi. null u pameti; beznemu uzivateli chodi vzdy null. */ + location: string | null; +} + +// ---------------------------------------------------------- odchozi adresa + +/** Jak nas vidi nase vlastni reverse proxy, kdyz se volani otoci pres domenu. */ +export interface ProxyView { + /** Tohle je to cislo: adresa z X-Forwarded-For. */ + ip: string | null; + /** Surova hlavicka, at je videt i to, co proxy nepripsala. */ + forwardedFor: string | null; + /** Adresa druheho konce spojeni. To je sama proxy, ne volajici. */ + remoteAddress: string | null; + /** Rozsah, ktery tu adresu pokryje cely. null = adresa je verejna. */ + suggestedRange: string | null; + /** Kam se volalo. */ + url: string; + error?: string; +} + +/** + * Odchozi IP adresa portalu, tedy ta, kterou vidi volana sluzba a ktera musi + * byt na jejim seznamu povolenych. Z containeru ji videt neni, zjistuje se + * echo sluzbou. + */ +export interface EgressIp { + /** null = nepodarilo se zjistit, nebo je funkce vypnuta. */ + ip: string | null; + /** Odkud to je. */ + source: string; + checkedAt: string; + /** Vyplnene jen kdyz `ip` je null. */ + error?: string; + /** null = nezmereno, chybi `PUBLIC_ORIGIN`, nebo se to nepodarilo. */ + viaProxy: ProxyView | null; +} diff --git a/src/shared/dashboard.ts b/src/shared/dashboard.ts new file mode 100644 index 0000000..d27ab61 --- /dev/null +++ b/src/shared/dashboard.ts @@ -0,0 +1,24 @@ +/** + * Souhrn prehledu a obecne obalky odpovedi. + */ + +export interface SeriesPoint { + date: string; + runs: number; + failures: number; +} + +/** Odpoved GET /dashboard/summary. */ +export interface DashboardSummary { + openTickets: number; + activeIncidents: number; + activeAutomations: number; + runsToday: number; + savedHoursMonth: number; + uptime: number; + series: SeriesPoint[]; +} + +export interface ListResponse { + items: T[]; +} diff --git a/src/shared/entities.ts b/src/shared/entities.ts new file mode 100644 index 0000000..d3f0a77 --- /dev/null +++ b/src/shared/entities.ts @@ -0,0 +1,24 @@ +/** + * Zakladni tvar zaznamu v ulozisti. Sdilene se webem, protoze nastaveni + * (firmy, lide, typy ticketu, role) vraci zaznamy tak, jak lezi v ulozisti. + * + * Soubory v `src/shared` jsou jen typy: zadny import z Node, zadny kod. + * Web je nacita pres alias `@shared/*`, viz web/tsconfig.json. + */ + +/** Kazdy zaznam ma ID a vi, kdy vznikl. */ +export interface Entity { + id: string; + createdAt: string; + updatedAt: string; +} + +/** + * Zaznam patrici firme. + * + * `tenantId: null` znamena platformni zaznam, tedy nas, ne zakaznikuv. + * Typicky systemova role nebo sluzba dostupna vsem. + */ +export interface TenantEntity extends Entity { + tenantId: string | null; +} diff --git a/src/shared/events.ts b/src/shared/events.ts new file mode 100644 index 0000000..f74c9c1 --- /dev/null +++ b/src/shared/events.ts @@ -0,0 +1,52 @@ +/** + * Udalosti dashboardu, jak chodi klientovi pres SSE stream. + */ + +/** + * Zmeny ciselniku z Nastaveni a konektoru. + * + * Portal si ciselniky drzi v pameti a bez techhle udalosti by je obnovil az + * prepnutim firmy. Payload nese cely zaznam pod klicem stejneho jmena + * (`{ person: {...} }`), aby se dal opravit na miste; u smazani jen `{ id }`. + */ +export type EntityEventKind = + | 'tenant' + | 'user' + | 'role' + | 'person' + | 'group' + | 'ticketType' + | 'action' + | 'widget' + | 'connector' + | 'feature'; +export type EntityEventType = `${EntityEventKind}.${'created' | 'updated' | 'deleted'}`; + +export type DashboardEventType = + | 'ticket.created' + | 'ticket.updated' + | 'ticket.assigned' + | 'ticket.resolved' + | 'incident.started' + | 'incident.updated' + | 'incident.resolved' + | 'automation.created' + | 'automation.updated' + | 'automation.deleted' + | 'automation.run' + | 'webhook.received' + /** Nekomu prislo upozorneni. Portal podle toho prekresli cislo u zalozky. */ + | 'notification.created' + | EntityEventType; + +export interface DashboardEvent { + id: string; + type: DashboardEventType; + at: string; + /** Kratka veta pro uzivatele, zobrazuje se v notifikaci. */ + message: string; + /** Doplnkova data, napr. id ticketu. */ + payload?: Record; + /** Ci firmy se to tyka. `null` = cele platformy. Stream podle toho filtruje. */ + tenantId: string | null; +} diff --git a/src/shared/incidents.ts b/src/shared/incidents.ts new file mode 100644 index 0000000..5b90733 --- /dev/null +++ b/src/shared/incidents.ts @@ -0,0 +1,27 @@ +/** + * Incident = hlaseni o vypadku. Muze byt firemni i platformni. + */ + +export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3'; +export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved'; + +export interface Incident { + id: string; + /** Firma, ktere se to tyka. `null` = platformni incident spolecny vsem. */ + tenantId: string | null; + /** Srozumitelne pro klienta. Zadne stack trace, zadne ID kroku. */ + title: string; + service: string; + severity: IncidentSeverity; + status: IncidentStatus; + /** Co s tim ma klient delat, nebo co to pro nej znamena. */ + impact: string; + /** Diagnostika pro admina. Beznemu uzivateli chodi vzdy `null`. */ + detail: string | null; + /** Odkud incident vznikl, napr. `run_ab12` nebo `rucne`. */ + source: string | null; + /** Ticket, u ktereho to prasklo. */ + ticketId: string | null; + startedAt: string; + resolvedAt: string | null; +} diff --git a/src/shared/index.ts b/src/shared/index.ts new file mode 100644 index 0000000..eb3c905 --- /dev/null +++ b/src/shared/index.ts @@ -0,0 +1,24 @@ +/** + * Typy API sdilene serverem a webem. Jen typy, zadny kod: po prekladu z + * teto slozky zbydou prazdne moduly a to je v poradku. + * + * Server: `import type { Ticket } from '../shared/tickets.js'`. + * Web: `import type { Ticket } from '@shared/tickets'`. + */ + +export type * from './access.js'; +export type * from './automations.js'; +export type * from './conditions.js'; +export type * from './connectors.js'; +export type * from './dashboard.js'; +export type * from './entities.js'; +export type * from './events.js'; +export type * from './incidents.js'; +export type * from './people.js'; +export type * from './permissions.js'; +export type * from './scripts.js'; +export type * from './services.js'; +export type * from './tenants.js'; +export type * from './tickets.js'; +export type * from './users.js'; +export type * from './widgets.js'; diff --git a/src/shared/people.ts b/src/shared/people.ts new file mode 100644 index 0000000..8147897 --- /dev/null +++ b/src/shared/people.ts @@ -0,0 +1,34 @@ +/** + * Resitele ticketu a jejich skupiny, jak je vraci API. + */ + +import type { TenantEntity } from './entities.js'; + +/** Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail. */ +export interface Person extends TenantEntity { + /** Firma, jejiz je clenem tymu. Hranice viditelnosti, proto nikdy null. */ + tenantId: string; + name: string; + email: string; + /** Cim se v tymu zabyva. Jen popisek, nic nerozhoduje. */ + role: string; + /** Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez. */ + capacity: number; + /** Vypnuty resitel se nenabizi k prirazeni, ale stare tickety nespadnou. */ + enabled: boolean; + /** ID, pod kterymi cloveka znaji cizi aplikace (voicebot, ustredna, chat). */ + externalIds: string[]; +} + +/** Skupina resitelu tak, jak ji vidi bezny uzivatel: jen jmeno. */ +export interface GroupRef { + id: string; + name: string; +} + +/** Odpoved GET /people. Skupiny chodi spolu s lidmi, kdo prirazuje, potrebuje oboji. */ +export interface PeopleResponse { + items: Person[]; + groups: GroupRef[]; + meId: string | null; +} diff --git a/src/shared/permissions.ts b/src/shared/permissions.ts new file mode 100644 index 0000000..b8cbfdf --- /dev/null +++ b/src/shared/permissions.ts @@ -0,0 +1,27 @@ +/** + * Prava a role jako data. Katalog prav je na serveru, tady je jen jeho tvar. + */ + +import type { TenantEntity } from './entities.js'; + +export type PermissionKey = string; + +export interface PermissionDefinition { + key: PermissionKey; + label: string; + /** Do ktere skupiny patri v nastaveni role. */ + group: string; + /** true = pravo je nase, klientsky admin ho neprideluje. */ + platform?: boolean; + hint?: string; +} + +export interface Role extends TenantEntity { + /** Stabilni klic, napr. `admin`. U vlastnich roli je stejny jako ID. */ + key: string; + name: string; + description: string; + permissions: PermissionKey[]; + /** true = systemova role, nejde smazat ani prejmenovat. */ + system: boolean; +} diff --git a/src/shared/scripts.ts b/src/shared/scripts.ts new file mode 100644 index 0000000..3e47722 --- /dev/null +++ b/src/shared/scripts.ts @@ -0,0 +1,157 @@ +/** + * Skripty konektoru: manifest, vysledek behu a odpovedi katalogu. + * + * Zdroj pravdy o manifestu je zod schema v src/scripts/types.ts. Tvar tady + * musi sedet s nim, server to hlida pri prekladu (`satisfies`). + */ + +import type { FieldType } from './conditions.js'; +import type { Connector } from './connectors.js'; + +/** Hodnota, se kterou skript pracuje. Cokoliv, co jde vyjadrit JSONem. */ +export type ScriptJson = + | string + | number + | boolean + | null + | ScriptJson[] + | { [key: string]: ScriptJson }; + +export type ScriptValue = ScriptJson; +export type ScriptValues = Record; + +/** Parametr skriptu. Stejny tvar pro vstup i vystup. */ +export interface ScriptField { + /** Pouziva se v sablonach jako `{{id}}`. Jen pismena, cislice a podtrzitko. */ + id: string; + label: string; + type: FieldType; + required: boolean; + /** Napoveda pod polem v builderu. */ + hint?: string; + /** Vyber z hodnot. Jina hodnota neprojde validaci. */ + options?: Array<{ value: string; label: string }>; + /** Jen u typu string: dalsi kontrola regularnim vyrazem. */ + pattern?: string; + /** Jen u typu string: pole na vic radku. */ + multiline?: boolean; + /** Cim se pole vyplnuje v builderu, kdyz to z typu nejde poznat. */ + control?: 'mapping' | 'json' | 'object'; + /** Dosadi se, kdyz hodnota chybi a parametr neni povinny. */ + default?: string | number | boolean | null; + /** Jen u `object` a `list`: strop na velikost v bajtech. */ + maxBytes?: number; +} + +/** Manifest skriptu. `id` ma tvar `.`. */ +export interface ScriptManifest { + id: string; + name: string; + description: string; + inputs: ScriptField[]; + outputs: ScriptField[]; + /** Strop na jeden beh. Kdyz chybi, pouzije se hodnota z konfigurace. */ + timeoutMs?: number; +} + +/** Manifest plus to, co si klient nema dopocitavat sam. Polozka katalogu. */ +export interface ScriptSummary extends ScriptManifest { + serviceId: string; + serviceName: string; + operationId: string; +} + +export interface FieldIssue { + field: string; + message: string; +} + +/** Skript, ktery se nepodarilo nacist. */ +export interface ScriptProblem { + /** Nazev souboru, ne cela cesta. */ + file: string; + scriptId: string | null; + message: string; + issues?: FieldIssue[]; +} + +/** Odpoved GET /scripts. */ +export interface ScriptCatalog { + items: ScriptSummary[]; + problems: ScriptProblem[]; + /** Vychozi konektor firmy podle ID sluzby. Chybi = sluzba neni napojena. */ + connectors: Record; + tenantId: string | null; + /** Slozka se skripty. Jen pro spravce platformy, ostatnim chodi null. */ + directory: string | null; +} + +/** Odpoved GET /scripts/:id. */ +export interface ScriptDetail { + id: string; + serviceId: string; + operationId: string; + serviceName: string; + /** null = soubor je rozbity, manifest se nepodarilo precist. */ + manifest: ScriptManifest | null; + code: string; + problem: ScriptProblem | null; + /** Vychozi konektor firmy, pod kterym se skript da vyzkouset. */ + connector: Connector | null; +} + +// --------------------------------------------------------------------- beh + +/** + * Druh selhani. `retryable` a `terminal` je to podstatne: timeout nebo 503 + * ma smysl zkusit znovu, chyba ve vstupu nebo 403 ne. + */ +export type ScriptErrorKind = + | 'not_found' + | 'config' + | 'validation' + | 'output' + | 'retryable' + | 'terminal' + | 'timeout' + | 'internal'; + +/** Ktere volani spadlo. Bez toho je chybova zprava jen pulka informace. */ +export interface ScriptRequestInfo { + method: string; + /** Cesta bez domeny. */ + path: string; + /** Cela adresa vcetne serveru, ale bez query. */ + url: string; +} + +export interface ScriptLogEntry { + at: string; + message: string; + detail?: string; +} + +export interface ScriptRunError { + kind: ScriptErrorKind; + message: string; + retryable: boolean; + status?: number; + /** Cela odpoved sluzby, uz zredigovana, nebo zasobnik volani u chyby ve skriptu. */ + detail?: string; + request?: ScriptRequestInfo; + /** Zasobnik volani. Jen mimo produkci. */ + stack?: string; + /** Vyplnene jen u chyb ve vstupu nebo vystupu. */ + issues?: FieldIssue[]; +} + +export interface ScriptRunResult { + ok: boolean; + scriptId: string; + /** Prazdne, kdyz beh selhal. */ + outputs: ScriptValues; + logs: ScriptLogEntry[]; + durationMs: number; + httpCalls: number; + error: ScriptRunError | null; +} diff --git a/src/shared/services.ts b/src/shared/services.ts new file mode 100644 index 0000000..c5212d6 --- /dev/null +++ b/src/shared/services.ts @@ -0,0 +1,168 @@ +/** + * Katalog sluzeb: co umime. Sluzba ma operace a rika, co je potreba k napojeni. + * Napojeni jedne firmy je konektor, viz ./connectors.ts. + */ + +import type { ConditionOperator, FieldType } from './conditions.js'; + +export type ServiceCategory = + /** Obecne veci, ktere ma kazdy. Nepotrebuji konektor. */ + | 'obecne' + | 'crm' + | 'ekonomika' + | 'logistika' + | 'komunikace' + | 'analytika' + | 'ai' + /** Prevody dat mezi kroky. Vlastni kategorie, je jich hodne. */ + | 'transformace'; + +/** available = sluzbu umime, planned = je na roadmape. */ +export type ServiceStatus = 'available' | 'planned'; + +/** Polozka nabidky kategorii, jak ji posila katalog. */ +export interface ServiceCategoryEntry { + id: ServiceCategory; + label: string; +} + +/** + * Kdo sluzbu vidi. `everyone` vsichni, `restricted` jen uvedene firmy a lide, + * `admin` jen spravce platformy. + */ +export interface ServiceVisibility { + mode: 'everyone' | 'restricted' | 'admin'; + tenantIds: string[]; + userIds: string[]; +} + +/** Jeden udaj, ktery je potreba vyplnit pri zakladani konektoru. */ +export interface ServiceCredentialField { + id: string; + label: string; + target: 'header' | 'config'; + /** Jmeno hlavicky u `header`, jmeno klice v `ctx.config` u `config`. */ + name: string; + required: boolean; + /** true = hodnota se ze serveru nikdy nevraci. */ + secret: boolean; + hint?: string; + /** Text, ktery se pred hodnotu doplni az pri sestaveni hlavicky (`Bearer `). */ + prefix?: string; + /** true = hodnotu spravuje portal, ve formulari se nezobrazuje. */ + managed?: boolean; +} + +/** + * Parametr, ktery spoustec nebo akce preda do stromu sama. Tvarem odpovida + * `TriggerField` bez cesty; ID musi byt stabilni, odkazuji se na nej podminky. + */ +export interface ProvidedField { + id: string; + name: string; + type: FieldType; + required: boolean; +} + +/** + * Nastavitelne pole akce. Hodnota je sablona, `{{nazev}}` se nahradi + * parametrem spoustece. + */ +export interface OperationField { + id: string; + label: string; + /** + * `json` a `mapping` nesou strukturu zapsanou jako JSON v retezci. + * `object` je odkaz na parametr typu objekt nebo seznam. + * `lookup` je ciselnik a zaroven volny text. + */ + kind: 'text' | 'longtext' | 'html' | 'choice' | 'lookup' | 'json' | 'mapping' | 'object'; + required: boolean; + options?: Array<{ value: string; label: string }>; + /** Odkud se doplni nabidka za behu. Server ji dosadi pri kazdem cteni katalogu. */ + optionsFrom?: 'people' | 'groups' | 'scripts' | 'ticketTypes'; + hint?: string; +} + +export interface ServiceOperation { + id: string; + name: string; + description: string; + /** Popis toho, co bude potreba nastavit. Jen napoveda, nikam se neuklada. */ + fields?: string[]; + /** Jen u akci: skutecne nastavitelna pole. Kdyz chybi, akci nejde nastavit. */ + inputs?: OperationField[]; + /** Jen u akci: co krok vrati dalsim krokum. Podminka se na to muze ptat. */ + outputFields?: ProvidedField[]; + /** Jen u triggeru: true = vstupni parametry si definuje uzivatel. */ + customPayload?: boolean; + /** Jen u triggeru: parametry, ktere sluzba predava sama. Uzivatel je nemeni. */ + providedFields?: ProvidedField[]; + /** `script` = operaci obsluhuje skript, tedy se opravdu vykona. */ + implementation?: 'script'; + /** Ktery skript operaci obsluhuje. Vyplnene spolu s `implementation`. */ + scriptId?: string; +} + +export interface Service { + id: string; + name: string; + category: ServiceCategory; + description: string; + /** Klic ikony, mapuje se na klientovi (lib/serviceIcons.ts). */ + icon: string; + status: ServiceStatus; + /** true = funguje bez konektoru (webhook, pauza, transformace dat). */ + general: boolean; + /** ID aplikace v services.csbot.cz/apps. null = sluzba nema jednu pevnou adresu. */ + appId: string | null; + /** + * `smtp` = sluzba se nevola pres HTTP, adresu nese konektor v udajich. + * `mcp` = totez, a navic zadne operace v katalogu nema, rekne je az server. + */ + transport?: 'http' | 'smtp' | 'mcp'; + /** Absolutni adresa sluzby, ktera nebezi u nas. Typicky OpenAI. */ + baseUrl?: string; + visibility: ServiceVisibility; + credentials: ServiceCredentialField[]; + /** Cteci volani, kterym se overi konektor. Kdyz chybi, overi se jen `/health`. */ + verifyPath?: string; + triggers: ServiceOperation[]; + actions: ServiceOperation[]; +} + +/** Sluzba ocima jedne firmy, tedy uz vcetne toho, co ma napojene. */ +export interface ServiceWithUsage extends Service { + baseUrl: string; + /** Kolik konektoru na ni firma ma. 0 = muze si napojit. */ + connectorCount: number; + /** Kolik akci uz opravdu neco dela, tedy ma skript. */ + implementedActions: number; +} + +/** Odpoved GET /connectors/services. */ +export interface ServiceOverview { + categories: ServiceCategoryEntry[]; + items: ServiceWithUsage[]; + tenantId: string | null; +} + +/** + * Sluzba v katalogu pro builder. `connected` doplnuje server podle konektoru + * firmy: katalog se kvuli tomu nefiltruje, log ticketu podle nej preklada ID + * operaci na jmena. Vybirat z nenapojene sluzby ale nema smysl. + */ +export interface CatalogService extends Service { + /** true = firma ma k teto sluzbe aspon jedno napojeni. */ + connected: boolean; +} + +/** Odpoved GET /dashboard/services, katalog pro builder. */ +export interface ServiceCatalog { + categories: ServiceCategoryEntry[]; + items: CatalogService[]; + /** Ktere operatory server povoli pro ktery typ parametru. */ + operatorsByType: Record; + /** Zaklad adresy webhooku, napr. "https://api.automia.cz/webhook". */ + webhookBaseUrl: string; +} diff --git a/src/shared/tenants.ts b/src/shared/tenants.ts new file mode 100644 index 0000000..dba96f6 --- /dev/null +++ b/src/shared/tenants.ts @@ -0,0 +1,40 @@ +/** + * Firma (tenant) = nas zakaznik, ktery servicedesk provozuje. + * Neplest s `ticket.customer`, to je zakaznik teto firmy. + */ + +import type { TenantEntity } from './entities.js'; + +/** Odkaz na firmu v seznamu prav (`Access.tenants`). Jen to, co je videt v prepinaci. */ +export interface TenantRef { + id: string; + name: string; +} + +/** Cely zaznam firmy, jak ho vraci nastaveni (`/settings/tenants`). */ +export interface Tenant extends TenantEntity { + name: string; + /** Kratka poznamka pro nas, klient ji nevidi. */ + note: string; + enabled: boolean; + /** + * Token pro prijem udalosti na `/webhook/ticket/:token`. Je na firme, ne na + * automatizaci: prijem ticketu ma fungovat i bez stromu. + */ + intakeToken: string; + /** + * Firma, ktera teto firme resi pozadavky z helpdesku. `null` = helpdesk nema + * komu poslat. Nastavuje spravce platformy. + */ + helpdeskProviderId: string | null; + /** + * Udaje z registru ARES, vsechny nepovinne: firma zalozena rucne je nema. + * Starsi zaznamy pole nemaji vubec, proto se ctou jako `undefined`. + */ + ico?: string | null; + dic?: string | null; + /** Textova adresa sidla. */ + address?: string | null; + /** Nazev pravni formy, napr. "Spolecnost s rucenim omezenym". */ + legalForm?: string | null; +} diff --git a/src/shared/tickets.ts b/src/shared/tickets.ts new file mode 100644 index 0000000..5b81279 --- /dev/null +++ b/src/shared/tickets.ts @@ -0,0 +1,225 @@ +/** + * Ticket = prichozi pozadavek odkudkoliv (WhatsApp, e-mail, hlasova linka, + * formular, portal). Tvar, ktery API vraci klientovi. + */ + +import type { TicketScope } from './access.js'; +import type { FieldType } from './conditions.js'; +import type { TenantEntity } from './entities.js'; +import type { Person } from './people.js'; + +/** + * Stav ticketu. Volny retezec, ne ciselnik: tickety chodi z cizich aplikaci, + * ktere maji svoje stavy. + */ +export type TicketStatus = string; +export type TicketPriority = 'low' | 'normal' | 'high' | 'critical'; + +/** Odkud pozadavek prisel. */ +export type TicketChannel = + | 'whatsapp' + | 'facebook' + | 'instagram' + | 'email' + | 'voice' + | 'form' + | 'portal'; + +export interface TicketCustomer { + /** ID firmy v CRM. null = zakaznika se nepodarilo dohledat. */ + id: string | null; + company: string; + /** Konkretni clovek, ktery pozadavek poslal. */ + contact: string; + /** Adresa nebo cislo, odkud to prislo a kam se odpovida. */ + reply: string; +} + +/** Odkaz na resitele v odpovedi API. Uvnitr se drzi jen jeho ID. */ +export interface TicketAssignee { + id: string; + name: string; +} + +export type TraceStatus = 'ok' | 'error' | 'skipped' | 'info'; +export type TraceKind = 'trigger' | 'action' | 'condition' | 'note'; + +/** Jeden radek v logu ticketu. Strom se sklada pres `parentId`. */ +export interface TicketTraceEntry { + id: string; + /** null = zaznam v hlavni sekvenci. */ + parentId: string | null; + kind: TraceKind; + /** Ktera sluzba to byla. null u poznamek a podminek. */ + serviceId: string | null; + operationId: string | null; + label: string; + status: TraceStatus; + /** Co sluzba vratila. Tohle je duvod, proc log existuje. */ + response: string | null; + durationMs: number | null; + at: string; +} + +/** Prichozi udalost k ticketu. Fakt zvenku, na rozdil od radku logu. */ +export interface TicketEvent { + id: string; + /** Typ od odesilatele, napr. `order.created`, `email.sent`. */ + type: string; + /** Kdo to poslal: `webhook`, `portal`, ID automatizace. */ + source: string; + /** Popisek do casove osy. */ + label: string; + /** Cela prijata data. Nic se nezahazuje. */ + payload: Record; + at: string; + /** Kolikrat prislo totez. Vic nez 1 znamena, ze odesilatel posila opakovane. */ + repeats: number; + /** Kdy prisla naposledy. Same jako `at`, dokud se neopakuje. */ + lastAt: string; +} + +export interface Ticket { + id: string; + /** Firma, ktere ticket patri. Hranice viditelnosti. */ + tenantId: string; + /** Firma, ze ktere pozadavek prisel pres helpdesk. `null` u beznych ticketu. */ + helpdeskSourceId: string | null; + /** + * ID u odesilatele, napr. cislo objednavky. Unikatni v ramci firmy. + * Dalsi zprava se stejnym ID se navesi na tenhle ticket. + */ + externalId: string | null; + /** Odkud externi ID pochazi. Jen informace, do klice nevstupuje. */ + externalSource: string | null; + subject: string; + /** Cely text pozadavku. Prazdny = krok "Zalozit ticket" obsah nenaplnil. */ + body: string; + /** Odkaz na zdrojovou zpravu u poskytovatele. null u rucne zalozenych. */ + sourceRef: string | null; + channel: TicketChannel; + customer: TicketCustomer; + status: TicketStatus; + priority: TicketPriority; + /** Kdo ma ticket u sebe. null = ceka ve fronte. */ + assignee: TicketAssignee | null; + /** Skupina resitelu, ktera ma ticket u sebe. */ + assigneeGroupId: string | null; + /** Typ ticketu. Rozhoduje o vlastnich polich a o tom, ktere akce se ukazou. */ + typeId: string | null; + /** Hodnoty vlastnich poli typu. Klic je `TicketTypeField.key`. */ + fields: Record; + /** + * Je vyrizeny? Nastavuje se vyslovne, neodvozuje se ze stavu. + * Fronta, vytizeni i statistiky se ptaji na tenhle priznak. + */ + closed: boolean; + /** Volne tagy. Akce se muze vazat na typ nebo na tag. */ + tags: string[]; + /** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */ + automationId: string | null; + /** Ucet, ktery ticket zalozil rucne. null u ticketu z automatizace. */ + createdById: string | null; + /** Kdy se ticketu prvne nekdo venoval. */ + firstResponseAt: string | null; + /** Kdy byl vyresen. Znovuotevreni to vynuluje. */ + resolvedAt: string | null; + /** Kdo ho vyresil. */ + resolvedById: string | null; + /** Kolikrat se vratil z vyreseno zpatky do prace. */ + reopenCount: number; + createdAt: string; + updatedAt: string; +} + +export interface TicketDetail extends Ticket { + trace: TicketTraceEntry[]; + /** Prichozi udalosti, nejstarsi nahore. */ + events: TicketEvent[]; +} + +/** Odpoved seznamu ticketu. `meId` rika, ktery resitel je prihlaseny uzivatel. */ +export interface TicketListResponse { + items: Ticket[]; + /** Kolik ticketu odpovida filtru, bez ohledu na strankovani. */ + total: number; + /** Stavy, ktere se v datech vyskytuji. Filtr z nich sklada nabidku. */ + statuses: string[]; + meId: string | null; + /** Pohled, ktery server skutecne pouzil. */ + scope: TicketScope; + /** Firma, na kterou je pohled zamereny. null u pohledu `all`. */ + tenantId: string | null; +} + +// ------------------------------------------------------------ typy ticketu + +/** Vlastni pole typu ticketu. */ +export interface TicketTypeField { + /** Stabilni, odkazuji se na nej podminky. Prejmenovani je nerozbije. */ + id: string; + /** Pouziva se v sablonach jako `{{orderNumber}}`. */ + key: string; + label: string; + type: FieldType; + required: boolean; + options?: Array<{ value: string; label: string }>; + hint?: string; +} + +/** Typ ticketu: za nim stoji vlastni pole, stavy a akce. */ +export interface TicketType extends TenantEntity { + tenantId: string; + /** Kratky klic, napr. `order`. Pouziva se ve filtrech a podminkach. */ + key: string; + name: string; + /** Klic ikony, mapuje se na klientovi. */ + icon: string; + /** Vlastni stavy. Prazdne = vychozi doporucena ctverice. */ + statuses: string[]; + fields: TicketTypeField[]; +} + +// ------------------------------------------------------- vytizeni a vykon + +export interface WorkloadRow { + person: Person; + /** Nevyresene tickety. */ + open: number; + /** Vcetne vyresenych. */ + total: number; + critical: number; + /** Nejstarsi nevyreseny ticket. null = nic nevisi. */ + oldestOpenAt: string | null; + /** true = ma vic nevyrizenych, nez je jeho kapacita. */ + overloaded: boolean; +} + +/** Prehled nad firmou: kdo co ma u sebe. */ +export interface Workload { + rows: WorkloadRow[]; + unassigned: number; + openTotal: number; +} + +/** Vykon jednoho resitele: vedle poctu i casy a vraceni. */ +export interface AgentStatsRow { + personId: string; + name: string; + /** Kolik vyresil za sledovane obdobi. */ + resolved: number; + /** Kolik ma prave ted nevyrizenych. */ + open: number; + /** Kolik z nevyrizenych je vyssi priority. */ + critical: number; + /** Medianovy cas do vyreseni v milisekundach. null = nic nevyresil. */ + medianResolveMs: number | null; + /** Medianovy cas do prvni reakce. null = nikde nereagoval. */ + medianResponseMs: number | null; + /** Kolikrat se mu ticket vratil z vyreseno zpatky. */ + reopened: number; + /** Nejstarsi nevyrizeny, aby bylo videt, co lezi. */ + oldestOpenAt: string | null; + /** Ma vic prace, nez unese. */ + overloaded: boolean; +} diff --git a/src/shared/users.ts b/src/shared/users.ts new file mode 100644 index 0000000..16f791b --- /dev/null +++ b/src/shared/users.ts @@ -0,0 +1,44 @@ +/** + * Ucet portalu tak, jak ho vidi klient. Hash hesla tu neni a nikdy nebude: + * serverovy `User` v src/types.ts ho ma navic a na klienta se nikdy neposila. + * + * Uzivatel muze patrit do vic firem najednou (napr. externista, ktery dela + * servicedesk dvema klientum). Proto `memberships`, ne jedno `tenantId`. + */ + +/** + * Clenstvi ve firme. + * + * `roleIds` je seznam, ne jedna role. Duvod: `'admin' | 'agent'` na ucetni, + * skladnika a vedouciho nestacilo a pridavat dalsi hodnoty do unionu je slepa + * ulicka, kazdy klient chce jine. Role jsou proto zaznamy, viz `data/permissions.ts`. + * + * Odkaz smi byt ID role, nebo klic systemove role (`admin`, `agent`), aby slo + * zapsat oboji a stara data dal fungovala. + */ +export interface Membership { + tenantId: string; + roleIds: string[]; + /** + * Vidi vsechny tickety firmy, ne jen svoje a svych sekci. + * + * Je to **postaveni uctu ve firme**, ne vlastnost resitele, proto to visi + * tady a ne na `Person`: clovek muze byt ve dvou firmach jednou reditel + * a jednou brigadnik. + * + * `undefined` u zaznamu ulozenych driv. Co pak plati, resi `visibilityFor` + * v data/access.ts - nemuze to byt tise `false`, jinak by pri nasazeni + * kazdemu spravci zmizela vetsina ticketu. + */ + seesAllTenant?: boolean; +} + +/** Verze uzivatele bezpecna pro odeslani na klienta. */ +export interface PublicUser { + id: string; + email: string; + name: string; + /** Vidi napric vsemi firmami a muze mezi nimi prepinat. */ + platformAdmin: boolean; + memberships: Membership[]; +} diff --git a/src/shared/widgets.ts b/src/shared/widgets.ts new file mode 100644 index 0000000..8fd19c0 --- /dev/null +++ b/src/shared/widgets.ts @@ -0,0 +1,174 @@ +/** + * Widgety prehledu: katalog, rozlozeni a data vlastnich widgetu. + */ + +import type { AgentStatsRow, TicketChannel, TicketStatus } from './tickets.js'; + +export type WidgetSize = 'third' | 'half' | 'full'; + +/** Jak se vlastni widget kresli. Odkud bere data, rika `WidgetSource`. */ +export type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge'; + +/** + * Pevny widget z katalogu. Vlastni widget pouziva `WidgetRender`, protoze + * u nej je kresleni a zdroj dat rozdelene. + */ +export type WidgetKind = + | 'stat' + | 'chart' + | 'ticketList' + | 'incidentList' + | 'workload' + /** Vykon resitelu. Data chodi z `/widget-data`, stejne jako u vlastnich. */ + | 'agents' + | WidgetRender; + +/** Jen u `stat`: ktere cislo ze souhrnu ukazat. Klic odpovida poli v `/summary`. */ +export type WidgetMetric = + | 'openTickets' + | 'activeIncidents' + | 'activeAutomations' + | 'runsToday' + | 'savedHoursMonth' + | 'uptime'; + +export interface WidgetDefinition { + id: string; + name: string; + description: string; + kind: WidgetKind; + /** Ktere sirky ma smysl nabizet. */ + sizes: WidgetSize[]; + defaultSize: WidgetSize; + metric?: WidgetMetric; +} + +/** Podle ceho se da seskupovat. */ +export type WidgetGroupBy = + | 'assignee' + | 'group' + /** Stav ticketu. Volny retezec, u ticketu z cizich aplikaci jejich vlastni. */ + | 'status' + | 'type' + | 'tag' + | 'channel'; + +export type WidgetPeriod = 'today' | '7d' | '30d' | 'month' | 'all'; + +/** Filtr nad tickety. Tentyz, ktery umi `GET /api/dashboard/tickets`. */ +export interface WidgetTicketFilter { + status?: TicketStatus[]; + channel?: TicketChannel[]; + typeIds?: string[]; + tags?: string[]; + /** `me` = prihlaseny, `unassigned` = fronta, jinak ID resitelu. */ + assignee?: string[]; + groupIds?: string[]; + period?: WidgetPeriod; + /** Jen vyrizene, nebo jen nevyrizene. Nevyplnene = oboji. */ + closed?: boolean; +} + +/** Odkud vlastni widget bere data. Uzivatel nepise dotazy, vybira z filtru. */ +export type WidgetSource = + | { kind: 'ticketCount'; filter: WidgetTicketFilter; groupBy?: WidgetGroupBy } + | { kind: 'ticketList'; filter: WidgetTicketFilter; limit: number } + | { kind: 'ticketSeries'; filter: WidgetTicketFilter; bucket: 'day' | 'week' } + | { kind: 'workload'; groupIds?: string[] } + /** Vykon resitelu. `period` plati jen na vyresene. */ + | { kind: 'agentStats'; period?: WidgetPeriod } + /** + * Data z napojene sluzby. Vola se tentyz skript jako v kroku automatizace, + * vysledek se cachuje (`ttlSec`). + */ + | { + kind: 'connector'; + serviceId: string; + operationId: string; + /** null = vychozi konektor firmy pro tuhle sluzbu. */ + connectorId: string | null; + inputs: Record; + /** Cesta ve vystupu skriptu. Prazdne = cely vystup. */ + path?: string; + /** U seznamu: co v radku ukazat jako popisek a co jako hodnotu. */ + labelPath?: string; + valuePath?: string; + /** Jak dlouho se vysledek drzi. Vychozi 300 s, nejmene 30. */ + ttlSec?: number; + }; + +/** Polozka katalogu tak, jak ji vidi klient. Vlastni widget nese i zdroj dat. */ +export interface CatalogWidget extends WidgetDefinition { + /** true = widget si firma nadefinovala sama, data chodi z `/widget-data`. */ + custom: boolean; + render?: WidgetRender; + source?: WidgetSource; + /** Porovnavaci hodnota u ukazatele. */ + target?: number; +} + +// ---------------------------------------------------------------- rozlozeni + +export interface LayoutItem { + /** Instance widgetu. Tentyz widget muze byt na dashboardu vickrat. */ + id: string; + widgetId: string; + size: WidgetSize; +} + +export interface LayoutResponse { + tenantId: string; + items: LayoutItem[]; + /** false = uzivatel kouka na vychozi rozlozeni, nic si neulozil. */ + custom: boolean; +} + +// --------------------------------------------------------------------- data + +/** + * Hodnota vlastniho widgetu ze serveru. Podle `kind` se pozna, co s tim. + * `href` sklada server, protoze on jediny zna filtr widgetu. + */ +export type WidgetValue = + | { kind: 'number'; value: number; href?: string } + | { + kind: 'groups'; + rows: Array<{ key: string; label: string; value: number; href?: string }>; + } + | { kind: 'series'; points: Array<{ date: string; value: number }>; href?: string } + | { + kind: 'tickets'; + items: Array<{ id: string; subject: string; status: TicketStatus; assignee: string | null }>; + href?: string; + } + | { + kind: 'workload'; + rows: Array<{ personId: string; name: string; open: number; overCapacity: boolean; href: string }>; + href?: string; + } + | { kind: 'agents'; rows: AgentStatsRow[] } + /** Vysledek z konektoru. `stale` rika, ze je to z mezipameti serveru. */ + | { + kind: 'external'; + value: number | null; + text: string | null; + rows: Array<{ key: string; label: string; value: number }>; + at: string; + stale: boolean; + /** Kdy se data z konektoru natahnou znovu. */ + nextAt: string; + /** Jak dlouho se drzi, v sekundach. Nastavuje se u widgetu. */ + ttlSec: number; + }; + +export interface WidgetResult { + id: string; + ok: boolean; + value?: WidgetValue; + error?: string; +} + +/** Odpoved davkoveho nacteni dat vlastnich widgetu. */ +export interface WidgetDataResponse { + items: WidgetResult[]; +} diff --git a/src/types.ts b/src/types.ts index f5fd194..97a835e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,32 +6,10 @@ * `platformAdmin` - to je nase pravo, ne zakaznicke. */ -/** - * Clenstvi ve firme. - * - * `roleIds` je seznam, ne jedna role. Duvod: `'admin' | 'agent'` na ucetni, - * skladnika a vedouciho nestacilo a pridavat dalsi hodnoty do unionu je slepa - * ulicka, kazdy klient chce jine. Role jsou proto zaznamy, viz `data/permissions.ts`. - * - * Odkaz smi byt ID role, nebo klic systemove role (`admin`, `agent`), aby slo - * zapsat oboji a stara data dal fungovala. - */ -export interface Membership { - tenantId: string; - roleIds: string[]; - /** - * Vidi vsechny tickety firmy, ne jen svoje a svych sekci. - * - * Je to **postaveni uctu ve firme**, ne vlastnost resitele, proto to visi - * tady a ne na `Person`: clovek muze byt ve dvou firmach jednou reditel - * a jednou brigadnik. - * - * `undefined` u zaznamu ulozenych driv. Co pak plati, resi `visibilityFor` - * v data/access.ts - nemuze to byt tise `false`, jinak by pri nasazeni - * kazdemu spravci zmizela vetsina ticketu. - */ - seesAllTenant?: boolean; -} +import type { Membership, PublicUser } from './shared/users.js'; + +/** Clenstvi a verejny tvar uctu jsou sdilene s webem, viz src/shared/users.ts. */ +export type { Membership, PublicUser }; export interface User { id: string; @@ -44,15 +22,6 @@ export interface User { memberships: Membership[]; } -/** Verze uzivatele bezpecna pro odeslani na klienta. */ -export interface PublicUser { - id: string; - email: string; - name: string; - platformAdmin: boolean; - memberships: Membership[]; -} - export interface JwtPayload { sub: string; email: string; @@ -66,6 +35,21 @@ export interface JwtPayload { writes?: boolean; } +/** + * Prava spocitana jednou za request, viz `middleware/tenant.ts`. + * + * Doplnek k `req.user` z `middleware/auth.ts`. Jen typ, zadny kod - proto + * import typu, ne modulu. + */ +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + access?: import('./data/access.js').Access; + } + } +} + export function toPublicUser(user: User): PublicUser { const { passwordHash: _passwordHash, ...rest } = user; return rest; diff --git a/vite.config.ts b/vite.config.ts index 2acee19..6bd12fc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -83,6 +83,8 @@ export default defineConfig({ // Musi zustat v souladu s "paths" ve web/tsconfig.json alias: { '@': path.resolve(here, 'web/src'), + // Typy API sdilene se serverem. Jen typy, do balicku se nic nedostane. + '@shared': path.resolve(here, 'src/shared'), }, }, server: { @@ -97,6 +99,8 @@ export default defineConfig({ // Server tuhle slozku obsluhuje jako statiku, viz src/index.ts. outDir: path.resolve(here, 'dist/public'), emptyOutDir: true, - sourcemap: true, + // Zadne mapy do produkce: dist/public se serviruje jako statika a mapy + // by byly verejne. Ve vyvoji je Vite dela sam. + sourcemap: false, }, }); diff --git a/web/src/App.tsx b/web/src/App.tsx index cf89279..a118d2f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,6 @@ import { Suspense, lazy } from 'react'; import { Route, Routes } from 'react-router-dom'; import { RequireAuth } from '@/auth/RequireAuth'; -import { DashboardLayout } from '@/components/dashboard/DashboardLayout'; import { PublicLayout } from '@/components/layout/PublicLayout'; import { Spinner } from '@/components/ui/Spinner'; @@ -14,6 +13,16 @@ import Login from '@/pages/Login'; import NotFound from '@/pages/NotFound'; import Services from '@/pages/Services'; +/* + * I ram portalu je liny. Byl importovany staticky, takze stream, toasty + * a cely sidebar lezely v balicku verejneho webu, i kdyz se navstevnik + * nikdy neprihlasil. + */ +const DashboardLayout = lazy(() => + import('@/components/dashboard/DashboardLayout').then((module) => ({ + default: module.DashboardLayout, + })), +); const Overview = lazy(() => import('@/pages/dashboard/Overview')); const Automations = lazy(() => import('@/pages/dashboard/Automations')); const AutomationDetail = lazy(() => import('@/pages/dashboard/AutomationDetail')); diff --git a/web/src/auth/AuthContext.tsx b/web/src/auth/AuthContext.tsx index 46de216..d04a292 100644 --- a/web/src/auth/AuthContext.tsx +++ b/web/src/auth/AuthContext.tsx @@ -1,25 +1,12 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; -import { apiFetch, getToken, setToken } from '@/lib/api'; +import { ApiError, AUTH_EXPIRED_EVENT, apiFetch, getToken, setToken } from '@/lib/api'; -/** - * Clenstvi ve firme. `roleIds` je seznam, protoze role uz nejsou dva pevne - * stavy - viz src/data/permissions.ts na serveru. - */ -export interface Membership { - tenantId: string; - roleIds: string[]; -} +import type { Membership, PublicUser } from '@shared/users'; -export interface AuthUser { - id: string; - email: string; - name: string; - /** Vidi napric vsemi firmami a muze mezi nimi prepinat. */ - platformAdmin: boolean; - /** Uzivatel muze patrit do vic firem, v kazde s jinou roli. */ - memberships: Membership[]; -} +/** Tvar uctu je sdileny se serverem, viz src/shared/users.ts. */ +export type { Membership }; +export type AuthUser = PublicUser; /** Kdyz je prihlaseni prepnute na jiny ucet, tady je na koho. */ export interface ImpersonationState { @@ -32,6 +19,11 @@ interface AuthState { user: AuthUser | null; /** true dokud probiha prvotni overeni ulozeneho tokenu */ loading: boolean; + /** + * true = prihlaseni vyprselo za behu. Stranka prihlaseni podle toho rekne + * proc je clovek zpatky na ni, misto aby to vypadalo jako chyba. + */ + expired: boolean; login: (email: string, password: string) => Promise; logout: () => Promise; /** Vyplnene jen pri prepnuti na jiny ucet. */ @@ -54,6 +46,23 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [impersonation, setImpersonation] = useState(null); + const [expired, setExpired] = useState(false); + + /* + * Vyprsely token hlasi `apiFetch` a stream udalosti okna. Tady se na ni + * zareaguje odhlasenim - RequireAuth pak posle na prihlaseni sam. + */ + useEffect(() => { + const onExpired = () => { + console.warn('[auth] prihlaseni vyprselo, odhlasuji'); + sessionStorage.removeItem(OWN_TOKEN_KEY); + setUser(null); + setImpersonation(null); + setExpired(true); + }; + window.addEventListener(AUTH_EXPIRED_EVENT, onExpired); + return () => window.removeEventListener(AUTH_EXPIRED_EVENT, onExpired); + }, []); // Pri startu zkusime obnovit session z ulozeneho tokenu. useEffect(() => { @@ -79,9 +88,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { } } } catch (err) { - // Expirovany/neplatny token - zahodime ho a pokracujeme jako neprihlaseny. - console.warn('[auth] obnoveni session selhalo, mazu token:', err); - setToken(null); + /* + * Token se zahazuje **jen kdyz ho server odmitl** (401, to uz udelal + * `apiFetch`). Vypadek site nebo 503 neznamena, ze prihlaseni + * neplati - smazat ho by cloveka odhlasilo kvuli chvilce bez spojeni. + */ + if (err instanceof ApiError && err.status === 401) { + console.warn('[auth] token uz neplati, mazu ho:', err); + setToken(null); + } else { + console.warn('[auth] obnoveni session selhalo, token nechavam:', err); + } if (!cancelled) setUser(null); } finally { if (!cancelled) setLoading(false); @@ -102,6 +119,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); setToken(data.token); setUser(data.user); + setExpired(false); }, []); const startImpersonation = useCallback(async (userId: string, allowWrites: boolean) => { @@ -149,8 +167,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); const value = useMemo( - () => ({ user, loading, login, logout, impersonation, startImpersonation, stopImpersonation }), - [user, loading, login, logout, impersonation, startImpersonation, stopImpersonation], + () => ({ + user, + loading, + expired, + login, + logout, + impersonation, + startImpersonation, + stopImpersonation, + }), + [user, loading, expired, login, logout, impersonation, startImpersonation, stopImpersonation], ); return {children}; diff --git a/web/src/auth/RequireAuth.tsx b/web/src/auth/RequireAuth.tsx index f027b7e..d408812 100644 --- a/web/src/auth/RequireAuth.tsx +++ b/web/src/auth/RequireAuth.tsx @@ -4,7 +4,7 @@ import { Spinner } from '@/components/ui/Spinner'; /** Obalka pro chranene routy. Neprihlaseneho posle na /prihlaseni a zapamatuje cil. */ export function RequireAuth() { - const { user, loading } = useAuth(); + const { user, loading, expired } = useAuth(); const location = useLocation(); if (loading) { @@ -16,7 +16,15 @@ export function RequireAuth() { } if (!user) { - return ; + // `reason` rekne prihlasovaci strance, ze clovek neprisel poprve, ale + // ze mu vyprselo prihlaseni - jinak by to vypadalo jako chyba portalu. + return ( + + ); } return ; diff --git a/web/src/components/ErrorBoundary.tsx b/web/src/components/ErrorBoundary.tsx index 2376401..7ff6193 100644 --- a/web/src/components/ErrorBoundary.tsx +++ b/web/src/components/ErrorBoundary.tsx @@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'; import { AlertTriangle, RotateCcw } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { buildInfo } from '@/config/version'; +import { getLocale, translate } from '@/i18n'; import { apiFetch } from '@/lib/api'; /** @@ -102,16 +103,16 @@ export class ErrorBoundary extends Component { if (!error) return this.props.children; if (this.props.fallback !== undefined) return this.props.fallback; + // Tridni komponenta nema hooky, preklad se bere primo z modulu. + const t = (key: Parameters[1]) => translate(getLocale(), key); + return (

- Tuhle část se nepodařilo vykreslit -

-

- Zbytek portálu funguje dál. Zkuste to znovu, a když to bude přetrvávat, - pošlete nám prosím tuhle hlášku. + {t('error.title')}

+

{t('error.text')}

{error.message}

); diff --git a/web/src/components/dashboard/AresTenantDialog.tsx b/web/src/components/dashboard/AresTenantDialog.tsx new file mode 100644 index 0000000..6cf10a9 --- /dev/null +++ b/web/src/components/dashboard/AresTenantDialog.tsx @@ -0,0 +1,353 @@ +import { Search } from 'lucide-react'; +import { useEffect, useState } from 'react'; +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 { Textarea } from '@/components/ui/form/Textarea'; +import { Modal } from '@/components/ui/Modal'; +import { Spinner } from '@/components/ui/Spinner'; +import { apiFetch } from '@/lib/api'; +import { useSubmit } from '@/lib/useSubmit'; + +/** + * Zalozeni firmy z registru ARES. + * + * Dva kroky v jednom dialogu: hledani (IC nebo nazev) a vyber osob. Osoby + * jsou soucasni clenove statutarnich organu a prokura; ARES e-maily nevede, + * takze kazda ma navrzeny nahradni `IC-poradi@placeholder.cz`, ktery jde + * prepsat rovnou tady, nebo pozdeji v Uzivatelich. Vybrani dostanou roli + * spravce firmy - rozhodnuti zadavatele, firma si dalsi lidi zalozi sama. + */ + +interface AresCompany { + ico: string; + name: string; + dic: string | null; + address: string; + legalForm: string; + /** Firma uz v portalu je. Druhe zalozeni server odmitne, tak se ani nenabizi. */ + existingTenantId: string | null; +} + +interface AresPerson { + name: string; + roles: string[]; + email: string; +} + +/** Radek vyberu: osoba plus to, co v ni clovek prepsal. */ +interface PersonRow extends AresPerson { + key: string; + selected: boolean; + /** Puvodni navrh, aby slo poznat, ze e-mail je nahradni. */ + placeholder: string; +} + +interface Created { + tenant: { id: string; name: string }; + users: Array<{ id: string; email: string; name: string }>; +} + +const PLACEHOLDER_SUFFIX = '@placeholder.cz'; + +export function AresTenantDialog({ + open, + onClose, + onCreated, +}: { + open: boolean; + onClose: () => void; + /** Zavola se po zalozeni, aby se seznam firem nacetl znovu. */ + onCreated: (tenantId: string) => void; +}) { + const [query, setQuery] = useState(''); + const [companies, setCompanies] = useState(null); + const [company, setCompany] = useState(null); + const [name, setName] = useState(''); + const [note, setNote] = useState(''); + const [persons, setPersons] = useState(null); + const [personsError, setPersonsError] = useState(null); + const [created, setCreated] = useState(null); + + // Pri novem otevreni zacit od zacatku, ne u minule firmy. + useEffect(() => { + if (!open) return; + setQuery(''); + setCompanies(null); + setCompany(null); + setName(''); + setNote(''); + setPersons(null); + setPersonsError(null); + setCreated(null); + }, [open]); + + const search = useSubmit(async () => { + const result = await apiFetch<{ companies: AresCompany[] }>( + `/api/dashboard/settings/ares/companies?query=${encodeURIComponent(query.trim())}`, + ); + setCompanies(result.companies); + setCompany(null); + setPersons(null); + }, 'Hledání v ARES selhalo.'); + + // Osoby se nacitaji az po vyberu firmy: je to druhe volani ven a u vysledku + // hledani podle nazvu by jich bylo deset naraz. + useEffect(() => { + if (!company) return; + let cancelled = false; + setPersons(null); + setPersonsError(null); + apiFetch<{ persons: AresPerson[] }>(`/api/dashboard/settings/ares/companies/${company.ico}/persons`) + .then((result) => { + if (cancelled) return; + setPersons( + result.persons.map((person, index) => ({ + ...person, + key: `${index}-${person.email}`, + selected: true, + placeholder: person.email, + })), + ); + }) + .catch((err: unknown) => { + if (cancelled) return; + console.warn('[ares] osoby se nenacetly:', err); + setPersonsError(err instanceof Error ? err.message : 'Osoby se nepodařilo načíst.'); + setPersons([]); + }); + return () => { + cancelled = true; + }; + }, [company]); + + const create = useSubmit(async () => { + if (!company) throw new Error('Nejdřív vyberte firmu.'); + const selected = (persons ?? []).filter((row) => row.selected); + const result = await apiFetch('/api/dashboard/settings/ares/tenants', { + method: 'POST', + body: { + ico: company.ico, + name: name.trim() || undefined, + note: note.trim() || undefined, + persons: selected.map((row) => ({ name: row.name.trim(), email: row.email.trim() })), + }, + }); + setCreated(result); + onCreated(result.tenant.id); + }, 'Firmu se nepodařilo založit.'); + + const updateRow = (key: string, patch: Partial) => + setPersons((rows) => (rows ?? []).map((row) => (row.key === key ? { ...row, ...patch } : row))); + + const selectAll = (selected: boolean) => + setPersons((rows) => (rows ?? []).map((row) => ({ ...row, selected }))); + + const selectedCount = (persons ?? []).filter((row) => row.selected).length; + + return ( + + {created ? ( +
+

+ Firma {created.tenant.name} je založená. +

+ {created.users.length > 0 ? ( +
+

Založené účty, všechny jako správce firmy:

+
    + {created.users.map((user) => ( +
  • + {user.name} + {user.email} + {user.email.endsWith(PLACEHOLDER_SUFFIX) && ( + náhradní e-mail, nahraďte skutečným + )} +
  • + ))} +
+

+ Heslo je náhodné. Nastavíte ho v Uživatelích, nebo člověku pošlete pozvánku. +

+
+ ) : ( +

Bez účtů. Lidi můžete přidat v Uživatelích.

+ )} +
+ +
+
+ ) : ( +
+
{ + event.preventDefault(); + void search.submit(); + }} + > + + setQuery(event.target.value)} + placeholder="27074358 nebo Asseco" + autoFocus + /> + + +
+ {search.error &&

{search.error}

} + + {companies && companies.length === 0 && ( +

ARES nic nenašel. Zkuste přesné IČ.

+ )} + + {companies && companies.length > 0 && ( +
    + {companies.map((item) => { + const active = company?.ico === item.ico; + const taken = item.existingTenantId !== null; + return ( +
  • + +
  • + ); + })} +
+ )} + + {company && ( +
+
+ + setName(event.target.value)} /> + + + + + + + + +