From 22dda2d1396bd753e073558a9e4eba7fd1f9fa05 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:11:02 +0200 Subject: [PATCH] Struktura podle zasad: rozdeleni souboru, lint, testy, prisny TypeScript Projekt srovnan se zasadami v D:\GitHubRepository\CLAUDE.md bez zmeny chovani. Struktura: scripts/ (skripty konektoru) -> connectors/, src/scripts -> src/runtime/scripts; src/index.ts jen startuje, novy src/app.ts s createApp(); routes/dashboard.ts a routes/settings.ts rozdeleny do slozek; openapi.ts rozdelen na openapi/{index,helpers,components} a paths/* (98 cest overeno shodnych); ticketStore, automationStore a services jsou fasady nad slozkami data/tickets, data/automations a data/services/catalog. process.env se cte jen v config.ts. Web: hooky v hooks/, sdilena ui/Table a ui/ServiceIcon, surove inputy nahrazeny komponentami, sedm velkych souboru rozdeleno. Nastroje: eslint (typescript-eslint, react-hooks v7), prettier, editorconfig, nvmrc, .env.example, vitest; skripty lint, format, test. Lint je cisty bez jedineho eslint-disable (nove hooky useLatest a useSyncFromSource, odvozeny stav misto setState v effectu). noUncheckedIndexedAccess v obou tsconfig, 84 mist zuzeno bez non-null operatoru; odhalilo zalohu backoffu fronty pri nule pokusu a Retry-After NaN pri max 0. Cely kod naformatovan prettierem. Testy: 8 souboru, 105 testu (prava, viditelnost, podminky a opakovani v executoru, redaktor tajemstvi, sitove guardy, migrace resitelu, tickety, health a prihlaseni pres supertest). Testy odhalily dve chyby ve vyhodnoceni podminek, obe opravene: chybejici castka se porovnavala jako nula a podminka nad vystupem druheho kroku cetla hodnotu prvniho se stejnym nazvem. Pojmenovane konstanty misto magickych hodnot, ctx.util.base64 pro skripty konektoru, README a dokumentace aktualizovany vcetne znamych odchylek. --- .editorconfig | 17 + .env.example | 66 + .nvmrc | 1 + .prettierignore | 7 + .prettierrc | 7 + Dockerfile | 2 +- README.md | 19 +- {scripts => connectors}/_sablona.js | 6 +- .../csob.list-transactions.js | 13 +- {scripts => connectors}/ga4.run-report.js | 0 .../google-ads.campaign-report.js | 0 .../google.append-sheet-row.js | 0 {scripts => connectors}/google.send-email.js | 24 +- .../idoklad.create-contact.js | 0 .../idoklad.create-invoice-from-object.js | 0 .../idoklad.create-issued-invoice.js | 0 .../idoklad.find-contact.js | 0 .../idoklad.find-issued-invoice.js | 0 .../idoklad.get-issued-invoice.js | 0 .../idoklad.register-payment.js | 4 +- .../idoklad.send-invoice-email.js | 0 .../idoklad.upsert-contact.js | 0 {scripts => connectors}/meta-ads.insights.js | 0 .../microsoft365.create-event.js | 0 .../microsoft365.send-mail.js | 0 .../openai.ask-about-file.js | 0 {scripts => connectors}/openai.chat.js | 0 {scripts => connectors}/openai.list-models.js | 0 .../openai.transcribe-audio.js | 0 {scripts => connectors}/openai.upload-file.js | 0 .../ppl.create-shipment.js | 0 {scripts => connectors}/ppl.track.js | 4 +- {scripts => connectors}/raynet.create-lead.js | 0 .../raynet.find-company.js | 0 .../raynet.upsert-contact.js | 0 .../sap-bo.find-business-partner.js | 0 {scripts => connectors}/sap-bo.list-orders.js | 6 +- .../search-console.run-report.js | 3 +- .../sklik.campaign-report.js | 0 .../transcription.transcribe.js | 0 .../transform.map-fields.js | 0 {scripts => connectors}/transform.to-json.js | 4 +- {scripts => connectors}/transform.to-xml.js | 0 documentation/00-pro-programatory.md | 51 +- documentation/01-prehled-a-stav.md | 17 + documentation/02-appfactory-proxy.md | 2 +- documentation/03-architektura-a-mapa-kodu.md | 124 +- documentation/04-api.md | 4 +- documentation/05-dashboard-a-builder.md | 12 +- documentation/06-tickety.md | 6 +- documentation/07-firmy-a-prava.md | 12 +- documentation/08-dashboard-widgety.md | 10 + documentation/09-navrh-rozsireni.md | 8 +- documentation/11-skripty-konektoru.md | 65 +- documentation/12-sluzby-a-konektory.md | 6 +- documentation/13-transformace-dat.md | 10 +- documentation/14-databaze.md | 4 +- documentation/15-rejstrik-funkci.md | 92 +- documentation/16-monetizace.md | 2 +- documentation/17-nastaveni-a-prava.md | 2 +- documentation/18-ticketovaci-system.md | 2 +- documentation/19-kapacita-200-firem.md | 2 +- documentation/20-fronta-a-runtime.md | 20 + documentation/21-realne-sluzby.md | 2 +- documentation/24-mcp-konektory.md | 2 +- documentation/25-navrh-pristupny-portal.md | 11 +- documentation/99-zmeny.md | 113 + eslint.config.js | 48 + package-lock.json | 1875 ++++++++- package.json | 18 +- src/app.ts | 297 ++ src/ares/client.ts | 18 +- src/config.ts | 17 +- src/data/automationStore.ts | 1349 +------ src/data/automations/index.ts | 64 + src/data/automations/model.ts | 88 + src/data/automations/persist.ts | 46 + src/data/automations/remap.ts | 33 + src/data/automations/runs.ts | 59 + src/data/automations/seed.ts | 165 + src/data/automations/seedDemo.ts | 467 +++ src/data/automations/state.ts | 33 + src/data/automations/store.ts | 241 ++ src/data/automations/validation.ts | 227 ++ src/data/automations/webhook.ts | 84 + src/data/connectors/postgres.ts | 16 +- src/data/egressIp.ts | 1 + src/data/mock.ts | 5 +- src/data/services.ts | 2828 +------------- src/data/services/catalog/ai.ts | 179 + src/data/services/catalog/analytics.ts | 273 ++ src/data/services/catalog/crm.ts | 114 + src/data/services/catalog/email.ts | 185 + src/data/services/catalog/finance.ts | 280 ++ src/data/services/catalog/incident.ts | 55 + src/data/services/catalog/index.ts | 50 + src/data/services/catalog/logistics.ts | 125 + src/data/services/catalog/mcp.ts | 160 + src/data/services/catalog/messaging.ts | 50 + src/data/services/catalog/office.ts | 160 + src/data/services/catalog/polstryn.ts | 53 + src/data/services/catalog/social.ts | 142 + src/data/services/catalog/ticket.ts | 520 +++ src/data/services/catalog/tools.ts | 153 + src/data/services/catalog/triggers.ts | 92 + src/data/services/index.ts | 334 ++ src/data/store/local.ts | 11 +- src/data/templates.ts | 2 +- src/data/ticketStore.ts | 1852 +-------- src/data/tickets/index.ts | 71 + src/data/tickets/intake.ts | 332 ++ src/data/tickets/model.ts | 144 + src/data/tickets/persist.ts | 104 + src/data/tickets/queries.ts | 198 + src/data/tickets/remap.ts | 33 + src/data/tickets/seed.ts | 433 +++ src/data/tickets/state.ts | 70 + src/data/tickets/stats.ts | 152 + src/data/tickets/store.ts | 405 ++ src/data/tickets/trace.ts | 95 + src/db/migrate.ts | 2 +- src/index.ts | 284 +- src/mail/smtp.ts | 21 +- src/mcp/auth.ts | 26 +- src/mcp/client.ts | 24 +- src/mcp/dialect.ts | 3 +- src/mcp/easyweb/crypto.ts | 5 +- src/mcp/easyweb/device.ts | 5 +- src/mcp/easyweb/session.ts | 13 +- src/mcp/schema.ts | 16 +- src/middleware/asyncHandler.ts | 10 +- src/middleware/auth.ts | 4 +- src/middleware/rateLimit.ts | 4 +- src/middleware/tenant.ts | 8 +- src/net/guard.ts | 7 +- src/openapi.ts | 3370 ----------------- src/openapi/components.ts | 612 +++ src/openapi/helpers.ts | 140 + src/openapi/index.ts | 72 + src/openapi/paths/admin.ts | 87 + src/openapi/paths/auth.ts | 60 + src/openapi/paths/automations.ts | 177 + src/openapi/paths/connectors.ts | 396 ++ src/openapi/paths/contact.ts | 39 + src/openapi/paths/dashboard.ts | 355 ++ src/openapi/paths/helpdesk.ts | 71 + src/openapi/paths/invites.ts | 108 + src/openapi/paths/ops.ts | 68 + src/openapi/paths/scripts.ts | 249 ++ src/openapi/paths/settings.ts | 372 ++ src/openapi/paths/tickets.ts | 564 +++ src/openapi/paths/webhook.ts | 132 + src/routes/admin.ts | 157 +- src/routes/ares.ts | 63 +- src/routes/auth.ts | 8 +- src/routes/connectors.ts | 51 +- src/routes/contact.ts | 8 +- src/routes/crud.ts | 23 +- src/routes/dashboard.ts | 1352 ------- src/routes/dashboard/automations.ts | 468 +++ src/routes/dashboard/clientCrash.ts | 134 + src/routes/dashboard/incidents.ts | 98 + src/routes/dashboard/index.ts | 74 + src/routes/dashboard/intake.ts | 104 + src/routes/dashboard/layout.ts | 95 + src/routes/dashboard/misc.ts | 102 + src/routes/dashboard/notifications.ts | 56 + src/routes/dashboard/people.ts | 71 + src/routes/dashboard/shared.ts | 29 + src/routes/dashboard/tickets.ts | 268 ++ src/routes/invites.ts | 15 +- src/routes/scripts.ts | 15 +- src/routes/settings.ts | 1155 ------ src/routes/settings/actions.ts | 174 + src/routes/settings/catalog.ts | 24 + src/routes/settings/features.ts | 100 + src/routes/settings/groups.ts | 46 + src/routes/settings/index.ts | 51 + src/routes/settings/people.ts | 307 ++ src/routes/settings/roles.ts | 53 + src/routes/settings/shared.ts | 10 + src/routes/settings/tenants.ts | 79 + src/routes/settings/ticketTypes.ts | 90 + src/routes/settings/users.ts | 252 ++ src/routes/settings/widgets.ts | 103 + src/routes/ticketActions.ts | 19 +- src/routes/webhook.ts | 38 +- src/routes/widgetData.ts | 101 +- src/runtime/builtinSteps.ts | 53 +- src/runtime/executor.ts | 37 +- src/runtime/queue.ts | 20 +- src/runtime/sandbox.ts | 4 +- src/{ => runtime}/scripts/connections.ts | 10 +- src/{ => runtime}/scripts/http.ts | 23 +- src/{ => runtime}/scripts/lookup.ts | 2 +- src/{ => runtime}/scripts/manifest.ts | 12 +- src/{ => runtime}/scripts/mapping.ts | 28 +- src/{ => runtime}/scripts/registry.ts | 20 +- src/{ => runtime}/scripts/runner.ts | 14 +- src/{ => runtime}/scripts/types.ts | 9 +- src/{ => runtime}/scripts/util.ts | 18 +- src/{ => runtime}/scripts/values.ts | 9 +- src/runtime/worker.ts | 28 +- src/shared/scripts.ts | 7 +- src/shared/tickets.ts | 8 +- src/shared/widgets.ts | 8 +- tests/data/access.test.ts | 220 ++ tests/data/migratePeople.test.ts | 186 + tests/data/permissions.test.ts | 147 + tests/data/tickets.test.ts | 201 + tests/net/guard.test.ts | 114 + tests/routes/health.test.ts | 110 + tests/runtime/executor.test.ts | 556 +++ tests/runtime/scripts/util.test.ts | 98 + tests/setup.ts | 24 + tests/tsconfig.json | 10 + tsconfig.json | 1 + vitest.config.ts | 23 + web/src/components/ErrorBoundary.tsx | 7 +- .../components/dashboard/AresTenantDialog.tsx | 135 +- .../components/dashboard/DashboardLayout.tsx | 33 +- web/src/components/dashboard/EntityAdmin.tsx | 363 +- web/src/components/dashboard/EntityForm.tsx | 307 ++ web/src/components/dashboard/ErrorDetail.tsx | 5 +- .../dashboard/EventStreamProvider.tsx | 19 +- web/src/components/dashboard/EventToasts.tsx | 11 +- web/src/components/dashboard/GroupPanel.tsx | 23 +- web/src/components/dashboard/InvitePanel.tsx | 56 +- .../components/dashboard/LiveIndicator.tsx | 11 +- .../components/dashboard/NewTicketDialog.tsx | 21 +- web/src/components/dashboard/QuickAssign.tsx | 2 +- web/src/components/dashboard/RolesAdmin.tsx | 71 +- web/src/components/dashboard/RunsChart.tsx | 15 +- web/src/components/dashboard/StatusBadge.tsx | 11 +- .../components/dashboard/TenantScripts.tsx | 20 +- .../components/dashboard/TicketActions.tsx | 52 +- web/src/components/dashboard/TicketBody.tsx | 9 +- web/src/components/dashboard/TicketCard.tsx | 9 +- web/src/components/dashboard/TicketTable.tsx | 2 +- web/src/components/dashboard/TicketTrace.tsx | 20 +- .../components/dashboard/TicketTypesAdmin.tsx | 101 +- .../components/dashboard/flow/ActionCard.tsx | 18 +- .../dashboard/flow/ConditionCard.tsx | 4 + .../components/dashboard/flow/FlowCanvas.tsx | 65 +- .../dashboard/flow/MappingEditor.tsx | 42 +- .../components/dashboard/flow/ModelTree.tsx | 77 + .../components/dashboard/flow/SampleBody.tsx | 140 + .../components/dashboard/flow/StepInputs.tsx | 93 +- .../components/dashboard/flow/StepPicker.tsx | 53 +- .../dashboard/flow/TriggerConfig.tsx | 416 +- .../dashboard/flow/WebhookCalls.tsx | 120 + .../dashboard/scripts/CodeEditor.tsx | 119 + .../dashboard/scripts/TestPanel.tsx | 223 ++ .../dashboard/settings/AuditView.tsx | 90 + .../dashboard/settings/FeaturesAdmin.tsx | 148 + .../components/dashboard/settings/types.ts | 17 + .../dashboard/widgets/CustomWidget.tsx | 27 +- .../components/dashboard/widgets/EditBar.tsx | 91 + .../dashboard/widgets/WidgetCard.tsx | 34 +- web/src/components/home/CallToAction.tsx | 4 +- web/src/components/home/Hero.tsx | 20 +- web/src/components/home/Process.tsx | 56 +- web/src/components/layout/Footer.tsx | 4 +- web/src/components/layout/Logo.tsx | 7 +- web/src/components/layout/Navbar.tsx | 22 +- web/src/components/ui/Card.tsx | 3 +- web/src/components/ui/Modal.tsx | 12 +- web/src/components/ui/ServiceIcon.tsx | 14 + web/src/components/ui/Table.tsx | 63 + web/src/{lib => hooks}/useApiQuery.ts | 58 +- web/src/hooks/useLatest.ts | 20 + web/src/hooks/useMediaQuery.ts | 30 + web/src/{lib => hooks}/usePageMeta.ts | 0 web/src/{lib => hooks}/useSubmit.ts | 44 +- web/src/hooks/useSyncFromSource.ts | 23 + web/src/{lib => hooks}/useUnsavedChanges.ts | 0 web/src/i18n/cs.ts | 12 +- web/src/i18n/en.ts | 15 +- web/src/lib/eventStream.ts | 11 +- web/src/lib/exampleBody.ts | 96 + web/src/lib/flow.ts | 7 +- web/src/lib/format.ts | 14 +- web/src/lib/useMediaQuery.ts | 27 - web/src/pages/About.tsx | 2 +- web/src/pages/Contact.tsx | 13 +- web/src/pages/Home.tsx | 2 +- web/src/pages/Invite.tsx | 24 +- web/src/pages/Login.tsx | 9 +- web/src/pages/NotFound.tsx | 2 +- web/src/pages/Services.tsx | 2 +- web/src/pages/dashboard/ActionDetail.tsx | 43 +- web/src/pages/dashboard/Actions.tsx | 211 +- web/src/pages/dashboard/AutomationDetail.tsx | 206 +- web/src/pages/dashboard/Automations.tsx | 12 +- web/src/pages/dashboard/Connectors.tsx | 26 +- web/src/pages/dashboard/Helpdesk.tsx | 75 +- web/src/pages/dashboard/IncidentDetail.tsx | 23 +- web/src/pages/dashboard/Incidents.tsx | 17 +- web/src/pages/dashboard/Overview.tsx | 176 +- web/src/pages/dashboard/People.tsx | 264 +- web/src/pages/dashboard/PersonDetail.tsx | 4 +- web/src/pages/dashboard/Scripts.tsx | 386 +- web/src/pages/dashboard/Services.tsx | 22 +- web/src/pages/dashboard/Settings.tsx | 306 +- web/src/pages/dashboard/TicketDetail.tsx | 35 +- web/src/pages/dashboard/Tickets.tsx | 69 +- web/src/pages/dashboard/Widgets.tsx | 23 +- .../dashboard/connectors/ConnectorCard.tsx | 24 +- .../dashboard/connectors/ConnectorEditor.tsx | 110 +- .../dashboard/connectors/ConnectorLogs.tsx | 2 +- .../dashboard/connectors/ConnectorTools.tsx | 8 +- web/tsconfig.json | 1 + 312 files changed, 21085 insertions(+), 15319 deletions(-) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .nvmrc create mode 100644 .prettierignore create mode 100644 .prettierrc rename {scripts => connectors}/_sablona.js (94%) rename {scripts => connectors}/csob.list-transactions.js (91%) rename {scripts => connectors}/ga4.run-report.js (100%) rename {scripts => connectors}/google-ads.campaign-report.js (100%) rename {scripts => connectors}/google.append-sheet-row.js (100%) rename {scripts => connectors}/google.send-email.js (82%) rename {scripts => connectors}/idoklad.create-contact.js (100%) rename {scripts => connectors}/idoklad.create-invoice-from-object.js (100%) rename {scripts => connectors}/idoklad.create-issued-invoice.js (100%) rename {scripts => connectors}/idoklad.find-contact.js (100%) rename {scripts => connectors}/idoklad.find-issued-invoice.js (100%) rename {scripts => connectors}/idoklad.get-issued-invoice.js (100%) rename {scripts => connectors}/idoklad.register-payment.js (96%) rename {scripts => connectors}/idoklad.send-invoice-email.js (100%) rename {scripts => connectors}/idoklad.upsert-contact.js (100%) rename {scripts => connectors}/meta-ads.insights.js (100%) rename {scripts => connectors}/microsoft365.create-event.js (100%) rename {scripts => connectors}/microsoft365.send-mail.js (100%) rename {scripts => connectors}/openai.ask-about-file.js (100%) rename {scripts => connectors}/openai.chat.js (100%) rename {scripts => connectors}/openai.list-models.js (100%) rename {scripts => connectors}/openai.transcribe-audio.js (100%) rename {scripts => connectors}/openai.upload-file.js (100%) rename {scripts => connectors}/ppl.create-shipment.js (100%) rename {scripts => connectors}/ppl.track.js (96%) rename {scripts => connectors}/raynet.create-lead.js (100%) rename {scripts => connectors}/raynet.find-company.js (100%) rename {scripts => connectors}/raynet.upsert-contact.js (100%) rename {scripts => connectors}/sap-bo.find-business-partner.js (100%) rename {scripts => connectors}/sap-bo.list-orders.js (93%) rename {scripts => connectors}/search-console.run-report.js (96%) rename {scripts => connectors}/sklik.campaign-report.js (100%) rename {scripts => connectors}/transcription.transcribe.js (100%) rename {scripts => connectors}/transform.map-fields.js (100%) rename {scripts => connectors}/transform.to-json.js (95%) rename {scripts => connectors}/transform.to-xml.js (100%) create mode 100644 eslint.config.js create mode 100644 src/app.ts create mode 100644 src/data/automations/index.ts create mode 100644 src/data/automations/model.ts create mode 100644 src/data/automations/persist.ts create mode 100644 src/data/automations/remap.ts create mode 100644 src/data/automations/runs.ts create mode 100644 src/data/automations/seed.ts create mode 100644 src/data/automations/seedDemo.ts create mode 100644 src/data/automations/state.ts create mode 100644 src/data/automations/store.ts create mode 100644 src/data/automations/validation.ts create mode 100644 src/data/automations/webhook.ts create mode 100644 src/data/services/catalog/ai.ts create mode 100644 src/data/services/catalog/analytics.ts create mode 100644 src/data/services/catalog/crm.ts create mode 100644 src/data/services/catalog/email.ts create mode 100644 src/data/services/catalog/finance.ts create mode 100644 src/data/services/catalog/incident.ts create mode 100644 src/data/services/catalog/index.ts create mode 100644 src/data/services/catalog/logistics.ts create mode 100644 src/data/services/catalog/mcp.ts create mode 100644 src/data/services/catalog/messaging.ts create mode 100644 src/data/services/catalog/office.ts create mode 100644 src/data/services/catalog/polstryn.ts create mode 100644 src/data/services/catalog/social.ts create mode 100644 src/data/services/catalog/ticket.ts create mode 100644 src/data/services/catalog/tools.ts create mode 100644 src/data/services/catalog/triggers.ts create mode 100644 src/data/services/index.ts create mode 100644 src/data/tickets/index.ts create mode 100644 src/data/tickets/intake.ts create mode 100644 src/data/tickets/model.ts create mode 100644 src/data/tickets/persist.ts create mode 100644 src/data/tickets/queries.ts create mode 100644 src/data/tickets/remap.ts create mode 100644 src/data/tickets/seed.ts create mode 100644 src/data/tickets/state.ts create mode 100644 src/data/tickets/stats.ts create mode 100644 src/data/tickets/store.ts create mode 100644 src/data/tickets/trace.ts delete mode 100644 src/openapi.ts create mode 100644 src/openapi/components.ts create mode 100644 src/openapi/helpers.ts create mode 100644 src/openapi/index.ts create mode 100644 src/openapi/paths/admin.ts create mode 100644 src/openapi/paths/auth.ts create mode 100644 src/openapi/paths/automations.ts create mode 100644 src/openapi/paths/connectors.ts create mode 100644 src/openapi/paths/contact.ts create mode 100644 src/openapi/paths/dashboard.ts create mode 100644 src/openapi/paths/helpdesk.ts create mode 100644 src/openapi/paths/invites.ts create mode 100644 src/openapi/paths/ops.ts create mode 100644 src/openapi/paths/scripts.ts create mode 100644 src/openapi/paths/settings.ts create mode 100644 src/openapi/paths/tickets.ts create mode 100644 src/openapi/paths/webhook.ts delete mode 100644 src/routes/dashboard.ts create mode 100644 src/routes/dashboard/automations.ts create mode 100644 src/routes/dashboard/clientCrash.ts create mode 100644 src/routes/dashboard/incidents.ts create mode 100644 src/routes/dashboard/index.ts create mode 100644 src/routes/dashboard/intake.ts create mode 100644 src/routes/dashboard/layout.ts create mode 100644 src/routes/dashboard/misc.ts create mode 100644 src/routes/dashboard/notifications.ts create mode 100644 src/routes/dashboard/people.ts create mode 100644 src/routes/dashboard/shared.ts create mode 100644 src/routes/dashboard/tickets.ts delete mode 100644 src/routes/settings.ts create mode 100644 src/routes/settings/actions.ts create mode 100644 src/routes/settings/catalog.ts create mode 100644 src/routes/settings/features.ts create mode 100644 src/routes/settings/groups.ts create mode 100644 src/routes/settings/index.ts create mode 100644 src/routes/settings/people.ts create mode 100644 src/routes/settings/roles.ts create mode 100644 src/routes/settings/shared.ts create mode 100644 src/routes/settings/tenants.ts create mode 100644 src/routes/settings/ticketTypes.ts create mode 100644 src/routes/settings/users.ts create mode 100644 src/routes/settings/widgets.ts rename src/{ => runtime}/scripts/connections.ts (95%) rename src/{ => runtime}/scripts/http.ts (96%) rename src/{ => runtime}/scripts/lookup.ts (90%) rename src/{ => runtime}/scripts/manifest.ts (93%) rename src/{ => runtime}/scripts/mapping.ts (94%) rename src/{ => runtime}/scripts/registry.ts (95%) rename src/{ => runtime}/scripts/runner.ts (96%) rename src/{ => runtime}/scripts/types.ts (98%) rename src/{ => runtime}/scripts/util.ts (94%) rename src/{ => runtime}/scripts/values.ts (96%) create mode 100644 tests/data/access.test.ts create mode 100644 tests/data/migratePeople.test.ts create mode 100644 tests/data/permissions.test.ts create mode 100644 tests/data/tickets.test.ts create mode 100644 tests/net/guard.test.ts create mode 100644 tests/routes/health.test.ts create mode 100644 tests/runtime/executor.test.ts create mode 100644 tests/runtime/scripts/util.test.ts create mode 100644 tests/setup.ts create mode 100644 tests/tsconfig.json create mode 100644 vitest.config.ts create mode 100644 web/src/components/dashboard/EntityForm.tsx create mode 100644 web/src/components/dashboard/flow/ModelTree.tsx create mode 100644 web/src/components/dashboard/flow/SampleBody.tsx create mode 100644 web/src/components/dashboard/flow/WebhookCalls.tsx create mode 100644 web/src/components/dashboard/scripts/CodeEditor.tsx create mode 100644 web/src/components/dashboard/scripts/TestPanel.tsx create mode 100644 web/src/components/dashboard/settings/AuditView.tsx create mode 100644 web/src/components/dashboard/settings/FeaturesAdmin.tsx create mode 100644 web/src/components/dashboard/settings/types.ts create mode 100644 web/src/components/dashboard/widgets/EditBar.tsx create mode 100644 web/src/components/ui/ServiceIcon.tsx create mode 100644 web/src/components/ui/Table.tsx rename web/src/{lib => hooks}/useApiQuery.ts (86%) create mode 100644 web/src/hooks/useLatest.ts create mode 100644 web/src/hooks/useMediaQuery.ts rename web/src/{lib => hooks}/usePageMeta.ts (100%) rename web/src/{lib => hooks}/useSubmit.ts (64%) create mode 100644 web/src/hooks/useSyncFromSource.ts rename web/src/{lib => hooks}/useUnsavedChanges.ts (100%) create mode 100644 web/src/lib/exampleBody.ts delete mode 100644 web/src/lib/useMediaQuery.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4b8ba90 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# Zakladni pravidla editoru, nezavisle na nastroji. UTF-8 bez BOM je pravidlo +# celeho adresare, viz D:\GitHubRepository\START.md, sekce 6. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.sql] +indent_size = 2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..57a15fc --- /dev/null +++ b/.env.example @@ -0,0 +1,66 @@ +# Promenne prostredi aplikace. Zkopirovat do .env (ten je mimo git) nebo +# nastavit v AppFactory jako variables a secrets. Vsechny se ctou jen +# v src/config.ts. Zadna neni povinna: aplikace nastartuje i bez nich, +# jen s omezenim popsanym u kazde. + +# --- beh a proxy --------------------------------------------------------- +# Port urcuje AppFactory sablona, nemenit bez upravy metadat. +PORT=3000 +# Prefix verejne adresy za Caddy, napr. /apps/csbot-prototype. +ROOT_PATH= +# Verejna adresa bez prefixu, napr. https://services.csbot.cz. Sklada se z ni +# absolutni adresa webhooku. Prazdne = relativni tvar. +PUBLIC_ORIGIN= +# Povolene originy CORS pro lokalni vyvoj s Vite. +CORS_ORIGIN=http://localhost:5173,http://localhost:4173 +NODE_ENV=development + +# --- prihlaseni (secret) ------------------------------------------------- +# Bez JWT_SECRET se vygeneruje nahodny klic platny do restartu, tokeny pak +# po restartu neplati. +JWT_SECRET= +JWT_EXPIRES_IN=8h + +# --- uloziste ------------------------------------------------------------ +# Postgres, napr. postgres://user:pass@host:5432/csbot. Prazdne = soubor. +DATABASE_URL= +# Klic pro sifrovani pristupovych udaju konektoru (secret). Bez nej se +# konektory neukladaji do databaze. Generovani viz documentation/14-databaze.md. +SECRETS_KEY= +DATABASE_POOL_MAX=10 +DATABASE_SSL=false +# Slozka pro JSON bez databaze. Prazdna hodnota = jen pamet procesu. +DATA_DIR=./data +# 1 = nasypat ukazkove tickety do prazdneho uloziste. +SEED_DEMO= + +# --- znacka -------------------------------------------------------------- +BRAND_NAME=WorkNuke +# Klient cte VITE_BRAND_NAME pri buildu (staticky soubor). +VITE_BRAND_NAME=WorkNuke + +# --- skripty konektoru a volani ven -------------------------------------- +# Slozka se skripty konektoru, vychozi ./connectors. +SCRIPTS_DIR= +# Zaklad adres napojenych sluzeb; konkretni sluzba jde presmerovat pres +# _BASE_URL (nazev promenne je v katalogu sluzby). +SERVICES_BASE_URL=https://services.csbot.cz/apps +# Odkud se zjistuje odchozi IP containeru. Prazdne = vypnuto. +EGRESS_IP_URL=https://api.ipify.org?format=json +EGRESS_IP_TTL_MS=600000 +SCRIPT_TIMEOUT_MS=15000 +SCRIPT_MAX_RESPONSE_BYTES=1000000 +SCRIPT_MAX_UPLOAD_BYTES=10000000 +SCRIPT_MAX_VALUE_BYTES=256000 +SCRIPT_ERROR_DETAIL_BYTES=8000 +# true jen pro lokalni vyvoj: povoli volani na localhost a privatni rozsahy. +ALLOW_PRIVATE_TARGETS=false +# Registr ARES pro zalozeni firmy. Menit jen pro testovaci zrcadlo. +ARES_BASE_URL=https://ares.gov.cz/ekonomicke-subjekty-v-be/rest + +# --- fronta a planovac --------------------------------------------------- +# 0 = tento proces frontu nezpracovava (jen prijima). Cokoliv jineho = ano. +WORKER=1 +SCHEDULER_INTERVAL_SEC=30 +# Pevny token ukazkove webhookove automatizace (jen pro test). +WEBHOOK_TOKEN_TEST= diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b453722 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +dist +node_modules +data +design +package-lock.json +# Dokumentace ma vlastni styl tabulek, prettier by je prelamal. +*.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..92e3837 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "printWidth": 100, + "trailingComma": "all", + "semi": true, + "endOfLine": "lf" +} diff --git a/Dockerfile b/Dockerfile index 7ef6a3f..352ddeb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ 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. -COPY --from=build /app/scripts ./scripts +COPY --from=build /app/connectors ./connectors # Migrace jsou .sql, ktere tsc do dist nekopiruje. Bez nich by se schema # nevytvorilo, aplikace by spadla na migracich a jela dal v pameti - tedy presne # to, co ma databaze resit. Cesta musi odpovidat dist/db/migrations, protoze diff --git a/README.md b/README.md index 00a5cad..0db52b2 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,19 @@ Secrets se nikdy nelogují ani neukladaji do kodu. ## Skripty -| Prikaz | Co dela | -| ------------------- | ------------------------------------ | -| `npm run build` | Zbuilduje server i web do `dist/` | -| `npm start` | Spusti zbuildovanou aplikaci | -| `npm run dev` | Vyvoj s hot reloadem | -| `npm run typecheck` | Kontrola typu bez generovani vystupu | +| Prikaz | Co dela | +| ---------------------- | ---------------------------------------------- | +| `npm run build` | Zbuilduje server i web do `dist/` | +| `npm start` | Spusti zbuildovanou aplikaci | +| `npm run dev` | Vyvoj s hot reloadem | +| `npm run typecheck` | Kontrola typu bez generovani vystupu | +| `npm run lint` | ESLint nad serverem, webem a skripty konektoru | +| `npm run format` | Prettier prepise soubory podle konfigurace | +| `npm run format:check` | Prettier jen zkontroluje, nic neprepisuje | +| `npm test` | Testy serveru (vitest) ve slozce `tests/` | + +Skripty konektoru (vykonna cast sluzeb) jsou obycejny JavaScript ve slozce +`connectors/`, do buildu se nekompiluji, jen kopiruji. ## Dokumentace diff --git a/scripts/_sablona.js b/connectors/_sablona.js similarity index 94% rename from scripts/_sablona.js rename to connectors/_sablona.js index bf7e049..19d992d 100644 --- a/scripts/_sablona.js +++ b/connectors/_sablona.js @@ -49,9 +49,7 @@ export const manifest = { * Povinny vystup, ktery skript nevrati, je chyba skriptu. Zamerne: strom by * jinak veril parametru, ktery nikdy nedosel. */ - outputs: [ - { id: 'prikladVystupu', label: 'Příklad výstupu', type: 'string', required: true }, - ], + outputs: [{ id: 'prikladVystupu', label: 'Příklad výstupu', type: 'string', required: true }], }; /** @@ -63,7 +61,7 @@ export const manifest = { * 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 }, + * quote: Function, base64: 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/connectors/csob.list-transactions.js similarity index 91% rename from scripts/csob.list-transactions.js rename to connectors/csob.list-transactions.js index 6e095be..bd8bdb4 100644 --- a/scripts/csob.list-transactions.js +++ b/connectors/csob.list-transactions.js @@ -77,10 +77,10 @@ export async function run(inputs, ctx) { const wanted = digits(inputs.accountNumber); const match = accounts.find((item) => { const iban = digits(pick(item, 'iban')); - const number = digits(pick(pick(item, 'accountNumber'), 'number') ?? pick(item, 'accountNumber')); - return ( - (iban !== '' && iban.includes(wanted)) || (number !== '' && wanted.endsWith(number)) + const number = digits( + pick(pick(item, 'accountNumber'), 'number') ?? pick(item, 'accountNumber'), ); + return (iban !== '' && iban.includes(wanted)) || (number !== '' && wanted.endsWith(number)); }); if (!match) { ctx.fail( @@ -110,8 +110,9 @@ export async function run(inputs, ctx) { accountId, count: transactions.length, transactions, - newest: transactions.length > 0 - ? text(pick(transactions[0], 'bookingDate', 'valueDate', 'creationDate')) - : null, + newest: + transactions.length > 0 + ? text(pick(transactions[0], 'bookingDate', 'valueDate', 'creationDate')) + : null, }; } diff --git a/scripts/ga4.run-report.js b/connectors/ga4.run-report.js similarity index 100% rename from scripts/ga4.run-report.js rename to connectors/ga4.run-report.js diff --git a/scripts/google-ads.campaign-report.js b/connectors/google-ads.campaign-report.js similarity index 100% rename from scripts/google-ads.campaign-report.js rename to connectors/google-ads.campaign-report.js diff --git a/scripts/google.append-sheet-row.js b/connectors/google.append-sheet-row.js similarity index 100% rename from scripts/google.append-sheet-row.js rename to connectors/google.append-sheet-row.js diff --git a/scripts/google.send-email.js b/connectors/google.send-email.js similarity index 82% rename from scripts/google.send-email.js rename to connectors/google.send-email.js index f2ffa26..9ee5178 100644 --- a/scripts/google.send-email.js +++ b/connectors/google.send-email.js @@ -8,8 +8,8 @@ * zakodovany do base64url, takze zpravu sklada skript. Predmet s diakritikou * se navic musi zabalit do MIME slova, jinak z nej cestou zbydou otazniky. * - * Kodovani se dela pres `TextEncoder` a `btoa`, ne pres Buffer: jsou to - * standardni funkce, ktere budou k dispozici i az skripty pobezi v sandboxu. + * Kodovani dela `ctx.util.base64`, ne Buffer ani `btoa`: skript nesmi + * zaviset na globalech prostredi, aby bezel stejne i az pobezi v sandboxu. */ export const manifest = { @@ -56,21 +56,13 @@ export const manifest = { timeoutMs: 30000, }; -/** Base64 z textu. Pres bajty, aby diakritika prezila. */ -function base64(value) { - const bytes = new TextEncoder().encode(value); - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); -} - -/** Base64url bez vypln, jak ho chce Gmail. */ -function base64Url(value) { +/** Base64url bez vypln, jak ho chce Gmail. `base64` je `ctx.util.base64`. */ +function base64Url(base64, value) { return base64(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } /** Hlavicka s diakritikou musi byt MIME slovo, jinak z ni zbydou otazniky. */ -function header(value) { +function header(base64, value) { for (const character of value) { if (character.codePointAt(0) > 127) return `=?UTF-8?B?${base64(value)}?=`; } @@ -78,7 +70,7 @@ function header(value) { } export async function run(inputs, ctx) { - const { pick, text, need, addresses } = ctx.util; + const { pick, text, need, addresses, base64 } = ctx.util; // Clovek pise carky, Gmail chce jeden retezec. const to = addresses(inputs.to); @@ -89,7 +81,7 @@ export async function run(inputs, ctx) { `To: ${to.join(', ')}`, ...(cc.length > 0 ? [`Cc: ${cc.join(', ')}`] : []), ...(inputs.from ? [`From: ${inputs.from}`] : []), - `Subject: ${header(inputs.subject)}`, + `Subject: ${header(base64, inputs.subject)}`, 'MIME-Version: 1.0', `Content-Type: text/${inputs.html ? 'html' : 'plain'}; charset="UTF-8"`, 'Content-Transfer-Encoding: 8bit', @@ -98,7 +90,7 @@ export async function run(inputs, ctx) { ]; const { body } = await ctx.http.post('/google/gmail/messages/send', { - raw: base64Url(lines.join('\r\n')), + raw: base64Url(base64, lines.join('\r\n')), }); ctx.log(`Odesláno ${to.length + cc.length} příjemcům.`); diff --git a/scripts/idoklad.create-contact.js b/connectors/idoklad.create-contact.js similarity index 100% rename from scripts/idoklad.create-contact.js rename to connectors/idoklad.create-contact.js diff --git a/scripts/idoklad.create-invoice-from-object.js b/connectors/idoklad.create-invoice-from-object.js similarity index 100% rename from scripts/idoklad.create-invoice-from-object.js rename to connectors/idoklad.create-invoice-from-object.js diff --git a/scripts/idoklad.create-issued-invoice.js b/connectors/idoklad.create-issued-invoice.js similarity index 100% rename from scripts/idoklad.create-issued-invoice.js rename to connectors/idoklad.create-issued-invoice.js diff --git a/scripts/idoklad.find-contact.js b/connectors/idoklad.find-contact.js similarity index 100% rename from scripts/idoklad.find-contact.js rename to connectors/idoklad.find-contact.js diff --git a/scripts/idoklad.find-issued-invoice.js b/connectors/idoklad.find-issued-invoice.js similarity index 100% rename from scripts/idoklad.find-issued-invoice.js rename to connectors/idoklad.find-issued-invoice.js diff --git a/scripts/idoklad.get-issued-invoice.js b/connectors/idoklad.get-issued-invoice.js similarity index 100% rename from scripts/idoklad.get-issued-invoice.js rename to connectors/idoklad.get-issued-invoice.js diff --git a/scripts/idoklad.register-payment.js b/connectors/idoklad.register-payment.js similarity index 96% rename from scripts/idoklad.register-payment.js rename to connectors/idoklad.register-payment.js index 4b21323..75125fe 100644 --- a/scripts/idoklad.register-payment.js +++ b/connectors/idoklad.register-payment.js @@ -60,7 +60,9 @@ export async function run(inputs, ctx) { } // Vzor nese zbytek k zaplaceni i vychozi zpusob platby. - const defaults = unwrap((await ctx.http.get(`/issued-payments/default/${inputs.invoiceId}`)).body); + const defaults = unwrap( + (await ctx.http.get(`/issued-payments/default/${inputs.invoiceId}`)).body, + ); if (!defaults || typeof defaults !== 'object') { ctx.fail(`K faktuře ${inputs.invoiceId} nejde zapsat úhradu, iDoklad ji nezná.`); } diff --git a/scripts/idoklad.send-invoice-email.js b/connectors/idoklad.send-invoice-email.js similarity index 100% rename from scripts/idoklad.send-invoice-email.js rename to connectors/idoklad.send-invoice-email.js diff --git a/scripts/idoklad.upsert-contact.js b/connectors/idoklad.upsert-contact.js similarity index 100% rename from scripts/idoklad.upsert-contact.js rename to connectors/idoklad.upsert-contact.js diff --git a/scripts/meta-ads.insights.js b/connectors/meta-ads.insights.js similarity index 100% rename from scripts/meta-ads.insights.js rename to connectors/meta-ads.insights.js diff --git a/scripts/microsoft365.create-event.js b/connectors/microsoft365.create-event.js similarity index 100% rename from scripts/microsoft365.create-event.js rename to connectors/microsoft365.create-event.js diff --git a/scripts/microsoft365.send-mail.js b/connectors/microsoft365.send-mail.js similarity index 100% rename from scripts/microsoft365.send-mail.js rename to connectors/microsoft365.send-mail.js diff --git a/scripts/openai.ask-about-file.js b/connectors/openai.ask-about-file.js similarity index 100% rename from scripts/openai.ask-about-file.js rename to connectors/openai.ask-about-file.js diff --git a/scripts/openai.chat.js b/connectors/openai.chat.js similarity index 100% rename from scripts/openai.chat.js rename to connectors/openai.chat.js diff --git a/scripts/openai.list-models.js b/connectors/openai.list-models.js similarity index 100% rename from scripts/openai.list-models.js rename to connectors/openai.list-models.js diff --git a/scripts/openai.transcribe-audio.js b/connectors/openai.transcribe-audio.js similarity index 100% rename from scripts/openai.transcribe-audio.js rename to connectors/openai.transcribe-audio.js diff --git a/scripts/openai.upload-file.js b/connectors/openai.upload-file.js similarity index 100% rename from scripts/openai.upload-file.js rename to connectors/openai.upload-file.js diff --git a/scripts/ppl.create-shipment.js b/connectors/ppl.create-shipment.js similarity index 100% rename from scripts/ppl.create-shipment.js rename to connectors/ppl.create-shipment.js diff --git a/scripts/ppl.track.js b/connectors/ppl.track.js similarity index 96% rename from scripts/ppl.track.js rename to connectors/ppl.track.js index 66a6dce..2ec0190 100644 --- a/scripts/ppl.track.js +++ b/connectors/ppl.track.js @@ -75,7 +75,9 @@ export async function run(inputs, ctx) { const events = pick(shipment, 'shipmentEvents', 'events', 'states'); const last = Array.isArray(events) && events.length > 0 ? events[events.length - 1] : null; - const state = text(pick(last, 'code', 'name', 'statusCode') ?? pick(shipment, 'statusCode', 'state')); + const state = text( + pick(last, 'code', 'name', 'statusCode') ?? pick(shipment, 'statusCode', 'state'), + ); const stateDate = text(pick(last, 'date', 'eventDate') ?? pick(shipment, 'statusDate')); return { diff --git a/scripts/raynet.create-lead.js b/connectors/raynet.create-lead.js similarity index 100% rename from scripts/raynet.create-lead.js rename to connectors/raynet.create-lead.js diff --git a/scripts/raynet.find-company.js b/connectors/raynet.find-company.js similarity index 100% rename from scripts/raynet.find-company.js rename to connectors/raynet.find-company.js diff --git a/scripts/raynet.upsert-contact.js b/connectors/raynet.upsert-contact.js similarity index 100% rename from scripts/raynet.upsert-contact.js rename to connectors/raynet.upsert-contact.js diff --git a/scripts/sap-bo.find-business-partner.js b/connectors/sap-bo.find-business-partner.js similarity index 100% rename from scripts/sap-bo.find-business-partner.js rename to connectors/sap-bo.find-business-partner.js diff --git a/scripts/sap-bo.list-orders.js b/connectors/sap-bo.list-orders.js similarity index 93% rename from scripts/sap-bo.list-orders.js rename to connectors/sap-bo.list-orders.js index 88d46aa..12bd602 100644 --- a/scripts/sap-bo.list-orders.js +++ b/connectors/sap-bo.list-orders.js @@ -12,8 +12,7 @@ export const manifest = { id: 'sap-bo.list-orders', name: 'Načíst objednávky', - description: - 'Vrátí prodejní objednávky partnera nebo za období. Nic nemění, jen čte.', + description: 'Vrátí prodejní objednávky partnera nebo za období. Nic nemění, jen čte.', inputs: [ { @@ -68,7 +67,8 @@ export async function run(inputs, ctx) { const { body } = await ctx.http.get('/api/orders', { query: { $filter: conditions.join(' and '), - $select: 'DocEntry,DocNum,CardCode,CardName,DocDate,DocDueDate,DocTotal,DocCurrency,DocumentStatus', + $select: + 'DocEntry,DocNum,CardCode,CardName,DocDate,DocDueDate,DocTotal,DocCurrency,DocumentStatus', $orderby: 'DocDate desc', $top: inputs.limit, }, diff --git a/scripts/search-console.run-report.js b/connectors/search-console.run-report.js similarity index 96% rename from scripts/search-console.run-report.js rename to connectors/search-console.run-report.js index 1419d35..ceab123 100644 --- a/scripts/search-console.run-report.js +++ b/connectors/search-console.run-report.js @@ -12,8 +12,7 @@ export const manifest = { id: 'search-console.run-report', name: 'Načíst výkon ve vyhledávání', - description: - 'Vrátí dotazy, prokliky, zobrazení, CTR a průměrnou pozici za období. Nic nemění.', + description: 'Vrátí dotazy, prokliky, zobrazení, CTR a průměrnou pozici za období. Nic nemění.', inputs: [ { diff --git a/scripts/sklik.campaign-report.js b/connectors/sklik.campaign-report.js similarity index 100% rename from scripts/sklik.campaign-report.js rename to connectors/sklik.campaign-report.js diff --git a/scripts/transcription.transcribe.js b/connectors/transcription.transcribe.js similarity index 100% rename from scripts/transcription.transcribe.js rename to connectors/transcription.transcribe.js diff --git a/scripts/transform.map-fields.js b/connectors/transform.map-fields.js similarity index 100% rename from scripts/transform.map-fields.js rename to connectors/transform.map-fields.js diff --git a/scripts/transform.to-json.js b/connectors/transform.to-json.js similarity index 95% rename from scripts/transform.to-json.js rename to connectors/transform.to-json.js index cc97fac..adaba57 100644 --- a/scripts/transform.to-json.js +++ b/connectors/transform.to-json.js @@ -43,9 +43,7 @@ export const manifest = { }, ], - outputs: [ - { id: 'result', label: 'Výsledek', type: 'object', required: true }, - ], + outputs: [{ id: 'result', label: 'Výsledek', type: 'object', required: true }], }; export async function run(inputs, ctx) { diff --git a/scripts/transform.to-xml.js b/connectors/transform.to-xml.js similarity index 100% rename from scripts/transform.to-xml.js rename to connectors/transform.to-xml.js diff --git a/documentation/00-pro-programatory.md b/documentation/00-pro-programatory.md index b463cf9..2888bf7 100644 --- a/documentation/00-pro-programatory.md +++ b/documentation/00-pro-programatory.md @@ -33,7 +33,7 @@ Ctyri pojmy. Plete se to a z te zamneny vznikaji nejhorsi chyby. | Pojem | Co to je | Ciji je | | ------------ | ----------------------------------------------- | ------------ | | **Sluzba** | co umime: iDoklad, e-mail, MCP server, webhook | nase | -| **Skript** | vykonna cast operace, obycejny JS ve `scripts/` | nase | +| **Skript** | vykonna cast operace, obycejny JS v `connectors/` | nase | | **Konektor** | pristupove udaje jedne firmy k jedne sluzbe | firmy | | **Krok** | jedno pouziti operace v automatizaci | automatizace | @@ -46,6 +46,46 @@ jeden pristupovy udaj. ## Pravidla, ktera plati vsude +Obecne zasady (struktura projektu, lint, testy, navrh kodu) jsou +v `D:\GitHubRepository\CLAUDE.md` a tenhle projekt se jimi ridi. Tady je jen +to, co je specificke pro portal, a par zasad, ktere se tu nejvic porusovaly. +Kde se projekt od zasad lisi, je to zapsane jako znamy stav +v [01-prehled-a-stav.md](01-prehled-a-stav.md). + +**`process.env` se cte jen v `src/config.ts`.** Kazda promenna ma vychozi +hodnotu, komentar a radek v `.env.example`. Jedina vyjimka s dynamickym nazvem +je `config.serviceBaseUrlOverride('_BASE_URL')`, protoze sluzby +pribyvaji v katalogu a vypisovat kazdou do konfigurace by znamenalo dve mista. +`process.env` jinde v `src/` je chyba, i kdyz "je to jen jedna promenna". + +**Testy lezi v `tests/` se stejnou cestou jako modul.** +`tests/data/tickets.test.ts` testuje `src/data/tickets/`. Bezi v rezimu pameti +(`tests/setup.ts`), bez databaze a bez site; co potrebuji ven, dostanou vstrikle. +Aspon na logiku, ktera rozhoduje o pravech, penezich a datech. Aplikace se +v testu stavi pres `createApp()` ze `src/app.ts`, ne pres `index.ts`, aby se +neotevrel port. + +**Pred predanim prace lint a typecheck.** `npm run lint` a `npm run typecheck` +maji byt ciste, bez `eslint-disable`. Spousti se ale jen se svolenim +uzivatele (pravidlo 2 v CLAUDE.md), takze se o ne pozada, nespusti se "pro +kontrolu". + +**Sdilene UI prvky jsou komponenty v `components/ui`.** Pole formulare, +tlacitka, odznaky, tabulky, dialogy. Stranka, ktera si kresli vlastni `` +nebo vlastni ``, je chyba, i kdyz je to poprve - presne tak vzniklo +13 verzi `inputClass` a ctyri tabulky v jinem odstinu. + +**Pravidlo tri.** Stejny kus se poprve napise, podruhe se snese kopie, +potreti se vytahne. Vytahnout se ma **tentyz pojem**, ne jen podobny kod: +`Table` v `ui/` vznikl ze ctyr seznamu, ale huste tabulky ticketu a vykonu +zustavaji zvlast, protoze jsou to jine tabulky. + +**Soubor nad 500 radku je signal k rozdeleni.** Route, uloziste i stranka +se deli podle odpovednosti, ne podle delky; puvodni cesta zustava jako fasada +(`ticketStore.ts`, `automationStore.ts`, `services.ts`), aby se nemusely +menit importy. Ktere soubory limit stale prekracuji a proc, je +v [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md). + **Filtr na firmu je povinny argument.** `listTickets(tenantIds)`, `getConnector(id, tenantIds)`, `listConnectors(tenantIds)`. Zapomenuty filtr tak neznamena "vse", ale nezkompiluje se. Cizi zaznam se chova jako neexistujici @@ -68,7 +108,7 @@ cely strom a zustane bila plocha. Obsah portalu je proto v pojistce v konzoli prohlizece uzivatele. Pomocna funkce, ktera jen neco vykresluje, si navic ma chybu osetrit sama a degradovat, ne spadnout. -**Katalog je zdroj pravdy.** Co neni v `src/data/services.ts`, to nejde ulozit +**Katalog je zdroj pravdy.** Co neni v `src/data/services/catalog/`, to nejde ulozit do stromu. Validace pri ukladani se pta katalogu, ne klienta. **Ve strome je vsechno retezec.** Hodnota kroku je sablona (`{{subject}}`), @@ -132,8 +172,8 @@ Tri cesty, kazda ma svuj duvod: | Cesta | Kdy | Kde | | -------------------- | --------------------------------------- | ----------------------------- | -| **Zapis v katalogu** | popis toho, co umime nebo budeme umet | `src/data/services.ts` | -| **Skript** | volani cizi sluzby pres HTTP | `scripts/*.js` + manifest | +| **Zapis v katalogu** | popis toho, co umime nebo budeme umet | `src/data/services/catalog/` | +| **Skript** | volani cizi sluzby pres HTTP | `connectors/*.js` + manifest | | **Vnitrni krok** | sahá do naseho uloziste, nebo neni HTTP | `src/runtime/builtinSteps.ts` | Runtime zkousi **nejdriv vnitrni krok, pak skript**. Kdyz operace nema ani @@ -202,7 +242,8 @@ prvni misto, kam se divat. **Novou sluzbu** popiste v katalogu vcetne `credentials` a `verifyPath`. Bez `verifyPath` overeni konektoru nerika nic o udajich, jen ze neco odpovida. -**Novy endpoint** patri do `src/openapi.ts`. Neni to volitelne, vyzaduje to +**Novy endpoint** patri do `src/openapi/paths/.ts` (jeden soubor na +router, sklada je `src/openapi/index.ts`). Neni to volitelne, vyzaduje to `AGENTS.md`, a nezdokumentovany endpoint neexistuje pro nikoho krome toho, kdo ho napsal. diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index 754bb5b..8491376 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -77,6 +77,10 @@ React aplikaci ze slozky `dist/public`. | 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 | +| Struktura podle zasad | hotovo | `index.ts` a `app.ts`, routy a data po slozkach, `connectors/` | +| Lint a formatovani v repu | hotovo | eslint a prettier, `npm run lint` cisty bez vyjimek | +| Prisny TypeScript | hotovo | `noUncheckedIndexedAccess` v obou tsconfig, zadne `!` | +| Testy | castecne | vitest v `tests/`, 8 souboru a 105 testu: prava, tickety, executor, sit, health | ## Znama omezeni @@ -114,6 +118,19 @@ Log ticketu uz plni skutecny beh: kazdy krok stromu se do nej zapise vcetne toho, co sluzba vratila. Ukazkova sada ticketu ma log psany rucne, aby bylo co ukazat i na prazdne instanci. +### Znamy stav proti zasadam + +Projekt se ridi `D:\GitHubRepository\CLAUDE.md`. Kde se od nej lisi, je to +zamer nebo odlozena prace, ne opomenuti: + +| Odchylka | Proc a co s tim | +| ------------------------------------------ | ------------------------------------------------------------------------------- | +| jeden `package.json` pro server i web | mala aplikace v jednom containeru; workspaces az bude mit kazda strana vlastni build | +| logovani `console.*` s prefixem modulu | strukturovany logger (`pino`) zatim neni potreba, prefix `[modul]` staci k dohledani | +| zadny soubor CI | lint, typecheck a testy se spousti rucne se svolenim (pravidlo 2) | +| testy jen na cast logiky | pokryta prava, tickety, executor, cteni tela a health; routy nastaveni a runtime fronty cekaji | +| ctyri soubory nad 500 radku | `AutomationDetail`, `TicketDetail`, `MappingEditor`, `catalog/ticket.ts`; duvod v [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md) | + ## Dalsi krok Runtime je hotovy: fronta, worker jako pool, opakovani jen u chyb, ktere diff --git a/documentation/02-appfactory-proxy.md b/documentation/02-appfactory-proxy.md index 480884d..176dda6 100644 --- a/documentation/02-appfactory-proxy.md +++ b/documentation/02-appfactory-proxy.md @@ -99,7 +99,7 @@ shodovala i s `/docs/` a presmerovani by se zacyklilo. ``` Diky tomu tlacitko Try it out vola endpointy pres prefix, ne na koreni domeny. -Definici sestavuje `src/openapi.ts`. +Definici sestavuje `src/openapi/paths/*.ts`. Pozn.: `/api/dashboard/stream` je Server-Sent Events. Swagger UI streamovanou odpoved rozumne nezobrazi, testuje se prohlizecem nebo curlem. diff --git a/documentation/03-architektura-a-mapa-kodu.md b/documentation/03-architektura-a-mapa-kodu.md index 13a64ce..6a2da37 100644 --- a/documentation/03-architektura-a-mapa-kodu.md +++ b/documentation/03-architektura-a-mapa-kodu.md @@ -10,10 +10,11 @@ | Validace | zod | | Docs | swagger-ui-express nad rucne psanou OpenAPI definici | -Jeden `package.json`. Runtime zavislosti jsou v `dependencies`, nastroje pro build -webu v `devDependencies` - runtime image je pak instaluje pres `--omit=dev`. +Jeden `package.json` pro server i web (znamy stav, viz nize). Runtime zavislosti +jsou v `dependencies`, nastroje pro build webu v `devDependencies` - runtime +image je pak instaluje pres `--omit=dev`. -## Build +## Build a kontroly ``` tsc src/**.ts -> dist/*.js @@ -21,17 +22,52 @@ vite web/ -> dist/public/ ``` Server obsluhuje `dist/public` jako statiku. Dockerfile kopiruje do vysledneho -image jen `dist`, takze staci jedna slozka. +image `dist` a `connectors/` - skripty konektoru se ctou za behu ze souboru, +ne z buildu. + +| Prikaz | Co dela | +| ---------------------- | ------------------------------------------------------------- | +| `npm run build` | server (`tsc`) a web (`vite build`) | +| `npm run typecheck` | `tsc --noEmit` pro oba `tsconfig`, nic nezapisuje | +| `npm run lint` | `eslint .`: server, web, testy i `connectors/` | +| `npm run format` | `prettier --write .`; `format:check` jen kontroluje | +| `npm run test` | `vitest run` nad `tests/**`; `test:watch` pri vyvoji | + +Build, lint i testy se spousti **jen se svolenim uzivatele** (pravidlo 2 +v `D:\GitHubRepository\CLAUDE.md`). + +Oba `tsconfig` maji `strict`, `noUncheckedIndexedAccess`, `noUnusedLocals` +a `noFallthroughCasesInSwitch`. Index do pole nebo slovniku je tak +`T | undefined` a kod to musi osetrit; `!` na umlceni se nepouziva. Prave +tahle volba odhalila dve skryte chyby (prodleva fronty a `Retry-After`), +viz [99-zmeny.md](99-zmeny.md). + +## Nastroje v korenu + +| Soubor | K cemu | +| -------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `eslint.config.js` | typescript-eslint, `react-hooks` v7 pro web, `connectors/**` jako obycejny JS bez globalu; zadne `any`, zadny prazdny `catch` | +| `.prettierrc`, `.prettierignore` | jednotne formatovani (jednoduche uvozovky, sirka 100) | +| `.editorconfig` | odsazeni, konce radku a kodovani pro editor | +| `.nvmrc` | Node 20, stejne jako `engines` v `package.json` | +| `.env.example` | vsechny promenne prostredi s popisem; `.env` neni v gitu | +| `vitest.config.ts` | testy z `tests/**/*.test.ts`, alias `@shared`, `tests/setup.ts` pred kazdym souborem | +| `tests/tsconfig.json` | typecheck testu nad `src/` bez emitu | +| `Dockerfile` | vicefazovy build, runtime jen s `--omit=dev`, kopiruje `dist` a `connectors` | ## Mapa kodu - server | Cesta | K cemu je | | ------------------------------ | ------------------------------------------------------------- | -| `src/index.ts` | vstupni bod: middleware, mount routeru, statika, SPA, Swagger | -| `src/config.ts` | cteni environment variables, normalizace `ROOT_PATH` | -| `src/openapi.ts` | OpenAPI definice vcetne `servers` s prefixem proxy | +| `src/index.ts` | start: nacteni dat, worker, `listen`, signaly, `process.on`; nic jineho | +| `src/app.ts` | `createApp()`: middleware, routery, health, Swagger, statika, SPA. Bez `listen`, aby sla postavit v testu (supertest) | +| `src/config.ts` | **jedine misto, kde se cte `process.env`**; `serviceBaseUrlOverride(variable)` pro `_BASE_URL` | +| `src/openapi/index.ts` | `buildOpenApiDocument()`: sklada dokument, `servers` s prefixem proxy | +| `src/openapi/helpers.ts` | `crudPaths` a opakujici se parametry, tela a odpovedi | +| `src/openapi/components.ts` | schemata a zabezpeceni | +| `src/openapi/paths/*.ts` | cesty po routerech: `ops`, `auth`, `dashboard`, `tickets`, `automations`, `settings`, `connectors`, `scripts`, `helpdesk`, `invites`, `admin`, `contact`, `webhook` | | `src/types.ts` | typy uzivatele a JWT payloadu | -| `src/shared/` | ciste typove moduly API, jediny zdroj typu pro server i web | +| `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 | @@ -41,18 +77,34 @@ image jen `dist`, takze staci jedna slozka. | `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, tickety, behy, CRUD automatizaci | +| `src/routes/crud.ts` | `crudRouter`: fabrika CRUD nad jednou entitou | +| `src/routes/dashboard/index.ts` | mount routeru dashboardu, `attachAccess` jednou za request | +| `src/routes/dashboard/misc.ts` | prava, prehled, uloziste, katalog sluzeb | +| `src/routes/dashboard/tickets.ts` | seznam s filtrem, rucni zalozeni, stavy, vytizeni, detail | +| `src/routes/dashboard/people.ts` | resitele pro nabidky a detail cloveka | +| `src/routes/dashboard/automations.ts` | strom akci, validace, webhook, fronta behu | +| `src/routes/dashboard/incidents.ts` | incidenty: seznam, detail, posun stavu | +| `src/routes/dashboard/layout.ts` | katalog widgetu a ulozene rozlozeni | +| `src/routes/dashboard/intake.ts` | adresa prijmu udalosti a jeji obnova | +| `src/routes/dashboard/notifications.ts` | upozorneni a pocet otevrenych ticketu | +| `src/routes/dashboard/clientCrash.ts` | hlaseni padu portalu, z nej incident | +| `src/routes/dashboard/shared.ts` | strankovani: `pageFrom`, `paginate` | | `src/routes/ticketActions.ts` | akce nad ticketem vcetne vestavenych, pravo za firmu ticketu | -| `src/routes/settings.ts` | CRUD entit pres `crud.ts`, uzivatele, ARES | +| `src/routes/settings/index.ts` | mount routeru nastaveni | +| `src/routes/settings/{tenants,users,roles,people,groups,features,ticketTypes,actions,widgets}.ts` | jedna entita = jeden soubor nad `crudRouter`; `people` a `users` maji vlastni handlery | +| `src/routes/settings/catalog.ts` | co jde v nastaveni zvolit: prava, moduly, limity, widgety | +| `src/routes/settings/shared.ts` | `memberOf` pro ucty a resitele | | `src/routes/ares.ts` | firma z registru ARES, jen spravce platformy | | `src/routes/connectors.ts` | konektory firmy, overeni, nastroje MCP | +| `src/routes/scripts.ts`, `tenantScripts.ts` | skripty konektoru a skripty firmy | | `src/routes/stream.ts` | SSE stream zmen, filtr podle firem uzivatele | | `src/routes/webhook.ts` | verejny prijem dat do automatizace | | `src/routes/contact.ts` | poptavkovy formular z webu | | `src/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/ticketStore.ts` | fasada nad `src/data/tickets/`, importy zustavaji | +| `src/data/tickets/` | `index` (verejne API), `model` (tvar, `toTicket`), `state` (pamet a indexy), `persist` (zapis, `initTickets`), `queries` (seznam, detail, strop viditelnosti), `store` (zapisy: zalozeni, stav, resitel, typ, tagy, skupina, komentar), `intake` (udalost zvenku), `trace` (log prubehu), `stats` (vytizeni a vykon), `seed`, `remap` | | `src/data/people.ts` | resitele jako pohled na clenstvi uctu (`personView`), skupiny | | `src/data/migratePeople.ts` | jednorazovy prevod starych zaznamu resitelu `ppl_` na ucty | | `src/data/tenants.ts` | firmy, ktere portal pouzivaji, vcetne udaju z ARES | @@ -60,16 +112,21 @@ image jen `dist`, takze staci jedna slozka. | `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, filtr na firmu povinny | -| `src/data/automationStore.ts` | automatizace, strom akci, tokeny webhooku | -| `src/data/services.ts` | katalog sluzeb, jejich spousteču a akci | +| `src/data/automationStore.ts` | fasada nad `src/data/automations/` | +| `src/data/automations/` | `index`, `model` (tvar, `rulesOf`, `matchOf`), `state` (pamet, citac ID), `persist` (zapis, `initAutomations`), `store` (cteni a zapisy, `recordRun`), `validation` (pocet kroku, nedodelky, druh), `webhook` (token, posledni volani), `runs` (historie po dnech), `seed`, `seedDemo`, `remap` | +| `src/data/services.ts` | fasada nad `src/data/services/` | +| `src/data/services/index.ts` | katalog za behu: `findService`, `actionsFor`, `setScriptActions`, `setMcpOperations`, `withRuntimeOptions`, `serviceCatalog` | +| `src/data/services/catalog/` | staticky zapis po skupinach: `triggers`, `incident`, `ticket`, `crm`, `finance`, `logistics`, `email`, `messaging`, `social`, `office`, `analytics`, `ai`, `mcp`, `tools`, `polstryn`; `index.ts` urcuje poradi v nabidce | | `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` | 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/runtime/` | fronta, worker, executor stromu, vestavene kroky, planovac, sandbox skriptu firmy | +| `src/runtime/scripts/` | runtime skriptu konektoru: registr, runner, HTTP, napojeni, manifest, kontrola hodnot, `mapping`, `util` (vcetne `ctx.util`) | +| `connectors/` | skripty konektoru (obycejny JS) a `_sablona.js`; cesta z `config.scriptsDir`, promenna `SCRIPTS_DIR` | | `src/mcp/` | klient MCP, prihlaseni, dialekty, `errors.ts` se spolecnou chybou prihlaseni | +| `tests/` | vitest, stejna cesta jako modul (`tests/data/tickets.test.ts` pro `src/data/tickets/`); `setup.ts` nastavi rezim pameti a umlci `console.info` | ## Mapa kodu - web @@ -81,24 +138,51 @@ image jen `dist`, takze staci jedna slozka. | `web/src/config/brand.ts` | vsechny firemni udaje na jednom miste | | `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, 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/lib/exampleBody.ts` | vzorove telo spoustece pro ukazku a strom modelu | +| `web/src/lib/serviceIcons.ts` | klic ikony ze serveru na komponentu lucide | +| `web/src/hooks/useApiQuery.ts` | nacitani dat, cache, spolecny debounce, `refreshing` misto odmontovani | +| `web/src/hooks/useSubmit.ts` | odeslani formulare: `busy`, chyba, `issues` na jednom miste | +| `web/src/hooks/useUnsavedChanges.ts` | varovani pri odchodu z rozepsaneho formulare | +| `web/src/hooks/useLatest.ts` | ref s posledni hodnotou pro callbacky mimo zavislosti effectu | +| `web/src/hooks/useSyncFromSource.ts` | prevzeti dat ze zdroje do rozepsaneho stavu pri vykresleni, ne v effectu | +| `web/src/hooks/useMediaQuery.ts` | sirka obrazovky pres `useSyncExternalStore` | +| `web/src/hooks/usePageMeta.ts` | titulek a popis stranky | | `web/src/types/` | fasada nad `src/shared` (alias `@shared/*`), zadne vlastni typy API | -| `web/src/components/ui/` | zakladni prvky, `Chip` | +| `web/src/components/ui/` | zakladni prvky: `Badge`, `Button`, `Card`, `Chip`, `Modal`, `Section`, `Spinner`, ... | +| `web/src/components/ui/Table.tsx` | `Table`, `TableHead`, `Th`, `TableRow`, `Td`: jedna tabulka seznamu pro `EntityAdmin`, `InvitePanel`, `People`, `AuditView` | +| `web/src/components/ui/ServiceIcon.tsx` | ikona sluzby podle klice z katalogu, misto `const Icon = serviceIcon()` v JSX | | `web/src/components/ui/form/` | `Field`, `Input`, `Select`, `Textarea`, `controlClass`: jedna sada trid | | `web/src/components/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/EntityAdmin.tsx`, `EntityForm.tsx` | sprava jedne entity: tabulka a formular v modalu | +| `web/src/components/dashboard/flow/` | strom akci: `FlowCanvas` a karty `ActionCard`, `ConditionCard`, `ForeachCard`, `StepControls`; k tomu `TriggerConfig`, `SampleBody`, `ModelTree`, `WebhookCalls`, `MappingEditor` | +| `web/src/components/dashboard/scripts/` | `TestPanel` (zkusebni spusteni) a `CodeEditor` (uprava kodu) pro stranku Skripty | +| `web/src/components/dashboard/settings/` | `FeaturesAdmin` (zalozky a limity), `AuditView`, `types` | +| `web/src/components/dashboard/widgets/` | `WidgetCard`, `WidgetPicker`, `CustomWidget`, `EditBar` (lista uprav rozlozeni) | | `web/src/components/dashboard/TicketTrace.tsx` | log ticketu jako strom | | `web/src/components/dashboard/TicketWorkload.tsx` | prehled, kdo co ma u sebe | | `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 | +### Soubory nad 500 radku + +Zasada rika, ze soubor nad 500 radku je signal k rozdeleni. Po rozdeleni +zustavaji ctyri, kazdy z duvodu: + +| Soubor | Proc zustava | +| ---------------------------------------------- | ---------------------------------------------------------------------- | +| `web/src/pages/dashboard/AutomationDetail.tsx` | stranka drzi stav stromu a ukladani; casti bez stavu uz jsou ve `flow/` | +| `web/src/pages/dashboard/TicketDetail.tsx` | detail sklada sest komponent, zbytek je stav a odeslani akci | +| `web/src/components/dashboard/flow/MappingEditor.tsx` | dva rezimy editoru nad jednim stavem, deleni by stav zdvojilo | +| `src/data/services/catalog/ticket.ts` | jedna sluzba s nejvic operacemi; deleni jedne sluzby do dvou souboru by rozbilo "jedna vec v jednom souboru" | + +Dalsi velke soubory (`builtinSteps.ts`, `mcp/client.ts`, `executor.ts`, +`routes/connectors.ts`) jsou kandidati na priste, az se do nich bude sahat. + ## Klicova rozhodnuti **Jeden container misto dvou.** AppFactory nasazuje jednu aplikaci, proto Express diff --git a/documentation/04-api.md b/documentation/04-api.md index 5633deb..49bcfa7 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -103,7 +103,7 @@ fabrika (`src/routes/crud.ts`): `widgets`, `features`. `people` ma stejne cesty a stejne pravo (`people.manage`), ale vlastni -handlery v `settings.ts`: zaznam, ktery se meni, je ucet bez firmy +handlery v `src/routes/settings/people.ts`: zaznam, ktery se meni, je ucet bez firmy a odpoved je pohled za jednu firmu, coz fabrika neumi. Popis je nize v sekci Lide. @@ -458,5 +458,5 @@ zkusit to znovu. ## Pri pridani endpointu -Soucasne aktualizovat `src/openapi.ts` a tenhle soubor. Swagger musi odpovidat +Soucasne aktualizovat `src/openapi/paths/*.ts` a tenhle soubor. Swagger musi odpovidat skutecnemu chovani aplikace, jinak je horsi nez zadny. diff --git a/documentation/05-dashboard-a-builder.md b/documentation/05-dashboard-a-builder.md index 1dfbcd1..ac66ab8 100644 --- a/documentation/05-dashboard-a-builder.md +++ b/documentation/05-dashboard-a-builder.md @@ -148,7 +148,15 @@ 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`. +Odchod z rozepsaneho stromu hlida `hooks/useUnsavedChanges.ts`. + +Stranka `pages/dashboard/AutomationDetail.tsx` uz jen drzi stav stromu +a ukladani. Casti bez vlastniho stavu jsou ve `flow/`: `TriggerConfig.tsx` +(nastaveni spoustece, vstupy z `ui/form`), `SampleBody.tsx` (ukazka tela), +`ModelTree.tsx` (strom modelu), `WebhookCalls.tsx` (posledni volani +webhooku); vzorove telo sklada `lib/exampleBody.ts`. Prevzeti nacteneho +stromu do rozepsaneho stavu dela `hooks/useSyncFromSource.ts` uz pri +vykresleni, ne v effectu, takze stara verze neproblikne. 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 @@ -205,7 +213,7 @@ je vypise. Rozdelana prace se nikdy nezahazuje. ## Pridani konektoru -1. Pridat zaznam do katalogu v `src/data/services.ts` vcetne `triggers` +1. Pridat zaznam do katalogu v `src/data/services/catalog/.ts` vcetne `triggers` a `actions`. ID operace musi byt v ramci sluzby unikatni, `checkOperationIds()` duplicitu pri nacteni zaloguje - druha by tise prekryla prvni. diff --git a/documentation/06-tickety.md b/documentation/06-tickety.md index 4523e47..f2f1e03 100644 --- a/documentation/06-tickety.md +++ b/documentation/06-tickety.md @@ -25,7 +25,7 @@ a **co ktera sluzba vratila**. Kdyz neco nesedi, neni potreba hadat. ## Datovy model -`src/data/ticketStore.ts` +`src/data/tickets/model.ts` (fasada `src/data/ticketStore.ts`) ```ts interface Ticket { @@ -415,7 +415,7 @@ automatizace na kanal, smerovani je jedna spolecna nad vsemi tickety. ## Vzorove automatizace -V `automationStore.ts` jsou nasazene presne v tomhle rozdeleni: +V `src/data/automations/seed.ts` (ukazkove v `seedDemo.ts`) jsou nasazene presne v tomhle rozdeleni: | Automatizace | Co ukazuje | | ----------------------------- | --------------------------------------------------- | @@ -459,7 +459,7 @@ Katalog je proto deklaruje v `providedFields` u operace. Chovaji se pak takhle: - builder je ukazuje **jen ke cteni**, pridat ani prejmenovat nejdou, - server je pri ulozeni stromu **vzdy dosadi z katalogu** a to, co poslal klient, - zahodi (`normalizeTriggerFields` v `src/routes/dashboard.ts`), + zahodi (`normalizeTriggerFields` v `src/routes/dashboard/automations.ts`), - dosazeni probiha **pred validaci**, jinak by podminky odkazujici na katalogova ID vypadaly jako rozbite. diff --git a/documentation/07-firmy-a-prava.md b/documentation/07-firmy-a-prava.md index 8d29d18..ba80204 100644 --- a/documentation/07-firmy-a-prava.md +++ b/documentation/07-firmy-a-prava.md @@ -219,14 +219,14 @@ 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 | jen spravce platformy (`platformOnly` u CRUD firem) | `src/routes/settings/tenants.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` | -| resitel (clen firmy, Lide) | `people.manage` jen ve sve firme, zaklada ucet s clenstvim | `src/routes/settings.ts` | +| uzivatel | spravce platformy, nebo `user.manage` jen ve sve firme | `src/routes/settings/users.ts` | +| resitel (clen firmy, Lide) | `people.manage` jen ve sve firme, zaklada ucet s clenstvim | `src/routes/settings/people.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` | -| stav incidentu | `incident.manage` za firmu incidentu, platformni jen spravce platformy | `src/routes/dashboard.ts` | +| konektor | `connector.manage` | `src/routes/connectors.ts`, `dashboard/misc.ts` | +| automatizace | `automation.edit` za firmu automatizace | `src/routes/dashboard/automations.ts` | +| stav incidentu | `incident.manage` za firmu incidentu, platformni jen spravce platformy | `src/routes/dashboard/incidents.ts` | | akce nad ticketem | pravo akce za firmu ticketu a strop viditelnosti | `src/routes/ticketActions.ts` | Spravce firmy s `user.manage` ma **jen svou firmu**: nenastavi `platformAdmin`, diff --git a/documentation/08-dashboard-widgety.md b/documentation/08-dashboard-widgety.md index e743f4d..e16e832 100644 --- a/documentation/08-dashboard-widgety.md +++ b/documentation/08-dashboard-widgety.md @@ -99,6 +99,16 @@ Neulozit je tady spravne. Klient by dostal zpatky neco, co neumi vykreslit. Widget, ktery mezitim z katalogu zmizel, se v prehledu ukaze jako cervena karta s jeho ID. Nesmi tise vypadnout z rozlozeni. +## Kde to je na klientovi + +Stranka `pages/dashboard/Overview.tsx` sklada `widgets/WidgetCard.tsx` +(vykresleni podle druhu), `widgets/WidgetPicker.tsx` (nabidka z katalogu), +`widgets/CustomWidget.tsx` (widgety, jejichz data pocita server) +a `widgets/EditBar.tsx` (lista rezimu uprav: ulozit, zahodit, obnovit +vychozi). Vstupy v rezimu uprav jsou z `components/ui/form`, stranka si zadny +vlastni nekresli. Server: `src/routes/dashboard/layout.ts`, +`src/data/widgets.ts`, `src/data/dashboardLayouts.ts`. + ## Jak pridat widget 1. Zaznam do `widgets` v `src/data/widgets.ts`. diff --git a/documentation/09-navrh-rozsireni.md b/documentation/09-navrh-rozsireni.md index 6a5bbf1..3a34387 100644 --- a/documentation/09-navrh-rozsireni.md +++ b/documentation/09-navrh-rozsireni.md @@ -1121,15 +1121,15 @@ Seznam mist, ktera navrh meni a je potreba je hlidat. | `accessFor(user)` -> `accessFor(user, tenantId)` | vsechny routy dashboardu, prava jsou az uvnitr firmy | | `Membership.role` -> `roleIds` | `types.ts`, `users.ts`, `access.ts`, `middleware/auth.ts` | | `requireRole` -> `requirePermission` | `src/middleware/auth.ts` a vsechna jeho pouziti | -| `Ticket` dostane `typeId` a `fields` | `ticketStore.ts`, `openapi.ts`, `web/src/types/dashboard.ts`, seznam, detail, simulace | +| `Ticket` dostane `typeId` a `fields` | `src/data/tickets/`, `src/openapi/paths/tickets.ts`, `src/shared/tickets.ts`, seznam, detail, simulace | | Katalog konektoru prestane byt spolecny | `connectors.ts`, `GET /connectors`, `Connectors.tsx` - vraci se za firmu | | `ConnectorStatus` se prestane cist z katalogu | pocita se z napojeni, dnes je to pevne pole | -| `FlowStep` dostane `connectionId` | `automationStore.ts`, `flow.ts`, validace stromu, builder | +| `FlowStep` dostane `connectionId` | `src/data/automations/`, `flow.ts`, validace stromu, builder | | Zalozky ze serveru | `web/src/components/dashboard/DashboardLayout.tsx`, dnes konstanta | | `WidgetKind` -> `render` plus `source` | `widgets.ts`, `WidgetCard.tsx`, ulozena rozlozeni potrebuji prevod ID | | Novy druh kroku `wait` a `call` | `flow.ts`, `flowScope.ts`, `FlowCanvas.tsx`, validace | | `visibleWhen` potrebuje AND vice podminek | model podminek dnes umi jedno porovnani, viz `conditions.ts` | -| Log ticketu potrebuje redakci tajemstvi | `ticketStore.ts`, zapis `response` do trace | +| Log ticketu potrebuje redakci tajemstvi | `src/data/tickets/trace.ts`, zapis `response` do trace | | Data z pameti do Postgresu | cele `src/data/`, routy zustavaji | Dve veci k modelu podminek. `visibleWhen` u akce potrebuje spojit vic porovnani, @@ -1138,5 +1138,5 @@ s AND na obou stranach (`src/data/conditions.ts` i `web/src/lib/flow.ts`, vzdy obe), nebo AND drzet jen u akci a nemichat to do stromu. Druha varianta je levnejsi, prvni upravnejsi. -A pri kazdem novem endpointu soucasne `src/openapi.ts` a tuhle dokumentaci. +A pri kazdem novem endpointu soucasne `src/openapi/paths/*.ts` a tuhle dokumentaci. Swagger, ktery neodpovida chovani, je horsi nez zadny. diff --git a/documentation/11-skripty-konektoru.md b/documentation/11-skripty-konektoru.md index e47a117..3b3c9fc 100644 --- a/documentation/11-skripty-konektoru.md +++ b/documentation/11-skripty-konektoru.md @@ -17,22 +17,32 @@ zeptat na jeho vystupy. ## Kde to je ``` -scripts/ soubory skriptu, obycejny JavaScript - _sablona.js sablona ke zkopirovani (podtrzitko = nenacita se) +connectors/ soubory skriptu, obycejny JavaScript + _sablona.js sablona ke zkopirovani (podtrzitko = nenacita se) idoklad.get-issued-invoice.js ... -src/scripts/types.ts co je skript, zod schema manifestu -src/scripts/values.ts kontrola vstupu a vystupu -src/scripts/util.ts pomocne funkce pro skripty, redakce tajemstvi -src/scripts/connections.ts kam se vola a cim se to autorizuje (podle konektoru) -src/scripts/http.ts HTTP klient predany skriptu -src/scripts/manifest.ts overeni manifestu, prevod na operaci katalogu -src/scripts/registry.ts nacitani ze souboru, hot reload, ukladani -src/scripts/runner.ts spusteni jednoho skriptu -src/routes/scripts.ts API -web/src/pages/dashboard/Scripts.tsx stranka /dashboard/skripty +src/runtime/scripts/types.ts co je skript, zod schema manifestu +src/runtime/scripts/values.ts kontrola vstupu a vystupu +src/runtime/scripts/util.ts pomocne funkce pro skripty (`ctx.util`), redakce tajemstvi +src/runtime/scripts/connections.ts kam se vola a cim se to autorizuje (podle konektoru) +src/runtime/scripts/http.ts HTTP klient predany skriptu +src/runtime/scripts/manifest.ts overeni manifestu, prevod na operaci katalogu +src/runtime/scripts/registry.ts nacitani ze souboru, hot reload, ukladani +src/runtime/scripts/runner.ts spusteni jednoho skriptu +src/runtime/scripts/lookup.ts ktery skript obsluhuje operaci katalogu +src/runtime/scripts/mapping.ts engine transformaci, viz 13-transformace-dat.md +src/routes/scripts.ts API +web/src/pages/dashboard/Scripts.tsx stranka /dashboard/skripty +web/src/components/dashboard/scripts/ TestPanel (zkusebni spusteni), CodeEditor (uprava kodu) ``` +Slozka se skriptu se jmenuje `connectors/`, protoze `scripts/` je podle zasad +misto pro pomocne skripty vyvoje, ne pro kod, ktery aplikace nacita za behu. +Cestu urcuje `config.scriptsDir` (promenna `SCRIPTS_DIR`, vychozi +`./connectors`), Dockerfile slozku kopiruje do image. Lint (`eslint.config.js`) +ji kontroluje jako obycejny JS **bez globalu**, takze `Buffer` nebo `process` +ve skriptu neprojde. + ## Nic se neotaci Soubory jsou zamerne **obycejny JavaScript, ne TypeScript**. TypeScript by se @@ -58,13 +68,13 @@ Nesoulad je chyba, ne varovani - jinak by se skript ulozil pod jednim jmenem a nacetl pod druhym. ``` -scripts/idoklad.get-issued-invoice.js +connectors/idoklad.get-issued-invoice.js \_____/ \________________/ sluzba operace ``` Z ID se dopocita, do ktere sluzby operace patri, takze se to nepise dvakrat. -Sluzba **musi existovat** v `src/data/services.ts`, jinak se skript ohlasi +Sluzba **musi existovat** v katalogu (`src/data/services/catalog/`), jinak se skript ohlasi jako problem. ## Manifest @@ -120,8 +130,8 @@ druhem (`output`). ## Co skript ma a co nema -Skript ma jen `ctx`. Zadny import, zadny pristup na sit mimo `ctx.http` -a **zadne pristupove udaje**. +Skript ma jen `ctx`. Zadny import, zadny global Node (`Buffer`, `process`), +zadny pristup na sit mimo `ctx.http` a **zadne pristupove udaje**. ```js export async function run(inputs, ctx) { /* ... */ } @@ -179,12 +189,15 @@ z nich by to resil spatne. | `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 | +| `base64(value)` | text do Base64. Skript nema `Buffer` ani `btoa`, kodovani mu da runtime | -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. +Ctyri z nich (`day`, `list`, `addresses`, `quote`) pribyly v zari 2026, kdyz +se ukazalo, ze osm skriptu ma kazdy svou verzi. `base64` pribyl pri prevodu +na zasady: `google.send-email` skladal zpravu pres `Buffer`, tedy pres global +Node, ktery skript mit nema. Sablona `connectors/_sablona.js` vsechny funkce +vypisuje, aby se nehledaly v kodu serveru. -`ctx.config` je slozene z `scriptConfig` v `src/scripts/connections.ts`: +`ctx.config` je slozene z `scriptConfig` v `src/runtime/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 @@ -222,13 +235,13 @@ nastaveni: | --------------------------- | ------------------------------------------------------ | | `SERVICES_BASE_URL` | zaklad adres, vychozi `https://services.csbot.cz/apps` | | `_BASE_URL` | presmerovani jedne sluzby, napr. `OPENAI_BASE_URL` | -| `SCRIPTS_DIR` | jina slozka se skripty | +| `SCRIPTS_DIR` | jina slozka se skripty, vychozi `./connectors` | | `SCRIPT_TIMEOUT_MS` | vychozi strop na beh, 15000 | | `SCRIPT_MAX_RESPONSE_BYTES` | strop na velikost odpovedi, 1000000 | | `SCRIPT_MAX_UPLOAD_BYTES` | strop na odeslany soubor, 10000000 | | `ALLOW_PRIVATE_TARGETS` | povoli volani na localhost, **jen pro lokalni vyvoj** | -Co ktera sluzba vyzaduje, je v `credentials` u sluzby v `src/data/services.ts`. +Co ktera sluzba vyzaduje, je v `credentials` u sluzby v `src/data/services/catalog/`. Hodnoty patri konektoru a zadavaji se v portalu. ## Redakce tajemstvi @@ -244,7 +257,7 @@ Redaktor (`createRedactor`) maskuje tajemstvi ve **ctyrech tvarech**: 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` +Zkracovani ma jednu konstantu, `DETAIL_BYTES` v `src/runtime/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. @@ -290,7 +303,7 @@ Skript se domeri do katalogu sluzeb jako akce s `implementation: 'script'` a `scriptId`. Kdyz nese ID operace, ktera uz v katalogu je, **skript vyhrava** - staticky zapis je popis toho, co umime, skript je to, co se opravdu stane. -Prekryv drzi `src/data/services.ts` (`setScriptActions`, `actionsFor`). +Prekryv drzi `src/data/services/index.ts` (`setScriptActions`, `actionsFor`). Je to zamerne tam, protoze vsechno ostatni se uz pta pres `findOperation`. Tim se skripty naraz objevi ve validaci stromu, ve vypoctu toho, co je v kterem kroku videt, i v sablonach - bez toho, aby se to psalo trikrat. @@ -359,11 +372,11 @@ cele je. ## Jak pridat skript -1. Zkopirovat `scripts/_sablona.js` na `..js`. +1. Zkopirovat `connectors/_sablona.js` na `..js`. 2. Srovnat `manifest.id` s nazvem souboru. 3. Vyplnit `inputs` a `outputs`. 4. Napsat `run`. -5. Kdyz sluzba jeste neni v `src/data/services.ts`, pridat ji. +5. Kdyz sluzba jeste neni v katalogu (`src/data/services/catalog/.ts`), pridat ji. 6. Kdyz potrebuje pristupove udaje, popsat je v `credentials` u te sluzby. Hodnoty pak zada firma v konektoru. diff --git a/documentation/12-sluzby-a-konektory.md b/documentation/12-sluzby-a-konektory.md index 4071f27..baafc0e 100644 --- a/documentation/12-sluzby-a-konektory.md +++ b/documentation/12-sluzby-a-konektory.md @@ -327,11 +327,11 @@ Cte se zvlast pres `/connectors/:id/checks`. ## Jak pridat sluzbu -1. Zaznam do `services` v `src/data/services.ts`: kategorie, ikona, `general`, +1. Zaznam do skupiny v `src/data/services/catalog/.ts` (fasada `src/data/services.ts`): kategorie, ikona, `general`, `appId` (nebo `baseUrl` u cizi sluzby), `visibility`, `credentials`, pripadne `verifyPath`. 2. Pokud pouziva novou ikonu, doplnit klic do `web/src/lib/serviceIcons.ts`. -3. Skripty operaci do `scripts/..js`, viz +3. Skripty operaci do `connectors/..js`, viz [11-skripty-konektoru.md](11-skripty-konektoru.md). Katalog, builder, stranka Sluzby i zakladani konektoru si ji vezmou samy. @@ -342,7 +342,7 @@ Kdo se v kodu orientoval podle stareho pojmenovani: | Driv | Ted | | --------------------------------- | ----------------------------- | -| `src/data/connectors.ts` | `src/data/services.ts` | +| `src/data/connectors.ts` | `src/data/services/` (fasada `services.ts`) | | `Connector`, `ConnectorOperation` | `Service`, `ServiceOperation` | | `connectorCategories` | `serviceCategories` | | `findConnector` | `findService` | diff --git a/documentation/13-transformace-dat.md b/documentation/13-transformace-dat.md index 34a4bd9..90766b8 100644 --- a/documentation/13-transformace-dat.md +++ b/documentation/13-transformace-dat.md @@ -72,7 +72,7 @@ nemuze se v nem udelat preklep v zavorce. ## Dva rezimy -Obe moznosti stoji na tom samem enginu v `src/scripts/mapping.ts`. +Obe moznosti stoji na tom samem enginu v `src/runtime/scripts/mapping.ts`. Volba je o tom, cehoz je vic: | Rezim | Kdy | Skript | @@ -271,10 +271,10 @@ mluvi o jejich schematu, ne o nasich datech. ## Kde to je ``` -src/scripts/mapping.ts engine: cesty, prevody, pravidla, sablona -scripts/transform.map-fields.js rezim 1 -scripts/transform.to-json.js rezim 2 -scripts/idoklad.create-invoice-from-object.js druha polovina prikladu +src/runtime/scripts/mapping.ts engine: cesty, prevody, pravidla, sablona +connectors/transform.map-fields.js rezim 1 +connectors/transform.to-json.js rezim 2 +connectors/idoklad.create-invoice-from-object.js druha polovina prikladu web/src/components/dashboard/flow/MappingEditor.tsx klikaci editor pravidel web/src/components/dashboard/flow/StepInputs.tsx pole typu mapping, json a object ``` diff --git a/documentation/14-databaze.md b/documentation/14-databaze.md index 4cc70cb..75d5c06 100644 --- a/documentation/14-databaze.md +++ b/documentation/14-databaze.md @@ -114,7 +114,9 @@ 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 +zaznam. U ticketu a automatizaci je zapis a nacteni pri startu v modulu +`persist.ts` jejich slozky (`src/data/tickets/`, `src/data/automations/`); +`ticketStore.ts` a `automationStore.ts` jsou uz jen fasady. 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 diff --git a/documentation/15-rejstrik-funkci.md b/documentation/15-rejstrik-funkci.md index 676cf09..8d4f37d 100644 --- a/documentation/15-rejstrik-funkci.md +++ b/documentation/15-rejstrik-funkci.md @@ -62,13 +62,48 @@ Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět. | `personName(id)` | `src/data/people.ts` | Jméno účtu bez ohledu na firmu, pro popisky u záznamů, které už prošly filtrem na firmu. | | `personIdFor(user, tenantId)` | `src/data/people.ts` | ID řešitele, kterým je uživatel ve firmě: ID účtu při členství, jinak `null`. Neptat se `user.id` přímo. | | `findPersonByExternalId(value, tenantIds)` | `src/data/people.ts` | Řešitel podle ID z cizí aplikace, například voicebotId. Externí ID visí na členství. | -| `migratePeople()` | `src/data/migratePeople.ts` | Jednorázový převod starých záznamů řešitelů (`ppl_`) na účty při startu. Přepisuje odkazy přes `remapPersonIds` v `ticketStore.ts` a `automationStore.ts`. | +| `migratePeople()` | `src/data/migratePeople.ts` | Jednorázový převod starých záznamů řešitelů (`ppl_`) na účty při startu. Přepisuje odkazy přes `remapPersonIds` v `tickets/remap.ts` a `automations/remap.ts`. | | `notify(input)` | `src/data/notifications.ts` | Upozorní člověka. Nečeká se a nevyhazuje chyby, stejně jako audit. | | `runFlow(steps, context, options)` | `src/runtime/executor.ts` | Vykoná strom kroků. Nikdy nevyhodí výjimku, chyba je výsledek. Používá to akce na ticketu i webhook, aby se strom choval všude stejně. | | `widgetCatalog(tenantIds, userId)` | `src/data/widgets.ts` | Jediná definice toho, co jde položit na dashboard. Používá ji nabídka i kontrola ukládaného rozložení. | -| `intakeEvent(input)` | `src/data/ticketStore.ts` | Přijme událost zvenku: podle externího ID buď založí ticket, nebo ji navěsí na existující. Jediná cesta, kterou se událost stává ticketem. Hodnoty z `input.apply` zapíše v obou případech, prázdné nemaže. | -| `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. | +| `intakeEvent(input)` | `src/data/tickets/intake.ts` | Přijme událost zvenku: podle externího ID buď založí ticket, nebo ji navěsí na existující. Jediná cesta, kterou se událost stává ticketem. Hodnoty z `input.apply` zapíše v obou případech, prázdné nemaže. | +| `getAgentStats(...)` | `src/data/tickets/stats.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/tickets/queries.ts` | Ticket firmy podle externího ID. Klíč je dvojice firma a ID. | +| `createApp()` | `src/app.ts` | Sestavi Express aplikaci: middleware, routery, health, Swagger, statika, SPA. Bez `listen` a bez nacteni dat, takze jde postavit v testu (supertest). `index.ts` ji jen spusti. | +| `config.serviceBaseUrlOverride(variable)` | `src/config.ts` | Jedine cteni `process.env` s dynamickym nazvem (`_BASE_URL`). Vraci normalizovanou adresu nebo `null`. Nikde jinde se `process.env` necte. | +| `buildOpenApiDocument()` | `src/openapi/index.ts` | Sklada OpenAPI z `components.ts` a `paths/*.ts`. Novy endpoint se popisuje v souboru sveho routeru, `crudPaths` v `helpers.ts` popise CRUD petici jednim radkem. | +| `pageFrom(query)`, `paginate(items, page)` | `src/routes/dashboard/shared.ts` | Strankovani seznamu ticketu a fronty behu: `limit`, `offset`, strop `MAX_PAGE_LIMIT`. Nepsat vlastni `slice` v route. | +| `memberOf(user, tenantId)` | `src/routes/settings/shared.ts` | Je ucet clenem firmy? Sdili sprava uctu a resitelu. | + +### Moduly dat po rozdeleni + +Puvodni soubory zustavaji jako fasady (`ticketStore.ts`, `automationStore.ts`, +`services.ts`), importy se nemeni. Kdo hleda, kde co je: + +| Modul | Co drzi | +| ---------------------------------- | ------------------------------------------------------------------------------------------- | +| `tickets/index.ts` | verejne API slozky, seed a `initTickets` v poradi, ve kterem se maji volat | +| `tickets/model.ts` | `StoredTicket`, `defaultStatuses`, `channelLabels`, `toTicket` (doplneni vychozich hodnot) | +| `tickets/state.ts` | pole ticketu, indexy podle ID a externiho ID, log, udalosti, citace ID | +| `tickets/persist.ts` | `persist`, `touch` (`updatedAt` a zapis v jednom), `initTickets` | +| `tickets/queries.ts` | `listTickets` s `TicketFilter`, `getTicket`, `findTicket`, `findByExternalId`, `ticketWithinVisibility` | +| `tickets/store.ts` | zapisy: `createTicket`, `updateTicketStatus`, `assignTicket`, `setTicketType`, `setTicketTags`, `assignTicketGroup`, `claimTicket`, `addComment` | +| `tickets/intake.ts` | `intakeEvent`: udalost zvenku se stane ticketem nebo se navesi | +| `tickets/trace.ts` | `appendTrace`, `flattenTrace`, `lastTraceId`, `describePayload`: log prubehu | +| `tickets/stats.ts` | `getWorkload`, `getAgentStats` | +| `tickets/seed.ts`, `remap.ts` | ukazkova data (`SEED_DEMO=1`), preznaceni resitelu pri migraci | +| `automations/index.ts` | verejne API slozky, seed a `initAutomations` | +| `automations/model.ts` | `StoredAutomation`, `rulesOf`, `matchOf` (cteni podminky ve stare i nove podobe) | +| `automations/state.ts` | `Map` automatizaci, `nextId`, `findWritable` | +| `automations/persist.ts` | `save`, `initAutomations`, `mirror` | +| `automations/store.ts` | `listAutomations`, `getAutomation`, `createAutomation`, `updateAutomation`, `regenerateWebhookToken`, `findByWebhookToken`, `recordRun`, `deleteAutomation` | +| `automations/validation.ts` | `countSteps`, `collectFlowIssues`, `deriveKind`, `withDerived`: ciste funkce nad stromem | +| `automations/webhook.ts` | `generateWebhookToken`, `withWebhookToken`, `recordWebhookCall`, `recentWebhookCalls`, `bodyForCall` | +| `automations/runs.ts` | historie behu po dnech, `KEEP_DAYS`, `statsOf` | +| `automations/seed.ts`, `seedDemo.ts`, `remap.ts` | skutecne automatizace (vzdy), ukazkove (`SEED_DEMO=1`), preznaceni resitelu | +| `services/index.ts` | katalog za behu: `findService`, `findOperation`, `actionsFor`, `serviceCatalog`, `setScriptActions`, `setMcpOperations`, `withRuntimeOptions`, `visibleServices` | +| `services/catalog/index.ts` | `services` a `serviceCategories` slozene ze skupin; poradi tady je poradi v nabidce | +| `services/catalog/.ts` | staticky zapis sluzeb jedne skupiny: `triggers`, `incident`, `ticket`, `crm`, `finance`, `logistics`, `email`, `messaging`, `social`, `office`, `analytics`, `ai`, `mcp`, `tools`, `polstryn` | | `findByIntakeToken(token)` | `src/data/tenants.ts` | Firma podle tokenu příjmu. Určuje i to, v jakém rozsahu je externí ID unikátní. | | `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. | @@ -79,25 +114,25 @@ Viz [11-skripty-konektoru.md](11-skripty-konektoru.md). | Co | Kde | K čemu | | ------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `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`, `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. | -| `serviceBaseUrl(service)` | `src/scripts/connections.ts` | Adresa služby: naše aplikace ze `SERVICES_BASE_URL`, cizí (OpenAI) z jejího `baseUrl`. Přebít jde přes `_BASE_URL`. | -| `targetSecrets(target)` | `src/scripts/connections.ts` | Co se musí vyškrtat z logu. Vrací i holý klíč bez předpony `Bearer `, protože v něm ho cizí služby vracejí v chybách. | -| `createHttp(...)` | `src/scripts/http.ts` | HTTP se timeoutem, limitem odpovědi a rozlišením "zkusit znovu" a "marné". | -| `isPrivateHost(host)` | `src/scripts/http.ts` | Míří jméno do vnitřní sítě? Jedno pravidlo pro HTTP i pro SMTP server z konektoru. | +| `runScript(id, inputs, ctx)` | `src/runtime/scripts/runner.ts` | Spustí skript. **Nikdy nevyhodí výjimku**, chybu vrací jako výsledek s celým hlášením. | +| `validateValues(...)` | `src/runtime/scripts/values.ts` | Jedna kontrola pro vstupy i výstupy skriptu podle manifestu. | +| `scriptUtil` | `src/runtime/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/runtime/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/runtime/scripts/util.ts` | Jeden limit na zkracování detailu chyby pro všechny vrstvy. Žádné vlastní `slice(0, 600)`. | +| `createRedactor(...)` | `src/runtime/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/runtime/scripts/connections.ts` | Nastavení napojení bez tajných polí. Jediné, co skript dostane jako `ctx.config`. | +| `applyRules`, `fillJson` | `src/runtime/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/runtime/scripts/mapping.ts` | Čtení `zakaznik.adresa.mesto` z neznámého objektu. | +| `resolveTarget(...)` | `src/runtime/scripts/connections.ts` | Z konektoru poskládá adresu a hlavičky. Přístupové údaje nikam jinam nevedou. | +| `serviceBaseUrl(service)` | `src/runtime/scripts/connections.ts` | Adresa služby: naše aplikace ze `SERVICES_BASE_URL`, cizí (OpenAI) z jejího `baseUrl`. Přebít jde přes `_BASE_URL`. | +| `targetSecrets(target)` | `src/runtime/scripts/connections.ts` | Co se musí vyškrtat z logu. Vrací i holý klíč bez předpony `Bearer `, protože v něm ho cizí služby vracejí v chybách. | +| `createHttp(...)` | `src/runtime/scripts/http.ts` | HTTP se timeoutem, limitem odpovědi a rozlišením "zkusit znovu" a "marné". | +| `isPrivateHost(host)` | `src/runtime/scripts/http.ts` | Míří jméno do vnitřní sítě? Jedno pravidlo pro HTTP i pro SMTP server z konektoru. | | `sendMail(target, message)` | `src/mail/smtp.ts` | Odešle e-mail přes SMTP z konektoru. Nikdy nevyhodí výjimku, vrací i to, jestli má smysl zkusit znovu. | | `verifySmtp(target)` | `src/mail/smtp.ts` | Přihlásí se na server bez odeslání zprávy. Tím se ověřuje konektor e-mailu. | | `escapeHtml(value)` | `src/data/templates.ts` | Escapuje **dosazenou hodnotu** v HTML šabloně. Značky autora šablony zůstávají, ostré závorky od zákazníka ne. | -| `ctx.http.postForm(...)` | `src/scripts/http.ts` | Odeslání souboru (`multipart/form-data`). Obsah přichází jako Base64, hranici dopisuje runtime. | -| `scriptIdFor(serviceId, operationId)` | `src/scripts/lookup.ts` | Který skript obsluhuje operaci z katalogu. | +| `ctx.http.postForm(...)` | `src/runtime/scripts/http.ts` | Odeslání souboru (`multipart/form-data`). Obsah přichází jako Base64, hranici dopisuje runtime. | +| `scriptIdFor(serviceId, operationId)` | `src/runtime/scripts/lookup.ts` | Který skript obsluhuje operaci z katalogu. | ## Klient @@ -115,28 +150,33 @@ 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`, `refreshing` a `total`. S `body` pošle POST, s `enabled: false` se neptá, `patchOn` opraví data z události bez dotazu. | +| `useApiQuery` | `hooks/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. | +| `useSubmit(fn)` | `hooks/useSubmit.ts` | Odeslání formuláře: `saving`, chyba, reset. Dvanáct řádků, které si dřív psal každý formulář zvlášť. | +| `useUnsavedChanges(dirty)` | `hooks/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. | +| `Table`, `TableHead`, `Th`, `TableRow`, `Td` | `components/ui/Table.tsx` | Tabulka seznamu v portalu (hlavicka verzalkami, radky s linkou). Ctyri stranky ji kreslily kazda jinak. Huste tabulky ticketu a vykonu zustavaji zvlast, jsou to jine tabulky. | +| `ServiceIcon` | `components/ui/ServiceIcon.tsx` | Ikona sluzby podle klice z katalogu. Misto `const Icon = serviceIcon(key)` v JSX, ktere lint hlasi jako komponentu vytvorenou pri vykresleni. | +| `EntityForm` | `components/dashboard/EntityForm.tsx` | Formular jedne entity v modalu, pouziva ho `EntityAdmin`. Pole z popisu sloupcu, hodnoty a chyby v propsech. | +| `useLatest(value)` | `hooks/useLatest.ts` | Ref s posledni hodnotou pro callbacky, ktere nemaji byt v zavislostech effectu. Zapis v layout effectu, aby vykresleni zustalo ciste. | +| `useSyncFromSource(source, apply)` | `hooks/useSyncFromSource.ts` | Prevzeti dat ze zdroje do rozepsaneho stavu uz pri vykresleni, ne v `useEffect`. Stara kopie neproblikne a stranka se nekresli dvakrat. | | `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í. | +| `useMediaQuery(query)`, `MD_UP` | `hooks/useMediaQuery.ts` | Tabulka nebo karty podle šířky pres `useSyncExternalStore`, prohlizec je zdroj pravdy. `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. | -| `usePageMeta` | `lib/usePageMeta.ts` | Titulek stránky. | +| `usePageMeta` | `hooks/usePageMeta.ts` | Titulek stránky. | | `Badge`, `Button`, `Modal`, `Card`, ... | `components/ui/` | Základní prvky. Nový vzhled tlačítka patří sem, ne do stránky. | ## Pravidla, která z toho plynou 1. **Nová entita v nastavení**: `defineStore` v modulu entity, řádek v - `bootstrap.ts`, `crudRouter` v `settings.ts`, popis v `Settings.tsx`. + `bootstrap.ts`, `crudRouter` v `src/routes/settings/.ts` a mount v `settings/index.ts`, popis v `Settings.tsx`. Nic jiného se psát nemusí. 2. **Data, která se mění za provozu**, jdou přes `withMirror`. Data, která se čtou při každém requestu a mění zřídka, přes `withCache`. Obojí nikdy. diff --git a/documentation/16-monetizace.md b/documentation/16-monetizace.md index 4444684..dde629f 100644 --- a/documentation/16-monetizace.md +++ b/documentation/16-monetizace.md @@ -43,7 +43,7 @@ i vyúčtovaná částka. Co k tomu je potřeba: -1. **Cena u operace.** Do katalogu (`src/data/services.ts`) přidat +1. **Cena u operace.** Do katalogu (`src/data/services/catalog/`) přidat `priceCzk` k `ServiceOperation`. Chybějící cena znamená 0, ne chybu - nová operace nesmí rozbít odhad. 2. **Očekávaný počet běhů.** Jedno číslo u automatizace, které zadá uživatel diff --git a/documentation/17-nastaveni-a-prava.md b/documentation/17-nastaveni-a-prava.md index 17c4073..a2c0f74 100644 --- a/documentation/17-nastaveni-a-prava.md +++ b/documentation/17-nastaveni-a-prava.md @@ -21,7 +21,7 @@ Proto tři vrstvy, každá napsaná jednou: | Klient | `components/dashboard/EntityAdmin.tsx` | Tabulka, modál, validace, mazání. | Nová entita v nastavení pak znamená: `defineStore` v modulu entity, jeden řádek -v `bootstrap.ts`, jeden `crudRouter` v `settings.ts`, jeden popis v +v `bootstrap.ts`, jeden `crudRouter` v `src/routes/settings/.ts` a mount v `settings/index.ts`, jeden popis v `Settings.tsx`. Nic víc. `crudRouter` navic s volbou `event` publikuje `.created`, `.updated` diff --git a/documentation/18-ticketovaci-system.md b/documentation/18-ticketovaci-system.md index e46bcef..75fe7f1 100644 --- a/documentation/18-ticketovaci-system.md +++ b/documentation/18-ticketovaci-system.md @@ -103,7 +103,7 @@ Medián, ne průměr: jeden ticket zapomenutý přes dovolenou by průměr úpln rozhodil. Fronta se počítá vždycky celá, bez ohledu na období - leží tam bez ohledu na to, na co se zrovna díváme. -Čísla počítá `getAgentStats` v `src/data/ticketStore.ts` a používá je widget +Čísla počítá `getAgentStats` v `src/data/tickets/stats.ts` a používá je widget i detail osoby. Kdyby si je stránka počítala sama, na dvou místech by vyšlo něco jiného. diff --git a/documentation/19-kapacita-200-firem.md b/documentation/19-kapacita-200-firem.md index feb3492..8fed481 100644 --- a/documentation/19-kapacita-200-firem.md +++ b/documentation/19-kapacita-200-firem.md @@ -110,7 +110,7 @@ v návrhu, ne v provozu: | Služba odpoví dvakrát jinak | Klíč proti dvojímu provedení u kroku, aby se nevystavila druhá faktura. | | Služba je pomalá jen pro jednu firmu | Fronta po firmách, aby jedna firma nezablokovala ostatní. | -Timeout a rozlišení "zkusit znovu" a "marné" už v `scripts/http.ts` je, +Timeout a rozlišení "zkusit znovu" a "marné" už v `src/runtime/scripts/http.ts` je, klíč proti dvojímu provedení taky. Chybí to, co je nad tím: fronta, opakování a vypínání služby po sérii chyb. diff --git a/documentation/20-fronta-a-runtime.md b/documentation/20-fronta-a-runtime.md index cab692c..1838cf9 100644 --- a/documentation/20-fronta-a-runtime.md +++ b/documentation/20-fronta-a-runtime.md @@ -113,6 +113,13 @@ behu poznat, co spustil clovek a co cas. Pak beh skonci jako `failed` a zustane k nahlednuti. Nemaze se: bez zaznamu by nikdo nezjistil, ze se neco nestalo. +Prodlevy jsou pole `BACKOFF_MS` v `src/runtime/queue.ts` a index do nej je +`attempts - 1`. Po neuspechu je `attempts` aspon 1, takze index sedi; kdyby +ale prisla nula, `BACKOFF_MS[-1]` je `undefined` a `new Date(NaN)` by beh +naplanoval na nikdy. `noUncheckedIndexedAccess` to odhalil, cteni ma proto +zalohu `MAX_BACKOFF_MS`. Podobna chyba byla v `rateLimit`: pri `max: 0` bylo +`Retry-After` `NaN`, ted je aspon 1 sekunda. + **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`: @@ -302,6 +309,19 @@ k 7. 9. 2026. Stara podoba (`fieldId` primo na kroku) se dal cte, prevadi ji `rulesOf`. +Prvni testy executoru (`tests/runtime/executor.test.ts`, zari 2026) nasly +v podminkach dve skutecne chyby, obe jsou opravene: + +- **Prazdna hodnota se porovnavala jako nula.** `ordered()` delal + `Number('')`, a to je `0`, takze `castka <= 1000` platilo i pro castku, + ktera nikdy nedorazila. Ted prazdna strana znamena "neda se porovnat" + a `gt`, `gte`, `lt`, `lte` jsou nepravda. Pro "nedorazilo" plati dal + pravidlo vyse: ptat se `isNotEmpty`. +- **Hole jmeno vystupu vyhravalo nad `krok.jmeno`.** `conditionValue()` + hledal nejdriv hole jmeno, a to drzi vystup **prvniho** kroku, ktery ho + zapsal. Podminka nad druhym krokem se stejnym nazvem vystupu tak cetla + hodnotu z prvniho. Poradi je ted `fieldId`, `krok.jmeno`, hole jmeno. + Radek podminky v logu proto nese i to, s cim se porovnavalo, a rozlisuje `nedorazilo` od `prázdné`: diff --git a/documentation/21-realne-sluzby.md b/documentation/21-realne-sluzby.md index 6026ff5..1fd005b 100644 --- a/documentation/21-realne-sluzby.md +++ b/documentation/21-realne-sluzby.md @@ -12,7 +12,7 @@ Seznam bezicich aplikaci je na `https://services.csbot.cz/apps`. Kazda ma `/docs` se Swaggerem a `/openapi.json` (u .NET aplikaci `/docs/v1/swagger.json`) se strojove citelnym popisem. -Katalog v `src/data/services.ts` z toho vychazi. **Neni to totez**: jedna +Katalog v `src/data/services/catalog/` z toho vychazi. **Neni to totez**: jedna aplikace muze nest vic sluzeb katalogu a nektere sluzby katalogu zatim zadnou aplikaci nemaji. diff --git a/documentation/24-mcp-konektory.md b/documentation/24-mcp-konektory.md index 262821f..4ceb4cf 100644 --- a/documentation/24-mcp-konektory.md +++ b/documentation/24-mcp-konektory.md @@ -418,7 +418,7 @@ a krok to rekne misto toho, aby predstiral selhani. | Tokeny EasyWebu | `src/mcp/easyweb/session.ts` | | Prevod schemat | `src/mcp/schema.ts` | | Nastroje v katalogu | `src/data/mcpTools.ts` | -| Obe sluzby | `src/data/services.ts` | +| Obe sluzby | `src/data/services/catalog/mcp.ts` | | Nacteni nastroju | `src/routes/connectors.ts` | | Vykonna cast kroku | `src/runtime/builtinSteps.ts`, `runMcpTool` | | Ulozeni u konektoru | `src/data/connectors/*`, migrace `004` | diff --git a/documentation/25-navrh-pristupny-portal.md b/documentation/25-navrh-pristupny-portal.md index a735007..b34edfc 100644 --- a/documentation/25-navrh-pristupny-portal.md +++ b/documentation/25-navrh-pristupny-portal.md @@ -180,7 +180,7 @@ coz je cil, to sedi. | Navrh | Kde to je | | ------------------------------- | ---------------------------------------------------------------- | | `Field`, `Input`, `Select`, `Textarea` | `components/ui/form/`, tridy v `controlClass.ts` | -| `useSubmit` | `lib/useSubmit.ts` | +| `useSubmit` | `hooks/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` | @@ -264,6 +264,13 @@ jsou velke a s widgety nesouvisi. Prevest to, ceho se dotykame (ticket, helpdesk plus verejne stranky, kde je drift videt nejvic, a zbytek nechat doputovat, jak se k nemu bude sahat. +Stav v zari 2026: doputovalo to. Vstupy jsou z `components/ui/form` vsude +vcetne `Overview` a `TriggerConfig`, a tabulky seznamu (`EntityAdmin`, +`InvitePanel`, `People`, `AuditView`) kresli jedna `components/ui/Table.tsx`. +Sprava entit je rozdelena na `EntityAdmin` (tabulka, mazani) a `EntityForm` +(formular v modalu); nastaveni ma `settings/FeaturesAdmin` a `settings/AuditView` +jako vlastni komponenty, `Settings.tsx` je jen sklada. + --- ## 3 - Hledani jako modal s kriterii @@ -409,7 +416,7 @@ members: Array<{ personId: string; seesAll: boolean }> ``` Doporuceni je **b**. `personIds` se cte na sesti mistech (`people.ts`, -`dashboard.ts`, `settings.ts`, `builtinSteps.ts`, `People.tsx`, seed), takze je to +`routes/dashboard/`, `routes/settings/`, `builtinSteps.ts`, `People.tsx`, seed), takze je to hodina prace a ne migrace, ktere by se clovek bal. U varianty a) vznikne za mesic skupina, kde nekdo "vidi vse" a pritom v ni neni. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 4564ee9..8a5c043 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,119 @@ Nejnovejsi nahore. +## 2026-09-09 - Struktura podle zasad: rozdeleni souboru, lint, testy + +`D:\GitHubRepository\CLAUDE.md` dostal zasady pro vsechny projekty (struktura +Node a React, jedno cteni `process.env`, lint a format v repu, testy +v `tests/`, soubor nad 500 radku je signal k rozdeleni). Projekt se od nich +lisil na nekolika mistech naraz: `index.ts` skladal aplikaci i poslouchal, +`routes/dashboard.ts` mel pres tisic radku a `openapi.ts` skoro tri tisice, +katalog sluzeb byl jeden soubor, lint ani testy neexistovaly a `process.env` +se cetl na sesti mistech. Tahle zmena to srovnava; nic z toho nemeni chovani +aplikace, az na dve skryte chyby nize a dve chyby, ktere nasly prvni testy. + +### Co se presunulo + +| Driv | Ted | Proc | +| --------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ | +| `scripts/*.js` | `connectors/*.js` | `scripts/` je podle zasad pro pomocne skripty vyvoje, ne pro kod nacitany za behu | +| `src/scripts/` | `src/runtime/scripts/` | je to runtime, ktery skripty spousti, patri k fronte a executoru | +| `src/index.ts` | `src/index.ts` (jen start) a `src/app.ts` (`createApp()`) | aplikace jde postavit v testu bez portu (supertest) | +| `src/routes/dashboard.ts` | `src/routes/dashboard/` (10 souboru po domenach a `shared.ts`) | jeden router = jedna domena, mount v `index.ts` | +| `src/routes/settings.ts` | `src/routes/settings/` (jedna entita = jeden soubor) | totez | +| `src/openapi.ts` | `src/openapi/{index,helpers,components}.ts` a `paths/*.ts` | popis endpointu lezi u sveho routeru; slozeny dokument ma stejnych 98 cest | +| `src/data/ticketStore.ts` | `src/data/tickets/` (model, state, persist, queries, store, intake, trace, stats, seed, remap) | fasada zustava, importy se nemeni | +| `src/data/automationStore.ts` | `src/data/automations/` (model, state, persist, store, validation, webhook, runs, seed, seedDemo, remap) | totez | +| `src/data/services.ts` | `src/data/services/index.ts` a `catalog/.ts` | jeden soubor na skupinu sluzeb, `catalog/index.ts` drzi poradi | +| `web/src/lib/use*.ts` | `web/src/hooks/` | hooky maji vlastni slozku, `lib/` jsou ciste funkce | + +`config.scriptsDir` ma vychozi `./connectors`, Dockerfile slozku kopiruje, +promenna `SCRIPTS_DIR` se nemeni. `process.env` se ted cte **jen** +v `src/config.ts`; pro `_BASE_URL` pribylo +`config.serviceBaseUrlOverride(variable)`, protoze nazev promenne sklada +katalog a vypisovat kazdou sluzbu do konfigurace by znamenalo dve mista. +Skripty konektoru dostaly `ctx.util.base64`, `google.send-email` uz nesaha na +`Buffer`. + +### Web + +Sdilene prvky: `components/ui/Table.tsx` (ctyri seznamy kreslily tabulku +kazdy jinak: `EntityAdmin`, `InvitePanel`, `People`, `AuditView`), +`components/ui/ServiceIcon.tsx`, vstupy v `Overview` a `TriggerConfig` z +`ui/form` misto vlastnich ``. Rozdelene soubory: +`components/dashboard/scripts/{TestPanel,CodeEditor}`, +`dashboard/settings/{FeaturesAdmin,AuditView,types}`, `flow/{SampleBody,ModelTree,WebhookCalls}`, +`lib/exampleBody.ts`, `components/dashboard/EntityForm.tsx`, `widgets/EditBar.tsx`. +Nad 500 radku zustavaji `AutomationDetail`, `TicketDetail`, `MappingEditor` +a na serveru `data/services/catalog/ticket.ts`; duvody jsou +v [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md). + +Magicka cisla na obou stranach dostala pojmenovane konstanty s komentarem +(`JSON_BODY_LIMIT`, `STATIC_MAX_AGE_SEC`, `MAX_PAGE_LIMIT`, `KEEP_DAYS`, +`WEBHOOK_TOKEN_BYTES`, `MD_UP`, ...), hodnoty se nezmenily. + +### Nastroje + +`eslint.config.js` (typescript-eslint, `react-hooks` v7 pro web, `connectors/` +jako obycejny JS bez globalu, zadne `any`, zadny prazdny `catch`), +`.prettierrc`, `.prettierignore`, `.editorconfig`, `.nvmrc` (20), +`.env.example` se vsemi promennymi a komentarem, `vitest.config.ts`. Skripty +`npm run lint`, `format`, `format:check`, `test`, `test:watch` vedle +`typecheck`. + +**Lint je cisty na celem repu bez jedineho `eslint-disable`.** Pravidla hooku +Reactu v7 hlasi zapis do refu pri vykresleni, `setState` v effectu +a komponentu vytvorenou pri vykresleni. Reseni je vzdy zmena navrhu, ne +vyjimka: `hooks/useLatest.ts` (ref s posledni hodnotou, zapis v layout +effectu), `hooks/useSyncFromSource.ts` (prevzeti dat ze zdroje pri vykresleni +misto `useEffect`), odvozeny stav misto kopie ve stavu, dialogy jako vlastni +komponenty, ktere se pri zavreni odmontuji (stav se resetuje sam), +`useMediaQuery` pres `useSyncExternalStore`, `ServiceIcon` pres +`createElement`. + +### Prisnejsi TypeScript + +Oba `tsconfig` maji `noUncheckedIndexedAccess`. Opraveno 55 mist na serveru +a 29 na webu, vzdy osetrenim `undefined`, nikde `!`. Dve z nich byly skutecne +chyby: + +- `BACKOFF_MS[attempts - 1]` v `src/runtime/queue.ts` je pri `attempts = 0` + `undefined` a `new Date(NaN)` by beh naplanoval na nikdy; cteni ma zalohu + `MAX_BACKOFF_MS`. +- `rateLimit` s `max: 0` posilal `Retry-After: NaN`; ted aspon 1 sekunda. + +### Testy + +`tests/` zrcadli `src/`: 8 souboru, 105 testu, vsechny prochazi. +`tests/setup.ts` nastavi rezim pameti (`DATA_DIR=''`, `DATABASE_URL=''`, +pevny `SECRETS_KEY` a `JWT_SECRET`, `SEED_DEMO=0`) a umlci `console.info` +a `console.warn`; test migrace si bere docasny `DATA_DIR`. + +| Soubor | Co hlida | +| ----------------------------------- | ------------------------------------------------------------------------ | +| `data/access.test.ts` | `accessFor`, `visibilityFor`, `resolveScope`: pohledy, strop viditelnosti, cizi firma je 404 | +| `data/permissions.test.ts` | `permissionsOf` za firmu, `syncSystemRoles` | +| `data/tickets.test.ts` | `intakeEvent` (externi ID za firmu), `listTickets` s povinnym filtrem, `updateTicketStatus` | +| `data/migratePeople.test.ts` | prevod `ppl_` na ucty vcetne ticketu, skupin a stromu; zapis na disk | +| `net/guard.test.ts` | `urlProblem` (privatni rozsahy), `readBodyLimited`, `readJsonLimited` | +| `routes/health.test.ts` | `createApp()` pres supertest: health, 404 jako JSON, 401, prihlaseni, `/access` za firmu | +| `runtime/executor.test.ts` | podminky (retezce, cisla, data, seznamy, `all`/`any`), stara podoba, stropy `MAX_STEPS`, `MAX_ACTIONS`, `MAX_LOOP_ITEMS`, vystupy, `retryable` | +| `runtime/scripts/util.test.ts` | `createRedactor` ve ctyrech tvarech, `parseBool`, `parseNumber`, `pick`, `truncate` | + +Prvni testy executoru nasly dve chyby v podminkach, obe opravene: prazdna +hodnota se pri `gt`/`lt` porovnavala jako nula (`Number('')` je `0`) a hole +jmeno vystupu melo prednost pred `krok.jmeno`, takze podminka nad druhym +krokem se stejnym vystupem cetla hodnotu z prvniho. Podrobne +v [20-fronta-a-runtime.md](20-fronta-a-runtime.md). + +### Znamy stav proti zasadam + +Jeden `package.json` pro server i web (workspaces az bude mit kazda strana +vlastni build), logovani `console.*` s prefixem modulu misto strukturovaneho +loggeru, zadny soubor CI. Prettier prosel celym kodem (`npm run format`, +se svolenim); markdown je z formatovani vyjmuty (`.prettierignore`), protoze +dokumentace ma vlastni styl tabulek. Zapsano v [01-prehled-a-stav.md](01-prehled-a-stav.md). + ## 2026-09-09 - Incident jde otevrit a posunout do dalsiho stavu Seznam incidentu ukazoval jen titulek, sluzbu a casy. Server pritom uz posilal diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..61f1d6a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,48 @@ +// Lint pro server (src), web (web/src), testy a skripty konektoru. +// Pravidla drzi to, co uz kod dodrzuje: zadne any, nepouzite promenne jen +// s podtrzitkem, hooky Reactu podle pravidel. Spousti se `npm run lint`. + +import js from '@eslint/js'; +import reactHooks from 'eslint-plugin-react-hooks'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['dist/**', 'node_modules/**', 'data/**', 'design/**', 'web/public/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: [ + 'src/**/*.ts', + 'tests/**/*.ts', + 'vite.config.ts', + 'vitest.config.ts', + 'eslint.config.js', + ], + languageOptions: { globals: globals.node }, + }, + { + files: ['web/src/**/*.{ts,tsx}'], + languageOptions: { globals: globals.browser }, + plugins: { 'react-hooks': reactHooks }, + rules: reactHooks.configs.recommended.rules, + }, + { + // Skripty konektoru jsou obycejny JS bez importu; `ctx` dostanou od runtime. + files: ['connectors/**/*.js'], + languageOptions: { sourceType: 'module', globals: {} }, + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + ], + // Prazdny catch je tiche selhani, viz pravidlo 7 ve START.md. + 'no-empty': ['error', { allowEmptyCatch: false }], + }, + }, +); diff --git a/package-lock.json b/package-lock.json index 14c5b8c..f331ed7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "zod": "^3.24.1" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.0.0", "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", @@ -28,17 +29,25 @@ "@types/pg": "^8.21.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@types/supertest": "^7.2.1", "@types/swagger-ui-express": "^4.1.7", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.1.2", + "eslint": "^10.10.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.12.0", "lucide-react": "^0.469.0", + "prettier": "^3.9.6", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.1", + "supertest": "^7.2.2", "tailwindcss": "^4.0.0", "tsx": "^4.19.2", "typescript": "^5.7.3", - "vite": "^6.0.7" + "typescript-eslint": "^8.70.0", + "vite": "^6.0.7", + "vitest": "^5.0.0" }, "engines": { "node": ">=20" @@ -326,6 +335,30 @@ "node": ">=6.9.0" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -768,6 +801,200 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -818,6 +1045,53 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@remix-run/router": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", @@ -1527,6 +1801,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -1537,6 +1822,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -1547,6 +1839,20 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1587,6 +1893,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -1598,6 +1911,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1719,6 +2039,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/swagger-ui-express": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", @@ -1730,6 +2074,249 @@ "@types/serve-static": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1751,6 +2338,54 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1764,6 +2399,46 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1796,6 +2471,40 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.8.tgz", @@ -1854,6 +2563,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -1903,6 +2625,20 @@ "node": ">= 0.8" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1953,6 +2689,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2018,6 +2764,29 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concurrently": { "version": "9.2.4", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", @@ -2086,6 +2855,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -2103,6 +2879,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2128,6 +2919,23 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2157,6 +2965,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2241,6 +3060,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -2253,6 +3079,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -2311,6 +3153,204 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2320,6 +3360,16 @@ "node": ">= 0.6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -2381,6 +3431,34 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2399,6 +3477,16 @@ } } }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -2432,6 +3520,77 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2531,6 +3690,32 @@ "node": ">= 0.4" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2572,6 +3757,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -2584,6 +3798,30 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2616,6 +3854,26 @@ "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2631,6 +3889,16 @@ "node": ">= 0.10" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2641,6 +3909,26 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2671,6 +3959,20 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2739,6 +4041,30 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3000,6 +4326,22 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -3154,6 +4496,22 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3179,6 +4537,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -3228,6 +4593,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3240,6 +4619,66 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3249,6 +4688,26 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -3352,9 +4811,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -3432,6 +4891,32 @@ "node": ">=0.10.0" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3445,6 +4930,36 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -3727,6 +5242,29 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shell-quote": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", @@ -3812,6 +5350,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3831,6 +5376,13 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3840,6 +5392,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3868,6 +5427,65 @@ "node": ">=8" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -3929,6 +5547,26 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3965,6 +5603,19 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3991,6 +5642,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -4018,6 +5682,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -4065,6 +5753,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -4642,6 +6340,142 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -4660,6 +6494,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -4715,6 +6556,19 @@ "node": ">=12" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -4723,6 +6577,19 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 11af4a6..1ddc205 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,12 @@ "dev": "concurrently -n api,web -c magenta,cyan \"npm:dev:server\" \"npm:dev:web\"", "dev:server": "tsx watch src/index.ts", "dev:web": "vite", - "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p web/tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p web/tsconfig.json --noEmit", + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "bcryptjs": "^2.4.3", @@ -25,6 +30,7 @@ "zod": "^3.24.1" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.0.0", "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", @@ -35,17 +41,25 @@ "@types/pg": "^8.21.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@types/supertest": "^7.2.1", "@types/swagger-ui-express": "^4.1.7", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.1.2", + "eslint": "^10.10.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.12.0", "lucide-react": "^0.469.0", + "prettier": "^3.9.6", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.1", + "supertest": "^7.2.2", "tailwindcss": "^4.0.0", "tsx": "^4.19.2", "typescript": "^5.7.3", - "vite": "^6.0.7" + "typescript-eslint": "^8.70.0", + "vite": "^6.0.7", + "vitest": "^5.0.0" }, "engines": { "node": ">=20" diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..a3b60cc --- /dev/null +++ b/src/app.ts @@ -0,0 +1,297 @@ +/** + * Sestaveni Express aplikace. + * + * Jen sklada middleware, routery, Swagger a SPA. Zadne `listen`, zadny + * bootstrap dat, zadne signaly - to vsechno je v `index.ts`. Diky tomu jde + * aplikaci postavit v testu (supertest) bez otevreneho portu. + */ + +import cors from 'cors'; +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'; +import swaggerUi from 'swagger-ui-express'; +import { config } from './config.js'; +import { storageStatus } from './data/connectorStore.js'; +import { databaseHealth } from './db/pool.js'; +import { safeRouter } from './middleware/asyncHandler.js'; +import { buildOpenApiDocument } from './openapi/index.js'; +import { adminRouter } from './routes/admin.js'; +import { authRouter } from './routes/auth.js'; +import { contactRouter } from './routes/contact.js'; +import { dashboardRouter } from './routes/dashboard/index.js'; +import { publicInviteRouter } from './routes/invites.js'; +import { webhookRouter } from './routes/webhook.js'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +/** Zbuildovana SPA. Vite ji zapisuje do dist/public, viz vite.config.ts. */ +const webRoot = path.join(here, 'public'); + +/** Strop tela JSON. Nejvetsi bezne telo je ukazka spoustece (100 kB), zbytek je rezerva. */ +const JSON_BODY_LIMIT = '256kb'; + +/** Rok. Soubory buildu maji hash v nazvu, takze se muzou cachovat na maximum. */ +const STATIC_MAX_AGE_SEC = 31_536_000; + +/** + * 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...'); +} + +function isOriginAllowed(origin: string): boolean { + if (config.corsOrigins.includes(origin)) return true; + // V dev rezimu si Vite pri obsazenem portu vezme jiny, proto cely localhost. + if (!config.isProduction && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) { + return true; + } + return false; +} + +/** + * Do index.html se za behu vklada base pro prohlizec. + * + * Prohlizec vidi adresu /apps//..., ale Vite build ma relativni cesty. + * Bez by se soubory na vnorenych cestach hledaly ve spatne slozce. + * Prefix se bere z ROOT_PATH, nikdy neni v kodu natvrdo. + */ +function renderIndexHtml(): string { + const file = path.join(webRoot, 'index.html'); + const html = fs.readFileSync(file, 'utf8'); + const base = `${config.rootPath}/`; + const injected = + `\n` + + ` `; + return html.replace('', `\n ${injected}`); +} + +/** Zakladni middleware: proxy, CORS, JSON, bezpecnostni hlavicky a log requestu. */ +function applyBaseMiddleware(app: express.Express): void { + /* + * 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({ + origin(origin, callback) { + // Bez Origin (curl, server-to-server) i stejna domena projdou vzdy. + if (!origin || isOriginAllowed(origin)) return callback(null, true); + console.warn(`[cors] zablokovan origin: ${origin}`); + return callback(null, false); + }, + credentials: true, + }), + ); + app.use(express.json({ limit: JSON_BODY_LIMIT })); + + /* + * 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(); + }); + + app.use((req, _res, next) => { + // Loguje se jen metoda a cesta, nikdy hlavicky ani telo - obsahuji secrets. + console.info(`[req] ${req.method} ${maskSecretsInUrl(req.originalUrl)}`); + next(); + }); +} + +/** Health, whoami, Swagger a vsechny API routery pod jednim routerem. */ +function buildApiRouter(): express.Router { + /** + * 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 + * misto toho, aby request visel a chyba skoncila jako unhandledRejection. + */ + const api = safeRouter({ strict: true }); + + /** + * Liveness. Zamerne **nezavisi na databazi**: kratky vypadek DB by jinak vedl + * k restartovani containeru, coz nic nespravi (AGENTS.md). + */ + api.get('/health', (_req, res) => { + res.json({ status: 'ok', uptimeSec: Math.round(process.uptime()) }); + }); + + /** + * Readiness. Tady uz databaze zalezi, a proto je to zvlast. + * Vysledek se par sekund cachuje, aby monitoring nedelal dotaz pri kazdem pingu. + */ + api.get('/health/ready', async (_req, res) => { + const database = await databaseHealth(); + const storage = storageStatus(); + const ready = !database.enabled || database.ok; + + res.status(ready ? 200 : 503).json({ + status: ready ? 'ok' : 'degraded', + database, + storage, + }); + }); + + /** + * Jak nas vidi ten, kdo nam vola. + * + * Vraci volajicimu jeho vlastni adresu tak, jak dorazila k serveru. Zni to + * zbytecne, ale je to jediny zpusob, jak zmerit, **s jakou zdrojovou adresou + * doruci reverse proxy volani, ktere vyslo z naseho containeru**. Container + * sam to nevidi, echo sluzba na internetu odpovi verejnou adresu, jenze + * volani na vlastni domenu se otaci zpatky na tentyz stroj a proxy pak muze + * videt adresu docker bridge, ne tu verejnou. A prave to rozhoduje o tom, + * jestli nas seznam povolenych IP pusti. + * + * Bez prihlaseni zamerne: neprozradi to nic, co by volajici uz nevedel, + * dostane svoji vlastni adresu. Stejne jako kterakoliv echo sluzba. + */ + api.get('/whoami', (req, res) => { + res.json({ + // `req.ip` uz je po `trust proxy`, tedy hodnota z X-Forwarded-For. + ip: req.ip ?? null, + // Surove, aby bylo videt i to, co proxy pripsala nebo nepripsala. + forwardedFor: req.headers['x-forwarded-for'] ?? null, + remoteAddress: req.socket.remoteAddress ?? null, + }); + }); + + /** + * Swagger UI. Cesta bez lomitka presmerujeme na variantu s lomitkem, + * jinak by se relativni odkazy na CSS a JS skladaly o uroven vys + * a za reverse proxy by se nenacetly. + */ + const openApiDocument = buildOpenApiDocument(); + const swaggerOptions: swaggerUi.SwaggerUiOptions = { + customSiteTitle: `${config.brandName} API`, + swaggerOptions: { persistAuthorization: true }, + }; + + api.get('/docs', (_req, res) => res.redirect(`${config.rootPath}/docs/`)); + api.get('/openapi.json', (_req, res) => res.json(openApiDocument)); + api.use( + '/docs', + swaggerUi.serveFiles(openApiDocument, swaggerOptions), + swaggerUi.setup(openApiDocument, swaggerOptions), + ); + + api.use('/api/auth', authRouter); + api.use('/api/dashboard', dashboardRouter); + // Verejne: kdo dostal odkaz na pozvanku, neni jeste prihlaseny. + api.use('/api/invites', publicInviteRouter); + api.use('/api/admin', adminRouter); + api.use('/api/contact', contactRouter); + api.use('/webhook', webhookRouter); + + return api; +} + +/** Staticke soubory buildu a SPA fallback. Bez buildu jen JSON s vysvetlenim. */ +function applyWeb(app: express.Express): void { + let cachedIndexHtml: string | null = null; + const hasWebBuild = fs.existsSync(path.join(webRoot, 'index.html')); + + if (!hasWebBuild) { + // Bez buildu webu nesmi aplikace tise vracet prazdno. + console.warn(`[start] build webu nenalezen v ${webRoot}, bezi jen API`); + app.get('/', (_req, res) => { + res.json({ + name: 'csbot-prototype', + status: 'ok', + note: 'Build webu chybi, dostupne je jen API a /docs.', + }); + }); + return; + } + + const serveStatic = express.static(webRoot, { + index: false, + // Soubory maji hash v nazvu, muzou se cachovat dlouho. index.html ne. + setHeaders(res, filePath) { + if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache'); + else res.setHeader('Cache-Control', `public, max-age=${STATIC_MAX_AGE_SEC}, immutable`); + }, + }); + + app.use(serveStatic); + if (config.rootPath) app.use(config.rootPath, serveStatic); + + // Vsechny ostatni cesty obsluhuje SPA, routovani si resi React Router. + app.get('*', (_req, res) => { + if (!cachedIndexHtml) cachedIndexHtml = renderIndexHtml(); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.send(cachedIndexHtml); + }); +} + +// Centralni error handler - nic nesmi propadnout bez logu. +function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction): void { + /* + * Rozbite telo pozadavku neni nase chyba, je to spatne polozeny dotaz. + * + * `express.json` na nej vyhodi vyjimku, ta propadla sem a uzivatel videl + * "Interni chyba serveru" - hlasku, ktera rika, ze je neco spatne u nas, + * a poslala ho hledat na spatnou stranu. Stalo to jedno odpoledne. + */ + const status = (err as { status?: number } | null)?.status; + const type = (err as { type?: string } | null)?.type; + if (status === 400 && typeof type === 'string' && type.startsWith('entity.')) { + console.warn(`[error] ${req.method} ${req.path}: neplatne telo pozadavku (${type})`); + res.status(400).json({ + error: 'bad_request', + message: 'Tělo požadavku není platný JSON objekt.', + }); + return; + } + + console.error('[error]', err); + const message = err instanceof Error ? err.message : 'Neznama chyba.'; + res.status(500).json({ + error: 'internal_error', + message: config.isProduction ? 'Interni chyba serveru.' : message, + }); +} + +/** Postavi aplikaci. Bez vedlejsich efektu: nic neposloucha a nic se nenacita. */ +export function createApp(): express.Express { + const app = express(); + applyBaseMiddleware(app); + + const api = buildApiRouter(); + // Mount na koren i na prefix proxy. Caddy prefix pres handle_path odstranuje, + // ale takhle aplikace funguje i kdyby ho nechal - a lokalne bez proxy taky. + 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 (apiPathPattern.test(req.path)) { + console.warn(`[404] ${req.method} ${req.originalUrl}`); + return res.status(404).json({ error: 'not_found', message: 'Endpoint neexistuje.' }); + } + return next(); + }); + + applyWeb(app); + app.use(errorHandler); + return app; +} diff --git a/src/ares/client.ts b/src/ares/client.ts index cbc2749..72b9e56 100644 --- a/src/ares/client.ts +++ b/src/ares/client.ts @@ -104,7 +104,9 @@ function record(value: unknown): Record | null { } function list(value: unknown): Record[] { - return Array.isArray(value) ? value.map(record).filter((v): v is Record => v !== null) : []; + return Array.isArray(value) + ? value.map(record).filter((v): v is Record => v !== null) + : []; } async function call(path: string, init?: RequestInit): Promise { @@ -134,7 +136,8 @@ async function call(path: string, init?: RequestInit): Promise { response.status, ); } - if (body.json === undefined) throw new AresError('ARES vrátil odpověď, která není JSON.', response.status); + if (body.json === undefined) + throw new AresError('ARES vrátil odpověď, která není JSON.', response.status); return body.json; } @@ -170,7 +173,11 @@ export async function searchCompanies(name: string, limit = 10): Promise sep + ch.toLocaleUpperCase('cs-CZ')); + .replace( + /(^|[\s\-'])(\p{L})/gu, + (_, sep: string, ch: string) => sep + ch.toLocaleUpperCase('cs-CZ'), + ); } /** diff --git a/src/config.ts b/src/config.ts index ec641dd..c1838c1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -133,9 +133,9 @@ export const config = { /** * Adresar se skripty konektoru. Relativne k adresari, ze ktereho aplikace - * bezi, aby to fungovalo v containeru (`/app/scripts`) i lokalne. + * bezi, aby to fungovalo v containeru (`/app/connectors`) i lokalne. */ - scriptsDir: path.resolve(process.env.SCRIPTS_DIR ?? path.join(process.cwd(), 'scripts')), + scriptsDir: path.resolve(process.env.SCRIPTS_DIR ?? path.join(process.cwd(), 'connectors')), /** * Zaklad adres napojenych sluzeb, napr. "https://services.csbot.cz/apps". * Konkretni konektor lze presmerovat pres `_BASE_URL`. @@ -230,6 +230,19 @@ export const config = { * interval znamena jen vic dotazu do fronty. */ schedulerIntervalSec: positiveNumber(process.env.SCHEDULER_INTERVAL_SEC, 30), + /** + * Presmerovani jedne sluzby promennou `_BASE_URL`, napr. + * `OPENAI_BASE_URL`. K cemu to je, rika `runtime/scripts/connections.ts`. + * + * Jedine misto s dynamickym nazvem promenne: sluzby pribyvaji v katalogu + * a vypisovat kazdou sem by znamenalo dve mista, ktera se rozejdou. Nazev + * sklada volajici, tady se hodnota jen cte a normalizuje - bez mezer, bez + * lomitka na konci. Prazdna nebo chybejici promenna je `null`. + */ + serviceBaseUrlOverride(variable: string): string | null { + const value = (process.env[variable] ?? '').trim().replace(/\/+$/, ''); + return value === '' ? null : value; + }, }; /** Zaklad verejne adresy aplikace vcetne prefixu proxy. */ diff --git a/src/data/automationStore.ts b/src/data/automationStore.ts index 0923b8a..1aedf62 100644 --- a/src/data/automationStore.ts +++ b/src/data/automationStore.ts @@ -1,1348 +1,7 @@ /** - * Uloziste automatizaci vcetne jejich stromu akci (flow). - * - * 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. + * Fasada nad slozkou `automations/`. Puvodni modul mel pres tisic radku, + * rozdelil se podle odpovednosti a tenhle soubor zustava, aby se nemusely + * menit importy jinde. */ -import { randomBytes } from 'node:crypto'; -import { config } from '../config.js'; -import { publish } from '../events/bus.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 ConditionRule, -} 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 } from './model.js'; - -import type { - Automation, - AutomationDetail, - AutomationFlow, - AutomationKind, - FlowStep, - FlowTrigger, - TriggerField, - WebhookCall, - WebhookCallField, -} from '../shared/automations.js'; - -/** - * Tvar automatizace a stromu je sdileny s webem, viz src/shared/automations.ts. - * Tady zustava uloziste a prace se stromem. - */ -export type { - Automation, - AutomationDetail, - AutomationFlow, - AutomationKind, - FlowStep, - FlowTrigger, - TriggerField, - WebhookCall, - WebhookCallField, -}; - -/** - * Otazky podminky, at uz je krok v jakekoliv podobe. - * - * **Jedine misto, kde se stara podoba prevadi.** Kdyby se `fieldId` cetlo - * primo, krok ulozeny driv by po zmene modelu prisel o svou otazku a vetvil - * by vzdycky stejne - tise a bez chyby. - */ -export function rulesOf(step: Extract): ConditionRule[] { - if (step.rules && step.rules.length > 0) return step.rules; - if (!step.fieldId || !step.operator) return []; - return [{ fieldId: step.fieldId, operator: step.operator, value: step.value }]; -} - -/** Jak se otazky spoji. Jedna otazka nema co spojovat, bere se `all`. */ -export function matchOf(step: Extract): ConditionMatch { - return step.match ?? 'all'; -} - -/** Neodhadnutelny token do adresy webhooku (32 znaku, base64url). */ -export function generateWebhookToken(): string { - return randomBytes(24).toString('base64url'); -} - -/** Kolik znaku tela se u volani drzi. Vic uz je v pameti zbytecne. */ -const MAX_BODY = 8_000; - -/** Telo do zaznamu: zmrazene na retezec, aby se pozdeji nezmenilo pod rukama. */ -export function bodyForCall(body: unknown): { body: string; truncated: boolean } { - let text: string; - try { - text = JSON.stringify(body, null, 2) ?? ''; - } catch { - // Cyklicka struktura nebo neco, co JSON neumi. Radeji nic nez pad. - text = ''; - } - if (text.length <= MAX_BODY) return { body: text, truncated: false }; - return { body: text.slice(0, MAX_BODY), truncated: true }; -} - -/** Kolik poslednich volani se u automatizace drzi. */ -const MAX_CALLS = 10; - -const calls = new Map(); - -/** Zapise, jak dopadlo jedno volani webhooku. Nejnovejsi je prvni. */ -export function recordWebhookCall(automationId: string, call: WebhookCall): void { - const list = calls.get(automationId) ?? []; - list.unshift(call); - if (list.length > MAX_CALLS) list.length = MAX_CALLS; - calls.set(automationId, list); -} - -/** Poslednich par volani. Cte se jen pres detail automatizace, ktery hlida firmu. */ -export function recentWebhookCalls(automationId: string): WebhookCall[] { - return calls.get(automationId) ?? []; -} - -interface StoredAutomation { - id: string; - tenantId: string; - name: string; - kind: AutomationKind; - enabled: boolean; - /** - * Behy po dnech, nejnovejsi nakonec. Drzi se poslednich `KEEP_DAYS`. - * - * Proc historie a ne jen citac pro dnesek: bez ni po pulnoci neni s cim - * srovnat a nikdo nepozna, jestli je dnesnich devet malo nebo hodne. - */ - days?: Array<{ day: string; runs: number; ok: number }>; - /** Behy od zalozeni. */ - runsTotal?: number; - runsToday: number; - successRate: number; - 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; -} - -/** Kolik dni zpatky se drzi. Dva tydny staci na "je to dnes jinak nez obvykle". */ -const KEEP_DAYS = 14; - -/** Dnesni den jako `2026-08-17`. Podle nej se pozna prelom dne. */ -function today(): string { - return new Date().toISOString().slice(0, 10); -} - -function dayBefore(day: string): string { - return new Date(new Date(`${day}T00:00:00Z`).getTime() - 86_400_000).toISOString().slice(0, 10); -} - -/** - * Statistika za dnesek z ulozene historie. - * - * Kdyz dnes jeste nic nebezelo, vraci nulu - **ne vcerejsi cislo**. Ukazat - * vcerejsi pocet jako dnesni je to, co bylo spatne. - */ -function statsOf(stored: StoredAutomation): { - runsToday: number; - runsYesterday: number; - runsTotal: number; - successRate: number; -} { - const days = stored.days ?? []; - const now = today(); - const mine = days.find((entry) => entry.day === now); - const before = days.find((entry) => entry.day === dayBefore(now)); - - /* - * Uspesnost dnesnich behu. Kdyz dnes zadny nebyl, bere se posledni den, kdy - * byly - nula procent u automatizace, ktera dnes jen nemela co delat, by - * vypadala jako porucha. - */ - const source = mine ?? [...days].reverse().find((entry) => entry.runs > 0); - - return { - runsToday: mine?.runs ?? 0, - runsYesterday: before?.runs ?? 0, - runsTotal: stored.runsTotal ?? stored.runsToday, - successRate: - source && source.runs > 0 - ? Math.round((source.ok / source.runs) * 1000) / 10 - : stored.successRate, - }; -} - -/** Rekurzivne secte kroky vcetne obou vetvi podminek. */ -export function countSteps(steps: FlowStep[]): number { - return steps.reduce((sum, step) => { - if (step.kind === 'condition') { - return sum + 1 + countSteps(step.yes) + countSteps(step.no); - } - if (step.kind === 'foreach') { - return sum + 1 + countSteps(step.steps); - } - return sum + 1; - }, 0); -} - -/** - * Nedodelky v nastaveni jednoho kroku: nevyplnene povinne pole a odkaz - * na parametr, ktery u spoustece neexistuje. - * - * Druhy pripad nastane hlavne po prejmenovani parametru. Sablonu proto - * nezahazujeme ani tise neopravujeme, jen rekneme, kde se ma doplnit. - */ -function actionInputIssues( - step: Extract, - available: TriggerField[], - /** - * Koreny, ktere jsou v poradku i kdyz nejsou deklarovanym parametrem: - * klice z ukazky tela, ID predchozich kroku, `item` a `index` uvnitr smycky. - */ - extraRoots: Set, -): string[] { - const knownNames = new Set(available.map((field) => field.name)); - const catalog = actionInputsFor(step.serviceId, step.operationId); - if (catalog.length === 0) return []; - - const issues: string[] = []; - const operationName = findService(step.serviceId)?.name ?? step.serviceId; - - for (const field of catalog) { - const value = step.inputs?.[field.id] ?? ''; - - if (field.required && value.trim().length === 0) { - issues.push(`Krok "${operationName}": chybí ${field.label.toLowerCase()}.`); - continue; - } - - for (const reference of referencedFields(value)) { - /* - * Overuje se jen **prvni cast** odkazu. Zbytek je cesta do struktury - * a tu predem overit nejde - co presne prijde v tele, vime az pri behu. - */ - const root = rootOf(reference); - if (!knownNames.has(root) && !extraRoots.has(root)) { - issues.push( - `Krok "${operationName}", pole ${field.label.toLowerCase()}: parametr "${root}" u spouštěče neexistuje.`, - ); - } - } - } - - return issues; -} - -/** - * Co brani zapnuti automatizace. Zamerne to NENI chyba pri ukladani - - * rozdelanou praci chceme ulozit, jen ji nesmime pustit do provozu. - */ -export function collectFlowIssues(flow: AutomationFlow): string[] { - const issues: string[] = []; - - if (!flow.trigger) { - issues.push('Chybí spouštěč.'); - return issues; - } - - if (flow.steps.length === 0) { - issues.push('Automatizace nemá žádný krok.'); - } - - // Webhook bez registrovaneho tokenu nelze zavolat. - const isWebhook = flow.trigger.serviceId === 'webhook'; - if (isWebhook && !flow.trigger.webhookToken) { - issues.push('Webhook nemá vygenerovanou adresu.'); - } - - const scopes = collectScopes(flow); - - /* - * Co dalsiho smi stat na zacatku odkazu. Klice z ukazky tela, protoze prave - * kvuli nim se ukazka vlepuje, a `_body` s celym telem. - */ - const sampleRoots = rootsOf(flow.trigger.sample); - sampleRoots.add('_body'); - - const walk = (steps: FlowStep[], roots: Set) => { - for (const step of steps) { - const available = scopeFor(scopes, step.id); - - for (const name of duplicateNames(available)) { - issues.push( - `Parametr "${name}" je v tomto místě stromu dvakrát, v šabloně by nešlo poznat který.`, - ); - } - - if (step.kind === 'action') { - issues.push(...actionInputIssues(step, available, roots)); - // ID kroku smi stat na zacatku odkazu: `{{st_faktura.invoiceId}}`. - roots.add(step.id); - continue; - } - - if (step.kind === 'foreach') { - if (step.path.trim().length === 0) { - issues.push('Smyčka nemá vyplněnou cestu k seznamu.'); - } - // Uvnitr smycky pribyva polozka a poradi, po ni vysledky za cely seznam. - walk(step.steps, new Set([...roots, 'item', 'index'])); - roots.add(step.id); - continue; - } - - // Kazda otazka podminky zvlast. Jedna spatna nesmi schovat ostatni. - const rules = rulesOf(step); - if (rules.length === 0) issues.push('Podmínka nemá žádnou otázku.'); - - for (const rule of rules) { - const field = available.find((candidate) => candidate.id === rule.fieldId); - if (!field) { - // Rozlisujeme "neexistuje" od "vznikne az pozdeji". Druhy pripad nastane - // po presunuti kroku a chce jinou radu nez smazat podminku. - issues.push( - scopes.all.has(rule.fieldId) - ? 'Podmínka se ptá na parametr, který vzniká až v pozdějším kroku. Posuňte ji níž.' - : 'Podmínka se odkazuje na parametr, který už neexistuje.', - ); - } else if (!isUnary(rule.operator) && (rule.value ?? '').trim().length === 0) { - issues.push(`Podmínka nad parametrem "${field.name}" nemá vyplněnou hodnotu.`); - } else if (field.type === 'number' && !isUnary(rule.operator)) { - if (Number.isNaN(Number(rule.value))) { - issues.push(`Podmínka nad parametrem "${field.name}" má nečíselnou hodnotu.`); - } - } - } - - /* - * Koreny z vetvi se **nesou dal**. Vystup z vetve je za podminkou - * k dispozici, jen nepovinne - viz data/flowScope.ts. - */ - walk(step.yes, roots); - walk(step.no, roots); - } - }; - walk(flow.steps, sampleRoots); - - // Stejny nedodelek muze vyjit z vic kroku (typicky duplicitni jmeno parametru). - // Uzivateli staci rict jednou. - return [...new Set(issues)]; -} - -/** Pouziva strom nekde konektor z dane kategorie? */ -function flowUsesCategory(steps: FlowStep[], category: string): boolean { - return steps.some((step) => { - if (step.kind === 'condition') { - return flowUsesCategory(step.yes, category) || flowUsesCategory(step.no, category); - } - if (step.kind === 'foreach') return flowUsesCategory(step.steps, category); - return findService(step.serviceId)?.category === category; - }); -} - -/** - * Druh automatizace se dopocitava ze stromu - klient ho nezadava. - * Je to jen stitek v seznamu, proto zamerne jednoducha heuristika: - * rozhoduje spoustec, u planovace jeste to, zda se ve krocich pracuje s analytikou. - */ -function deriveKind(flow: AutomationFlow): AutomationKind { - if (!flow.trigger) return 'workflow'; - - const connector = findService(flow.trigger.serviceId); - if (!connector) { - console.warn(`[automations] spoustec odkazuje na neznama sluzba: ${flow.trigger.serviceId}`); - return 'workflow'; - } - - if (connector.id === 'voicebot') return 'voicebot'; - if (connector.category === 'analytika') return 'report'; - - // Planovac + prace s analytikou = pravidelny report, ne obecne workflow. - if (connector.id === 'scheduler' && flowUsesCategory(flow.steps, 'analytika')) return 'report'; - - if ( - connector.category === 'crm' || - connector.category === 'ekonomika' || - connector.category === 'logistika' - ) { - return 'integrace'; - } - - return 'workflow'; -} - -const store = new Map(); -let idCounter = 0; - -/** - * Uloziste. Automatizace se drzi v pameti kvuli synchronnimu cteni (webhook - * hleda podle tokenu pri kazdem requestu) a po kazde zmene se cela zapise. - * Kam - databaze, soubor, nebo nikam - rozhoduje `data/store/index.ts`. - */ -const mirror = withMirror(defineStore('automation')); - -/** Zapise do pameti i do uloziste. Kazda zmena jde skrz tohle. */ -function save(automation: StoredAutomation): void { - store.set(automation.id, automation); - 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. - * - * Kdyz uloziste nic nema, ulozi se ukazkova sada, ktera je v tuhle chvili - * v pameti. - */ -export async function initAutomations(): Promise { - const rows = await mirror.load(() => [...store.values()]); - - store.clear(); - /* - * 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. - idCounter = Math.max(idCounter, highestNumber(store.keys(), 'AUT')); -} - -/** - * Prepise ID resitelu ve stromech podle mapy stare -> nove. Vraci pocet - * zmenenych automatizaci. - * - * Jen pro migraci (data/migratePeople.ts). ID resitele muze byt v kroku - * `ticket/assign`, ve vstupu `assigneeId` u zalozeni ticketu i v podmince, - * proto se nahrazuje v JSON podobe celeho stromu, ne po znamych polich - - * nove pole by se jinak zapomnelo. Nahrazuje se jen cely retezec `"ppl_x"`, - * ne podretezec. - */ -export function remapPersonIds(map: Map): number { - let changed = 0; - for (const automation of store.values()) { - const before = JSON.stringify(automation.flow); - let after = before; - for (const [oldId, newId] of map) { - after = after.split(JSON.stringify(oldId)).join(JSON.stringify(newId)); - } - if (after === before) continue; - changed += 1; - save(withDerived({ ...automation, flow: JSON.parse(after) as AutomationFlow })); - } - return changed; -} - -function nextId(): string { - idCounter += 1; - return `AUT-${String(idCounter).padStart(2, '0')}`; -} - -function seed( - automation: Omit & { - tenantId?: string; - }, -) { - const id = nextId(); - store.set( - id, - 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), - }), - ); -} - -/** - * Ukazkove automatizace. - * - * Nasypou se **jen se `SEED_DEMO=1`**. Na instanci, kde uz nekdo pracuje, - * jsou to cizi zaznamy, ktere se po kazdem redeployi vraceji - a mazat je - * porad dokola nikoho nebavi. - */ -function seedDemoAutomations(): void { - seed({ - name: 'Objednávka, sklad a fakturace', - enabled: true, - runsToday: 428, - successRate: 99.3, - avgDurationMs: 1_240, - lastRunAt: minutesAgo(3), - flow: { - trigger: { - serviceId: 'eshop', - operationId: 'order-created', - fields: [ - { id: 'f_1', name: 'orderId', type: 'string', required: true }, - { id: 'f_2', name: 'total', type: 'number', required: true }, - { id: 'f_3', name: 'customerEmail', type: 'string', required: true }, - ], - }, - steps: [ - { id: 'st_1', kind: 'action', serviceId: 'transform', operationId: 'map-fields' }, - { id: 'st_2', kind: 'action', serviceId: 'eshop', operationId: 'update-stock' }, - { - id: 'st_3', - kind: 'condition', - fieldId: 'f_2', - operator: 'gte', - value: '5000', - yes: [ - { id: 'st_4', kind: 'action', serviceId: 'idoklad', operationId: 'create-proforma' }, - { id: 'st_5', kind: 'action', serviceId: 'email', operationId: 'send' }, - ], - no: [{ id: 'st_6', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' }], - }, - { id: 'st_7', kind: 'action', serviceId: 'ppl', operationId: 'create-shipment' }, - ], - }, - }); - - seed({ - name: 'Voicebot: příjem poptávek 24/7', - enabled: true, - runsToday: 137, - successRate: 96.1, - avgDurationMs: 74_000, - lastRunAt: minutesAgo(11), - flow: { - trigger: { - serviceId: 'voicebot', - operationId: 'call-received', - fields: [ - { id: 'f_11', name: 'callerNumber', type: 'string', required: true }, - { id: 'f_12', name: 'wantsOperator', type: 'boolean', required: false }, - ], - }, - steps: [ - { id: 'st_11', kind: 'action', serviceId: 'voicebot', operationId: 'play-scenario' }, - { id: 'st_12', kind: 'action', serviceId: 'transcription', operationId: 'transcribe' }, - { id: 'st_13', kind: 'action', serviceId: 'openai', operationId: 'chat' }, - { - id: 'st_14', - kind: 'condition', - fieldId: 'f_12', - operator: 'isTrue', - yes: [{ id: 'st_15', kind: 'action', serviceId: 'voicebot', operationId: 'transfer' }], - no: [ - { id: 'st_16', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' }, - { id: 'st_17', kind: 'action', serviceId: 'email', operationId: 'send' }, - ], - }, - ], - }, - }); - - seed({ - name: 'Synchronizace CRM a účetnictví', - enabled: true, - runsToday: 96, - successRate: 98.9, - avgDurationMs: 2_050, - lastRunAt: minutesAgo(26), - flow: { - trigger: { - serviceId: 'raynet', - operationId: 'company-changed', - fields: [{ id: 'f_21', name: 'companyId', type: 'string', required: true }], - }, - steps: [ - { id: 'st_21', kind: 'action', serviceId: 'transform', operationId: 'deduplicate' }, - { id: 'st_22', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' }, - { id: 'st_23', kind: 'action', serviceId: 'log', operationId: 'write' }, - ], - }, - }); - - seed({ - name: 'Noční report pro management', - enabled: false, - runsToday: 0, - successRate: 100, - avgDurationMs: 18_400, - lastRunAt: minutesAgo(1_020), - flow: { - trigger: { serviceId: 'scheduler', operationId: 'interval', fields: [] }, - steps: [ - { id: 'st_31', kind: 'action', serviceId: 'ga4', operationId: 'run-report' }, - { id: 'st_32', kind: 'action', serviceId: 'google-ads', operationId: 'campaign-report' }, - { id: 'st_33', kind: 'action', serviceId: 'sklik', operationId: 'campaign-report' }, - { id: 'st_34', kind: 'action', serviceId: 'openai', operationId: 'chat' }, - { id: 'st_35', kind: 'action', serviceId: 'email', operationId: 'send' }, - ], - }, - }); - - // Ukazka webhooku s deklarovanymi parametry a podminkou nad cislem. - seed({ - name: 'Webhook: hodnocení z dotazníku', - enabled: true, - runsToday: 61, - successRate: 100, - avgDurationMs: 640, - lastRunAt: minutesAgo(18), - flow: { - trigger: { - serviceId: 'webhook', - operationId: 'received', - webhookToken: generateWebhookToken(), - fields: [ - { id: 'f_41', name: 'customer', type: 'string', required: true }, - { id: 'f_42', name: 'score', type: 'number', required: true }, - { id: 'f_43', name: 'comment', type: 'string', required: false }, - ], - }, - steps: [ - { - id: 'st_41', - kind: 'condition', - fieldId: 'f_42', - operator: 'gte', - value: '15', - yes: [{ id: 'st_42', kind: 'action', serviceId: 'raynet', operationId: 'add-activity' }], - no: [ - { - id: 'st_43', - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - inputs: { - subject: 'Nízké hodnocení od {{customer}}', - body: 'Hodnocení {{score}} z dotazníku. Komentář: {{comment}}', - company: '{{customer}}', - priority: 'high', - assigneeId: 'usr_2', - }, - }, - { id: 'st_44', kind: 'action', serviceId: 'microsoft365', operationId: 'post-teams' }, - ], - }, - ], - }, - }); - - /* - * Prijem z kanalu: jedna automatizace na kanal, zadne rozhodovani o resiteli. - * Smerovani resi jedna spolecna automatizace nize - viz documentation/06-tickety.md. - */ - - seed({ - name: 'WhatsApp: zpráva do ticketu', - enabled: true, - runsToday: 34, - successRate: 100, - avgDurationMs: 1_950, - lastRunAt: minutesAgo(7), - flow: { - trigger: { - serviceId: 'whatsapp', - operationId: 'message-received', - fields: [ - { id: 'whatsapp.phone', name: 'phone', type: 'string', required: true }, - { id: 'whatsapp.profileName', name: 'profileName', type: 'string', required: false }, - { id: 'whatsapp.text', name: 'text', type: 'string', required: true }, - { id: 'whatsapp.hasMedia', name: 'hasMedia', type: 'boolean', required: false }, - { id: 'whatsapp.receivedAt', name: 'receivedAt', type: 'date', required: true }, - ], - }, - steps: [ - // Predvalidace: nejdriv se zeptame CRM, teprve podle odpovedi zakladame. - { - id: 'st_51', - kind: 'action', - serviceId: 'raynet', - operationId: 'find-company', - inputs: { phone: '{{phone}}' }, - }, - { - id: 'st_52', - kind: 'condition', - // Odkaz na vystup kroku st_51, ne na parametr spoustece. - fieldId: 'st_51.raynet.customerKnown', - operator: 'isTrue', - yes: [ - { - id: 'st_53', - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - inputs: { - subject: 'WhatsApp od {{profileName}}', - body: '{{text}}', - company: '{{companyName}}', - contact: '{{profileName}}', - reply: '{{phone}}', - priority: 'normal', - assigneeId: '', - }, - }, - ], - no: [ - { - id: 'st_54', - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - inputs: { - subject: 'WhatsApp od neznámého čísla', - body: '{{text}}', - contact: '{{profileName}}', - reply: '{{phone}}', - priority: 'normal', - assigneeId: '', - }, - }, - { id: 'st_55', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' }, - ], - }, - ], - }, - }); - - seed({ - name: 'Facebook: zpráva do ticketu', - enabled: true, - runsToday: 11, - successRate: 100, - avgDurationMs: 720, - lastRunAt: minutesAgo(52), - flow: { - trigger: { - serviceId: 'facebook', - operationId: 'message-received', - fields: [ - { id: 'facebook.senderId', name: 'senderId', type: 'string', required: true }, - { id: 'facebook.senderName', name: 'senderName', type: 'string', required: false }, - { id: 'facebook.text', name: 'text', type: 'string', required: true }, - { id: 'facebook.pageName', name: 'pageName', type: 'string', required: true }, - { id: 'facebook.receivedAt', name: 'receivedAt', type: 'date', required: true }, - ], - }, - // Bez predvalidace. Z Messengeru nemame e-mail ani telefon, podle ceho - // by se firma dohledala, takze zakladame rovnou a dohledani nechavame na cloveku. - steps: [ - { - id: 'st_61', - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - inputs: { - subject: 'Facebook od {{senderName}}', - body: '{{text}}', - contact: '{{senderName}}', - reply: '{{senderId}}', - priority: 'normal', - assigneeId: '', - }, - }, - ], - }, - }); - - seed({ - name: 'E-mail: požadavky do ticketu', - enabled: true, - runsToday: 58, - successRate: 99.1, - avgDurationMs: 2_310, - lastRunAt: minutesAgo(14), - flow: { - trigger: { - serviceId: 'email', - operationId: 'received', - fields: [ - { id: 'email.from', name: 'from', type: 'string', required: true }, - { id: 'email.subject', name: 'subject', type: 'string', required: true }, - { id: 'email.body', name: 'body', type: 'string', required: false }, - { id: 'email.hasAttachment', name: 'hasAttachment', type: 'boolean', required: false }, - { id: 'email.receivedAt', name: 'receivedAt', type: 'date', required: true }, - ], - }, - steps: [ - { - id: 'st_65', - kind: 'action', - serviceId: 'raynet', - operationId: 'find-company', - inputs: { email: '{{from}}' }, - }, - { - id: 'st_66', - kind: 'condition', - fieldId: 'st_65.raynet.customerKnown', - operator: 'isTrue', - yes: [ - { - id: 'st_67', - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - inputs: { - subject: '{{subject}}', - body: '{{body}}', - company: '{{companyName}}', - contact: '{{from}}', - reply: '{{from}}', - priority: 'normal', - assigneeId: '', - }, - }, - ], - no: [ - { - id: 'st_68', - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - inputs: { - subject: '{{subject}}', - body: '{{body}}', - contact: '{{from}}', - reply: '{{from}}', - priority: 'low', - assigneeId: '', - }, - }, - { - id: 'st_69', - kind: 'action', - serviceId: 'email', - operationId: 'send', - inputs: { - to: '{{from}}', - subject: 'Přijali jsme váš požadavek', - body: 'Dobrý den, požadavek jsme zaevidovali a ozveme se. Tým podpory.', - }, - }, - ], - }, - ], - }, - }); - - /* - * Jedna spolecna automatizace nad vsemi tickety, at vznikly odkudkoliv. - * Tohle je to misto, kde se dela logika zpracovani na miru zakaznikovi. - */ - seed({ - name: 'Směrování ticketů na řešitele', - /* - * Ukazkova automatizace, ktera **meni data**, je vypnuta. - * - * Zapnuta by prebirala tickety, ktere uz nekomu patri podle skutecne - * automatizace zakaznika - ukazkova data nemaji sahat na zivy provoz. - * Kdo si ji chce vyzkouset, zapne si ji. - */ - enabled: false, - runsToday: 103, - successRate: 100, - avgDurationMs: 310, - lastRunAt: minutesAgo(4), - flow: { - trigger: { - serviceId: 'ticket', - operationId: 'created', - fields: [ - { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, - { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, - { id: 'ticket.body', name: 'body', type: 'string', required: false }, - { id: 'ticket.channel', name: 'channel', type: 'string', required: true }, - { id: 'ticket.company', name: 'company', type: 'string', required: false }, - { id: 'ticket.contact', name: 'contact', type: 'string', required: false }, - { id: 'ticket.priority', name: 'priority', type: 'string', required: true }, - { id: 'ticket.knownCustomer', name: 'knownCustomer', type: 'boolean', required: true }, - { id: 'ticket.assigned', name: 'assigned', type: 'boolean', required: true }, - ], - }, - steps: [ - { - id: 'st_71', - kind: 'condition', - // Uz prirazeny ticket nepreberame, jinak bychom prepsali rucni rozhodnuti. - fieldId: 'ticket.assigned', - operator: 'isFalse', - yes: [ - { - id: 'st_72', - kind: 'condition', - fieldId: 'ticket.body', - operator: 'contains', - value: 'faktur', - yes: [ - { - id: 'st_73', - kind: 'action', - serviceId: 'ticket', - operationId: 'assign', - inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_3' }, - }, - ], - no: [ - { - id: 'st_74', - kind: 'condition', - fieldId: 'ticket.body', - operator: 'contains', - value: 'voicebot', - yes: [ - { - id: 'st_75', - kind: 'action', - serviceId: 'ticket', - operationId: 'assign', - inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_novakova' }, - }, - ], - no: [ - { - id: 'st_76', - kind: 'action', - serviceId: 'ticket', - operationId: 'assign', - inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_2' }, - }, - ], - }, - ], - }, - ], - no: [], - }, - ], - }, - }); -} - -/** - * Automatizace, ktere na instanci opravdu bezi. - * - * 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 ztrate dat - * nemenila a odesilatel ji nemusel prepisovat. - * - * 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({ - name: 'TEST', - enabled: true, - runsToday: 0, - successRate: 100, - avgDurationMs: 0, - lastRunAt: minutesAgo(0), - flow: { - trigger: { - serviceId: 'webhook', - operationId: 'received', - fields: [ - { id: 'f_callsid', name: 'callSid', type: 'string', required: true }, - { id: 'f_status', name: 'status', type: 'string', required: true }, - { id: 'f_voicebot', name: 'voicebotId', type: 'string', required: true }, - { id: 'f_mtjqv4qj_1', name: 'result', type: 'string', required: false, path: 'data.result' }, - { id: 'f_mtjqv4zn_2', name: 'rating', type: 'string', required: false, path: 'data.rating' }, - // Objekt a nepovinne: telo ho posila jako strukturu a prvni zprava - // hovoru ho jeste nema. Deklarace `string` a povinny odmitala oboji. - { id: 'f_mtjqvws7_3', name: 'data', type: 'object', required: false, path: 'data' }, - ], - webhookToken: config.seedWebhookToken || generateWebhookToken(), - }, - steps: [ - { - id: 'st_upsert', - kind: 'action', - serviceId: 'ticket', - operationId: 'upsert', - inputs: { - externalId: '{{callSid}}', - body: '{{voicebotId}}, {{data}}', - status: '{{result}}', - tags: '{{voicebotId}}', - priority: 'low', - }, - }, - /* - * **Nejdriv se ptame, jestli vysledek vubec prisel.** - * - * Jeden hovor posle vic zprav a ta prvni jen ohlasi, ze zacal: - * `data` je null, takze `{{result}}` je prazdne. Bez teto podminky - * spadlo prazdno rovnou do vetve "neni to Chybejici informace" - * a ticket se zavrel uz pri zvoneni. Na instanci to znamenalo, ze - * vsech 131 ticketu bylo vyrizenych a ve frontě nezustalo nic. - */ - { - id: 'st_mtjqwey5_4', - kind: 'condition', - fieldId: 'f_mtjqv4qj_1', - operator: 'isNotEmpty', - value: '', - yes: [ - /* - * Az ted se rozhoduje podle vysledku, a **kladne**: hledame - * "Chybejici informace". Puvodni `neq` znamenalo "vsechno - * ostatni vcetne toho, co jeste nevime". - */ - { - id: 'st_mtml9001_2', - kind: 'condition', - fieldId: 'f_mtjqv4qj_1', - operator: 'eq', - value: 'Chybějící informace', - // Chybejici informace jde na servicedesk a s vyssi prioritou. - yes: [ - { - id: 'st_mtjqxs41_6', - kind: 'action', - serviceId: 'ticket', - operationId: 'upsert', - inputs: { - externalId: '{{callSid}}', - priority: 'high', - groupId: 'grp_servicedesk', - }, - }, - { - id: 'st_mtml8hx0_1', - kind: 'action', - serviceId: 'ticket', - operationId: 'assign-group', - inputs: { - groupId: 'grp_servicedesk', - autoAssign: 'true', - }, - }, - ], - // Hovor dopadl, ticket se zavira. - no: [ - { - id: 'st_mtjqx6t1_5', - kind: 'action', - serviceId: 'ticket', - operationId: 'upsert', - inputs: { - externalId: '{{callSid}}', - closed: 'true', - }, - }, - ], - }, - ], - // Vysledek jeste nedorazil. Ticket uz existuje, vic se ted delat nema. - no: [], - }, - ], - }, - }); -} - -seedRealAutomations(); - -if (config.seedDemo) { - seedDemoAutomations(); -} else { - console.info('[automatizace] ukazkova data vypnuta (SEED_DEMO neni 1)'); -} - -if (!config.seedWebhookToken) { - console.warn( - '[automatizace] WEBHOOK_TOKEN_TEST neni nastaveny, adresa webhooku se pri kazdem ' + - 'nasazeni zmeni. Nastavte ji jako promennou aplikace.', - ); -} - -function toSummary(stored: StoredAutomation): Automation { - const { - flow: _flow, - createdAt: _createdAt, - updatedAt: _updatedAt, - days: _days, - ...rest - } = stored; - return { - ...rest, - /* - * Cisla se pocitaji z historie po dnech, ne z ulozeneho citace. Po pulnoci - * je "dnes" nula, dokud opravdu neco nebezi. - */ - ...statsOf(stored), - // 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: stored.issues ?? collectFlowIssues(stored.flow), - }; -} - -function toDetail(stored: StoredAutomation): AutomationDetail { - return { - ...toSummary(stored), - flow: stored.flow, - model: stored.flow.trigger?.sample === undefined - ? [] - : describeModel(stored.flow.trigger.sample), - recentCalls: recentWebhookCalls(stored.id), - createdAt: stored.createdAt, - updatedAt: stored.updatedAt, - }; -} - -/** Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse". */ -export function listAutomations(tenantIds: string[]): Automation[] { - // Nejnovejsi nahoru, aby prave vytvorena automatizace byla hned videt. - return [...store.values()] - .filter((stored) => tenantIds.includes(stored.tenantId)) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .map(toSummary); -} - -export function getAutomation(id: string, tenantIds: string[]): AutomationDetail | undefined { - const stored = store.get(id); - if (!stored) return undefined; - if (!tenantIds.includes(stored.tenantId)) { - console.warn(`[automations] pokus o cteni ${id} mimo povolene firmy`); - return undefined; - } - return toDetail(stored); -} - -export function createAutomation(name: string, tenantId: string): AutomationDetail { - const id = nextId(); - const now = new Date().toISOString(); - const stored = withDerived({ - id, - tenantId, - name, - kind: 'workflow', - enabled: false, - runsToday: 0, - successRate: 100, - avgDurationMs: 0, - lastRunAt: now, - 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 }, tenantId); - return toDetail(stored); -} - -/** Automatizace z povolenych firem. Cizi se chova jako neexistujici. */ -function findWritable(id: string, tenantIds: string[]): StoredAutomation | undefined { - return writableOrWarn(store.get(id), id, tenantIds, 'automations'); -} - -export function updateAutomation( - id: string, - patch: { name?: string; enabled?: boolean; flow?: AutomationFlow }, - tenantIds: string[], -): AutomationDetail | undefined { - const stored = findWritable(id, tenantIds); - if (!stored) { - console.warn(`[automations] pokus o upravu nedostupne automatizace: ${id}`); - return undefined; - } - - const flow = withWebhookToken(patch.flow ?? stored.flow, stored.flow, id); - 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 = updated.issues ?? []; - if (updated.enabled && issues.length > 0) { - console.warn(`[automations] ${id}: zapnuti odmitnuto - ${issues.join(' ')}`); - updated.enabled = false; - } - - save(updated); - console.info( - `[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); -} - -/** - * Token webhooku spravuje VYHRADNE server: - * - webhook spoustec bez tokenu ho dostane vygenerovany, - * - existujici token se prevezme z ulozene verze (klient ho nemuze zmenit), - * - pri zmene spoustece na neco jineho se token zahodi. - */ -function withWebhookToken( - next: AutomationFlow, - previous: AutomationFlow, - id: string, -): AutomationFlow { - if (!next.trigger) return next; - - if (next.trigger.serviceId !== 'webhook') { - if (next.trigger.webhookToken) { - console.info(`[automations] ${id}: spoustec neni webhook, zahazuji token`); - } - return { ...next, trigger: { ...next.trigger, webhookToken: undefined } }; - } - - // Existujici token drzime, aby se uz zaregistrovana adresa nezmenila pod rukama. - const keptToken = - previous.trigger?.serviceId === 'webhook' ? previous.trigger.webhookToken : undefined; - - if (keptToken) { - return { ...next, trigger: { ...next.trigger, webhookToken: keptToken } }; - } - - const token = generateWebhookToken(); - console.info(`[automations] ${id}: vygenerovana adresa webhooku`); - return { ...next, trigger: { ...next.trigger, webhookToken: token } }; -} - -/** Vygeneruje novy token - stara adresa okamzite prestane fungovat. */ -export function regenerateWebhookToken( - id: string, - tenantIds: string[], -): AutomationDetail | undefined { - const stored = findWritable(id, tenantIds); - if (!stored) { - console.warn(`[automations] regenerace tokenu pro nedostupnou automatizaci: ${id}`); - return undefined; - } - if (stored.flow.trigger?.serviceId !== 'webhook') { - console.warn(`[automations] ${id}: regenerace tokenu, ale spoustec neni webhook`); - return undefined; - } - - // 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); -} - -/** 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 && timingSafeEqualString(stored.flow.trigger.webhookToken, token)) return toDetail(stored); - } - return undefined; -} - -/** Zapise beh automatizace - drzi metriky i graf zive. */ -export function recordRun(id: string, ok = true, tenantIds?: string[]): AutomationDetail | undefined { - // Bez omezeni volá webhook, ktery se autorizuje tokenem, ne prihlasenim. - const stored = tenantIds ? findWritable(id, tenantIds) : store.get(id); - if (!stored) { - console.warn(`[automations] recordRun pro nedostupnou automatizaci: ${id}`); - return undefined; - } - - /* - * Zapisuje se do dnesniho dne. Po pulnoci vznikne novy zaznam, takze citac - * nepokracuje pres den - to byla puvodni chyba. - */ - const now = today(); - const days = [...(stored.days ?? [])]; - const index = days.findIndex((entry) => entry.day === now); - - if (index === -1) { - days.push({ day: now, runs: 1, ok: ok ? 1 : 0 }); - } else { - days[index] = { - day: now, - runs: days[index].runs + 1, - ok: days[index].ok + (ok ? 1 : 0), - }; - } - if (days.length > KEEP_DAYS) days.splice(0, days.length - KEEP_DAYS); - - const total = (stored.runsTotal ?? stored.runsToday) + 1; - const dayEntry = days.find((entry) => entry.day === now)!; - - const updated: StoredAutomation = { - ...stored, - days, - runsTotal: total, - runsToday: dayEntry.runs, - successRate: Math.round((dayEntry.ok / dayEntry.runs) * 1000) / 10, - lastRunAt: new Date().toISOString(), - }; - save(updated); - - publish( - 'automation.run', - ok - ? `Automatizace ${id} proběhla: ${updated.name}` - : `Automatizace ${id} skončila chybou: ${updated.name}`, - { automationId: id, ok }, - updated.tenantId, - ); - return toDetail(updated); -} - -export function deleteAutomation(id: string, tenantIds: string[]): boolean { - const stored = findWritable(id, tenantIds); - const name = stored?.name; - const existed = stored !== undefined && store.delete(id); - if (existed) mirror.drop(id); - if (!existed) { - console.warn(`[automations] pokus o smazani nedostupne automatizace: ${id}`); - } else { - console.info(`[automations] smazana automatizace ${id}`); - publish('automation.deleted', `Automatizace ${id} smazána: ${name ?? ''}`, { - automationId: id, - }, stored?.tenantId ?? null); - } - return existed; -} +export * from './automations/index.js'; diff --git a/src/data/automations/index.ts b/src/data/automations/index.ts new file mode 100644 index 0000000..2ded48e --- /dev/null +++ b/src/data/automations/index.ts @@ -0,0 +1,64 @@ +/** + * Uloziste automatizaci vcetne jejich stromu akci (flow). + * + * 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. + * + * Slozka je rozdelena podle odpovednosti: `model` (tvar), `state` (pamet), + * `persist` (zapis a nacteni), `validation` (co se ze stromu dopocitava), + * `webhook` (token a volani), `runs` (historie behu), `store` (cteni a + * zapisy), `seed`, `seedDemo` a `remap`. Zvenku se importuje jen tenhle + * soubor, a to pres `data/automationStore.ts`. + */ + +import { config } from '../../config.js'; +import { seedRealAutomations } from './seed.js'; +import { seedDemoAutomations } from './seedDemo.js'; + +export type { + Automation, + AutomationDetail, + AutomationFlow, + AutomationKind, + FlowStep, + FlowTrigger, + TriggerField, + WebhookCall, + WebhookCallField, +} from './model.js'; +export { matchOf, rulesOf } from './model.js'; +export { + bodyForCall, + generateWebhookToken, + recentWebhookCalls, + recordWebhookCall, +} from './webhook.js'; +export { collectFlowIssues, countSteps } from './validation.js'; +export { initAutomations } from './persist.js'; +export { + createAutomation, + deleteAutomation, + findByWebhookToken, + getAutomation, + listAutomations, + recordRun, + regenerateWebhookToken, + updateAutomation, +} from './store.js'; +export { remapPersonIds } from './remap.js'; + +seedRealAutomations(); + +if (config.seedDemo) { + seedDemoAutomations(); +} else { + console.info('[automatizace] ukazkova data vypnuta (SEED_DEMO neni 1)'); +} + +if (!config.seedWebhookToken) { + console.warn( + '[automatizace] WEBHOOK_TOKEN_TEST neni nastaveny, adresa webhooku se pri kazdem ' + + 'nasazeni zmeni. Nastavte ji jako promennou aplikace.', + ); +} diff --git a/src/data/automations/model.ts b/src/data/automations/model.ts new file mode 100644 index 0000000..1689d37 --- /dev/null +++ b/src/data/automations/model.ts @@ -0,0 +1,88 @@ +/** + * Tvar automatizace v ulozisti a cteni kroku podminky. + * + * Tvar automatizace a stromu je sdileny s webem, viz src/shared/automations.ts. + * Tady je jen to, co web nevidi: zaznam v ulozisti. + */ + +import type { ConditionMatch, ConditionRule } from '../conditions.js'; + +import type { + Automation, + AutomationDetail, + AutomationFlow, + AutomationKind, + FlowStep, + FlowTrigger, + TriggerField, + WebhookCall, + WebhookCallField, +} from '../../shared/automations.js'; + +/** + * Tvar automatizace a stromu je sdileny s webem, viz src/shared/automations.ts. + * Tady zustava uloziste a prace se stromem. + */ +export type { + Automation, + AutomationDetail, + AutomationFlow, + AutomationKind, + FlowStep, + FlowTrigger, + TriggerField, + WebhookCall, + WebhookCallField, +}; + +/** + * Otazky podminky, at uz je krok v jakekoliv podobe. + * + * **Jedine misto, kde se stara podoba prevadi.** Kdyby se `fieldId` cetlo + * primo, krok ulozeny driv by po zmene modelu prisel o svou otazku a vetvil + * by vzdycky stejne - tise a bez chyby. + */ +export function rulesOf(step: Extract): ConditionRule[] { + if (step.rules && step.rules.length > 0) return step.rules; + if (!step.fieldId || !step.operator) return []; + return [{ fieldId: step.fieldId, operator: step.operator, value: step.value }]; +} + +/** Jak se otazky spoji. Jedna otazka nema co spojovat, bere se `all`. */ +export function matchOf(step: Extract): ConditionMatch { + return step.match ?? 'all'; +} + +export interface StoredAutomation { + id: string; + tenantId: string; + name: string; + kind: AutomationKind; + enabled: boolean; + /** + * Behy po dnech, nejnovejsi nakonec. Drzi se poslednich `KEEP_DAYS`. + * + * Proc historie a ne jen citac pro dnesek: bez ni po pulnoci neni s cim + * srovnat a nikdo nepozna, jestli je dnesnich devet malo nebo hodne. + */ + days?: Array<{ day: string; runs: number; ok: number }>; + /** Behy od zalozeni. */ + runsTotal?: number; + runsToday: number; + successRate: number; + 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; +} diff --git a/src/data/automations/persist.ts b/src/data/automations/persist.ts new file mode 100644 index 0000000..9484150 --- /dev/null +++ b/src/data/automations/persist.ts @@ -0,0 +1,46 @@ +/** + * Zapis automatizace do uloziste a nacteni pri startu. + * + * Po kazde zmene se cela automatizace zapise (`withMirror`). Kam - databaze, + * soubor, nebo nikam - rozhoduje `data/store/index.ts`; viz + * documentation/14-databaze.md. + */ + +import { defineStore } from '../store/index.js'; +import { withMirror } from '../store/mirror.js'; +import type { StoredAutomation } from './model.js'; +import { continueCounter, store } from './state.js'; +import { withDerived } from './validation.js'; + +/** + * Uloziste. Automatizace se drzi v pameti kvuli synchronnimu cteni (webhook + * hleda podle tokenu pri kazdem requestu) a po kazde zmene se cela zapise. + * Kam - databaze, soubor, nebo nikam - rozhoduje `data/store/index.ts`. + */ +export const mirror = withMirror(defineStore('automation')); + +/** Zapise do pameti i do uloziste. Kazda zmena jde skrz tohle. */ +export function save(automation: StoredAutomation): void { + store.set(automation.id, automation); + mirror.save(automation); +} + +/** + * Nacte automatizace z uloziste. Vola se pri startu, viz data/bootstrap.ts. + * + * Kdyz uloziste nic nema, ulozi se ukazkova sada, ktera je v tuhle chvili + * v pameti. + */ +export async function initAutomations(): Promise { + const rows = await mirror.load(() => [...store.values()]); + + store.clear(); + /* + * 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)); + + continueCounter(); +} diff --git a/src/data/automations/remap.ts b/src/data/automations/remap.ts new file mode 100644 index 0000000..f184771 --- /dev/null +++ b/src/data/automations/remap.ts @@ -0,0 +1,33 @@ +/** + * Preznaceni resitelu pri migraci, viz data/migratePeople.ts. + */ + +import type { AutomationFlow } from '../../shared/automations.js'; +import { save } from './persist.js'; +import { store } from './state.js'; +import { withDerived } from './validation.js'; + +/** + * Prepise ID resitelu ve stromech podle mapy stare -> nove. Vraci pocet + * zmenenych automatizaci. + * + * Jen pro migraci (data/migratePeople.ts). ID resitele muze byt v kroku + * `ticket/assign`, ve vstupu `assigneeId` u zalozeni ticketu i v podmince, + * proto se nahrazuje v JSON podobe celeho stromu, ne po znamych polich - + * nove pole by se jinak zapomnelo. Nahrazuje se jen cely retezec `"ppl_x"`, + * ne podretezec. + */ +export function remapPersonIds(map: Map): number { + let changed = 0; + for (const automation of store.values()) { + const before = JSON.stringify(automation.flow); + let after = before; + for (const [oldId, newId] of map) { + after = after.split(JSON.stringify(oldId)).join(JSON.stringify(newId)); + } + if (after === before) continue; + changed += 1; + save(withDerived({ ...automation, flow: JSON.parse(after) as AutomationFlow })); + } + return changed; +} diff --git a/src/data/automations/runs.ts b/src/data/automations/runs.ts new file mode 100644 index 0000000..4b6c3c3 --- /dev/null +++ b/src/data/automations/runs.ts @@ -0,0 +1,59 @@ +/** + * Historie behu po dnech. + * + * Bez ni po pulnoci neni s cim srovnat a nikdo nepozna, jestli je dnesnich + * devet malo nebo hodne. Zapis behu je v `store.ts` (`recordRun`), tady je + * jen cteni a hranice, kolik se drzi. + */ + +import type { StoredAutomation } from './model.js'; + +/** Jeden den v milisekundach. */ +const DAY_MS = 86_400_000; + +/** Kolik dni zpatky se drzi. Dva tydny staci na "je to dnes jinak nez obvykle". */ +export const KEEP_DAYS = 14; + +/** Dnesni den jako `2026-08-17`. Podle nej se pozna prelom dne. */ +export function today(): string { + return new Date().toISOString().slice(0, 10); +} + +function dayBefore(day: string): string { + return new Date(new Date(`${day}T00:00:00Z`).getTime() - DAY_MS).toISOString().slice(0, 10); +} + +/** + * Statistika za dnesek z ulozene historie. + * + * Kdyz dnes jeste nic nebezelo, vraci nulu - **ne vcerejsi cislo**. Ukazat + * vcerejsi pocet jako dnesni je to, co bylo spatne. + */ +export function statsOf(stored: StoredAutomation): { + runsToday: number; + runsYesterday: number; + runsTotal: number; + successRate: number; +} { + const days = stored.days ?? []; + const now = today(); + const mine = days.find((entry) => entry.day === now); + const before = days.find((entry) => entry.day === dayBefore(now)); + + /* + * Uspesnost dnesnich behu. Kdyz dnes zadny nebyl, bere se posledni den, kdy + * byly - nula procent u automatizace, ktera dnes jen nemela co delat, by + * vypadala jako porucha. + */ + const source = mine ?? [...days].reverse().find((entry) => entry.runs > 0); + + return { + runsToday: mine?.runs ?? 0, + runsYesterday: before?.runs ?? 0, + runsTotal: stored.runsTotal ?? stored.runsToday, + successRate: + source && source.runs > 0 + ? Math.round((source.ok / source.runs) * 1000) / 10 + : stored.successRate, + }; +} diff --git a/src/data/automations/seed.ts b/src/data/automations/seed.ts new file mode 100644 index 0000000..2557689 --- /dev/null +++ b/src/data/automations/seed.ts @@ -0,0 +1,165 @@ +/** + * Vychozi sada automatizaci pro prazdne uloziste. + * + * Skutecne automatizace (`seedRealAutomations`) se nasypou vzdy, ukazkove + * jsou v `seedDemo.ts` a zapinaji se pres `SEED_DEMO=1`. O obojim rozhoduje + * `index.ts`, tady je jen zapis do pameti bez uloziste. + */ + +import { config } from '../../config.js'; +import { minutesAgo } from '../store/index.js'; +import type { StoredAutomation } from './model.js'; +import { nextId, store } from './state.js'; +import { deriveKind, withDerived } from './validation.js'; +import { generateWebhookToken } from './webhook.js'; + +/** Ukazkova automatizace vznikla pred 90 dny a naposledy se menila pred 12 hodinami. */ +const SEED_CREATED_MINUTES_AGO = 60 * 24 * 90; +const SEED_UPDATED_MINUTES_AGO = 60 * 12; + +export function seed( + automation: Omit & { + tenantId?: string; + }, +) { + const id = nextId(); + store.set( + id, + withDerived({ + // Ukazkova data patri Automii, kdyz neni receno jinak. + tenantId: 'tnt_automia', + ...automation, + id, + kind: deriveKind(automation.flow), + createdAt: minutesAgo(SEED_CREATED_MINUTES_AGO), + updatedAt: minutesAgo(SEED_UPDATED_MINUTES_AGO), + }), + ); +} + +/** + * Automatizace, ktere na instanci opravdu bezi. + * + * 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 ztrate dat + * nemenila a odesilatel ji nemusel prepisovat. + * + * Opsano z bezici instance 2026-09-02. Kdyz se strom na instanci zmeni a ma + * prezit i ztratu dat, patri ta zmena sem. + */ +export function seedRealAutomations(): void { + seed({ + name: 'TEST', + enabled: true, + runsToday: 0, + successRate: 100, + avgDurationMs: 0, + lastRunAt: minutesAgo(0), + flow: { + trigger: { + serviceId: 'webhook', + operationId: 'received', + fields: [ + { id: 'f_callsid', name: 'callSid', type: 'string', required: true }, + { id: 'f_status', name: 'status', type: 'string', required: true }, + { id: 'f_voicebot', name: 'voicebotId', type: 'string', required: true }, + { id: 'f_mtjqv4qj_1', name: 'result', type: 'string', required: false, path: 'data.result' }, + { id: 'f_mtjqv4zn_2', name: 'rating', type: 'string', required: false, path: 'data.rating' }, + // Objekt a nepovinne: telo ho posila jako strukturu a prvni zprava + // hovoru ho jeste nema. Deklarace `string` a povinny odmitala oboji. + { id: 'f_mtjqvws7_3', name: 'data', type: 'object', required: false, path: 'data' }, + ], + webhookToken: config.seedWebhookToken || generateWebhookToken(), + }, + steps: [ + { + id: 'st_upsert', + kind: 'action', + serviceId: 'ticket', + operationId: 'upsert', + inputs: { + externalId: '{{callSid}}', + body: '{{voicebotId}}, {{data}}', + status: '{{result}}', + tags: '{{voicebotId}}', + priority: 'low', + }, + }, + /* + * **Nejdriv se ptame, jestli vysledek vubec prisel.** + * + * Jeden hovor posle vic zprav a ta prvni jen ohlasi, ze zacal: + * `data` je null, takze `{{result}}` je prazdne. Bez teto podminky + * spadlo prazdno rovnou do vetve "neni to Chybejici informace" + * a ticket se zavrel uz pri zvoneni. Na instanci to znamenalo, ze + * vsech 131 ticketu bylo vyrizenych a ve frontě nezustalo nic. + */ + { + id: 'st_mtjqwey5_4', + kind: 'condition', + fieldId: 'f_mtjqv4qj_1', + operator: 'isNotEmpty', + value: '', + yes: [ + /* + * Az ted se rozhoduje podle vysledku, a **kladne**: hledame + * "Chybejici informace". Puvodni `neq` znamenalo "vsechno + * ostatni vcetne toho, co jeste nevime". + */ + { + id: 'st_mtml9001_2', + kind: 'condition', + fieldId: 'f_mtjqv4qj_1', + operator: 'eq', + value: 'Chybějící informace', + // Chybejici informace jde na servicedesk a s vyssi prioritou. + yes: [ + { + id: 'st_mtjqxs41_6', + kind: 'action', + serviceId: 'ticket', + operationId: 'upsert', + inputs: { + externalId: '{{callSid}}', + priority: 'high', + groupId: 'grp_servicedesk', + }, + }, + { + id: 'st_mtml8hx0_1', + kind: 'action', + serviceId: 'ticket', + operationId: 'assign-group', + inputs: { + groupId: 'grp_servicedesk', + autoAssign: 'true', + }, + }, + ], + // Hovor dopadl, ticket se zavira. + no: [ + { + id: 'st_mtjqx6t1_5', + kind: 'action', + serviceId: 'ticket', + operationId: 'upsert', + inputs: { + externalId: '{{callSid}}', + closed: 'true', + }, + }, + ], + }, + ], + // Vysledek jeste nedorazil. Ticket uz existuje, vic se ted delat nema. + no: [], + }, + ], + }, + }); +} diff --git a/src/data/automations/seedDemo.ts b/src/data/automations/seedDemo.ts new file mode 100644 index 0000000..6ee95c7 --- /dev/null +++ b/src/data/automations/seedDemo.ts @@ -0,0 +1,467 @@ +/** + * Ukazkove automatizace. Nasypou se jen se `SEED_DEMO=1`, viz `index.ts`. + */ + +import { minutesAgo } from '../store/index.js'; +import { seed } from './seed.js'; +import { generateWebhookToken } from './webhook.js'; + +/** + * Ukazkove automatizace. + * + * Nasypou se **jen se `SEED_DEMO=1`**. Na instanci, kde uz nekdo pracuje, + * jsou to cizi zaznamy, ktere se po kazdem redeployi vraceji - a mazat je + * porad dokola nikoho nebavi. + */ +export function seedDemoAutomations(): void { + seed({ + name: 'Objednávka, sklad a fakturace', + enabled: true, + runsToday: 428, + successRate: 99.3, + avgDurationMs: 1_240, + lastRunAt: minutesAgo(3), + flow: { + trigger: { + serviceId: 'eshop', + operationId: 'order-created', + fields: [ + { id: 'f_1', name: 'orderId', type: 'string', required: true }, + { id: 'f_2', name: 'total', type: 'number', required: true }, + { id: 'f_3', name: 'customerEmail', type: 'string', required: true }, + ], + }, + steps: [ + { id: 'st_1', kind: 'action', serviceId: 'transform', operationId: 'map-fields' }, + { id: 'st_2', kind: 'action', serviceId: 'eshop', operationId: 'update-stock' }, + { + id: 'st_3', + kind: 'condition', + fieldId: 'f_2', + operator: 'gte', + value: '5000', + yes: [ + { id: 'st_4', kind: 'action', serviceId: 'idoklad', operationId: 'create-proforma' }, + { id: 'st_5', kind: 'action', serviceId: 'email', operationId: 'send' }, + ], + no: [{ id: 'st_6', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' }], + }, + { id: 'st_7', kind: 'action', serviceId: 'ppl', operationId: 'create-shipment' }, + ], + }, + }); + + seed({ + name: 'Voicebot: příjem poptávek 24/7', + enabled: true, + runsToday: 137, + successRate: 96.1, + avgDurationMs: 74_000, + lastRunAt: minutesAgo(11), + flow: { + trigger: { + serviceId: 'voicebot', + operationId: 'call-received', + fields: [ + { id: 'f_11', name: 'callerNumber', type: 'string', required: true }, + { id: 'f_12', name: 'wantsOperator', type: 'boolean', required: false }, + ], + }, + steps: [ + { id: 'st_11', kind: 'action', serviceId: 'voicebot', operationId: 'play-scenario' }, + { id: 'st_12', kind: 'action', serviceId: 'transcription', operationId: 'transcribe' }, + { id: 'st_13', kind: 'action', serviceId: 'openai', operationId: 'chat' }, + { + id: 'st_14', + kind: 'condition', + fieldId: 'f_12', + operator: 'isTrue', + yes: [{ id: 'st_15', kind: 'action', serviceId: 'voicebot', operationId: 'transfer' }], + no: [ + { id: 'st_16', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' }, + { id: 'st_17', kind: 'action', serviceId: 'email', operationId: 'send' }, + ], + }, + ], + }, + }); + + seed({ + name: 'Synchronizace CRM a účetnictví', + enabled: true, + runsToday: 96, + successRate: 98.9, + avgDurationMs: 2_050, + lastRunAt: minutesAgo(26), + flow: { + trigger: { + serviceId: 'raynet', + operationId: 'company-changed', + fields: [{ id: 'f_21', name: 'companyId', type: 'string', required: true }], + }, + steps: [ + { id: 'st_21', kind: 'action', serviceId: 'transform', operationId: 'deduplicate' }, + { id: 'st_22', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' }, + { id: 'st_23', kind: 'action', serviceId: 'log', operationId: 'write' }, + ], + }, + }); + + seed({ + name: 'Noční report pro management', + enabled: false, + runsToday: 0, + successRate: 100, + avgDurationMs: 18_400, + lastRunAt: minutesAgo(1_020), + flow: { + trigger: { serviceId: 'scheduler', operationId: 'interval', fields: [] }, + steps: [ + { id: 'st_31', kind: 'action', serviceId: 'ga4', operationId: 'run-report' }, + { id: 'st_32', kind: 'action', serviceId: 'google-ads', operationId: 'campaign-report' }, + { id: 'st_33', kind: 'action', serviceId: 'sklik', operationId: 'campaign-report' }, + { id: 'st_34', kind: 'action', serviceId: 'openai', operationId: 'chat' }, + { id: 'st_35', kind: 'action', serviceId: 'email', operationId: 'send' }, + ], + }, + }); + + // Ukazka webhooku s deklarovanymi parametry a podminkou nad cislem. + seed({ + name: 'Webhook: hodnocení z dotazníku', + enabled: true, + runsToday: 61, + successRate: 100, + avgDurationMs: 640, + lastRunAt: minutesAgo(18), + flow: { + trigger: { + serviceId: 'webhook', + operationId: 'received', + webhookToken: generateWebhookToken(), + fields: [ + { id: 'f_41', name: 'customer', type: 'string', required: true }, + { id: 'f_42', name: 'score', type: 'number', required: true }, + { id: 'f_43', name: 'comment', type: 'string', required: false }, + ], + }, + steps: [ + { + id: 'st_41', + kind: 'condition', + fieldId: 'f_42', + operator: 'gte', + value: '15', + yes: [{ id: 'st_42', kind: 'action', serviceId: 'raynet', operationId: 'add-activity' }], + no: [ + { + id: 'st_43', + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + inputs: { + subject: 'Nízké hodnocení od {{customer}}', + body: 'Hodnocení {{score}} z dotazníku. Komentář: {{comment}}', + company: '{{customer}}', + priority: 'high', + assigneeId: 'usr_2', + }, + }, + { id: 'st_44', kind: 'action', serviceId: 'microsoft365', operationId: 'post-teams' }, + ], + }, + ], + }, + }); + + /* + * Prijem z kanalu: jedna automatizace na kanal, zadne rozhodovani o resiteli. + * Smerovani resi jedna spolecna automatizace nize - viz documentation/06-tickety.md. + */ + + seed({ + name: 'WhatsApp: zpráva do ticketu', + enabled: true, + runsToday: 34, + successRate: 100, + avgDurationMs: 1_950, + lastRunAt: minutesAgo(7), + flow: { + trigger: { + serviceId: 'whatsapp', + operationId: 'message-received', + fields: [ + { id: 'whatsapp.phone', name: 'phone', type: 'string', required: true }, + { id: 'whatsapp.profileName', name: 'profileName', type: 'string', required: false }, + { id: 'whatsapp.text', name: 'text', type: 'string', required: true }, + { id: 'whatsapp.hasMedia', name: 'hasMedia', type: 'boolean', required: false }, + { id: 'whatsapp.receivedAt', name: 'receivedAt', type: 'date', required: true }, + ], + }, + steps: [ + // Predvalidace: nejdriv se zeptame CRM, teprve podle odpovedi zakladame. + { + id: 'st_51', + kind: 'action', + serviceId: 'raynet', + operationId: 'find-company', + inputs: { phone: '{{phone}}' }, + }, + { + id: 'st_52', + kind: 'condition', + // Odkaz na vystup kroku st_51, ne na parametr spoustece. + fieldId: 'st_51.raynet.customerKnown', + operator: 'isTrue', + yes: [ + { + id: 'st_53', + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + inputs: { + subject: 'WhatsApp od {{profileName}}', + body: '{{text}}', + company: '{{companyName}}', + contact: '{{profileName}}', + reply: '{{phone}}', + priority: 'normal', + assigneeId: '', + }, + }, + ], + no: [ + { + id: 'st_54', + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + inputs: { + subject: 'WhatsApp od neznámého čísla', + body: '{{text}}', + contact: '{{profileName}}', + reply: '{{phone}}', + priority: 'normal', + assigneeId: '', + }, + }, + { id: 'st_55', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' }, + ], + }, + ], + }, + }); + + seed({ + name: 'Facebook: zpráva do ticketu', + enabled: true, + runsToday: 11, + successRate: 100, + avgDurationMs: 720, + lastRunAt: minutesAgo(52), + flow: { + trigger: { + serviceId: 'facebook', + operationId: 'message-received', + fields: [ + { id: 'facebook.senderId', name: 'senderId', type: 'string', required: true }, + { id: 'facebook.senderName', name: 'senderName', type: 'string', required: false }, + { id: 'facebook.text', name: 'text', type: 'string', required: true }, + { id: 'facebook.pageName', name: 'pageName', type: 'string', required: true }, + { id: 'facebook.receivedAt', name: 'receivedAt', type: 'date', required: true }, + ], + }, + // Bez predvalidace. Z Messengeru nemame e-mail ani telefon, podle ceho + // by se firma dohledala, takze zakladame rovnou a dohledani nechavame na cloveku. + steps: [ + { + id: 'st_61', + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + inputs: { + subject: 'Facebook od {{senderName}}', + body: '{{text}}', + contact: '{{senderName}}', + reply: '{{senderId}}', + priority: 'normal', + assigneeId: '', + }, + }, + ], + }, + }); + + seed({ + name: 'E-mail: požadavky do ticketu', + enabled: true, + runsToday: 58, + successRate: 99.1, + avgDurationMs: 2_310, + lastRunAt: minutesAgo(14), + flow: { + trigger: { + serviceId: 'email', + operationId: 'received', + fields: [ + { id: 'email.from', name: 'from', type: 'string', required: true }, + { id: 'email.subject', name: 'subject', type: 'string', required: true }, + { id: 'email.body', name: 'body', type: 'string', required: false }, + { id: 'email.hasAttachment', name: 'hasAttachment', type: 'boolean', required: false }, + { id: 'email.receivedAt', name: 'receivedAt', type: 'date', required: true }, + ], + }, + steps: [ + { + id: 'st_65', + kind: 'action', + serviceId: 'raynet', + operationId: 'find-company', + inputs: { email: '{{from}}' }, + }, + { + id: 'st_66', + kind: 'condition', + fieldId: 'st_65.raynet.customerKnown', + operator: 'isTrue', + yes: [ + { + id: 'st_67', + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + inputs: { + subject: '{{subject}}', + body: '{{body}}', + company: '{{companyName}}', + contact: '{{from}}', + reply: '{{from}}', + priority: 'normal', + assigneeId: '', + }, + }, + ], + no: [ + { + id: 'st_68', + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + inputs: { + subject: '{{subject}}', + body: '{{body}}', + contact: '{{from}}', + reply: '{{from}}', + priority: 'low', + assigneeId: '', + }, + }, + { + id: 'st_69', + kind: 'action', + serviceId: 'email', + operationId: 'send', + inputs: { + to: '{{from}}', + subject: 'Přijali jsme váš požadavek', + body: 'Dobrý den, požadavek jsme zaevidovali a ozveme se. Tým podpory.', + }, + }, + ], + }, + ], + }, + }); + + /* + * Jedna spolecna automatizace nad vsemi tickety, at vznikly odkudkoliv. + * Tohle je to misto, kde se dela logika zpracovani na miru zakaznikovi. + */ + seed({ + name: 'Směrování ticketů na řešitele', + /* + * Ukazkova automatizace, ktera **meni data**, je vypnuta. + * + * Zapnuta by prebirala tickety, ktere uz nekomu patri podle skutecne + * automatizace zakaznika - ukazkova data nemaji sahat na zivy provoz. + * Kdo si ji chce vyzkouset, zapne si ji. + */ + enabled: false, + runsToday: 103, + successRate: 100, + avgDurationMs: 310, + lastRunAt: minutesAgo(4), + flow: { + trigger: { + serviceId: 'ticket', + operationId: 'created', + fields: [ + { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, + { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, + { id: 'ticket.body', name: 'body', type: 'string', required: false }, + { id: 'ticket.channel', name: 'channel', type: 'string', required: true }, + { id: 'ticket.company', name: 'company', type: 'string', required: false }, + { id: 'ticket.contact', name: 'contact', type: 'string', required: false }, + { id: 'ticket.priority', name: 'priority', type: 'string', required: true }, + { id: 'ticket.knownCustomer', name: 'knownCustomer', type: 'boolean', required: true }, + { id: 'ticket.assigned', name: 'assigned', type: 'boolean', required: true }, + ], + }, + steps: [ + { + id: 'st_71', + kind: 'condition', + // Uz prirazeny ticket nepreberame, jinak bychom prepsali rucni rozhodnuti. + fieldId: 'ticket.assigned', + operator: 'isFalse', + yes: [ + { + id: 'st_72', + kind: 'condition', + fieldId: 'ticket.body', + operator: 'contains', + value: 'faktur', + yes: [ + { + id: 'st_73', + kind: 'action', + serviceId: 'ticket', + operationId: 'assign', + inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_3' }, + }, + ], + no: [ + { + id: 'st_74', + kind: 'condition', + fieldId: 'ticket.body', + operator: 'contains', + value: 'voicebot', + yes: [ + { + id: 'st_75', + kind: 'action', + serviceId: 'ticket', + operationId: 'assign', + inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_novakova' }, + }, + ], + no: [ + { + id: 'st_76', + kind: 'action', + serviceId: 'ticket', + operationId: 'assign', + inputs: { ticketId: '{{ticketId}}', assigneeId: 'usr_2' }, + }, + ], + }, + ], + }, + ], + no: [], + }, + ], + }, + }); +} diff --git a/src/data/automations/state.ts b/src/data/automations/state.ts new file mode 100644 index 0000000..4c438f9 --- /dev/null +++ b/src/data/automations/state.ts @@ -0,0 +1,33 @@ +/** + * Pamet automatizaci a citac ID. + * + * Automatizace se drzi v pameti kvuli synchronnimu cteni (webhook hleda + * podle tokenu pri kazdem requestu). Zapis do uloziste je v `persist.ts`. + */ + +import { highestNumber, writableOrWarn } from '../store/index.js'; +import type { StoredAutomation } from './model.js'; + +/** Na kolik mist se cislo v ID doplnuje nulami: `AUT-01`. */ +const ID_DIGITS = 2; + +export const store = new Map(); +let idCounter = 0; + +export function nextId(): string { + idCounter += 1; + return `AUT-${String(idCounter).padStart(ID_DIGITS, '0')}`; +} + +/** + * Po nacteni z uloziste. Citac musi pokracovat za nejvyssim ulozenym cislem, + * jinak by nova automatizace prepsala starou. + */ +export function continueCounter(): void { + idCounter = Math.max(idCounter, highestNumber(store.keys(), 'AUT')); +} + +/** Automatizace z povolenych firem. Cizi se chova jako neexistujici. */ +export function findWritable(id: string, tenantIds: string[]): StoredAutomation | undefined { + return writableOrWarn(store.get(id), id, tenantIds, 'automations'); +} diff --git a/src/data/automations/store.ts b/src/data/automations/store.ts new file mode 100644 index 0000000..9ba9d5c --- /dev/null +++ b/src/data/automations/store.ts @@ -0,0 +1,241 @@ +/** + * Cteni a zapisy automatizaci: seznam, detail, zalozeni, uprava, token + * webhooku, zaznam behu, smazani. + * + * Kazdy zapis jde pres `save` a posle udalost na sbernici. + */ + +import { publish } from '../../events/bus.js'; +import { timingSafeEqualString } from '../../lib/secure.js'; +import { describeModel } from '../model.js'; +import type { Automation, AutomationDetail, AutomationFlow } from '../../shared/automations.js'; +import type { StoredAutomation } from './model.js'; +import { mirror, save } from './persist.js'; +import { KEEP_DAYS, statsOf, today } from './runs.js'; +import { findWritable, nextId, store } from './state.js'; +import { collectFlowIssues, countSteps, deriveKind, withDerived } from './validation.js'; +import { generateWebhookToken, recentWebhookCalls, withWebhookToken } from './webhook.js'; + +function toSummary(stored: StoredAutomation): Automation { + const { + flow: _flow, + createdAt: _createdAt, + updatedAt: _updatedAt, + days: _days, + ...rest + } = stored; + return { + ...rest, + /* + * Cisla se pocitaji z historie po dnech, ne z ulozeneho citace. Po pulnoci + * je "dnes" nula, dokud opravdu neco nebezi. + */ + ...statsOf(stored), + // 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: stored.issues ?? collectFlowIssues(stored.flow), + }; +} + +function toDetail(stored: StoredAutomation): AutomationDetail { + return { + ...toSummary(stored), + flow: stored.flow, + model: stored.flow.trigger?.sample === undefined + ? [] + : describeModel(stored.flow.trigger.sample), + recentCalls: recentWebhookCalls(stored.id), + createdAt: stored.createdAt, + updatedAt: stored.updatedAt, + }; +} + +/** Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse". */ +export function listAutomations(tenantIds: string[]): Automation[] { + // Nejnovejsi nahoru, aby prave vytvorena automatizace byla hned videt. + return [...store.values()] + .filter((stored) => tenantIds.includes(stored.tenantId)) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .map(toSummary); +} + +export function getAutomation(id: string, tenantIds: string[]): AutomationDetail | undefined { + const stored = store.get(id); + if (!stored) return undefined; + if (!tenantIds.includes(stored.tenantId)) { + console.warn(`[automations] pokus o cteni ${id} mimo povolene firmy`); + return undefined; + } + return toDetail(stored); +} + +export function createAutomation(name: string, tenantId: string): AutomationDetail { + const id = nextId(); + const now = new Date().toISOString(); + const stored = withDerived({ + id, + tenantId, + name, + kind: 'workflow', + enabled: false, + runsToday: 0, + successRate: 100, + avgDurationMs: 0, + lastRunAt: now, + 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 }, tenantId); + return toDetail(stored); +} + +export function updateAutomation( + id: string, + patch: { name?: string; enabled?: boolean; flow?: AutomationFlow }, + tenantIds: string[], +): AutomationDetail | undefined { + const stored = findWritable(id, tenantIds); + if (!stored) { + console.warn(`[automations] pokus o upravu nedostupne automatizace: ${id}`); + return undefined; + } + + const flow = withWebhookToken(patch.flow ?? stored.flow, stored.flow, id); + 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 = updated.issues ?? []; + if (updated.enabled && issues.length > 0) { + console.warn(`[automations] ${id}: zapnuti odmitnuto - ${issues.join(' ')}`); + updated.enabled = false; + } + + save(updated); + console.info( + `[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); +} + +/** Vygeneruje novy token - stara adresa okamzite prestane fungovat. */ +export function regenerateWebhookToken( + id: string, + tenantIds: string[], +): AutomationDetail | undefined { + const stored = findWritable(id, tenantIds); + if (!stored) { + console.warn(`[automations] regenerace tokenu pro nedostupnou automatizaci: ${id}`); + return undefined; + } + if (stored.flow.trigger?.serviceId !== 'webhook') { + console.warn(`[automations] ${id}: regenerace tokenu, ale spoustec neni webhook`); + return undefined; + } + + // 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); +} + +/** 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 && timingSafeEqualString(stored.flow.trigger.webhookToken, token)) return toDetail(stored); + } + return undefined; +} + +/** Zapise beh automatizace - drzi metriky i graf zive. */ +export function recordRun(id: string, ok = true, tenantIds?: string[]): AutomationDetail | undefined { + // Bez omezeni volá webhook, ktery se autorizuje tokenem, ne prihlasenim. + const stored = tenantIds ? findWritable(id, tenantIds) : store.get(id); + if (!stored) { + console.warn(`[automations] recordRun pro nedostupnou automatizaci: ${id}`); + return undefined; + } + + /* + * Zapisuje se do dnesniho dne. Po pulnoci vznikne novy zaznam, takze citac + * nepokracuje pres den - to byla puvodni chyba. + */ + const now = today(); + const days = [...(stored.days ?? [])]; + const index = days.findIndex((entry) => entry.day === now); + const current = index === -1 ? undefined : days[index]; + + if (current === undefined) { + days.push({ day: now, runs: 1, ok: ok ? 1 : 0 }); + } else { + days[index] = { + day: now, + runs: current.runs + 1, + ok: current.ok + (ok ? 1 : 0), + }; + } + if (days.length > KEEP_DAYS) days.splice(0, days.length - KEEP_DAYS); + + const total = (stored.runsTotal ?? stored.runsToday) + 1; + const dayEntry = days.find((entry) => entry.day === now)!; + + const updated: StoredAutomation = { + ...stored, + days, + runsTotal: total, + runsToday: dayEntry.runs, + successRate: Math.round((dayEntry.ok / dayEntry.runs) * 1000) / 10, + lastRunAt: new Date().toISOString(), + }; + save(updated); + + publish( + 'automation.run', + ok + ? `Automatizace ${id} proběhla: ${updated.name}` + : `Automatizace ${id} skončila chybou: ${updated.name}`, + { automationId: id, ok }, + updated.tenantId, + ); + return toDetail(updated); +} + +export function deleteAutomation(id: string, tenantIds: string[]): boolean { + const stored = findWritable(id, tenantIds); + const name = stored?.name; + const existed = stored !== undefined && store.delete(id); + if (existed) mirror.drop(id); + if (!existed) { + console.warn(`[automations] pokus o smazani nedostupne automatizace: ${id}`); + } else { + 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/automations/validation.ts b/src/data/automations/validation.ts new file mode 100644 index 0000000..6d37ff1 --- /dev/null +++ b/src/data/automations/validation.ts @@ -0,0 +1,227 @@ +/** + * Co se ze stromu dopocitava: pocet kroku, nedodelky a druh automatizace. + * + * Vsechno jsou ciste funkce nad stromem. Uklada se to s automatizaci + * (`withDerived`), aby se strom neprochazel pri kazdem cteni seznamu. + */ + +import { isUnary } from '../conditions.js'; +import { collectScopes, duplicateNames, scopeFor } from '../flowScope.js'; +import { rootsOf } from '../model.js'; +import { actionInputsFor, findService } from '../services.js'; +import { referencedFields, rootOf } from '../templates.js'; +import type { + AutomationFlow, + AutomationKind, + FlowStep, + TriggerField, +} from '../../shared/automations.js'; +import { rulesOf, type StoredAutomation } from './model.js'; + +/** Rekurzivne secte kroky vcetne obou vetvi podminek. */ +export function countSteps(steps: FlowStep[]): number { + return steps.reduce((sum, step) => { + if (step.kind === 'condition') { + return sum + 1 + countSteps(step.yes) + countSteps(step.no); + } + if (step.kind === 'foreach') { + return sum + 1 + countSteps(step.steps); + } + return sum + 1; + }, 0); +} + +/** + * Nedodelky v nastaveni jednoho kroku: nevyplnene povinne pole a odkaz + * na parametr, ktery u spoustece neexistuje. + * + * Druhy pripad nastane hlavne po prejmenovani parametru. Sablonu proto + * nezahazujeme ani tise neopravujeme, jen rekneme, kde se ma doplnit. + */ +function actionInputIssues( + step: Extract, + available: TriggerField[], + /** + * Koreny, ktere jsou v poradku i kdyz nejsou deklarovanym parametrem: + * klice z ukazky tela, ID predchozich kroku, `item` a `index` uvnitr smycky. + */ + extraRoots: Set, +): string[] { + const knownNames = new Set(available.map((field) => field.name)); + const catalog = actionInputsFor(step.serviceId, step.operationId); + if (catalog.length === 0) return []; + + const issues: string[] = []; + const operationName = findService(step.serviceId)?.name ?? step.serviceId; + + for (const field of catalog) { + const value = step.inputs?.[field.id] ?? ''; + + if (field.required && value.trim().length === 0) { + issues.push(`Krok "${operationName}": chybí ${field.label.toLowerCase()}.`); + continue; + } + + for (const reference of referencedFields(value)) { + /* + * Overuje se jen **prvni cast** odkazu. Zbytek je cesta do struktury + * a tu predem overit nejde - co presne prijde v tele, vime az pri behu. + */ + const root = rootOf(reference); + if (!knownNames.has(root) && !extraRoots.has(root)) { + issues.push( + `Krok "${operationName}", pole ${field.label.toLowerCase()}: parametr "${root}" u spouštěče neexistuje.`, + ); + } + } + } + + return issues; +} + +/** + * Co brani zapnuti automatizace. Zamerne to NENI chyba pri ukladani - + * rozdelanou praci chceme ulozit, jen ji nesmime pustit do provozu. + */ +export function collectFlowIssues(flow: AutomationFlow): string[] { + const issues: string[] = []; + + if (!flow.trigger) { + issues.push('Chybí spouštěč.'); + return issues; + } + + if (flow.steps.length === 0) { + issues.push('Automatizace nemá žádný krok.'); + } + + // Webhook bez registrovaneho tokenu nelze zavolat. + const isWebhook = flow.trigger.serviceId === 'webhook'; + if (isWebhook && !flow.trigger.webhookToken) { + issues.push('Webhook nemá vygenerovanou adresu.'); + } + + const scopes = collectScopes(flow); + + /* + * Co dalsiho smi stat na zacatku odkazu. Klice z ukazky tela, protoze prave + * kvuli nim se ukazka vlepuje, a `_body` s celym telem. + */ + const sampleRoots = rootsOf(flow.trigger.sample); + sampleRoots.add('_body'); + + const walk = (steps: FlowStep[], roots: Set) => { + for (const step of steps) { + const available = scopeFor(scopes, step.id); + + for (const name of duplicateNames(available)) { + issues.push( + `Parametr "${name}" je v tomto místě stromu dvakrát, v šabloně by nešlo poznat který.`, + ); + } + + if (step.kind === 'action') { + issues.push(...actionInputIssues(step, available, roots)); + // ID kroku smi stat na zacatku odkazu: `{{st_faktura.invoiceId}}`. + roots.add(step.id); + continue; + } + + if (step.kind === 'foreach') { + if (step.path.trim().length === 0) { + issues.push('Smyčka nemá vyplněnou cestu k seznamu.'); + } + // Uvnitr smycky pribyva polozka a poradi, po ni vysledky za cely seznam. + walk(step.steps, new Set([...roots, 'item', 'index'])); + roots.add(step.id); + continue; + } + + // Kazda otazka podminky zvlast. Jedna spatna nesmi schovat ostatni. + const rules = rulesOf(step); + if (rules.length === 0) issues.push('Podmínka nemá žádnou otázku.'); + + for (const rule of rules) { + const field = available.find((candidate) => candidate.id === rule.fieldId); + if (!field) { + // Rozlisujeme "neexistuje" od "vznikne az pozdeji". Druhy pripad nastane + // po presunuti kroku a chce jinou radu nez smazat podminku. + issues.push( + scopes.all.has(rule.fieldId) + ? 'Podmínka se ptá na parametr, který vzniká až v pozdějším kroku. Posuňte ji níž.' + : 'Podmínka se odkazuje na parametr, který už neexistuje.', + ); + } else if (!isUnary(rule.operator) && (rule.value ?? '').trim().length === 0) { + issues.push(`Podmínka nad parametrem "${field.name}" nemá vyplněnou hodnotu.`); + } else if (field.type === 'number' && !isUnary(rule.operator)) { + if (Number.isNaN(Number(rule.value))) { + issues.push(`Podmínka nad parametrem "${field.name}" má nečíselnou hodnotu.`); + } + } + } + + /* + * Koreny z vetvi se **nesou dal**. Vystup z vetve je za podminkou + * k dispozici, jen nepovinne - viz data/flowScope.ts. + */ + walk(step.yes, roots); + walk(step.no, roots); + } + }; + walk(flow.steps, sampleRoots); + + // Stejny nedodelek muze vyjit z vic kroku (typicky duplicitni jmeno parametru). + // Uzivateli staci rict jednou. + return [...new Set(issues)]; +} + +/** Pouziva strom nekde konektor z dane kategorie? */ +function flowUsesCategory(steps: FlowStep[], category: string): boolean { + return steps.some((step) => { + if (step.kind === 'condition') { + return flowUsesCategory(step.yes, category) || flowUsesCategory(step.no, category); + } + if (step.kind === 'foreach') return flowUsesCategory(step.steps, category); + return findService(step.serviceId)?.category === category; + }); +} + +/** + * Druh automatizace se dopocitava ze stromu - klient ho nezadava. + * Je to jen stitek v seznamu, proto zamerne jednoducha heuristika: + * rozhoduje spoustec, u planovace jeste to, zda se ve krocich pracuje s analytikou. + */ +export function deriveKind(flow: AutomationFlow): AutomationKind { + if (!flow.trigger) return 'workflow'; + + const connector = findService(flow.trigger.serviceId); + if (!connector) { + console.warn(`[automations] spoustec odkazuje na neznama sluzba: ${flow.trigger.serviceId}`); + return 'workflow'; + } + + if (connector.id === 'voicebot') return 'voicebot'; + if (connector.category === 'analytika') return 'report'; + + // Planovac + prace s analytikou = pravidelny report, ne obecne workflow. + if (connector.id === 'scheduler' && flowUsesCategory(flow.steps, 'analytika')) return 'report'; + + if ( + connector.category === 'crm' || + connector.category === 'ekonomika' || + connector.category === 'logistika' + ) { + return 'integrace'; + } + + return 'workflow'; +} + +/** Doplni to, co se ze stromu dopocitava a uklada s nim (nedodelky, pocet kroku). */ +export function withDerived(automation: StoredAutomation): StoredAutomation { + return { + ...automation, + stepCount: countSteps(automation.flow.steps), + issues: collectFlowIssues(automation.flow), + }; +} diff --git a/src/data/automations/webhook.ts b/src/data/automations/webhook.ts new file mode 100644 index 0000000..dd637f1 --- /dev/null +++ b/src/data/automations/webhook.ts @@ -0,0 +1,84 @@ +/** + * Webhook automatizace: token v adrese a zaznamy poslednich volani. + * + * Token spravuje vyhradne server, viz `withWebhookToken`. Volani se drzi jen + * v pameti - je to napoveda pri ladeni, ne historie. + */ + +import { randomBytes } from 'node:crypto'; +import type { AutomationFlow, WebhookCall } from '../../shared/automations.js'; + +/** Delka tokenu v bajtech. 24 bajtu je 32 znaku base64url, dost na neuhodnutelnost. */ +const WEBHOOK_TOKEN_BYTES = 24; + +/** Neodhadnutelny token do adresy webhooku (32 znaku, base64url). */ +export function generateWebhookToken(): string { + return randomBytes(WEBHOOK_TOKEN_BYTES).toString('base64url'); +} + +/** Kolik znaku tela se u volani drzi. Vic uz je v pameti zbytecne. */ +const MAX_BODY = 8_000; + +/** Telo do zaznamu: zmrazene na retezec, aby se pozdeji nezmenilo pod rukama. */ +export function bodyForCall(body: unknown): { body: string; truncated: boolean } { + let text: string; + try { + text = JSON.stringify(body, null, 2) ?? ''; + } catch { + // Cyklicka struktura nebo neco, co JSON neumi. Radeji nic nez pad. + text = ''; + } + if (text.length <= MAX_BODY) return { body: text, truncated: false }; + return { body: text.slice(0, MAX_BODY), truncated: true }; +} + +/** Kolik poslednich volani se u automatizace drzi. */ +const MAX_CALLS = 10; + +const calls = new Map(); + +/** Zapise, jak dopadlo jedno volani webhooku. Nejnovejsi je prvni. */ +export function recordWebhookCall(automationId: string, call: WebhookCall): void { + const list = calls.get(automationId) ?? []; + list.unshift(call); + if (list.length > MAX_CALLS) list.length = MAX_CALLS; + calls.set(automationId, list); +} + +/** Poslednich par volani. Cte se jen pres detail automatizace, ktery hlida firmu. */ +export function recentWebhookCalls(automationId: string): WebhookCall[] { + return calls.get(automationId) ?? []; +} + +/** + * Token webhooku spravuje VYHRADNE server: + * - webhook spoustec bez tokenu ho dostane vygenerovany, + * - existujici token se prevezme z ulozene verze (klient ho nemuze zmenit), + * - pri zmene spoustece na neco jineho se token zahodi. + */ +export function withWebhookToken( + next: AutomationFlow, + previous: AutomationFlow, + id: string, +): AutomationFlow { + if (!next.trigger) return next; + + if (next.trigger.serviceId !== 'webhook') { + if (next.trigger.webhookToken) { + console.info(`[automations] ${id}: spoustec neni webhook, zahazuji token`); + } + return { ...next, trigger: { ...next.trigger, webhookToken: undefined } }; + } + + // Existujici token drzime, aby se uz zaregistrovana adresa nezmenila pod rukama. + const keptToken = + previous.trigger?.serviceId === 'webhook' ? previous.trigger.webhookToken : undefined; + + if (keptToken) { + return { ...next, trigger: { ...next.trigger, webhookToken: keptToken } }; + } + + const token = generateWebhookToken(); + console.info(`[automations] ${id}: vygenerovana adresa webhooku`); + return { ...next, trigger: { ...next.trigger, webhookToken: token } }; +} diff --git a/src/data/connectors/postgres.ts b/src/data/connectors/postgres.ts index 5168492..c99b9f1 100644 --- a/src/data/connectors/postgres.ts +++ b/src/data/connectors/postgres.ts @@ -41,6 +41,16 @@ interface ConnectorRow { updated_at: Date; } +/** + * Radek z `RETURNING`. Zapis, ktery nic nevrati, je chyba databaze, ne stav, + * ktery by mel volajici resit - proto vyjimka s jasnou hlaskou. + */ +function returnedRow(rows: ConnectorRow[], operation: string): ConnectorRow { + const row = rows[0]; + if (row === undefined) throw new Error(`Databaze nevratila radek konektoru (${operation}).`); + return row; +} + function toConnector(row: ConnectorRow): Connector { return { id: row.id, @@ -149,7 +159,7 @@ export const postgresConnectors: ConnectorRepository = { ], ); - return toConnector(row.rows[0]); + return toConnector(returnedRow(row.rows, 'create')); }); }, @@ -205,7 +215,7 @@ export const postgresConnectors: ConnectorRepository = { ], ); - return toConnector(updated.rows[0]); + return toConnector(returnedRow(updated.rows, 'update')); }); }, @@ -282,7 +292,7 @@ export const postgresConnectors: ConnectorRepository = { RETURNING ${COLUMNS}`, [id, JSON.stringify(sealAll(merged))], ); - return toConnector(updated.rows[0]); + return toConnector(returnedRow(updated.rows, 'setManagedValues')); }); }, diff --git a/src/data/egressIp.ts b/src/data/egressIp.ts index 4ded868..f5a383c 100644 --- a/src/data/egressIp.ts +++ b/src/data/egressIp.ts @@ -43,6 +43,7 @@ export function suggestRange(raw: string | null): string | null { const parts = ip.split('.'); if (parts.length !== 4) return null; const [first, second] = parts.map((part) => Number(part)); + if (first === undefined || second === undefined) return null; if (!Number.isInteger(first) || !Number.isInteger(second)) return null; if (first === 127) return '127.0.0.0/8'; diff --git a/src/data/mock.ts b/src/data/mock.ts index f83b858..f5d85e5 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -34,8 +34,9 @@ export function getSummary(tenantIds: string[]): DashboardSummary { // Dnesni sloupec grafu doplnujeme o skutecne behy automatizaci. const runsToday = automations.reduce((sum, a) => sum + a.runsToday, 0); - if (series.length > 0) { - series[series.length - 1] = { ...series[series.length - 1], runs: runsToday }; + const last = series.at(-1); + if (last !== undefined) { + series[series.length - 1] = { ...last, runs: runsToday }; } return { diff --git a/src/data/services.ts b/src/data/services.ts index bfe81e6..33324d5 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -1,2827 +1,7 @@ /** - * Katalog SLUZEB = zdroj pravdy o tom, co lze v automatizaci a v akcich pouzit. - * - * Pozor na dve veci, ktere se snadno pletou: - * - **Sluzba** je to, co umime. iDoklad, Shoptet, tickety, HTTP pozadavek. - * Definujeme ji my, ma svoje operace a rika, co je potreba k napojeni. - * - **Konektor** je napojeni jedne firmy na jednu sluzbu vcetne jejich - * pristupovych udaju. Zaklada si ho firma, uloziste je `connectorStore.ts`. - * - * Sluzba tedy rika "iDoklad potrebuje X-ClientId a X-ClientSecret", - * konektor rika "a tohle jsou nase". - * - * Kazda sluzba ma: - * - triggers: udalosti, kterymi muze automatizace ZACIT (spoustec) - * - actions: co se s ni da UDELAT uprostred behu - * - * Sluzba muze mit jen triggery (webhook), jen akce (odeslani e-mailu), nebo obojí. - * Jak pridat sluzbu: documentation/12-sluzby-a-konektory.md + * Fasada nad slozkou `services/`. Katalog mel skoro tri tisice radku, + * rozdelil se po skupinach sluzeb a tenhle soubor zustava, aby se nemusely + * menit importy jinde. */ -import type { User } from '../types.js'; -import { isMcpService, MCP_EASYWEB_SERVICE_ID, MCP_SERVICE_ID } from '../mcp/dialect.js'; -import { tenantHasService } from './tenantFeatures.js'; - -import type { - OperationField, - ProvidedField, - Service, - ServiceCategory, - ServiceCategoryEntry, - ServiceCredentialField, - ServiceOperation, - ServiceStatus, - ServiceVisibility, -} from '../shared/services.js'; - -/** - * Tvar sluzby je sdileny s webem, viz src/shared/services.ts. Tady je katalog - * samotny a to, co se z nej pocita. - */ -export type { - OperationField, - ProvidedField, - Service, - ServiceCategory, - ServiceCategoryEntry, - ServiceCredentialField, - ServiceOperation, - ServiceStatus, - ServiceVisibility, -}; - -/** - * Nabidka resitelu do vyberu u akci. - * - * Seznam se **nezapisuje do katalogu**, protoze resitele se nacitaji az za behu - * z uloziste, kdezto katalog vznika pri importu modulu. Misto hodnot je tu - * priznak `optionsFrom` a doplni je `serviceCatalog()` pri kazdem volani. - * Diky tomu novy clovek v tymu neni potreba nikde registrovat. - */ -const assigneeOptions: Array<{ value: string; label: string }> = [ - { value: '', label: 'Nechat ve frontě' }, -]; - -const priorityOptions = [ - { value: 'low', label: 'Nízká' }, - { value: 'normal', label: 'Běžná' }, - { value: 'high', label: 'Vysoká' }, - { value: 'critical', label: 'Kritická' }, -]; - -export const serviceCategories: ServiceCategoryEntry[] = [ - { id: 'obecne', label: 'Obecné' }, - { id: 'crm', label: 'CRM' }, - { id: 'ekonomika', label: 'Ekonomika a banky' }, - { id: 'logistika', label: 'Logistika' }, - { id: 'komunikace', label: 'Komunikace' }, - { id: 'analytika', label: 'Analytika' }, - { id: 'ai', label: 'AI a hlas' }, - { id: 'transformace', label: 'Transformace dat' }, -]; - -export const services: Service[] = [ - // ------------------------------------------------- obecne: spoustece - { - id: 'webhook', - name: 'Webhook', - category: 'obecne', - description: 'Spustí automatizaci příchozím HTTP požadavkem z libovolného systému.', - icon: 'Webhook', - status: 'available', - general: true, - appId: null, - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [ - { - id: 'received', - name: 'Přijat požadavek', - description: - 'Portál vygeneruje neodhadnutelnou adresu. Vy určíte, jaké parametry na ni budou přicházet.', - customPayload: true, - }, - ], - actions: [], - }, - { - id: 'scheduler', - name: 'Plánovač', - category: 'obecne', - description: 'Spouštění podle času, každou hodinu, denně, nebo podle cron výrazu.', - icon: 'Clock', - status: 'available', - general: true, - appId: null, - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [ - { - id: 'interval', - name: 'V pravidelném intervalu', - description: 'Například každých 15 minut nebo každý den v 6:00.', - fields: ['Interval / cron výraz', 'Časová zóna'], - }, - ], - actions: [], - }, - { - id: 'manual', - name: 'Ruční spuštění', - category: 'obecne', - description: 'Automatizaci spustí člověk tlačítkem v portálu. Vhodné pro testování.', - icon: 'MousePointerClick', - status: 'available', - general: true, - appId: null, - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [ - { - id: 'button', - name: 'Spuštěno z portálu', - description: 'Spustí se stiskem tlačítka na detailu automatizace.', - }, - ], - actions: [], - }, - { - id: 'form', - name: 'Webový formulář', - category: 'obecne', - description: 'Odeslání formuláře z webu: poptávka, registrace, reklamace.', - icon: 'FileInput', - status: 'available', - general: true, - appId: null, - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [ - { - id: 'submitted', - name: 'Formulář odeslán', - description: 'Spustí se po odeslání formuláře. Pole formuláře si definujete sami.', - customPayload: true, - }, - ], - actions: [], - }, - - // -------------------------------------------------------------- servicedesk - /** - * Tickety nejsou jen akce na konci stromu. Jsou to obe strany: - * kanaly do nich ustuji (WhatsApp, e-mail, hlas) a zalozeny ticket - * je zase spoustecem navazne automatizace - typicky "mame zakaznika?". - */ - { - id: 'incident', - name: 'Incidenty', - category: 'obecne', - description: - 'Výpadek nebo porucha, která se týká víc lidí najednou. Na rozdíl od ticketu ' + - 'neřeší jednoho zákazníka, ale stav služby.', - icon: 'AlarmClock', - status: 'available', - general: true, - appId: null, - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [], - actions: [ - { - id: 'create', - name: 'Založit incident', - description: - 'Když se chyba netýká jednoho ticketu, ale celé služby. Typicky navazuje ' + - 'na ticket typu chyba.', - implementation: 'script', - inputs: [ - { id: 'title', label: 'Název', kind: 'text', required: true }, - { - id: 'service', - label: 'Čeho se týká', - kind: 'text', - required: false, - hint: 'Název služby nebo aplikace, například Web nebo Voicebot.', - }, - { - id: 'severity', - label: 'Závažnost', - kind: 'choice', - required: false, - options: [ - { value: 'sev1', label: 'SEV1, kritická' }, - { value: 'sev2', label: 'SEV2, vážná' }, - { value: 'sev3', label: 'SEV3, menší' }, - ], - }, - ], - outputFields: [{ id: 'incidentId', name: 'incidentId', type: 'string', required: true }], - }, - ], - }, - { - id: 'ticket', - name: 'Tickety', - category: 'obecne', - description: - 'Servicedesk. Požadavek od zákazníka, který má svého řešitele a dohledatelný průběh.', - icon: 'LifeBuoy', - status: 'available', - general: true, - appId: null, - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [ - { - id: 'created', - name: 'Založen ticket', - description: - 'Spustí se při každém novém ticketu, ať vznikl z kanálu nebo ručně. ' + - 'Podle parametru knownCustomer se pozná, jestli se firma dohledala v CRM.', - providedFields: [ - { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, - { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, - { id: 'ticket.body', name: 'body', type: 'string', required: false }, - { id: 'ticket.channel', name: 'channel', type: 'string', required: true }, - { id: 'ticket.company', name: 'company', type: 'string', required: false }, - { id: 'ticket.contact', name: 'contact', type: 'string', required: false }, - { id: 'ticket.priority', name: 'priority', type: 'string', required: true }, - { id: 'ticket.knownCustomer', name: 'knownCustomer', type: 'boolean', required: true }, - { id: 'ticket.assigned', name: 'assigned', type: 'boolean', required: true }, - ], - }, - { - id: 'unknown-customer', - name: 'Ticket bez zákazníka', - description: - 'Spustí se, když se k ticketu nepodařilo dohledat firmu. Sem patří založení ' + - 'obchodního případu nebo dotaz zpět na zadavatele.', - providedFields: [ - { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, - { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, - { id: 'ticket.body', name: 'body', type: 'string', required: false }, - { id: 'ticket.channel', name: 'channel', type: 'string', required: true }, - { id: 'ticket.contact', name: 'contact', type: 'string', required: false }, - { id: 'ticket.reply', name: 'reply', type: 'string', required: true }, - ], - }, - { - id: 'assigned', - name: 'Ticket přiřazen řešiteli', - description: 'Spustí se, jakmile ticket dostane konkrétního člověka.', - providedFields: [ - { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, - { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, - { id: 'ticket.assignee', name: 'assignee', type: 'string', required: true }, - { id: 'ticket.assigneeEmail', name: 'assigneeEmail', type: 'string', required: true }, - { id: 'ticket.priority', name: 'priority', type: 'string', required: true }, - ], - }, - { - id: 'changed', - name: 'Ticket vznikl nebo se změnil', - description: - 'Spustí se při každé změně ticketu, včetně vzniku. Na tomhle stojí ' + - 'automatické přidělování práce: podle typu a štítku se rozhodne, kdo to dostane.', - providedFields: [ - { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, - { id: 'ticket.externalId', name: 'externalId', type: 'string', required: false }, - { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, - { id: 'ticket.status', name: 'status', type: 'string', required: true }, - { id: 'ticket.priority', name: 'priority', type: 'string', required: true }, - { id: 'ticket.typeId', name: 'typeId', type: 'string', required: false }, - { id: 'ticket.closed', name: 'closed', type: 'boolean', required: true }, - { id: 'ticket.tags', name: 'tags', type: 'list', required: false }, - { id: 'ticket.assigneeId', name: 'assigneeId', type: 'string', required: false }, - { id: 'ticket.company', name: 'company', type: 'string', required: false }, - ], - }, - { - id: 'status-changed', - name: 'Změna stavu ticketu', - description: 'Spustí se při přechodu do jiného stavu, včetně vyřešení.', - providedFields: [ - { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, - { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, - { id: 'ticket.status', name: 'status', type: 'string', required: true }, - { id: 'ticket.previousStatus', name: 'previousStatus', type: 'string', required: true }, - { id: 'ticket.assignee', name: 'assignee', type: 'string', required: false }, - ], - }, - ], - actions: [ - { - id: 'upsert', - name: 'Založit nebo doplnit ticket', - description: - 'Podle externího ID buď založí nový ticket, nebo na existující navěsí událost. ' + - 'Externí ID je unikátní v rámci firmy, takže druhá zpráva o téže objednávce ' + - 'skončí na jednom místě. Co je tady vyplněné, zapíše se i na existující ticket - ' + - 'prázdná hodnota nikdy nic nesmaže, takže data můžou chodit po částech.', - implementation: 'script', - inputs: [ - { - id: 'externalId', - label: 'Externí ID', - kind: 'text', - required: false, - hint: 'ID u odesílatele, typicky číslo objednávky. Bez něj vznikne vždy nový ticket.', - }, - { id: 'subject', label: 'Předmět', kind: 'text', required: false }, - { - id: 'body', - label: 'Obsah', - kind: 'longtext', - required: false, - hint: 'Text může dorazit až druhou zprávou, doplní se i na existující ticket.', - }, - { - id: 'typeId', - label: 'Typ ticketu', - kind: 'lookup', - optionsFrom: 'ticketTypes', - required: false, - hint: - 'Vyberte typ ze seznamu, nebo hodnotu dosaďte z dat, například ' + - '{{data.typ}}. Za typem stojí vlastní pole, ze kterých pak akce čerpají.', - }, - { - id: 'tags', - label: 'Štítky', - kind: 'text', - required: false, - hint: 'Oddělené čárkou. Přidají se, existující se nemažou.', - }, - { - id: 'priority', - label: 'Priorita', - kind: 'choice', - required: false, - options: [ - { value: 'low', label: 'Nízká' }, - { value: 'normal', label: 'Běžná' }, - { value: 'high', label: 'Vysoká' }, - { value: 'critical', label: 'Kritická' }, - ], - }, - { - id: 'status', - label: 'Stav', - kind: 'text', - required: false, - hint: 'Cokoliv chcete, například ringing nebo Připraveno k expedici.', - }, - { - id: 'closed', - label: 'Vyřízený', - kind: 'choice', - required: false, - options: [ - { value: 'true', label: 'Ano' }, - { value: 'false', label: 'Ne' }, - ], - hint: 'Prázdné = nechat, jak je. Podle tohohle se počítá fronta a statistiky.', - }, - { - id: 'fields', - label: 'Vlastní pole typu', - kind: 'json', - required: false, - hint: - 'JSON s klíči podle typu ticketu, například {"orderNumber":"{{orderId}}"}. ' + - 'Klíče se sčítají: co přinesla minulá zpráva, zůstane.', - }, - { - id: 'company', - label: 'Zákazník: firma', - kind: 'text', - required: false, - hint: 'Prázdná hodnota jméno nesmaže, takže zpráva bez firmy nic nepokazí.', - }, - { id: 'contact', label: 'Zákazník: kontakt', kind: 'text', required: false }, - { - id: 'reply', - label: 'Zákazník: kam odpovídat', - kind: 'text', - required: false, - hint: 'E-mail nebo číslo, odkud to přišlo.', - }, - { - id: 'channel', - label: 'Odkud požadavek přišel', - kind: 'choice', - required: false, - hint: - 'Jen pro filtrování a ikonu v seznamu ticketů. Na chování ' + - 'automatizace to nemá vliv, vyplňovat se nemusí.', - options: [ - { value: 'whatsapp', label: 'WhatsApp' }, - { value: 'facebook', label: 'Facebook Messenger' }, - { value: 'instagram', label: 'Instagram' }, - { value: 'email', label: 'E-mail' }, - { value: 'voice', label: 'Hlasová linka' }, - { value: 'form', label: 'Webový formulář' }, - { value: 'portal', label: 'Portál' }, - ], - }, - { - id: 'sourceRef', - label: 'Odkaz na zdroj', - kind: 'text', - required: false, - hint: 'ID zprávy u odesílatele, ať je dohledatelná.', - }, - { - id: 'assigneeId', - label: 'Řešitel', - kind: 'choice', - required: false, - options: [], - optionsFrom: 'people', - hint: 'Když víte rovnou, komu to patří. Jinak použijte samostatný krok.', - }, - { - id: 'groupId', - label: 'Skupina', - kind: 'choice', - required: false, - options: [], - optionsFrom: 'groups', - }, - { id: 'event', label: 'Typ události', kind: 'text', required: false }, - { id: 'label', label: 'Popisek do časové osy', kind: 'text', required: false }, - ], - outputFields: [ - { id: 'ticketId', name: 'ticketId', type: 'string', required: true }, - { id: 'created', name: 'created', type: 'boolean', required: true }, - ], - }, - { - id: 'assign-group', - name: 'Předat skupině', - description: - 'Ticket se objeví ve frontě skupiny a kdo má čas, si ho převezme. ' + - 'Když zaškrtnete rovnou přiřadit, dostane ho hned ten, kdo má nejmíň práce - ' + - 'to se hodí tam, kde se čeká na rychlou reakci.', - implementation: 'script', - inputs: [ - { - id: 'groupId', - label: 'Skupina', - kind: 'choice', - required: true, - options: [], - optionsFrom: 'groups', - }, - { - id: 'autoAssign', - label: 'Rovnou přiřadit nejvolnějšímu', - kind: 'choice', - required: false, - options: [ - { value: 'true', label: 'Ano' }, - { value: 'false', label: 'Ne, nechat ve frontě skupiny' }, - ], - hint: 'Prázdné = nechat ve frontě, ať si to lidé vezmou sami.', - }, - { id: 'ticketId', label: 'Ticket', kind: 'text', required: false }, - ], - outputFields: [ - { id: 'groupId', name: 'groupId', type: 'string', required: true }, - { id: 'groupName', name: 'groupName', type: 'string', required: true }, - ], - }, - { - id: 'assign-least-busy', - name: 'Předat nejvolnějšímu ze skupiny', - description: - 'Najde ve skupině toho, kdo má nejmíň nevyřízených ticketů, a předá mu to. ' + - 'Při shodě rozhoduje podíl ke kapacitě. Vypnutí lidé se přeskočí.', - implementation: 'script', - inputs: [ - { - id: 'groupId', - label: 'Skupina', - kind: 'choice', - required: true, - options: [], - optionsFrom: 'groups', - hint: 'Například sklad nebo IT. Skupiny se spravují v Nastavení.', - }, - { - id: 'ticketId', - label: 'Ticket', - kind: 'text', - required: false, - hint: 'Prázdné = ticket, kvůli kterému běh vznikl.', - }, - ], - outputFields: [ - { id: 'assigneeId', name: 'assigneeId', type: 'string', required: true }, - { id: 'assigneeName', name: 'assigneeName', type: 'string', required: true }, - ], - }, - { - id: 'assign-by-external', - name: 'Předat podle ID z cizí aplikace', - description: - 'Najde řešitele, který má u sebe uvedené externí ID, a předá mu ticket. ' + - 'Typicky voicebotId nebo klapka. Vazba se nastavuje u řešitele, takže ' + - 'při změně člověka se opravuje na jednom místě.', - implementation: 'script', - inputs: [ - { - id: 'value', - label: 'Hodnota', - kind: 'text', - required: true, - hint: 'Například {{voicebotId}}.', - }, - { - id: 'fallbackGroupId', - label: 'Náhradní skupina', - kind: 'text', - required: false, - hint: 'Když se nikdo nenajde, předá se nejvolnějšímu z této skupiny.', - }, - { id: 'ticketId', label: 'Ticket', kind: 'text', required: false }, - ], - outputFields: [ - { id: 'assigneeId', name: 'assigneeId', type: 'string', required: true }, - { id: 'assigneeName', name: 'assigneeName', type: 'string', required: true }, - ], - }, - { - id: 'set-type', - name: 'Nastavit typ ticketu', - description: 'Za typem stojí vlastní pole a podle typu se ukazují akce.', - implementation: 'script', - inputs: [ - { - id: 'typeId', - label: 'Typ ticketu', - kind: 'lookup', - optionsFrom: 'ticketTypes', - required: true, - hint: 'Vyberte ze seznamu, nebo dosaďte z dat.', - }, - { id: 'ticketId', label: 'Ticket', kind: 'text', required: false }, - ], - }, - { - id: 'add-tags', - name: 'Přidat štítky', - description: 'Existující štítky zůstanou, jinak by se dva kroky přebíjely.', - implementation: 'script', - inputs: [ - { id: 'tags', label: 'Štítky', kind: 'text', required: true, hint: 'Oddělené čárkou.' }, - { id: 'ticketId', label: 'Ticket', kind: 'text', required: false }, - ], - }, - /* - * "Posunout do dalsi faze" tady bylo, ale fazi nema ani ticket, ani typ - * ticketu - v modelu nikdy nevznikla. Krok nemel co vykonat a pole Faze - * u zalozeni ticketu se tise zahazovalo. To, co mela faze delat, uz umi - * stav: je prave jeden, je to volny retezec a typ ticketu si k nemu muze - * nabidnout svoje hodnoty. - */ - { - id: 'set-status', - name: 'Změnit stav ticketu', - description: - 'Stav je libovolný text, žádný číselník. Jestli je ticket vyřízený, ' + - 'říká samostatné pole - podle něj se počítá fronta a statistiky.', - implementation: 'script', - inputs: [ - { - id: 'status', - label: 'Stav', - kind: 'text', - required: true, - hint: 'Cokoliv chcete, například completed nebo Předáno dopravci.', - }, - { - id: 'closed', - label: 'Vyřízený', - kind: 'choice', - required: false, - options: [ - { value: 'true', label: 'Ano' }, - { value: 'false', label: 'Ne' }, - ], - hint: 'Prázdné = nechat, jak je.', - }, - { id: 'ticketId', label: 'Ticket', kind: 'text', required: false }, - ], - }, - { - id: 'create', - name: 'Založit ticket', - description: 'Vytvoří požadavek. Co se kam uloží, určíte v nastavení kroku.', - inputs: [ - { - id: 'subject', - label: 'Předmět', - kind: 'text', - required: true, - hint: 'Krátké shrnutí. Typicky předmět e-mailu nebo začátek zprávy.', - }, - { - id: 'body', - label: 'Obsah', - kind: 'longtext', - required: false, - hint: 'Celý text požadavku. Sem patří tělo e-mailu nebo zpráva z WhatsApp.', - }, - { id: 'company', label: 'Firma', kind: 'text', required: false }, - { id: 'contact', label: 'Kontakt', kind: 'text', required: false }, - { - id: 'reply', - label: 'Adresa pro odpověď', - kind: 'text', - required: false, - hint: 'E-mail nebo telefon, odkud to přišlo.', - }, - { - id: 'priority', - label: 'Priorita', - kind: 'choice', - required: true, - options: priorityOptions, - }, - { - id: 'assigneeId', - label: 'Řešitel', - kind: 'choice', - required: false, - options: assigneeOptions, - optionsFrom: 'people', - }, - ], - outputFields: [{ id: 'ticket.newId', name: 'newTicketId', type: 'string', required: true }], - }, - { - id: 'assign', - name: 'Přiřadit řešiteli', - description: - 'Předá ticket konkrétnímu člověku. Objeví se mu mezi jeho tickety a dostane ' + - 'upozornění. Když nechcete vybírat ručně, použijte Předat nejvolnějšímu ze skupiny.', - implementation: 'script', - inputs: [ - { - id: 'ticketId', - label: 'ID ticketu', - kind: 'text', - required: true, - hint: 'Obvykle {{ticketId}} ze spouštěče.', - }, - { - id: 'assigneeId', - label: 'Řešitel', - kind: 'choice', - required: true, - options: assigneeOptions, - optionsFrom: 'people', - }, - ], - }, - /* - * 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', - description: 'Doplní ticketu firmu z CRM. Používá se poté, co se zákazník dohledá.', - inputs: [ - { id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true }, - { id: 'companyId', label: 'ID firmy v CRM', kind: 'text', required: true }, - ], - }, - { - id: 'comment', - name: 'Přidat komentář', - description: 'Zapíše komentář do logu ticketu, aby byl na stejné časové ose jako běh.', - inputs: [ - { id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true }, - { id: 'author', label: 'Autor', kind: 'text', required: false }, - { id: 'text', label: 'Text', kind: 'longtext', required: true }, - ], - }, - ], - }, - - // --------------------------------------------------------------------- crm - { - id: 'raynet', - name: 'RAYNET CRM', - category: 'crm', - description: 'Firmy, kontakty, obchodní případy a aktivity v RAYNET CRM.', - icon: 'Users', - status: 'available', - general: false, - appId: 'raynet', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'apiKey', - label: 'API klíč', - target: 'header', - name: 'X-Api-Key', - required: true, - secret: true, - hint: 'RAYNET CRM: Nastavení, Klíč k API.', - }, - { - id: 'email', - label: 'E-mail uživatele', - target: 'header', - name: 'X-Raynet-Email', - required: true, - secret: false, - hint: 'Přihlašovací e-mail. S API klíčem tvoří Basic Auth.', - }, - { - id: 'instanceName', - label: 'Název instance', - target: 'header', - name: 'X-Instance-Name', - required: true, - secret: false, - hint: 'Subdoména účtu, tedy část před .raynetcrm.com.', - }, - ], - // Seznam firem o jedne polozce: nic nemeni a bez platnych udaju neprojde. - verifyPath: '/company?limit=1', - triggers: [ - { - id: 'lead-created', - name: 'Nový obchodní případ', - description: 'Spustí se při založení nového obchodního případu.', - }, - { - id: 'company-changed', - name: 'Změna firmy', - description: 'Spustí se při úpravě údajů firmy.', - }, - ], - actions: [ - { - id: 'create-lead', - name: 'Založit obchodní případ', - description: 'Vytvoří nový obchodní případ včetně napojení na firmu.', - fields: ['Název', 'Firma', 'Vlastník', 'Fáze'], - outputFields: [{ id: 'raynet.leadId', name: 'leadId', type: 'string', required: true }], - }, - { - id: 'find-company', - name: 'Dohledat firmu', - description: - 'Zjistí, jestli odesílatele známe. Nic nezakládá. Podle výsledku se pak strom větví.', - inputs: [ - { - id: 'email', - label: 'E-mail', - kind: 'text', - required: false, - hint: 'Například {{from}} u e-mailu.', - }, - { - id: 'phone', - label: 'Telefon', - kind: 'text', - required: false, - hint: 'Například {{phone}} u WhatsApp.', - }, - ], - outputFields: [ - { id: 'raynet.customerKnown', name: 'customerKnown', type: 'boolean', required: true }, - { id: 'raynet.companyId', name: 'companyId', type: 'string', required: false }, - { id: 'raynet.companyName', name: 'companyName', type: 'string', required: false }, - { id: 'raynet.ownerName', name: 'ownerName', type: 'string', required: false }, - ], - }, - { - id: 'upsert-contact', - name: 'Založit nebo aktualizovat kontakt', - description: 'Podle e-mailu kontakt najde a doplní, jinak vytvoří nový.', - fields: ['E-mail', 'Jméno', 'Telefon', 'Firma'], - outputFields: [ - { id: 'raynet.contactId', name: 'contactId', type: 'string', required: true }, - ], - }, - { - id: 'add-activity', - name: 'Přidat aktivitu', - description: 'Zapíše hovor, e-mail nebo poznámku k záznamu.', - fields: ['Typ aktivity', 'Text', 'Vazba na záznam'], - }, - ], - }, - - // -------------------------------------------------------------- ekonomika - { - id: 'idoklad', - name: 'iDoklad', - category: 'ekonomika', - description: 'Fakturace: vydané i přijaté doklady, kontakty, úhrady.', - icon: 'Receipt', - status: 'available', - general: false, - appId: 'idoklad', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'clientId', - label: 'Client ID', - target: 'header', - name: 'X-ClientId', - required: true, - secret: false, - hint: 'Z vývojářského portálu iDokladu.', - }, - { - id: 'clientSecret', - label: 'Client Secret', - target: 'header', - name: 'X-ClientSecret', - required: true, - secret: true, - hint: 'Uloží se jen pro odesílání a nikdy se nevrací zpátky.', - }, - { - id: 'applicationId', - label: 'Application ID', - target: 'header', - name: 'X-ApplicationId', - required: false, - secret: false, - hint: 'Jen partnerské aplikace. Běžná aplikace ho nepotřebuje.', - }, - { - id: 'language', - label: 'Jazyk odpovědí', - target: 'header', - name: 'X-Idoklad-Language', - required: false, - secret: false, - hint: 'Cz, Sk nebo En.', - }, - ], - // Vrati udaje o agende: nic nemeni a bez platnych udaju neprojde. - verifyPath: '/account/agenda', - triggers: [ - { - id: 'invoice-paid', - name: 'Faktura uhrazena', - description: 'Spustí se, jakmile je vydaná faktura označená jako zaplacená.', - }, - { - id: 'invoice-overdue', - name: 'Faktura po splatnosti', - description: 'Spustí se v den, kdy faktura překročí splatnost.', - }, - ], - actions: [ - { - id: 'create-invoice', - name: 'Vystavit fakturu', - description: 'Vytvoří vydanou fakturu včetně položek a odešle ji odběrateli.', - fields: ['Odběratel', 'Položky', 'Splatnost', 'Odeslat e-mailem'], - }, - { - id: 'create-proforma', - name: 'Vystavit proforma fakturu', - description: 'Vytvoří zálohovou fakturu.', - fields: ['Odběratel', 'Položky'], - }, - { - id: 'mark-paid', - name: 'Označit jako uhrazenou', - description: 'Zapíše úhradu k existující faktuře.', - fields: ['Číslo faktury', 'Datum úhrady'], - }, - ], - }, - { - id: 'csob', - name: 'ČSOB (PSD2)', - category: 'ekonomika', - description: 'Bankovní pohyby a zůstatky přes PSD2 rozhraní ČSOB.', - icon: 'Landmark', - status: 'available', - general: false, - appId: 'csob', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'certificate', - label: 'Certifikát QWAC (Base64 PFX)', - target: 'header', - name: 'X-CSOB-Certificate', - required: true, - secret: true, - hint: 'eIDAS certifikát jako PFX zakódovaný do Base64. Slouží k mutual TLS.', - }, - { - id: 'certificatePassword', - label: 'Heslo k certifikátu', - target: 'header', - name: 'X-CSOB-Certificate-Password', - required: false, - secret: true, - hint: 'Jen když je PFX chráněný heslem.', - }, - { - id: 'apiKey', - label: 'API klíč aplikace', - target: 'header', - name: 'X-API-Key', - required: true, - secret: true, - hint: 'Z vývojářského portálu ČSOB. Posílá se dál jako APIKEY.', - }, - { - id: 'tppName', - label: 'Název TPP', - target: 'header', - name: 'X-TPP-Name', - required: true, - secret: false, - hint: 'Název registrované organizace, posílá se dál jako TPP-Name.', - }, - { - id: 'accessToken', - label: 'Access token klienta', - target: 'header', - name: 'X-Access-Token', - required: true, - secret: true, - hint: - 'OAuth2 token konkrétního klienta banky. Získá se přes /oauth/* a je ' + - 'časově omezený, takže po vypršení se musí přepsat.', - }, - { - id: 'clientId', - label: 'OAuth Client ID', - target: 'header', - name: 'X-CSOB-Client-Id', - required: false, - secret: false, - hint: 'Jen pro obnovu tokenu přes OAuth endpointy.', - }, - { - id: 'clientSecret', - label: 'OAuth Client Secret', - target: 'header', - name: 'X-CSOB-Client-Secret', - required: false, - secret: true, - hint: 'Jen pro obnovu tokenu přes OAuth endpointy.', - }, - ], - // Seznam uctu klienta: cteci volani, ktere bez platneho tokenu neprojde. - verifyPath: '/accounts?size=1', - triggers: [ - { - id: 'payment-received', - name: 'Přijatá platba', - description: 'Spustí se při nové příchozí platbě na účtu.', - }, - ], - actions: [ - { - id: 'list-transactions', - name: 'Načíst pohyby', - description: 'Stáhne transakce za zvolené období pro další zpracování.', - fields: ['Účet', 'Období'], - }, - { - id: 'match-payment', - name: 'Spárovat platbu s fakturou', - description: 'Podle variabilního symbolu a částky najde odpovídající fakturu.', - fields: ['Tolerance částky'], - }, - ], - }, - - { - id: 'sap-bo', - name: 'SAP Business One', - category: 'ekonomika', - description: 'Obchodní partneři, položky, objednávky a doklady v SAP Business One.', - icon: 'Database', - status: 'available', - general: false, - appId: 'sap-bo', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'serviceLayerUrl', - label: 'Adresa Service Layer', - target: 'header', - name: 'X-SAP-B1-BaseUrl', - required: true, - secret: false, - hint: 'Například https://sap.firma.cz:50000. Adresa musí být dostupná z internetu.', - }, - { - id: 'companyDb', - label: 'Databáze firmy', - target: 'header', - name: 'X-SAP-B1-CompanyDB', - required: true, - secret: false, - hint: 'Název company databáze, například SBODEMOCZ.', - }, - { - id: 'username', - label: 'Uživatel', - target: 'header', - name: 'X-SAP-B1-Username', - required: true, - secret: false, - }, - { - id: 'password', - label: 'Heslo', - target: 'header', - name: 'X-SAP-B1-Password', - required: true, - secret: true, - }, - { - id: 'language', - label: 'Jazyk', - target: 'header', - name: 'X-SAP-B1-Language', - required: false, - secret: false, - hint: 'Kód jazyka Service Layer, například cs-CZ.', - }, - { - id: 'rejectUnauthorized', - label: 'Kontrolovat certifikát', - target: 'header', - name: 'X-SAP-B1-Reject-Unauthorized', - required: false, - secret: false, - hint: 'false povolí self-signed certifikát Service Layer. Výchozí je kontrolovat.', - }, - ], - // Prihlasi se a vrati verzi Service Layer. Nic nezaklada. - verifyPath: '/api/system/info', - triggers: [], - actions: [ - { - id: 'find-business-partner', - name: 'Najít obchodního partnera', - description: 'Dohledá partnera podle kódu, IČO nebo názvu. Nic nezakládá.', - fields: ['Kód partnera', 'IČO', 'Název'], - }, - { - id: 'list-orders', - name: 'Načíst objednávky', - description: 'Vrátí objednávky partnera nebo za období.', - fields: ['Partner', 'Období'], - }, - { - id: 'create-order', - name: 'Založit objednávku', - description: 'Vytvoří prodejní objednávku včetně řádků.', - fields: ['Partner', 'Položky', 'Datum dodání'], - }, - ], - }, - - // -------------------------------------------------------------- logistika - { - id: 'ppl', - name: 'PPL CPL', - category: 'logistika', - description: 'Zásilky, štítky a svozy v systému PPL.', - icon: 'Truck', - status: 'available', - general: false, - appId: 'pplcplapi', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'clientId', - label: 'Client ID', - target: 'header', - name: 'X-Client-Id', - required: true, - secret: false, - hint: 'Z vývojářského portálu PPL CPL.', - }, - { - id: 'clientSecret', - label: 'Client Secret', - target: 'header', - name: 'X-Client-Secret', - required: true, - secret: true, - }, - { - id: 'environment', - label: 'Prostředí', - target: 'header', - name: 'X-Environment', - required: false, - secret: false, - hint: 'production (výchozí) nebo test. Test nevytváří skutečné zásilky.', - }, - ], - // Udaje o zakaznikovi: nic nezaklada a bez platnych udaju neprojde. - verifyPath: '/customer', - triggers: [ - { - id: 'shipment-delivered', - name: 'Zásilka doručena', - description: 'Spustí se při změně stavu zásilky na doručeno.', - }, - ], - actions: [ - { - id: 'create-shipment', - name: 'Vytvořit zásilku', - description: 'Založí zásilku a vrátí číslo balíku i štítek k tisku.', - fields: ['Příjemce', 'Adresa', 'Hmotnost', 'Služba'], - }, - { - id: 'order-pickup', - name: 'Objednat svoz', - description: 'Objedná svoz na zvolený den a adresu.', - fields: ['Datum svozu', 'Adresa', 'Počet zásilek'], - }, - { - id: 'track', - name: 'Zjistit stav zásilky', - description: 'Vrátí aktuální stav a historii zásilky.', - fields: ['Číslo zásilky'], - }, - ], - }, - { - id: 'eshop', - name: 'E-shop', - category: 'logistika', - description: 'Objednávky, sklad a zákazníci z e-shopu (Shoptet, WooCommerce, vlastní).', - icon: 'ShoppingCart', - status: 'available', - general: false, - appId: 'eshop', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [ - { - id: 'order-created', - name: 'Nová objednávka', - description: - 'Spustí se při vytvoření objednávky v e-shopu. Celá objednávka projde ' + - 'dál jako objekt, takže se dá přemapovat na doklad.', - providedFields: [ - { id: 'eshop.orderNumber', name: 'orderNumber', type: 'string', required: true }, - { id: 'eshop.orderTotal', name: 'orderTotal', type: 'number', required: true }, - { id: 'eshop.customerEmail', name: 'customerEmail', type: 'string', required: false }, - // Cela objednavka. Do sablony se nedosazuje, predava se dalsimu kroku - // jako celek - typicky do transformace dat. - { id: 'eshop.order', name: 'order', type: 'object', required: true }, - { id: 'eshop.items', name: 'items', type: 'list', required: true }, - ], - }, - { - id: 'order-status-changed', - name: 'Změna stavu objednávky', - description: 'Spustí se při přechodu objednávky do jiného stavu.', - }, - ], - actions: [ - { - id: 'update-order', - name: 'Změnit stav objednávky', - description: 'Nastaví objednávce nový stav a volitelně informuje zákazníka.', - fields: ['Číslo objednávky', 'Nový stav'], - }, - { - id: 'update-stock', - name: 'Upravit stav skladu', - description: 'Naskladní nebo odepíše položky.', - fields: ['SKU', 'Množství'], - }, - ], - }, - - // ------------------------------------------------------------- komunikace - { - id: 'email', - name: 'E-mail', - category: 'komunikace', - description: 'Příjem i odesílání e-mailů včetně příloh.', - icon: 'Mail', - status: 'available', - general: false, - // Posmovni server neni nase aplikace za /apps a adresa je u konektoru, - // protoze kazda firma odesila ze sve schranky. - appId: null, - transport: 'smtp', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'host', - label: 'SMTP server', - target: 'config', - name: 'host', - required: true, - secret: false, - hint: 'Například smtp.seznam.cz nebo smtp.gmail.com.', - }, - { - id: 'port', - label: 'Port', - target: 'config', - name: 'port', - required: true, - secret: false, - hint: '587 pro STARTTLS, 465 pro šifrované spojení od začátku, 25 bez šifrování.', - }, - { - id: 'security', - label: 'Šifrování', - target: 'config', - name: 'security', - required: false, - secret: false, - hint: - 'Prázdné se řídí portem: 465 je ssl, jinak starttls. ' + - 'Přepsat jde hodnotou ssl, starttls nebo zadne.', - }, - { - id: 'user', - label: 'Uživatel', - target: 'config', - name: 'user', - required: true, - secret: false, - hint: 'Přihlašovací jméno ke schránce, obvykle celá e-mailová adresa.', - }, - { - id: 'password', - label: 'Heslo', - target: 'config', - name: 'password', - required: true, - secret: true, - hint: - 'U schránek s dvoufázovým ověřením to musí být heslo pro aplikaci, ' + - 'ne heslo k účtu.', - }, - { - id: 'from', - label: 'Adresa odesílatele', - target: 'config', - name: 'from', - required: true, - secret: false, - hint: 'Server ji musí povolit. Obvykle stejná jako uživatel.', - }, - { - id: 'fromName', - label: 'Jméno odesílatele', - target: 'config', - name: 'fromName', - required: false, - secret: false, - hint: 'Co uvidí příjemce místo holé adresy, například Podpora Automia.', - }, - { - id: 'replyTo', - label: 'Adresa pro odpovědi', - target: 'config', - name: 'replyTo', - required: false, - secret: false, - hint: 'Kam mají chodit odpovědi, když jinam než na adresu odesílatele.', - }, - ], - triggers: [ - { - id: 'received', - name: 'Přijat e-mail', - description: - 'Spustí se při doručení e-mailu do sledované schránky. Typický začátek ticketu.', - providedFields: [ - { id: 'email.from', name: 'from', type: 'string', required: true }, - { id: 'email.subject', name: 'subject', type: 'string', required: true }, - { id: 'email.body', name: 'body', type: 'string', required: false }, - { id: 'email.hasAttachment', name: 'hasAttachment', type: 'boolean', required: false }, - { id: 'email.receivedAt', name: 'receivedAt', type: 'date', required: true }, - ], - }, - ], - actions: [ - { - id: 'send', - name: 'Odeslat e-mail', - description: - 'Odešle zprávu ze schránky uvedené v konektoru. Předmět, příjemce ' + - 'i tělo se skládají z parametrů spouštěče a výstupů předchozích kroků.', - inputs: [ - { - id: 'to', - label: 'Příjemce', - kind: 'text', - required: true, - hint: 'Adresy oddělené čárkou. Například {{from}}, když odpovídáte na příchozí e-mail.', - }, - { - id: 'cc', - label: 'Kopie', - kind: 'text', - required: false, - hint: 'Adresy oddělené čárkou.', - }, - { - id: 'bcc', - label: 'Skrytá kopie', - kind: 'text', - required: false, - hint: 'Příjemci se navzájem neuvidí.', - }, - { - id: 'subject', - label: 'Předmět', - kind: 'text', - required: true, - hint: 'Například Ticket {{ticketId}}: {{subject}}.', - }, - { - id: 'html', - label: 'Tělo zprávy (HTML)', - kind: 'html', - required: true, - hint: - 'Píše se jako HTML. Parametry se dosazují stejně jako jinde, ' + - 'tedy {{jmeno}}, a dosazuje se bezpečně - ostré závorky v hodnotě ' + - 'rozvržení nerozhodí.', - }, - { - id: 'text', - label: 'Textová verze', - kind: 'longtext', - required: false, - hint: - 'Pro klienty, kteří HTML nezobrazí. Bez vyplnění se vyrobí z HTML ' + - 'odstraněním značek.', - }, - { - id: 'replyTo', - label: 'Adresa pro odpovědi', - kind: 'text', - required: false, - hint: 'Přebije adresu z konektoru. Hodí se, když má odpověď zamířit do ticketu.', - }, - ], - outputFields: [ - { id: 'email.messageId', name: 'messageId', type: 'string', required: true }, - { id: 'email.accepted', name: 'accepted', type: 'number', required: true }, - { id: 'email.rejected', name: 'rejected', type: 'number', required: true }, - ], - }, - ], - }, - { - id: 'whatsapp', - name: 'WhatsApp', - category: 'komunikace', - description: 'Příjem a odesílání zpráv přes WhatsApp Business. Nejrychlejší cesta k ticketu.', - icon: 'MessageCircle', - status: 'available', - general: false, - appId: 'whatsapp', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [ - { - id: 'message-received', - name: 'Přijata zpráva', - description: 'Spustí se při doručení zprávy na firemní číslo.', - providedFields: [ - { id: 'whatsapp.phone', name: 'phone', type: 'string', required: true }, - { id: 'whatsapp.profileName', name: 'profileName', type: 'string', required: false }, - { id: 'whatsapp.text', name: 'text', type: 'string', required: true }, - { id: 'whatsapp.hasMedia', name: 'hasMedia', type: 'boolean', required: false }, - { id: 'whatsapp.receivedAt', name: 'receivedAt', type: 'date', required: true }, - ], - }, - ], - actions: [ - { - id: 'send', - name: 'Odeslat zprávu', - description: 'Odpoví na číslo, ze kterého zpráva přišla, nebo na zadané číslo.', - inputs: [ - { - id: 'phone', - label: 'Telefon', - kind: 'text', - required: true, - hint: 'Například {{phone}} pro odpověď odesílateli.', - }, - { id: 'text', label: 'Text', kind: 'longtext', required: true }, - ], - }, - { - id: 'send-template', - name: 'Odeslat schválenou šablonu', - description: 'Pošle předschválenou šablonu. Nutné mimo 24hodinové okno konverzace.', - fields: ['Telefon', 'Šablona', 'Proměnné'], - }, - ], - }, - { - id: 'facebook', - name: 'Facebook Messenger', - category: 'komunikace', - description: 'Zprávy z firemní stránky na Facebooku.', - icon: 'Facebook', - status: 'available', - general: false, - appId: 'facebook', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [ - { - id: 'message-received', - name: 'Přijata zpráva', - description: 'Spustí se při doručení zprávy do schránky stránky.', - providedFields: [ - { id: 'facebook.senderId', name: 'senderId', type: 'string', required: true }, - { id: 'facebook.senderName', name: 'senderName', type: 'string', required: false }, - { id: 'facebook.text', name: 'text', type: 'string', required: true }, - { id: 'facebook.pageName', name: 'pageName', type: 'string', required: true }, - { id: 'facebook.receivedAt', name: 'receivedAt', type: 'date', required: true }, - ], - }, - ], - actions: [ - { - id: 'send', - name: 'Odeslat zprávu', - description: 'Odpoví do konverzace, ze které zpráva přišla.', - inputs: [ - { - id: 'recipientId', - label: 'Příjemce', - kind: 'text', - required: true, - hint: 'Například {{senderId}} pro odpověď odesílateli.', - }, - { id: 'text', label: 'Text', kind: 'longtext', required: true }, - ], - }, - ], - }, - { - id: 'instagram', - name: 'Instagram', - category: 'komunikace', - description: 'Přímé zprávy na firemním účtu Instagramu.', - icon: 'Instagram', - status: 'available', - general: false, - appId: 'instagram', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [ - { - id: 'message-received', - name: 'Přijata zpráva', - description: 'Spustí se při doručení přímé zprávy.', - providedFields: [ - { id: 'instagram.senderId', name: 'senderId', type: 'string', required: true }, - { id: 'instagram.username', name: 'username', type: 'string', required: false }, - { id: 'instagram.text', name: 'text', type: 'string', required: true }, - { id: 'instagram.receivedAt', name: 'receivedAt', type: 'date', required: true }, - ], - }, - ], - actions: [ - { - id: 'send', - name: 'Odeslat zprávu', - description: 'Odpoví do konverzace, ze které zpráva přišla.', - inputs: [ - { - id: 'recipientId', - label: 'Příjemce', - kind: 'text', - required: true, - hint: 'Například {{senderId}} pro odpověď odesílateli.', - }, - { id: 'text', label: 'Text', kind: 'longtext', required: true }, - ], - }, - ], - }, - { - id: 'microsoft365', - name: 'Microsoft 365', - category: 'komunikace', - description: 'Outlook, kalendář, Teams, SharePoint a OneDrive.', - icon: 'Building2', - status: 'available', - general: false, - appId: 'microsoft-365-service', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'tenantId', - label: 'Tenant ID', - target: 'header', - name: 'X-MS365-Tenant-Id', - required: true, - secret: false, - hint: 'ID adresáře v Entra ID (dříve Azure AD).', - }, - { - id: 'clientId', - label: 'Client ID', - target: 'header', - name: 'X-MS365-Client-Id', - required: true, - secret: false, - hint: 'ID registrované aplikace.', - }, - { - id: 'clientSecret', - label: 'Client Secret', - target: 'header', - name: 'X-MS365-Client-Secret', - required: true, - secret: true, - hint: 'Tajný klíč aplikace. Má omezenou platnost, po vypršení se přepíše.', - }, - ], - // Stav napojeni na Graph: prihlasi se udaji z hlavicek, nic nemeni. - verifyPath: '/status', - triggers: [ - { - id: 'calendar-event', - name: 'Nová schůzka v kalendáři', - description: 'Spustí se při založení schůzky ve sledovaném kalendáři.', - }, - ], - actions: [ - { - id: 'create-event', - name: 'Vytvořit schůzku', - description: 'Založí schůzku a pozve účastníky.', - fields: ['Kalendář', 'Termín', 'Účastníci'], - }, - { - id: 'upload-file', - name: 'Uložit soubor', - description: 'Nahraje dokument do SharePointu nebo OneDrive.', - fields: ['Knihovna', 'Cesta', 'Soubor'], - }, - { - id: 'post-teams', - name: 'Poslat zprávu do Teams', - description: 'Odešle zprávu do kanálu nebo konkrétnímu člověku.', - fields: ['Kanál', 'Text zprávy'], - }, - ], - }, - { - id: 'google', - name: 'Google Workspace', - category: 'komunikace', - description: 'Gmail, Kalendář, Disk, Tabulky, Dokumenty a Úkoly pod jedním napojením.', - icon: 'Chrome', - status: 'available', - general: false, - appId: 'google-service', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'serviceAccountJson', - label: 'JSON klíč service accountu', - target: 'header', - name: 'X-Google-Service-Account-Json', - required: false, - secret: true, - hint: - 'Celý obsah staženého JSON souboru. Tohle je cesta pro provoz bez člověka: ' + - 'službě z něj sama vznikne token. Alternativou je hotový access token.', - }, - { - id: 'serviceAccountScopes', - label: 'Oprávnění (scopes)', - target: 'header', - name: 'X-Google-Service-Account-Scopes', - required: false, - secret: false, - hint: - 'Oddělené mezerou. Ověření konektoru čte Disk, takže potřebuje aspoň ' + - 'https://www.googleapis.com/auth/drive.readonly.', - }, - { - id: 'serviceAccountSubject', - label: 'Zastupovaný uživatel', - target: 'header', - name: 'X-Google-Service-Account-Subject', - required: false, - secret: false, - hint: 'E-mail uživatele Workspace při domain-wide delegation. Bez něj jedná service account sám za sebe.', - }, - { - id: 'accessToken', - label: 'Access token', - target: 'header', - name: 'X-Google-Access-Token', - required: false, - secret: true, - hint: 'Hotový OAuth2 token. Platí zhruba hodinu, takže na trvalý provoz se nehodí.', - }, - ], - // Seznam souboru na Disku: cteci volani, ktere bez platnych udaju neprojde. - // Predpoklada scope drive.readonly, viz napoveda u pole s opravnenimi. - verifyPath: '/google/drive/files', - triggers: [], - actions: [ - { - id: 'send-email', - name: 'Odeslat e-mail', - description: 'Pošle e-mail přes Gmail účtu, pod kterým je napojení.', - fields: ['Příjemce', 'Předmět', 'Text'], - }, - { - id: 'append-sheet-row', - name: 'Přidat řádek do tabulky', - description: 'Připíše řádek na konec listu v Google Tabulkách.', - fields: ['Tabulka', 'List', 'Hodnoty'], - }, - { - id: 'create-event', - name: 'Vytvořit událost v kalendáři', - description: 'Založí událost a pozve účastníky.', - fields: ['Kalendář', 'Termín', 'Účastníci'], - }, - { - id: 'find-file', - name: 'Najít soubor na Disku', - description: 'Dohledá soubor podle názvu nebo dotazu. Nic nemění.', - fields: ['Dotaz'], - }, - ], - }, - - { - id: 'sms', - name: 'SMS', - category: 'komunikace', - description: 'Odesílání SMS zpráv zákazníkům nebo obsluze.', - icon: 'MessageSquare', - status: 'available', - general: false, - appId: 'sms', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [], - actions: [ - { - id: 'send', - name: 'Odeslat SMS', - description: 'Odešle krátkou zprávu na telefonní číslo.', - fields: ['Telefon', 'Text'], - }, - ], - }, - { - id: 'slack', - name: 'Slack', - category: 'komunikace', - description: 'Notifikace a interní komunikace v Slacku.', - icon: 'Hash', - status: 'planned', - general: false, - appId: 'slack', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [], - actions: [ - { - id: 'post-message', - name: 'Poslat zprávu do kanálu', - description: 'Odešle zprávu do zvoleného kanálu.', - fields: ['Kanál', 'Text'], - }, - ], - }, - - // --------------------------------------------------------------- analytika - { - id: 'ga4', - name: 'Google Analytics 4', - category: 'analytika', - description: 'Návštěvnost, konverze a chování uživatelů.', - icon: 'BarChart3', - status: 'available', - general: false, - appId: 'analytics', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'credentials', - label: 'Klíč service accountu (Base64)', - target: 'header', - name: 'X-GA-Credentials', - required: false, - secret: true, - hint: 'JSON klíč service accountu zakódovaný do Base64. Vyplňte tohle, nebo access token.', - }, - { - id: 'accessToken', - label: 'Access token', - target: 'header', - name: 'X-GA-Access-Token', - required: false, - secret: true, - hint: 'Hotový OAuth2 token. Platí zhruba hodinu, takže na provoz se hodí service account.', - }, - { - id: 'quotaProject', - label: 'Projekt pro kvótu', - target: 'header', - name: 'X-GA-Quota-Project', - required: false, - secret: false, - hint: 'ID projektu v Google Cloud, na který se má počítat kvóta.', - }, - ], - // Seznam uctu: cteci volani, ktere bez platnych udaju neprojde. - verifyPath: '/ga/admin/accountSummaries', - triggers: [], - actions: [ - { - id: 'run-report', - name: 'Načíst report', - description: 'Stáhne metriky za období pro další zpracování nebo report.', - fields: ['Property', 'Metriky', 'Dimenze', 'Období'], - }, - ], - }, - { - id: 'search-console', - name: 'Search Console', - category: 'analytika', - description: 'Pozice ve vyhledávání, dotazy a prokliky.', - icon: 'Search', - status: 'available', - general: false, - appId: 'analytics', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'credentials', - label: 'Klíč service accountu (Base64)', - target: 'header', - name: 'X-GSC-Credentials', - required: false, - secret: true, - hint: 'JSON klíč service accountu zakódovaný do Base64. Vyplňte tohle, nebo access token.', - }, - { - id: 'accessToken', - label: 'Access token', - target: 'header', - name: 'X-GSC-Access-Token', - required: false, - secret: true, - hint: 'Hotový OAuth2 token se scope webmasters.readonly.', - }, - { - id: 'quotaProject', - label: 'Projekt pro kvótu', - target: 'header', - name: 'X-GSC-Quota-Project', - required: false, - secret: false, - }, - ], - // Seznam webu v uctu: cteci volani, ktere bez platnych udaju neprojde. - verifyPath: '/gsc/sites', - triggers: [], - actions: [ - { - id: 'run-report', - name: 'Načíst výkon ve vyhledávání', - description: 'Vrátí dotazy, prokliky, zobrazení a průměrnou pozici.', - fields: ['Web', 'Období'], - }, - ], - }, - { - id: 'google-ads', - name: 'Google Ads', - category: 'analytika', - description: 'Výkon kampaní a náklady na reklamu.', - icon: 'Megaphone', - status: 'available', - general: false, - appId: 'analytics', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'developerToken', - label: 'Developer token', - target: 'header', - name: 'X-GAds-Developer-Token', - required: true, - secret: true, - hint: 'Ze správcovského (MCC) účtu: Tools, API Center. Musí mít schválený přístup.', - }, - { - id: 'accessToken', - label: 'Access token', - target: 'header', - name: 'X-GAds-Access-Token', - required: false, - secret: true, - hint: 'OAuth2 token se scope adwords. Vyplňte tohle, nebo klíč service accountu.', - }, - { - id: 'credentials', - label: 'Klíč service accountu (Base64)', - target: 'header', - name: 'X-GAds-Credentials', - required: false, - secret: true, - hint: 'Funguje jen se zapnutou domain-wide delegation.', - }, - { - id: 'loginCustomerId', - label: 'ID správcovského účtu', - target: 'header', - name: 'X-GAds-Login-Customer-Id', - required: false, - secret: false, - hint: 'MCC účet, přes který se přistupuje k podřízenému účtu. Bez pomlček.', - }, - ], - // Seznam uctu, na ktere udaje dosahnou. Nic nemeni. - verifyPath: '/googleads/customers:listAccessibleCustomers', - triggers: [], - actions: [ - { - id: 'campaign-report', - name: 'Načíst výkon kampaní', - description: 'Stáhne náklady, konverze a ROAS podle kampaní.', - fields: ['Účet', 'Období'], - }, - ], - }, - { - id: 'sklik', - name: 'Sklik', - category: 'analytika', - description: 'Kampaně a náklady v Skliku.', - icon: 'MousePointer', - status: 'available', - general: false, - appId: 'analytics', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'token', - label: 'Token API Drak', - target: 'header', - name: 'X-Sklik-Token', - required: true, - secret: true, - hint: - 'Sklik: uživatelské jméno, Nastavení, Přístup k API Drak. ' + - 'Vygenerování nového tokenu zneplatní ten předchozí.', - }, - { - id: 'userId', - label: 'ID cizího účtu', - target: 'header', - name: 'X-Sklik-User-Id', - required: false, - secret: false, - hint: 'Jen pro agenturní přístup ke spravovanému účtu.', - }, - ], - // Kvoty a limity uctu: cteci volani, ktere bez platneho tokenu neprojde. - verifyPath: '/sklik/limits', - triggers: [], - actions: [ - { - id: 'campaign-report', - name: 'Načíst výkon kampaní', - description: 'Stáhne statistiky kampaní za období.', - fields: ['Účet', 'Období'], - }, - ], - }, - - { - id: 'meta-ads', - name: 'Meta Ads', - category: 'analytika', - description: 'Výkon reklam na Facebooku a Instagramu: účty, kampaně, sestavy a insighty.', - icon: 'Facebook', - status: 'available', - general: false, - appId: 'meta', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'accessToken', - label: 'Access token', - target: 'header', - name: 'X-Meta-Access-Token', - required: true, - secret: true, - hint: - 'Token systémového uživatele z Business Manageru. Nepřestane platit, ' + - 'když někdo odejde z firmy, na rozdíl od uživatelského tokenu.', - }, - { - id: 'appSecret', - label: 'App Secret', - target: 'header', - name: 'X-Meta-App-Secret', - required: false, - secret: true, - hint: - 'Se zapnutým appsecret_proof je povinný, jinak Meta volání odmítne. ' + - 'Služba z něj podpis dopočítá sama.', - }, - { - id: 'apiVersion', - label: 'Verze Graph API', - target: 'header', - name: 'X-Meta-Api-Version', - required: false, - secret: false, - hint: 'Například v25.0. Bez vyplnění se použije verze nastavená ve službě.', - }, - ], - // Seznam reklamnich uctu, na ktere token dosahne. Nic nemeni. - verifyPath: '/ads/me/adaccounts', - triggers: [], - actions: [ - { - id: 'list-accounts', - name: 'Načíst reklamní účty', - description: 'Vrátí účty, na které přihlašovací údaje dosáhnou.', - }, - { - id: 'insights', - name: 'Načíst výkon reklam', - description: 'Stáhne útratu, prokliky a konverze za období pro účet, kampaň nebo sestavu.', - fields: ['Objekt', 'Období', 'Úroveň'], - }, - ], - }, - - // ---------------------------------------------------------------------- ai - { - id: 'voicebot', - name: 'Voicebot', - category: 'ai', - description: 'Hlasová linka: příjem hovorů, rozpoznání záměru, předání operátorovi.', - icon: 'PhoneCall', - status: 'available', - general: false, - appId: 'voicebot', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [ - { - id: 'call-received', - name: 'Příchozí hovor', - description: 'Spustí se při přijetí hovoru na hlasovou linku.', - providedFields: [ - { id: 'voicebot.callerNumber', name: 'callerNumber', type: 'string', required: true }, - { id: 'voicebot.line', name: 'line', type: 'string', required: true }, - { id: 'voicebot.wantsOperator', name: 'wantsOperator', type: 'boolean', required: false }, - { id: 'voicebot.startedAt', name: 'startedAt', type: 'date', required: true }, - ], - }, - { - id: 'call-ended', - name: 'Hovor ukončen', - description: 'Spustí se po skončení hovoru, k dispozici je přepis i záměr.', - }, - ], - actions: [ - { - id: 'play-scenario', - name: 'Přehrát scénář', - description: 'Provede volajícího hlasovým scénářem a vrátí odpovědi.', - fields: ['Scénář', 'Jazyk'], - }, - { - id: 'transfer', - name: 'Předat operátorovi', - description: 'Přepojí hovor na člověka a předá mu souhrn.', - fields: ['Skupina', 'Souhrn'], - }, - { - id: 'outbound-call', - name: 'Zavolat zákazníkovi', - description: 'Zahájí odchozí hovor podle scénáře.', - fields: ['Telefon', 'Scénář'], - }, - ], - }, - { - id: 'transcription', - name: 'Přepis hovoru', - category: 'ai', - description: 'Přepis zvuku na text (Deepgram + Whisper) se sloučením výsledků.', - icon: 'FileAudio', - status: 'available', - general: false, - appId: 'audio-transcription', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'deepgramApiKey', - label: 'Deepgram API klíč', - target: 'header', - name: 'X-Deepgram-Api-Key', - required: true, - secret: true, - hint: 'Deepgram Console, sekce API Keys.', - }, - { - id: 'openaiApiKey', - label: 'OpenAI API klíč', - target: 'header', - name: 'X-OpenAI-Api-Key', - required: true, - secret: true, - hint: 'platform.openai.com, sekce API keys. Použije se na Whisper i na sloučení.', - }, - ], - // Sluzba nema zadne cteci volani s autorizaci: prepis se uctuje a jiny - // endpoint neni. Overi se proto jen dostupnost a rekne se to nahlas. - triggers: [], - actions: [ - { - id: 'transcribe', - name: 'Přepsat nahrávku', - description: - 'Přepíše nahrávku dvěma enginy naráz a oba přepisy sloučí do jednoho výsledku.', - fields: ['Nahrávka', 'Jazyk'], - }, - ], - }, - - /** - * OpenAI je jina nez zbytek katalogu: **nebezi u nas**. Nema tedy `appId`, - * ale absolutni `baseUrl`, protoze adresa je cizi a nemuzeme s ni hnout. - * Prepsat ji jde promennou `OPENAI_BASE_URL` nebo adresou u konektoru, coz - * je cesta na Azure OpenAI a na kompatibilni brany. - */ - { - id: 'openai', - name: 'OpenAI', - category: 'ai', - description: - 'Dotazy na jazykové modely, práce se soubory a přepis zvuku pod vlastním API klíčem.', - icon: 'Bot', - status: 'available', - general: false, - appId: null, - baseUrl: 'https://api.openai.com/v1', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'apiKey', - label: 'API klíč', - target: 'header', - name: 'Authorization', - // Uzivatel vlepi klic tak, jak ho dostal. Slovo Bearer dopise runtime. - prefix: 'Bearer ', - required: true, - secret: true, - hint: 'platform.openai.com, sekce API keys. Vložte jen klíč, slovo Bearer doplní portál.', - }, - { - id: 'organization', - label: 'ID organizace', - target: 'header', - name: 'OpenAI-Organization', - required: false, - secret: false, - hint: 'Jen když účet patří do víc organizací a útrata se má počítat konkrétní z nich.', - }, - { - id: 'project', - label: 'ID projektu', - target: 'header', - name: 'OpenAI-Project', - required: false, - secret: false, - hint: 'Rozliší útratu mezi projekty jedné organizace.', - }, - ], - // Seznam modelu: nejlevnejsi cteci volani, ktere vyzaduje platny klic. - verifyPath: '/models', - triggers: [], - actions: [ - { - id: 'chat', - name: 'Zeptat se modelu', - description: 'Pošle otázku vybranému modelu a vrátí odpověď i spotřebu tokenů.', - fields: ['Model', 'Instrukce', 'Otázka'], - }, - { - id: 'ask-about-file', - name: 'Zeptat se na soubor', - description: 'Odpoví na otázku nad nahraným souborem nebo obrázkem.', - fields: ['Model', 'ID souboru', 'Otázka'], - }, - { - id: 'upload-file', - name: 'Nahrát soubor', - description: 'Odešle soubor do OpenAI a vrátí jeho ID pro další kroky.', - fields: ['Název souboru', 'Obsah', 'Účel'], - }, - { - id: 'transcribe-audio', - name: 'Přepsat zvuk', - description: 'Přepíše nahrávku na text jedním z přepisovacích modelů.', - fields: ['Nahrávka', 'Model', 'Jazyk'], - }, - { - id: 'list-models', - name: 'Načíst seznam modelů', - description: 'Vrátí modely, na které účet dosáhne. Nic nemění a nic nestojí.', - }, - ], - }, - - /** - * MCP server podle oficialni specifikace. - * - * Jedina sluzba v katalogu, ktera **nema zadne pevne operace** - rekne je az - * server. Doplnuje je `src/data/mcpTools.ts`. - * - * Prihlaseni ma tri podoby a firma vyplni tu, kterou ji provozovatel serveru - * dal. Vic jich je zamerne: verejne MCP servery vydavaji hotovy token, firemni - * jedou na OAuth. Kdyby slo jen jedno, cast serveru by nesla napojit. - * - * Adresa serveru je mezi udaji, ne v poli "vlastni adresa sluzby". U ostatnich - * sluzeb je adresa vlastnost sluzby a konektor ji smi jen prepsat, tady je to - * naopak: sluzba zadnou adresu nema, kazda firma ma svuj server. Stejne to ma - * SMTP i EasyWeb nize. - */ - { - id: MCP_SERVICE_ID, - name: 'MCP server', - category: 'ai', - description: - 'Napojení na libovolný MCP server. Portál si od něj vyžádá seznam nástrojů a ty se pak dají použít jako kroky automatizace.', - icon: 'Plug', - status: 'available', - general: false, - appId: null, - transport: 'mcp', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'serverUrl', - label: 'Adresa MCP serveru', - target: 'config', - name: 'serverUrl', - required: true, - secret: false, - hint: 'Celá adresa endpointu, například https://mcp.firma.cz/mcp. Musí být dostupná z internetu.', - }, - { - id: 'token', - label: 'Token', - target: 'config', - name: 'token', - required: false, - secret: true, - hint: 'Když jste od provozovatele dostali hotový token. Portál ho pošle tak, jak je, a nic dalšího neřeší.', - }, - { - id: 'clientId', - label: 'ID aplikace', - target: 'config', - name: 'clientId', - required: false, - secret: false, - hint: 'Druhá možnost: server má přihlášení přes OAuth. Portál si pak přístup vyzvedne sám a obnovuje ho.', - }, - { - id: 'clientSecret', - label: 'Tajemství aplikace', - target: 'config', - name: 'clientSecret', - required: false, - secret: true, - hint: 'Patří k ID aplikace.', - }, - { - id: 'tokenUrl', - label: 'Adresa pro přihlášení', - target: 'config', - name: 'tokenUrl', - required: false, - secret: false, - hint: 'Nechte prázdné. Vyplňuje se jen tehdy, když ji portál u serveru sám nenajde.', - }, - { - id: 'scope', - label: 'Rozsah oprávnění', - target: 'config', - name: 'scope', - required: false, - secret: false, - hint: 'Nechte prázdné, pokud vám provozovatel serveru neřekl konkrétní hodnotu.', - }, - ], - triggers: [], - // Prazdne zamerne: vsechny operace jsou nastroje ze serveru. - actions: [], - }, - - /** - * MCP server EasyWebu (Centaur). - * - * Vlastni sluzba, ne varianta te predchozi. Duvod je v tom, co firma - * vyplnuje: **dostane adresu, jmeno a heslo**, zadne ID aplikace a zadny - * token. EasyWeb nema OAuth ani `.well-known`, prihlaseni je vlastni - * (`POST /login` s HTTP Basic vrati trojici tokenu). - * - * Slucovat to s obecnou sluzbou by znamenalo formular, kde je pulka poli - * vzdycky k nicemu, a hadani, ktera pulka to prave je. Rozdily jsou popsane - * v `src/mcp/dialect.ts`. - */ - { - id: MCP_EASYWEB_SERVICE_ID, - name: 'MCP EasyWeb', - category: 'ai', - description: - 'Napojení na MCP server EasyWebu. Stačí adresa, jméno a heslo - portál si vyžádá seznam nástrojů a ty se dají použít jako kroky automatizace.', - icon: 'Plug', - status: 'available', - general: false, - appId: null, - transport: 'mcp', - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [ - { - id: 'serverUrl', - label: 'Adresa MCP serveru', - target: 'config', - name: 'serverUrl', - required: true, - secret: false, - hint: 'Endpoint bez koncového lomítka, například https://web.firmy.cz/centaur/mcp. Přihlašovací adresy si portál odvodí sám.', - }, - { - id: 'username', - label: 'Jméno', - target: 'config', - name: 'username', - required: true, - secret: false, - hint: 'Uživatel, pod kterým se má portál k serveru hlásit.', - }, - { - id: 'password', - label: 'Heslo', - target: 'config', - name: 'password', - required: true, - secret: true, - hint: 'Použije se jednou, na registraci zařízení. Dál se portál hlásí klíčem, který si vyrobí sám.', - }, - { - id: 'deviceName', - label: 'Název zařízení', - target: 'config', - name: 'deviceName', - required: false, - secret: false, - hint: 'Pod tímhle názvem uvidíte přihlášení v logu serveru. Prázdné znamená WorkNuke.', - }, - /* - * Klic zarizeni. Vyrabi ho portal pri prvnim prihlaseni a od te chvile je - * to identita, kterou server pozna - jmeno a heslo uz se nepouziva. - * - * Je to pole konektoru, a ne zvlastni tabulka, protoze udaje konektoru se - * uz ukladaji zasifrovane a tohle je privatni klic. `managed` znamena, ze - * ho ve formulari nikdo nevidi a nevyplnuje. - */ - { - id: 'deviceJwk', - label: 'Klíč zařízení', - target: 'config', - name: 'deviceJwk', - required: false, - secret: true, - managed: true, - }, - { - id: 'deviceFingerprint', - label: 'Otisk zařízení', - target: 'config', - name: 'deviceFingerprint', - required: false, - secret: false, - managed: true, - }, - ], - triggers: [], - actions: [], - }, - - /** - * Ukazka omezene viditelnosti: tuhle sluzbu vidi jen LogiTrans a spravce - * platformy. Ostatni firmy ji v katalogu vubec nedostanou, takze se ani - * nedozvi, ze existuje. - */ - { - id: 'polstryn-sap', - name: 'Polstryn SAP', - category: 'ekonomika', - description: 'Zakázková integrace na podnikový systém jednoho klienta.', - icon: 'Boxes', - status: 'planned', - general: false, - appId: 'polstryn-sap', - visibility: { mode: 'restricted', tenantIds: ['tnt_logitrans'], userIds: [] }, - credentials: [ - { - id: 'apiKey', - label: 'API klíč', - target: 'header', - name: 'X-Api-Key', - required: true, - secret: true, - }, - { - id: 'plant', - label: 'Číslo závodu', - target: 'config', - name: 'plant', - required: true, - secret: false, - hint: 'Předá se skriptu jako ctx.config.plant.', - }, - ], - triggers: [ - { - id: 'order-released', - name: 'Uvolněna výrobní zakázka', - description: 'Spustí se, jakmile SAP uvolní zakázku do výroby.', - }, - ], - actions: [ - { - id: 'post-goods-issue', - name: 'Zaúčtovat výdej materiálu', - description: 'Zapíše výdej materiálu k zakázce.', - fields: ['Číslo zakázky', 'Materiál', 'Množství'], - }, - ], - }, - - // -------------------------------------------------- obecne: nastroje - { - id: 'http', - name: 'HTTP požadavek', - category: 'obecne', - description: 'Zavolá libovolné API, které nemá vlastní konektor.', - icon: 'Globe', - status: 'available', - general: true, - appId: null, - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [], - actions: [ - { - id: 'request', - name: 'Zavolat API', - description: 'Odešle HTTP požadavek a vrátí odpověď dalším krokům.', - fields: ['Metoda', 'URL', 'Hlavičky', 'Tělo'], - }, - ], - }, - { - id: 'transform', - name: 'Transformace dat', - category: 'transformace', - description: 'Přemapování polí, formátování a čištění dat mezi kroky.', - icon: 'Shuffle', - status: 'available', - general: true, - appId: null, - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [], - actions: [ - { - id: 'map-fields', - name: 'Přemapovat pole', - description: 'Přeloží data z jednoho tvaru do druhého.', - fields: ['Mapování polí'], - }, - { - id: 'deduplicate', - name: 'Odstranit duplicity', - description: 'Vyřadí záznamy, které už systémem prošly.', - fields: ['Klíč pro srovnání'], - }, - { - id: 'custom', - name: 'Vlastní skript', - description: - 'Převod dat napsaný v JavaScriptu. Hodí se, když je pravidel tolik, ' + - 'že je kód čitelnější než jejich seznam.', - fields: ['Skript', 'Zdrojová data'], - inputs: [ - { - id: 'scriptId', - label: 'Skript', - kind: 'choice', - required: true, - optionsFrom: 'scripts', - hint: 'Skripty firmy se píšou v záložce Skripty.', - }, - { - id: 'source', - label: 'Zdrojová data', - kind: 'object', - required: true, - hint: 'Objekt, který skript dostane jako input. Třeba {{_body}}.', - }, - { - id: 'extra', - label: 'Co přidat ke vstupu', - kind: 'json', - required: false, - hint: - 'Hodnoty z předchozích kroků, například ' + - '{"partnerId": "{{st_kontakt.contactId}}"}. Skript je najde v input.extra.', - }, - ], - outputFields: [ - { id: 'transform.result', name: 'result', type: 'object', required: true }, - ], - }, - ], - }, - { - id: 'delay', - name: 'Pauza', - category: 'obecne', - description: 'Pozdrží běh o daný čas nebo do konkrétního okamžiku.', - icon: 'Timer', - status: 'available', - general: true, - appId: null, - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [], - actions: [ - { - id: 'wait', - name: 'Počkat', - description: 'Pozastaví běh na zadanou dobu.', - fields: ['Doba čekání'], - inputs: [ - { - id: 'seconds', - label: 'Sekund', - kind: 'text', - required: true, - hint: 'Nejvýš 60. Delší čekání patří do plánovače, ne do běhu.', - }, - ], - }, - ], - }, - { - id: 'log', - name: 'Zápis do logu', - category: 'obecne', - description: 'Uloží zprávu do provozního logu automatizace.', - icon: 'ScrollText', - status: 'available', - general: true, - appId: null, - visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], - triggers: [], - actions: [ - { - id: 'write', - name: 'Zapsat zprávu', - description: 'Přidá záznam do historie běhu, užitečné při ladění.', - fields: ['Zpráva'], - inputs: [ - { - id: 'message', - label: 'Zpráva', - kind: 'text', - required: true, - hint: 'Může obsahovat odkazy, například {{data.order.code}}.', - }, - ], - }, - ], - }, -]; - -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 - -/** - * Vidi uzivatel tuhle sluzbu? - * - * Obecne sluzby vidi vzdy vsichni - webhook, pauza nebo transformace dat nejsou - * nic, co by se komu odepiralo. Spravce platformy vidi vzdy vsechno. - * - * Sluzba, kterou uzivatel nevidi, se **nevraci vubec**, ne se stavem "nemate - * pravo". Firma nema z odpovedi poznat, ze taková sluzba existuje - stejne - * pravidlo jako u ticketu v documentation/07-firmy-a-prava.md. - */ -export function canSeeService(service: Service, user: User, tenantId: string | null): boolean { - if (service.general) return true; - - switch (service.visibility.mode) { - case 'everyone': - return true; - case 'admin': - return user.platformAdmin; - case 'restricted': { - // Zakazkova integrace pro konkretniho cloveka, at uz je prepnuty kamkoliv. - if (service.visibility.userIds.includes(user.id)) return true; - - /* - * **Rozhoduje firma, ne clovek.** Spravce platformy driv videl vsechny - * sluzby vzdycky, i po prepnuti do firmy, ktera je nema - takze si mohl - * do jeji automatizace vybrat zakazkovou integraci jineho klienta. - * Kdyz je firma vybrana, plati jeji seznam; bez vybrane firmy spravuje - * spravce platformy katalog a vidi vsechno. - */ - if (tenantId === null) return user.platformAdmin; - return service.visibility.tenantIds.includes(tenantId) || tenantHasService(tenantId, service.id); - } - default: - return false; - } -} - -export function visibleServices(user: User, tenantId: string | null): Service[] { - return services.filter((service) => canSeeService(service, user, tenantId)); -} - -// ------------------------------------------------------- akce ze skriptu - -/** - * Akce domerene ze skriptu sluzby. Plni to `src/scripts/registry.ts` - * pri kazdem nacteni skriptu. - * - * Je to prekryv, ne zapis do `services`. Dva duvody: staticky katalog zustane - * citelny a z operace jde poznat, odkud je (`implementation`). - * - * Prekryv je zamerne tady, ne ve zvlastnim modulu. Vsechno ostatni v aplikaci - * uz se pta pres `findOperation`, takze tim se skripty naraz objevi ve validaci - * stromu, ve vypoctu scope i v sablonach - bez toho, aby se to psalo trikrat. - */ -const scriptActions = new Map(); - -/** Nahradi cely prekryv. Volani je idempotentni, poradi nezalezi. */ -export function setScriptActions(byService: Map): void { - scriptActions.clear(); - for (const [serviceId, operations] of byService) { - scriptActions.set(serviceId, operations); - } -} - - -/** - * Nastroje MCP serveru. - * - * Druhy prekryv katalogu, a jineho druhu nez skripty. Skript je nas kod, takze - * je znamy pri prekladu. Nastroj MCP je **cizi a zjisti se az od serveru**, - * proto s sebou nese firmu: co ma jedna firma na svem serveru, druhe do - * katalogu nepatri. - * - * Plni to `src/data/mcpTools.ts`. - */ -let mcpOperations: Array<{ tenantId: string; serviceId: string; operation: ServiceOperation }> = []; - -/** Nahradi cely seznam nastroju. */ -export function setMcpOperations( - items: Array<{ tenantId: string; serviceId: string; operation: ServiceOperation }>, -): void { - mcpOperations = items; -} - -const byName = (a: ServiceOperation, b: ServiceOperation): number => - a.name.localeCompare(b.name, 'cs'); - -/** - * Nastroje **jedne firmy**. `null` znamena zadne, ne vsechny. - * - * Firma bez vybrane firmy v adrese nema videt nastroje cizich serveru, a to ani - * jmenem. Nazev nastroje umi prozradit dost: `zrus_objednavku_v_soap_bridge` - * rekne o cizi firme vic, nez by melo. - */ -export function mcpActionsFor(tenantId: string | null, serviceId: string): ServiceOperation[] { - if (tenantId === null) return []; - return mcpOperations - .filter((item) => item.tenantId === tenantId && item.serviceId === serviceId) - .map((item) => item.operation) - .sort(byName); -} - -/** - * Nastroje napric firmami. - * - * Jen pro vnitrni dohledani operace (`findOperation`, dosazovani sablon). - * Ven se to neposila - od toho je `mcpActionsFor`. - */ -function allMcpActions(serviceId: string): ServiceOperation[] { - return mcpOperations - .filter((item) => item.serviceId === serviceId) - .map((item) => item.operation) - .sort(byName); -} - -/** - * Akce sluzby vcetne tech ze skriptu. - * Kdyz skript nese ID operace, ktera uz v katalogu je, **skript vyhrava**. - * Staticky zapis je popis toho, co umime, skript je to, co se opravdu stane. - */ -export function actionsFor(serviceId: string): ServiceOperation[] { - const service = findService(serviceId); - if (!service) return []; - - // MCP nema skripty, ma nastroje serveru. Napric firmami, viz `allMcpActions`. - if (isMcpService(serviceId)) return [...service.actions, ...allMcpActions(serviceId)]; - - const fromScripts = scriptActions.get(serviceId); - if (!fromScripts || fromScripts.length === 0) return service.actions; - - const replaced = new Set(fromScripts.map((operation) => operation.id)); - return [ - ...service.actions.filter((action) => !replaced.has(action.id)), - ...fromScripts, - ].sort((a, b) => a.name.localeCompare(b.name, 'cs')); -} - -/** - * Doplni nabidky, ktere se nedaji zapsat do katalogu. - * - * Katalog vznika pri importu modulu, ale resitele a skupiny se nacitaji - * z uloziste az pozdeji. Pole s `optionsFrom` proto dostane hodnoty az tady. - */ -export function withRuntimeOptions( - items: Service[], - options: { - people: Array<{ id: string; name: string }>; - groups: Array<{ id: string; name: string }>; - scripts?: Array<{ id: string; name: string }>; - /** Typy ticketu **teto firmy**. Kazda firma ma svoje, ID se nemaji opisovat. */ - ticketTypes?: Array<{ id: string; name: string }>; - }, -): Service[] { - /** Prazdna volba nahore. U kazdeho zdroje znamena neco jineho. */ - const empty: Record, string> = { - people: 'Nechat ve frontě', - groups: 'Bez skupiny', - scripts: '- vyberte skript -', - ticketTypes: 'Bez typu', - }; - - const fill = (field: OperationField): OperationField => { - if (!field.optionsFrom) return field; - const source = - field.optionsFrom === 'people' - ? options.people - : field.optionsFrom === 'groups' - ? options.groups - : field.optionsFrom === 'ticketTypes' - ? (options.ticketTypes ?? []) - : (options.scripts ?? []); - return { - ...field, - options: [ - { value: '', label: empty[field.optionsFrom] }, - ...source.map((item) => ({ value: item.id, label: item.name })), - ], - }; - }; - - const fillOperation = (operation: ServiceOperation): ServiceOperation => - operation.inputs ? { ...operation, inputs: operation.inputs.map(fill) } : operation; - - return items.map((service) => ({ - ...service, - triggers: service.triggers.map(fillOperation), - actions: service.actions.map(fillOperation), - })); -} - -/** - * Katalog sluzeb vcetne akci ze skriptu. Nemodifikuje `services`. - * - * `tenantId` je potreba kvuli MCP: nastroje jsou vlastnost napojeni jedne - * firmy, ne sluzby. Bez nej se zadne nevraci, coz je spravna vychozi hodnota - - * zapomenuty argument tak neznamena "vsechny". - */ -export function serviceCatalog(tenantId: string | null = null): Service[] { - return services.map((service) => { - if (isMcpService(service.id)) { - return { ...service, actions: [...service.actions, ...mcpActionsFor(tenantId, service.id)] }; - } - return scriptActions.has(service.id) ? { ...service, actions: actionsFor(service.id) } : service; - }); -} - -/** - * Overi, ze sluzba existuje a ma danou operaci pozadovaneho druhu. - * Pouziva se pri ukladani stromu, aby se do nej nedostaly neexistujici kroky. - */ -export function findOperation( - serviceId: string, - operationId: string, - type: 'trigger' | 'action', -): ServiceOperation | undefined { - const service = findService(serviceId); - if (!service) return undefined; - const pool = type === 'trigger' ? service.triggers : actionsFor(serviceId); - return pool.find((operation) => operation.id === operationId); -} - -/** - * Parametry, ktere spoustec predava sam. `undefined` znamena, ze si je - * deklaruje uzivatel (webhook, formular) - katalog do toho nemluvi. - */ -export function providedFieldsFor( - serviceId: string, - operationId: string, -): ProvidedField[] | undefined { - return findOperation(serviceId, operationId, 'trigger')?.providedFields; -} - -/** Nastavitelna pole akce. Prazdne pole = akci zatim nejde konfigurovat. */ -export function actionInputsFor(serviceId: string, operationId: string): OperationField[] { - return findOperation(serviceId, operationId, 'action')?.inputs ?? []; -} - -/** Co akce vrati dalsim krokum. Prazdne pole = nic, na co by se dalo ptat. */ -export function actionOutputsFor(serviceId: string, operationId: string): ProvidedField[] { - return findOperation(serviceId, operationId, 'action')?.outputFields ?? []; -} +export * from './services/index.js'; diff --git a/src/data/services/catalog/ai.ts b/src/data/services/catalog/ai.ts new file mode 100644 index 0000000..cd29677 --- /dev/null +++ b/src/data/services/catalog/ai.ts @@ -0,0 +1,179 @@ +/** + * AI a hlas: voicebot, prepis hovoru, OpenAI. + */ + +import type { Service } from '../../../shared/services.js'; + +export const aiServices: Service[] = [ + { + id: 'voicebot', + name: 'Voicebot', + category: 'ai', + description: 'Hlasová linka: příjem hovorů, rozpoznání záměru, předání operátorovi.', + icon: 'PhoneCall', + status: 'available', + general: false, + appId: 'voicebot', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [ + { + id: 'call-received', + name: 'Příchozí hovor', + description: 'Spustí se při přijetí hovoru na hlasovou linku.', + providedFields: [ + { id: 'voicebot.callerNumber', name: 'callerNumber', type: 'string', required: true }, + { id: 'voicebot.line', name: 'line', type: 'string', required: true }, + { id: 'voicebot.wantsOperator', name: 'wantsOperator', type: 'boolean', required: false }, + { id: 'voicebot.startedAt', name: 'startedAt', type: 'date', required: true }, + ], + }, + { + id: 'call-ended', + name: 'Hovor ukončen', + description: 'Spustí se po skončení hovoru, k dispozici je přepis i záměr.', + }, + ], + actions: [ + { + id: 'play-scenario', + name: 'Přehrát scénář', + description: 'Provede volajícího hlasovým scénářem a vrátí odpovědi.', + fields: ['Scénář', 'Jazyk'], + }, + { + id: 'transfer', + name: 'Předat operátorovi', + description: 'Přepojí hovor na člověka a předá mu souhrn.', + fields: ['Skupina', 'Souhrn'], + }, + { + id: 'outbound-call', + name: 'Zavolat zákazníkovi', + description: 'Zahájí odchozí hovor podle scénáře.', + fields: ['Telefon', 'Scénář'], + }, + ], + }, + { + id: 'transcription', + name: 'Přepis hovoru', + category: 'ai', + description: 'Přepis zvuku na text (Deepgram + Whisper) se sloučením výsledků.', + icon: 'FileAudio', + status: 'available', + general: false, + appId: 'audio-transcription', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'deepgramApiKey', + label: 'Deepgram API klíč', + target: 'header', + name: 'X-Deepgram-Api-Key', + required: true, + secret: true, + hint: 'Deepgram Console, sekce API Keys.', + }, + { + id: 'openaiApiKey', + label: 'OpenAI API klíč', + target: 'header', + name: 'X-OpenAI-Api-Key', + required: true, + secret: true, + hint: 'platform.openai.com, sekce API keys. Použije se na Whisper i na sloučení.', + }, + ], + // Sluzba nema zadne cteci volani s autorizaci: prepis se uctuje a jiny + // endpoint neni. Overi se proto jen dostupnost a rekne se to nahlas. + triggers: [], + actions: [ + { + id: 'transcribe', + name: 'Přepsat nahrávku', + description: + 'Přepíše nahrávku dvěma enginy naráz a oba přepisy sloučí do jednoho výsledku.', + fields: ['Nahrávka', 'Jazyk'], + }, + ], + }, + { + id: 'openai', + name: 'OpenAI', + category: 'ai', + description: + 'Dotazy na jazykové modely, práce se soubory a přepis zvuku pod vlastním API klíčem.', + icon: 'Bot', + status: 'available', + general: false, + appId: null, + baseUrl: 'https://api.openai.com/v1', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'apiKey', + label: 'API klíč', + target: 'header', + name: 'Authorization', + // Uzivatel vlepi klic tak, jak ho dostal. Slovo Bearer dopise runtime. + prefix: 'Bearer ', + required: true, + secret: true, + hint: 'platform.openai.com, sekce API keys. Vložte jen klíč, slovo Bearer doplní portál.', + }, + { + id: 'organization', + label: 'ID organizace', + target: 'header', + name: 'OpenAI-Organization', + required: false, + secret: false, + hint: 'Jen když účet patří do víc organizací a útrata se má počítat konkrétní z nich.', + }, + { + id: 'project', + label: 'ID projektu', + target: 'header', + name: 'OpenAI-Project', + required: false, + secret: false, + hint: 'Rozliší útratu mezi projekty jedné organizace.', + }, + ], + // Seznam modelu: nejlevnejsi cteci volani, ktere vyzaduje platny klic. + verifyPath: '/models', + triggers: [], + actions: [ + { + id: 'chat', + name: 'Zeptat se modelu', + description: 'Pošle otázku vybranému modelu a vrátí odpověď i spotřebu tokenů.', + fields: ['Model', 'Instrukce', 'Otázka'], + }, + { + id: 'ask-about-file', + name: 'Zeptat se na soubor', + description: 'Odpoví na otázku nad nahraným souborem nebo obrázkem.', + fields: ['Model', 'ID souboru', 'Otázka'], + }, + { + id: 'upload-file', + name: 'Nahrát soubor', + description: 'Odešle soubor do OpenAI a vrátí jeho ID pro další kroky.', + fields: ['Název souboru', 'Obsah', 'Účel'], + }, + { + id: 'transcribe-audio', + name: 'Přepsat zvuk', + description: 'Přepíše nahrávku na text jedním z přepisovacích modelů.', + fields: ['Nahrávka', 'Model', 'Jazyk'], + }, + { + id: 'list-models', + name: 'Načíst seznam modelů', + description: 'Vrátí modely, na které účet dosáhne. Nic nemění a nic nestojí.', + }, + ], + }, +]; diff --git a/src/data/services/catalog/analytics.ts b/src/data/services/catalog/analytics.ts new file mode 100644 index 0000000..9250125 --- /dev/null +++ b/src/data/services/catalog/analytics.ts @@ -0,0 +1,273 @@ +/** + * Analytika a reklama: GA4, Search Console, Google Ads, Sklik, Meta Ads. + */ + +import type { Service } from '../../../shared/services.js'; + +export const analyticsServices: Service[] = [ + { + id: 'ga4', + name: 'Google Analytics 4', + category: 'analytika', + description: 'Návštěvnost, konverze a chování uživatelů.', + icon: 'BarChart3', + status: 'available', + general: false, + appId: 'analytics', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'credentials', + label: 'Klíč service accountu (Base64)', + target: 'header', + name: 'X-GA-Credentials', + required: false, + secret: true, + hint: 'JSON klíč service accountu zakódovaný do Base64. Vyplňte tohle, nebo access token.', + }, + { + id: 'accessToken', + label: 'Access token', + target: 'header', + name: 'X-GA-Access-Token', + required: false, + secret: true, + hint: 'Hotový OAuth2 token. Platí zhruba hodinu, takže na provoz se hodí service account.', + }, + { + id: 'quotaProject', + label: 'Projekt pro kvótu', + target: 'header', + name: 'X-GA-Quota-Project', + required: false, + secret: false, + hint: 'ID projektu v Google Cloud, na který se má počítat kvóta.', + }, + ], + // Seznam uctu: cteci volani, ktere bez platnych udaju neprojde. + verifyPath: '/ga/admin/accountSummaries', + triggers: [], + actions: [ + { + id: 'run-report', + name: 'Načíst report', + description: 'Stáhne metriky za období pro další zpracování nebo report.', + fields: ['Property', 'Metriky', 'Dimenze', 'Období'], + }, + ], + }, + { + id: 'search-console', + name: 'Search Console', + category: 'analytika', + description: 'Pozice ve vyhledávání, dotazy a prokliky.', + icon: 'Search', + status: 'available', + general: false, + appId: 'analytics', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'credentials', + label: 'Klíč service accountu (Base64)', + target: 'header', + name: 'X-GSC-Credentials', + required: false, + secret: true, + hint: 'JSON klíč service accountu zakódovaný do Base64. Vyplňte tohle, nebo access token.', + }, + { + id: 'accessToken', + label: 'Access token', + target: 'header', + name: 'X-GSC-Access-Token', + required: false, + secret: true, + hint: 'Hotový OAuth2 token se scope webmasters.readonly.', + }, + { + id: 'quotaProject', + label: 'Projekt pro kvótu', + target: 'header', + name: 'X-GSC-Quota-Project', + required: false, + secret: false, + }, + ], + // Seznam webu v uctu: cteci volani, ktere bez platnych udaju neprojde. + verifyPath: '/gsc/sites', + triggers: [], + actions: [ + { + id: 'run-report', + name: 'Načíst výkon ve vyhledávání', + description: 'Vrátí dotazy, prokliky, zobrazení a průměrnou pozici.', + fields: ['Web', 'Období'], + }, + ], + }, + { + id: 'google-ads', + name: 'Google Ads', + category: 'analytika', + description: 'Výkon kampaní a náklady na reklamu.', + icon: 'Megaphone', + status: 'available', + general: false, + appId: 'analytics', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'developerToken', + label: 'Developer token', + target: 'header', + name: 'X-GAds-Developer-Token', + required: true, + secret: true, + hint: 'Ze správcovského (MCC) účtu: Tools, API Center. Musí mít schválený přístup.', + }, + { + id: 'accessToken', + label: 'Access token', + target: 'header', + name: 'X-GAds-Access-Token', + required: false, + secret: true, + hint: 'OAuth2 token se scope adwords. Vyplňte tohle, nebo klíč service accountu.', + }, + { + id: 'credentials', + label: 'Klíč service accountu (Base64)', + target: 'header', + name: 'X-GAds-Credentials', + required: false, + secret: true, + hint: 'Funguje jen se zapnutou domain-wide delegation.', + }, + { + id: 'loginCustomerId', + label: 'ID správcovského účtu', + target: 'header', + name: 'X-GAds-Login-Customer-Id', + required: false, + secret: false, + hint: 'MCC účet, přes který se přistupuje k podřízenému účtu. Bez pomlček.', + }, + ], + // Seznam uctu, na ktere udaje dosahnou. Nic nemeni. + verifyPath: '/googleads/customers:listAccessibleCustomers', + triggers: [], + actions: [ + { + id: 'campaign-report', + name: 'Načíst výkon kampaní', + description: 'Stáhne náklady, konverze a ROAS podle kampaní.', + fields: ['Účet', 'Období'], + }, + ], + }, + { + id: 'sklik', + name: 'Sklik', + category: 'analytika', + description: 'Kampaně a náklady v Skliku.', + icon: 'MousePointer', + status: 'available', + general: false, + appId: 'analytics', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'token', + label: 'Token API Drak', + target: 'header', + name: 'X-Sklik-Token', + required: true, + secret: true, + hint: + 'Sklik: uživatelské jméno, Nastavení, Přístup k API Drak. ' + + 'Vygenerování nového tokenu zneplatní ten předchozí.', + }, + { + id: 'userId', + label: 'ID cizího účtu', + target: 'header', + name: 'X-Sklik-User-Id', + required: false, + secret: false, + hint: 'Jen pro agenturní přístup ke spravovanému účtu.', + }, + ], + // Kvoty a limity uctu: cteci volani, ktere bez platneho tokenu neprojde. + verifyPath: '/sklik/limits', + triggers: [], + actions: [ + { + id: 'campaign-report', + name: 'Načíst výkon kampaní', + description: 'Stáhne statistiky kampaní za období.', + fields: ['Účet', 'Období'], + }, + ], + }, + { + id: 'meta-ads', + name: 'Meta Ads', + category: 'analytika', + description: 'Výkon reklam na Facebooku a Instagramu: účty, kampaně, sestavy a insighty.', + icon: 'Facebook', + status: 'available', + general: false, + appId: 'meta', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'accessToken', + label: 'Access token', + target: 'header', + name: 'X-Meta-Access-Token', + required: true, + secret: true, + hint: + 'Token systémového uživatele z Business Manageru. Nepřestane platit, ' + + 'když někdo odejde z firmy, na rozdíl od uživatelského tokenu.', + }, + { + id: 'appSecret', + label: 'App Secret', + target: 'header', + name: 'X-Meta-App-Secret', + required: false, + secret: true, + hint: + 'Se zapnutým appsecret_proof je povinný, jinak Meta volání odmítne. ' + + 'Služba z něj podpis dopočítá sama.', + }, + { + id: 'apiVersion', + label: 'Verze Graph API', + target: 'header', + name: 'X-Meta-Api-Version', + required: false, + secret: false, + hint: 'Například v25.0. Bez vyplnění se použije verze nastavená ve službě.', + }, + ], + // Seznam reklamnich uctu, na ktere token dosahne. Nic nemeni. + verifyPath: '/ads/me/adaccounts', + triggers: [], + actions: [ + { + id: 'list-accounts', + name: 'Načíst reklamní účty', + description: 'Vrátí účty, na které přihlašovací údaje dosáhnou.', + }, + { + id: 'insights', + name: 'Načíst výkon reklam', + description: 'Stáhne útratu, prokliky a konverze za období pro účet, kampaň nebo sestavu.', + fields: ['Objekt', 'Období', 'Úroveň'], + }, + ], + }, +]; diff --git a/src/data/services/catalog/crm.ts b/src/data/services/catalog/crm.ts new file mode 100644 index 0000000..ce4d1a1 --- /dev/null +++ b/src/data/services/catalog/crm.ts @@ -0,0 +1,114 @@ +/** + * CRM: Raynet. + */ + +import type { Service } from '../../../shared/services.js'; + +export const crmServices: Service[] = [ + { + id: 'raynet', + name: 'RAYNET CRM', + category: 'crm', + description: 'Firmy, kontakty, obchodní případy a aktivity v RAYNET CRM.', + icon: 'Users', + status: 'available', + general: false, + appId: 'raynet', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'apiKey', + label: 'API klíč', + target: 'header', + name: 'X-Api-Key', + required: true, + secret: true, + hint: 'RAYNET CRM: Nastavení, Klíč k API.', + }, + { + id: 'email', + label: 'E-mail uživatele', + target: 'header', + name: 'X-Raynet-Email', + required: true, + secret: false, + hint: 'Přihlašovací e-mail. S API klíčem tvoří Basic Auth.', + }, + { + id: 'instanceName', + label: 'Název instance', + target: 'header', + name: 'X-Instance-Name', + required: true, + secret: false, + hint: 'Subdoména účtu, tedy část před .raynetcrm.com.', + }, + ], + // Seznam firem o jedne polozce: nic nemeni a bez platnych udaju neprojde. + verifyPath: '/company?limit=1', + triggers: [ + { + id: 'lead-created', + name: 'Nový obchodní případ', + description: 'Spustí se při založení nového obchodního případu.', + }, + { + id: 'company-changed', + name: 'Změna firmy', + description: 'Spustí se při úpravě údajů firmy.', + }, + ], + actions: [ + { + id: 'create-lead', + name: 'Založit obchodní případ', + description: 'Vytvoří nový obchodní případ včetně napojení na firmu.', + fields: ['Název', 'Firma', 'Vlastník', 'Fáze'], + outputFields: [{ id: 'raynet.leadId', name: 'leadId', type: 'string', required: true }], + }, + { + id: 'find-company', + name: 'Dohledat firmu', + description: + 'Zjistí, jestli odesílatele známe. Nic nezakládá. Podle výsledku se pak strom větví.', + inputs: [ + { + id: 'email', + label: 'E-mail', + kind: 'text', + required: false, + hint: 'Například {{from}} u e-mailu.', + }, + { + id: 'phone', + label: 'Telefon', + kind: 'text', + required: false, + hint: 'Například {{phone}} u WhatsApp.', + }, + ], + outputFields: [ + { id: 'raynet.customerKnown', name: 'customerKnown', type: 'boolean', required: true }, + { id: 'raynet.companyId', name: 'companyId', type: 'string', required: false }, + { id: 'raynet.companyName', name: 'companyName', type: 'string', required: false }, + { id: 'raynet.ownerName', name: 'ownerName', type: 'string', required: false }, + ], + }, + { + id: 'upsert-contact', + name: 'Založit nebo aktualizovat kontakt', + description: 'Podle e-mailu kontakt najde a doplní, jinak vytvoří nový.', + fields: ['E-mail', 'Jméno', 'Telefon', 'Firma'], + outputFields: [ + { id: 'raynet.contactId', name: 'contactId', type: 'string', required: true }, + ], + }, + { + id: 'add-activity', + name: 'Přidat aktivitu', + description: 'Zapíše hovor, e-mail nebo poznámku k záznamu.', + fields: ['Typ aktivity', 'Text', 'Vazba na záznam'], + }, + ], + }, +]; diff --git a/src/data/services/catalog/email.ts b/src/data/services/catalog/email.ts new file mode 100644 index 0000000..b975218 --- /dev/null +++ b/src/data/services/catalog/email.ts @@ -0,0 +1,185 @@ +/** + * E-mail: prijem i odeslani. + */ + +import type { Service } from '../../../shared/services.js'; + +export const emailServices: Service[] = [ + { + id: 'email', + name: 'E-mail', + category: 'komunikace', + description: 'Příjem i odesílání e-mailů včetně příloh.', + icon: 'Mail', + status: 'available', + general: false, + // Posmovni server neni nase aplikace za /apps a adresa je u konektoru, + // protoze kazda firma odesila ze sve schranky. + appId: null, + transport: 'smtp', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'host', + label: 'SMTP server', + target: 'config', + name: 'host', + required: true, + secret: false, + hint: 'Například smtp.seznam.cz nebo smtp.gmail.com.', + }, + { + id: 'port', + label: 'Port', + target: 'config', + name: 'port', + required: true, + secret: false, + hint: '587 pro STARTTLS, 465 pro šifrované spojení od začátku, 25 bez šifrování.', + }, + { + id: 'security', + label: 'Šifrování', + target: 'config', + name: 'security', + required: false, + secret: false, + hint: + 'Prázdné se řídí portem: 465 je ssl, jinak starttls. ' + + 'Přepsat jde hodnotou ssl, starttls nebo zadne.', + }, + { + id: 'user', + label: 'Uživatel', + target: 'config', + name: 'user', + required: true, + secret: false, + hint: 'Přihlašovací jméno ke schránce, obvykle celá e-mailová adresa.', + }, + { + id: 'password', + label: 'Heslo', + target: 'config', + name: 'password', + required: true, + secret: true, + hint: + 'U schránek s dvoufázovým ověřením to musí být heslo pro aplikaci, ' + + 'ne heslo k účtu.', + }, + { + id: 'from', + label: 'Adresa odesílatele', + target: 'config', + name: 'from', + required: true, + secret: false, + hint: 'Server ji musí povolit. Obvykle stejná jako uživatel.', + }, + { + id: 'fromName', + label: 'Jméno odesílatele', + target: 'config', + name: 'fromName', + required: false, + secret: false, + hint: 'Co uvidí příjemce místo holé adresy, například Podpora Automia.', + }, + { + id: 'replyTo', + label: 'Adresa pro odpovědi', + target: 'config', + name: 'replyTo', + required: false, + secret: false, + hint: 'Kam mají chodit odpovědi, když jinam než na adresu odesílatele.', + }, + ], + triggers: [ + { + id: 'received', + name: 'Přijat e-mail', + description: + 'Spustí se při doručení e-mailu do sledované schránky. Typický začátek ticketu.', + providedFields: [ + { id: 'email.from', name: 'from', type: 'string', required: true }, + { id: 'email.subject', name: 'subject', type: 'string', required: true }, + { id: 'email.body', name: 'body', type: 'string', required: false }, + { id: 'email.hasAttachment', name: 'hasAttachment', type: 'boolean', required: false }, + { id: 'email.receivedAt', name: 'receivedAt', type: 'date', required: true }, + ], + }, + ], + actions: [ + { + id: 'send', + name: 'Odeslat e-mail', + description: + 'Odešle zprávu ze schránky uvedené v konektoru. Předmět, příjemce ' + + 'i tělo se skládají z parametrů spouštěče a výstupů předchozích kroků.', + inputs: [ + { + id: 'to', + label: 'Příjemce', + kind: 'text', + required: true, + hint: 'Adresy oddělené čárkou. Například {{from}}, když odpovídáte na příchozí e-mail.', + }, + { + id: 'cc', + label: 'Kopie', + kind: 'text', + required: false, + hint: 'Adresy oddělené čárkou.', + }, + { + id: 'bcc', + label: 'Skrytá kopie', + kind: 'text', + required: false, + hint: 'Příjemci se navzájem neuvidí.', + }, + { + id: 'subject', + label: 'Předmět', + kind: 'text', + required: true, + hint: 'Například Ticket {{ticketId}}: {{subject}}.', + }, + { + id: 'html', + label: 'Tělo zprávy (HTML)', + kind: 'html', + required: true, + hint: + 'Píše se jako HTML. Parametry se dosazují stejně jako jinde, ' + + 'tedy {{jmeno}}, a dosazuje se bezpečně - ostré závorky v hodnotě ' + + 'rozvržení nerozhodí.', + }, + { + id: 'text', + label: 'Textová verze', + kind: 'longtext', + required: false, + hint: + 'Pro klienty, kteří HTML nezobrazí. Bez vyplnění se vyrobí z HTML ' + + 'odstraněním značek.', + }, + { + id: 'replyTo', + label: 'Adresa pro odpovědi', + kind: 'text', + required: false, + hint: 'Přebije adresu z konektoru. Hodí se, když má odpověď zamířit do ticketu.', + }, + ], + outputFields: [ + { id: 'email.messageId', name: 'messageId', type: 'string', required: true }, + { id: 'email.accepted', name: 'accepted', type: 'number', required: true }, + { id: 'email.rejected', name: 'rejected', type: 'number', required: true }, + ], + }, + ], + }, +]; diff --git a/src/data/services/catalog/finance.ts b/src/data/services/catalog/finance.ts new file mode 100644 index 0000000..f05e176 --- /dev/null +++ b/src/data/services/catalog/finance.ts @@ -0,0 +1,280 @@ +/** + * Ekonomika a banky: iDoklad, CSOB, SAP Business One. + */ + +import type { Service } from '../../../shared/services.js'; + +export const financeServices: Service[] = [ + { + id: 'idoklad', + name: 'iDoklad', + category: 'ekonomika', + description: 'Fakturace: vydané i přijaté doklady, kontakty, úhrady.', + icon: 'Receipt', + status: 'available', + general: false, + appId: 'idoklad', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'clientId', + label: 'Client ID', + target: 'header', + name: 'X-ClientId', + required: true, + secret: false, + hint: 'Z vývojářského portálu iDokladu.', + }, + { + id: 'clientSecret', + label: 'Client Secret', + target: 'header', + name: 'X-ClientSecret', + required: true, + secret: true, + hint: 'Uloží se jen pro odesílání a nikdy se nevrací zpátky.', + }, + { + id: 'applicationId', + label: 'Application ID', + target: 'header', + name: 'X-ApplicationId', + required: false, + secret: false, + hint: 'Jen partnerské aplikace. Běžná aplikace ho nepotřebuje.', + }, + { + id: 'language', + label: 'Jazyk odpovědí', + target: 'header', + name: 'X-Idoklad-Language', + required: false, + secret: false, + hint: 'Cz, Sk nebo En.', + }, + ], + // Vrati udaje o agende: nic nemeni a bez platnych udaju neprojde. + verifyPath: '/account/agenda', + triggers: [ + { + id: 'invoice-paid', + name: 'Faktura uhrazena', + description: 'Spustí se, jakmile je vydaná faktura označená jako zaplacená.', + }, + { + id: 'invoice-overdue', + name: 'Faktura po splatnosti', + description: 'Spustí se v den, kdy faktura překročí splatnost.', + }, + ], + actions: [ + { + id: 'create-invoice', + name: 'Vystavit fakturu', + description: 'Vytvoří vydanou fakturu včetně položek a odešle ji odběrateli.', + fields: ['Odběratel', 'Položky', 'Splatnost', 'Odeslat e-mailem'], + }, + { + id: 'create-proforma', + name: 'Vystavit proforma fakturu', + description: 'Vytvoří zálohovou fakturu.', + fields: ['Odběratel', 'Položky'], + }, + { + id: 'mark-paid', + name: 'Označit jako uhrazenou', + description: 'Zapíše úhradu k existující faktuře.', + fields: ['Číslo faktury', 'Datum úhrady'], + }, + ], + }, + { + id: 'csob', + name: 'ČSOB (PSD2)', + category: 'ekonomika', + description: 'Bankovní pohyby a zůstatky přes PSD2 rozhraní ČSOB.', + icon: 'Landmark', + status: 'available', + general: false, + appId: 'csob', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'certificate', + label: 'Certifikát QWAC (Base64 PFX)', + target: 'header', + name: 'X-CSOB-Certificate', + required: true, + secret: true, + hint: 'eIDAS certifikát jako PFX zakódovaný do Base64. Slouží k mutual TLS.', + }, + { + id: 'certificatePassword', + label: 'Heslo k certifikátu', + target: 'header', + name: 'X-CSOB-Certificate-Password', + required: false, + secret: true, + hint: 'Jen když je PFX chráněný heslem.', + }, + { + id: 'apiKey', + label: 'API klíč aplikace', + target: 'header', + name: 'X-API-Key', + required: true, + secret: true, + hint: 'Z vývojářského portálu ČSOB. Posílá se dál jako APIKEY.', + }, + { + id: 'tppName', + label: 'Název TPP', + target: 'header', + name: 'X-TPP-Name', + required: true, + secret: false, + hint: 'Název registrované organizace, posílá se dál jako TPP-Name.', + }, + { + id: 'accessToken', + label: 'Access token klienta', + target: 'header', + name: 'X-Access-Token', + required: true, + secret: true, + hint: + 'OAuth2 token konkrétního klienta banky. Získá se přes /oauth/* a je ' + + 'časově omezený, takže po vypršení se musí přepsat.', + }, + { + id: 'clientId', + label: 'OAuth Client ID', + target: 'header', + name: 'X-CSOB-Client-Id', + required: false, + secret: false, + hint: 'Jen pro obnovu tokenu přes OAuth endpointy.', + }, + { + id: 'clientSecret', + label: 'OAuth Client Secret', + target: 'header', + name: 'X-CSOB-Client-Secret', + required: false, + secret: true, + hint: 'Jen pro obnovu tokenu přes OAuth endpointy.', + }, + ], + // Seznam uctu klienta: cteci volani, ktere bez platneho tokenu neprojde. + verifyPath: '/accounts?size=1', + triggers: [ + { + id: 'payment-received', + name: 'Přijatá platba', + description: 'Spustí se při nové příchozí platbě na účtu.', + }, + ], + actions: [ + { + id: 'list-transactions', + name: 'Načíst pohyby', + description: 'Stáhne transakce za zvolené období pro další zpracování.', + fields: ['Účet', 'Období'], + }, + { + id: 'match-payment', + name: 'Spárovat platbu s fakturou', + description: 'Podle variabilního symbolu a částky najde odpovídající fakturu.', + fields: ['Tolerance částky'], + }, + ], + }, + { + id: 'sap-bo', + name: 'SAP Business One', + category: 'ekonomika', + description: 'Obchodní partneři, položky, objednávky a doklady v SAP Business One.', + icon: 'Database', + status: 'available', + general: false, + appId: 'sap-bo', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'serviceLayerUrl', + label: 'Adresa Service Layer', + target: 'header', + name: 'X-SAP-B1-BaseUrl', + required: true, + secret: false, + hint: 'Například https://sap.firma.cz:50000. Adresa musí být dostupná z internetu.', + }, + { + id: 'companyDb', + label: 'Databáze firmy', + target: 'header', + name: 'X-SAP-B1-CompanyDB', + required: true, + secret: false, + hint: 'Název company databáze, například SBODEMOCZ.', + }, + { + id: 'username', + label: 'Uživatel', + target: 'header', + name: 'X-SAP-B1-Username', + required: true, + secret: false, + }, + { + id: 'password', + label: 'Heslo', + target: 'header', + name: 'X-SAP-B1-Password', + required: true, + secret: true, + }, + { + id: 'language', + label: 'Jazyk', + target: 'header', + name: 'X-SAP-B1-Language', + required: false, + secret: false, + hint: 'Kód jazyka Service Layer, například cs-CZ.', + }, + { + id: 'rejectUnauthorized', + label: 'Kontrolovat certifikát', + target: 'header', + name: 'X-SAP-B1-Reject-Unauthorized', + required: false, + secret: false, + hint: 'false povolí self-signed certifikát Service Layer. Výchozí je kontrolovat.', + }, + ], + // Prihlasi se a vrati verzi Service Layer. Nic nezaklada. + verifyPath: '/api/system/info', + triggers: [], + actions: [ + { + id: 'find-business-partner', + name: 'Najít obchodního partnera', + description: 'Dohledá partnera podle kódu, IČO nebo názvu. Nic nezakládá.', + fields: ['Kód partnera', 'IČO', 'Název'], + }, + { + id: 'list-orders', + name: 'Načíst objednávky', + description: 'Vrátí objednávky partnera nebo za období.', + fields: ['Partner', 'Období'], + }, + { + id: 'create-order', + name: 'Založit objednávku', + description: 'Vytvoří prodejní objednávku včetně řádků.', + fields: ['Partner', 'Položky', 'Datum dodání'], + }, + ], + }, +]; diff --git a/src/data/services/catalog/incident.ts b/src/data/services/catalog/incident.ts new file mode 100644 index 0000000..a87caa5 --- /dev/null +++ b/src/data/services/catalog/incident.ts @@ -0,0 +1,55 @@ +/** + * Incidenty. Zvlast od ticketu, protoze incident je udalost provozu, ne pozadavek cloveka. + */ + +import type { Service } from '../../../shared/services.js'; + +export const incidentServices: Service[] = [ + { + id: 'incident', + name: 'Incidenty', + category: 'obecne', + description: + 'Výpadek nebo porucha, která se týká víc lidí najednou. Na rozdíl od ticketu ' + + 'neřeší jednoho zákazníka, ale stav služby.', + icon: 'AlarmClock', + status: 'available', + general: true, + appId: null, + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [], + actions: [ + { + id: 'create', + name: 'Založit incident', + description: + 'Když se chyba netýká jednoho ticketu, ale celé služby. Typicky navazuje ' + + 'na ticket typu chyba.', + implementation: 'script', + inputs: [ + { id: 'title', label: 'Název', kind: 'text', required: true }, + { + id: 'service', + label: 'Čeho se týká', + kind: 'text', + required: false, + hint: 'Název služby nebo aplikace, například Web nebo Voicebot.', + }, + { + id: 'severity', + label: 'Závažnost', + kind: 'choice', + required: false, + options: [ + { value: 'sev1', label: 'SEV1, kritická' }, + { value: 'sev2', label: 'SEV2, vážná' }, + { value: 'sev3', label: 'SEV3, menší' }, + ], + }, + ], + outputFields: [{ id: 'incidentId', name: 'incidentId', type: 'string', required: true }], + }, + ], + }, +]; diff --git a/src/data/services/catalog/index.ts b/src/data/services/catalog/index.ts new file mode 100644 index 0000000..c971c5e --- /dev/null +++ b/src/data/services/catalog/index.ts @@ -0,0 +1,50 @@ +/** + * Katalog sluzeb slozeny ze skupin. Poradi tady je poradi v nabidce, proto + * se nemeni jen tak - a proto je zakazkova integrace az za MCP, kde byla. + */ + +import type { Service, ServiceCategoryEntry } from '../../../shared/services.js'; +import { triggerServices } from './triggers.js'; +import { incidentServices } from './incident.js'; +import { ticketServices } from './ticket.js'; +import { crmServices } from './crm.js'; +import { financeServices } from './finance.js'; +import { logisticsServices } from './logistics.js'; +import { emailServices } from './email.js'; +import { socialServices } from './social.js'; +import { officeServices } from './office.js'; +import { messagingServices } from './messaging.js'; +import { analyticsServices } from './analytics.js'; +import { aiServices } from './ai.js'; +import { mcpServices } from './mcp.js'; +import { polstrynServices } from './polstryn.js'; +import { toolServices } from './tools.js'; + +export const serviceCategories: ServiceCategoryEntry[] = [ + { id: 'obecne', label: 'Obecné' }, + { id: 'crm', label: 'CRM' }, + { id: 'ekonomika', label: 'Ekonomika a banky' }, + { id: 'logistika', label: 'Logistika' }, + { id: 'komunikace', label: 'Komunikace' }, + { id: 'analytika', label: 'Analytika' }, + { id: 'ai', label: 'AI a hlas' }, + { id: 'transformace', label: 'Transformace dat' }, +]; + +export const services: Service[] = [ + ...triggerServices, + ...incidentServices, + ...ticketServices, + ...crmServices, + ...financeServices, + ...logisticsServices, + ...emailServices, + ...socialServices, + ...officeServices, + ...messagingServices, + ...analyticsServices, + ...aiServices, + ...mcpServices, + ...polstrynServices, + ...toolServices, +]; diff --git a/src/data/services/catalog/logistics.ts b/src/data/services/catalog/logistics.ts new file mode 100644 index 0000000..c5d11a1 --- /dev/null +++ b/src/data/services/catalog/logistics.ts @@ -0,0 +1,125 @@ +/** + * Logistika: PPL a e-shop. + */ + +import type { Service } from '../../../shared/services.js'; + +export const logisticsServices: Service[] = [ + { + id: 'ppl', + name: 'PPL CPL', + category: 'logistika', + description: 'Zásilky, štítky a svozy v systému PPL.', + icon: 'Truck', + status: 'available', + general: false, + appId: 'pplcplapi', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'clientId', + label: 'Client ID', + target: 'header', + name: 'X-Client-Id', + required: true, + secret: false, + hint: 'Z vývojářského portálu PPL CPL.', + }, + { + id: 'clientSecret', + label: 'Client Secret', + target: 'header', + name: 'X-Client-Secret', + required: true, + secret: true, + }, + { + id: 'environment', + label: 'Prostředí', + target: 'header', + name: 'X-Environment', + required: false, + secret: false, + hint: 'production (výchozí) nebo test. Test nevytváří skutečné zásilky.', + }, + ], + // Udaje o zakaznikovi: nic nezaklada a bez platnych udaju neprojde. + verifyPath: '/customer', + triggers: [ + { + id: 'shipment-delivered', + name: 'Zásilka doručena', + description: 'Spustí se při změně stavu zásilky na doručeno.', + }, + ], + actions: [ + { + id: 'create-shipment', + name: 'Vytvořit zásilku', + description: 'Založí zásilku a vrátí číslo balíku i štítek k tisku.', + fields: ['Příjemce', 'Adresa', 'Hmotnost', 'Služba'], + }, + { + id: 'order-pickup', + name: 'Objednat svoz', + description: 'Objedná svoz na zvolený den a adresu.', + fields: ['Datum svozu', 'Adresa', 'Počet zásilek'], + }, + { + id: 'track', + name: 'Zjistit stav zásilky', + description: 'Vrátí aktuální stav a historii zásilky.', + fields: ['Číslo zásilky'], + }, + ], + }, + { + id: 'eshop', + name: 'E-shop', + category: 'logistika', + description: 'Objednávky, sklad a zákazníci z e-shopu (Shoptet, WooCommerce, vlastní).', + icon: 'ShoppingCart', + status: 'available', + general: false, + appId: 'eshop', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [ + { + id: 'order-created', + name: 'Nová objednávka', + description: + 'Spustí se při vytvoření objednávky v e-shopu. Celá objednávka projde ' + + 'dál jako objekt, takže se dá přemapovat na doklad.', + providedFields: [ + { id: 'eshop.orderNumber', name: 'orderNumber', type: 'string', required: true }, + { id: 'eshop.orderTotal', name: 'orderTotal', type: 'number', required: true }, + { id: 'eshop.customerEmail', name: 'customerEmail', type: 'string', required: false }, + // Cela objednavka. Do sablony se nedosazuje, predava se dalsimu kroku + // jako celek - typicky do transformace dat. + { id: 'eshop.order', name: 'order', type: 'object', required: true }, + { id: 'eshop.items', name: 'items', type: 'list', required: true }, + ], + }, + { + id: 'order-status-changed', + name: 'Změna stavu objednávky', + description: 'Spustí se při přechodu objednávky do jiného stavu.', + }, + ], + actions: [ + { + id: 'update-order', + name: 'Změnit stav objednávky', + description: 'Nastaví objednávce nový stav a volitelně informuje zákazníka.', + fields: ['Číslo objednávky', 'Nový stav'], + }, + { + id: 'update-stock', + name: 'Upravit stav skladu', + description: 'Naskladní nebo odepíše položky.', + fields: ['SKU', 'Množství'], + }, + ], + }, +]; diff --git a/src/data/services/catalog/mcp.ts b/src/data/services/catalog/mcp.ts new file mode 100644 index 0000000..83595b9 --- /dev/null +++ b/src/data/services/catalog/mcp.ts @@ -0,0 +1,160 @@ +/** + * MCP servery. Nastroje se zjisti az od serveru, viz data/mcpTools.ts. + */ + +import { MCP_EASYWEB_SERVICE_ID, MCP_SERVICE_ID } from '../../../mcp/dialect.js'; +import type { Service } from '../../../shared/services.js'; + +export const mcpServices: Service[] = [ + { + id: MCP_SERVICE_ID, + name: 'MCP server', + category: 'ai', + description: + 'Napojení na libovolný MCP server. Portál si od něj vyžádá seznam nástrojů a ty se pak dají použít jako kroky automatizace.', + icon: 'Plug', + status: 'available', + general: false, + appId: null, + transport: 'mcp', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'serverUrl', + label: 'Adresa MCP serveru', + target: 'config', + name: 'serverUrl', + required: true, + secret: false, + hint: 'Celá adresa endpointu, například https://mcp.firma.cz/mcp. Musí být dostupná z internetu.', + }, + { + id: 'token', + label: 'Token', + target: 'config', + name: 'token', + required: false, + secret: true, + hint: 'Když jste od provozovatele dostali hotový token. Portál ho pošle tak, jak je, a nic dalšího neřeší.', + }, + { + id: 'clientId', + label: 'ID aplikace', + target: 'config', + name: 'clientId', + required: false, + secret: false, + hint: 'Druhá možnost: server má přihlášení přes OAuth. Portál si pak přístup vyzvedne sám a obnovuje ho.', + }, + { + id: 'clientSecret', + label: 'Tajemství aplikace', + target: 'config', + name: 'clientSecret', + required: false, + secret: true, + hint: 'Patří k ID aplikace.', + }, + { + id: 'tokenUrl', + label: 'Adresa pro přihlášení', + target: 'config', + name: 'tokenUrl', + required: false, + secret: false, + hint: 'Nechte prázdné. Vyplňuje se jen tehdy, když ji portál u serveru sám nenajde.', + }, + { + id: 'scope', + label: 'Rozsah oprávnění', + target: 'config', + name: 'scope', + required: false, + secret: false, + hint: 'Nechte prázdné, pokud vám provozovatel serveru neřekl konkrétní hodnotu.', + }, + ], + triggers: [], + // Prazdne zamerne: vsechny operace jsou nastroje ze serveru. + actions: [], + }, + { + id: MCP_EASYWEB_SERVICE_ID, + name: 'MCP EasyWeb', + category: 'ai', + description: + 'Napojení na MCP server EasyWebu. Stačí adresa, jméno a heslo - portál si vyžádá seznam nástrojů a ty se dají použít jako kroky automatizace.', + icon: 'Plug', + status: 'available', + general: false, + appId: null, + transport: 'mcp', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'serverUrl', + label: 'Adresa MCP serveru', + target: 'config', + name: 'serverUrl', + required: true, + secret: false, + hint: 'Endpoint bez koncového lomítka, například https://web.firmy.cz/centaur/mcp. Přihlašovací adresy si portál odvodí sám.', + }, + { + id: 'username', + label: 'Jméno', + target: 'config', + name: 'username', + required: true, + secret: false, + hint: 'Uživatel, pod kterým se má portál k serveru hlásit.', + }, + { + id: 'password', + label: 'Heslo', + target: 'config', + name: 'password', + required: true, + secret: true, + hint: 'Použije se jednou, na registraci zařízení. Dál se portál hlásí klíčem, který si vyrobí sám.', + }, + { + id: 'deviceName', + label: 'Název zařízení', + target: 'config', + name: 'deviceName', + required: false, + secret: false, + hint: 'Pod tímhle názvem uvidíte přihlášení v logu serveru. Prázdné znamená WorkNuke.', + }, + /* + * Klic zarizeni. Vyrabi ho portal pri prvnim prihlaseni a od te chvile je + * to identita, kterou server pozna - jmeno a heslo uz se nepouziva. + * + * Je to pole konektoru, a ne zvlastni tabulka, protoze udaje konektoru se + * uz ukladaji zasifrovane a tohle je privatni klic. `managed` znamena, ze + * ho ve formulari nikdo nevidi a nevyplnuje. + */ + { + id: 'deviceJwk', + label: 'Klíč zařízení', + target: 'config', + name: 'deviceJwk', + required: false, + secret: true, + managed: true, + }, + { + id: 'deviceFingerprint', + label: 'Otisk zařízení', + target: 'config', + name: 'deviceFingerprint', + required: false, + secret: false, + managed: true, + }, + ], + triggers: [], + actions: [], + }, +]; diff --git a/src/data/services/catalog/messaging.ts b/src/data/services/catalog/messaging.ts new file mode 100644 index 0000000..890f243 --- /dev/null +++ b/src/data/services/catalog/messaging.ts @@ -0,0 +1,50 @@ +/** + * Kratke zpravy: SMS a Slack. + */ + +import type { Service } from '../../../shared/services.js'; + +export const messagingServices: Service[] = [ + { + id: 'sms', + name: 'SMS', + category: 'komunikace', + description: 'Odesílání SMS zpráv zákazníkům nebo obsluze.', + icon: 'MessageSquare', + status: 'available', + general: false, + appId: 'sms', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [], + actions: [ + { + id: 'send', + name: 'Odeslat SMS', + description: 'Odešle krátkou zprávu na telefonní číslo.', + fields: ['Telefon', 'Text'], + }, + ], + }, + { + id: 'slack', + name: 'Slack', + category: 'komunikace', + description: 'Notifikace a interní komunikace v Slacku.', + icon: 'Hash', + status: 'planned', + general: false, + appId: 'slack', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [], + actions: [ + { + id: 'post-message', + name: 'Poslat zprávu do kanálu', + description: 'Odešle zprávu do zvoleného kanálu.', + fields: ['Kanál', 'Text'], + }, + ], + }, +]; diff --git a/src/data/services/catalog/office.ts b/src/data/services/catalog/office.ts new file mode 100644 index 0000000..d0b197f --- /dev/null +++ b/src/data/services/catalog/office.ts @@ -0,0 +1,160 @@ +/** + * Kancelarske baliky: Microsoft 365 a Google Workspace. + */ + +import type { Service } from '../../../shared/services.js'; + +export const officeServices: Service[] = [ + { + id: 'microsoft365', + name: 'Microsoft 365', + category: 'komunikace', + description: 'Outlook, kalendář, Teams, SharePoint a OneDrive.', + icon: 'Building2', + status: 'available', + general: false, + appId: 'microsoft-365-service', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'tenantId', + label: 'Tenant ID', + target: 'header', + name: 'X-MS365-Tenant-Id', + required: true, + secret: false, + hint: 'ID adresáře v Entra ID (dříve Azure AD).', + }, + { + id: 'clientId', + label: 'Client ID', + target: 'header', + name: 'X-MS365-Client-Id', + required: true, + secret: false, + hint: 'ID registrované aplikace.', + }, + { + id: 'clientSecret', + label: 'Client Secret', + target: 'header', + name: 'X-MS365-Client-Secret', + required: true, + secret: true, + hint: 'Tajný klíč aplikace. Má omezenou platnost, po vypršení se přepíše.', + }, + ], + // Stav napojeni na Graph: prihlasi se udaji z hlavicek, nic nemeni. + verifyPath: '/status', + triggers: [ + { + id: 'calendar-event', + name: 'Nová schůzka v kalendáři', + description: 'Spustí se při založení schůzky ve sledovaném kalendáři.', + }, + ], + actions: [ + { + id: 'create-event', + name: 'Vytvořit schůzku', + description: 'Založí schůzku a pozve účastníky.', + fields: ['Kalendář', 'Termín', 'Účastníci'], + }, + { + id: 'upload-file', + name: 'Uložit soubor', + description: 'Nahraje dokument do SharePointu nebo OneDrive.', + fields: ['Knihovna', 'Cesta', 'Soubor'], + }, + { + id: 'post-teams', + name: 'Poslat zprávu do Teams', + description: 'Odešle zprávu do kanálu nebo konkrétnímu člověku.', + fields: ['Kanál', 'Text zprávy'], + }, + ], + }, + { + id: 'google', + name: 'Google Workspace', + category: 'komunikace', + description: 'Gmail, Kalendář, Disk, Tabulky, Dokumenty a Úkoly pod jedním napojením.', + icon: 'Chrome', + status: 'available', + general: false, + appId: 'google-service', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [ + { + id: 'serviceAccountJson', + label: 'JSON klíč service accountu', + target: 'header', + name: 'X-Google-Service-Account-Json', + required: false, + secret: true, + hint: + 'Celý obsah staženého JSON souboru. Tohle je cesta pro provoz bez člověka: ' + + 'službě z něj sama vznikne token. Alternativou je hotový access token.', + }, + { + id: 'serviceAccountScopes', + label: 'Oprávnění (scopes)', + target: 'header', + name: 'X-Google-Service-Account-Scopes', + required: false, + secret: false, + hint: + 'Oddělené mezerou. Ověření konektoru čte Disk, takže potřebuje aspoň ' + + 'https://www.googleapis.com/auth/drive.readonly.', + }, + { + id: 'serviceAccountSubject', + label: 'Zastupovaný uživatel', + target: 'header', + name: 'X-Google-Service-Account-Subject', + required: false, + secret: false, + hint: 'E-mail uživatele Workspace při domain-wide delegation. Bez něj jedná service account sám za sebe.', + }, + { + id: 'accessToken', + label: 'Access token', + target: 'header', + name: 'X-Google-Access-Token', + required: false, + secret: true, + hint: 'Hotový OAuth2 token. Platí zhruba hodinu, takže na trvalý provoz se nehodí.', + }, + ], + // Seznam souboru na Disku: cteci volani, ktere bez platnych udaju neprojde. + // Predpoklada scope drive.readonly, viz napoveda u pole s opravnenimi. + verifyPath: '/google/drive/files', + triggers: [], + actions: [ + { + id: 'send-email', + name: 'Odeslat e-mail', + description: 'Pošle e-mail přes Gmail účtu, pod kterým je napojení.', + fields: ['Příjemce', 'Předmět', 'Text'], + }, + { + id: 'append-sheet-row', + name: 'Přidat řádek do tabulky', + description: 'Připíše řádek na konec listu v Google Tabulkách.', + fields: ['Tabulka', 'List', 'Hodnoty'], + }, + { + id: 'create-event', + name: 'Vytvořit událost v kalendáři', + description: 'Založí událost a pozve účastníky.', + fields: ['Kalendář', 'Termín', 'Účastníci'], + }, + { + id: 'find-file', + name: 'Najít soubor na Disku', + description: 'Dohledá soubor podle názvu nebo dotazu. Nic nemění.', + fields: ['Dotaz'], + }, + ], + }, +]; diff --git a/src/data/services/catalog/polstryn.ts b/src/data/services/catalog/polstryn.ts new file mode 100644 index 0000000..a7f8e48 --- /dev/null +++ b/src/data/services/catalog/polstryn.ts @@ -0,0 +1,53 @@ +/** + * Zakazkova integrace jednoho klienta na SAP. + */ + +import type { Service } from '../../../shared/services.js'; + +export const polstrynServices: Service[] = [ + { + id: 'polstryn-sap', + name: 'Polstryn SAP', + category: 'ekonomika', + description: 'Zakázková integrace na podnikový systém jednoho klienta.', + icon: 'Boxes', + status: 'planned', + general: false, + appId: 'polstryn-sap', + visibility: { mode: 'restricted', tenantIds: ['tnt_logitrans'], userIds: [] }, + credentials: [ + { + id: 'apiKey', + label: 'API klíč', + target: 'header', + name: 'X-Api-Key', + required: true, + secret: true, + }, + { + id: 'plant', + label: 'Číslo závodu', + target: 'config', + name: 'plant', + required: true, + secret: false, + hint: 'Předá se skriptu jako ctx.config.plant.', + }, + ], + triggers: [ + { + id: 'order-released', + name: 'Uvolněna výrobní zakázka', + description: 'Spustí se, jakmile SAP uvolní zakázku do výroby.', + }, + ], + actions: [ + { + id: 'post-goods-issue', + name: 'Zaúčtovat výdej materiálu', + description: 'Zapíše výdej materiálu k zakázce.', + fields: ['Číslo zakázky', 'Materiál', 'Množství'], + }, + ], + }, +]; diff --git a/src/data/services/catalog/social.ts b/src/data/services/catalog/social.ts new file mode 100644 index 0000000..655070a --- /dev/null +++ b/src/data/services/catalog/social.ts @@ -0,0 +1,142 @@ +/** + * Chatovaci kanaly: WhatsApp, Facebook Messenger, Instagram. + */ + +import type { Service } from '../../../shared/services.js'; + +export const socialServices: Service[] = [ + { + id: 'whatsapp', + name: 'WhatsApp', + category: 'komunikace', + description: 'Příjem a odesílání zpráv přes WhatsApp Business. Nejrychlejší cesta k ticketu.', + icon: 'MessageCircle', + status: 'available', + general: false, + appId: 'whatsapp', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [ + { + id: 'message-received', + name: 'Přijata zpráva', + description: 'Spustí se při doručení zprávy na firemní číslo.', + providedFields: [ + { id: 'whatsapp.phone', name: 'phone', type: 'string', required: true }, + { id: 'whatsapp.profileName', name: 'profileName', type: 'string', required: false }, + { id: 'whatsapp.text', name: 'text', type: 'string', required: true }, + { id: 'whatsapp.hasMedia', name: 'hasMedia', type: 'boolean', required: false }, + { id: 'whatsapp.receivedAt', name: 'receivedAt', type: 'date', required: true }, + ], + }, + ], + actions: [ + { + id: 'send', + name: 'Odeslat zprávu', + description: 'Odpoví na číslo, ze kterého zpráva přišla, nebo na zadané číslo.', + inputs: [ + { + id: 'phone', + label: 'Telefon', + kind: 'text', + required: true, + hint: 'Například {{phone}} pro odpověď odesílateli.', + }, + { id: 'text', label: 'Text', kind: 'longtext', required: true }, + ], + }, + { + id: 'send-template', + name: 'Odeslat schválenou šablonu', + description: 'Pošle předschválenou šablonu. Nutné mimo 24hodinové okno konverzace.', + fields: ['Telefon', 'Šablona', 'Proměnné'], + }, + ], + }, + { + id: 'facebook', + name: 'Facebook Messenger', + category: 'komunikace', + description: 'Zprávy z firemní stránky na Facebooku.', + icon: 'Facebook', + status: 'available', + general: false, + appId: 'facebook', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [ + { + id: 'message-received', + name: 'Přijata zpráva', + description: 'Spustí se při doručení zprávy do schránky stránky.', + providedFields: [ + { id: 'facebook.senderId', name: 'senderId', type: 'string', required: true }, + { id: 'facebook.senderName', name: 'senderName', type: 'string', required: false }, + { id: 'facebook.text', name: 'text', type: 'string', required: true }, + { id: 'facebook.pageName', name: 'pageName', type: 'string', required: true }, + { id: 'facebook.receivedAt', name: 'receivedAt', type: 'date', required: true }, + ], + }, + ], + actions: [ + { + id: 'send', + name: 'Odeslat zprávu', + description: 'Odpoví do konverzace, ze které zpráva přišla.', + inputs: [ + { + id: 'recipientId', + label: 'Příjemce', + kind: 'text', + required: true, + hint: 'Například {{senderId}} pro odpověď odesílateli.', + }, + { id: 'text', label: 'Text', kind: 'longtext', required: true }, + ], + }, + ], + }, + { + id: 'instagram', + name: 'Instagram', + category: 'komunikace', + description: 'Přímé zprávy na firemním účtu Instagramu.', + icon: 'Instagram', + status: 'available', + general: false, + appId: 'instagram', + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [ + { + id: 'message-received', + name: 'Přijata zpráva', + description: 'Spustí se při doručení přímé zprávy.', + providedFields: [ + { id: 'instagram.senderId', name: 'senderId', type: 'string', required: true }, + { id: 'instagram.username', name: 'username', type: 'string', required: false }, + { id: 'instagram.text', name: 'text', type: 'string', required: true }, + { id: 'instagram.receivedAt', name: 'receivedAt', type: 'date', required: true }, + ], + }, + ], + actions: [ + { + id: 'send', + name: 'Odeslat zprávu', + description: 'Odpoví do konverzace, ze které zpráva přišla.', + inputs: [ + { + id: 'recipientId', + label: 'Příjemce', + kind: 'text', + required: true, + hint: 'Například {{senderId}} pro odpověď odesílateli.', + }, + { id: 'text', label: 'Text', kind: 'longtext', required: true }, + ], + }, + ], + }, +]; diff --git a/src/data/services/catalog/ticket.ts b/src/data/services/catalog/ticket.ts new file mode 100644 index 0000000..132a584 --- /dev/null +++ b/src/data/services/catalog/ticket.ts @@ -0,0 +1,520 @@ +/** + * Tickety nejsou jen akce na konci stromu. Jsou to obe strany: + * kanaly do nich ustuji (WhatsApp, e-mail, hlas) a zalozeny ticket + * je zase spoustecem navazne automatizace - typicky "mame zakaznika?". + */ + +import type { Service } from '../../../shared/services.js'; + +/** + * Nabidka resitelu do vyberu u akci. + * + * Seznam se **nezapisuje do katalogu**, protoze resitele se nacitaji az za behu + * z uloziste, kdezto katalog vznika pri importu modulu. Misto hodnot je tu + * priznak `optionsFrom` a doplni je `serviceCatalog()` pri kazdem volani. + * Diky tomu novy clovek v tymu neni potreba nikde registrovat. + */ +const assigneeOptions: Array<{ value: string; label: string }> = [ + { value: '', label: 'Nechat ve frontě' }, +]; + +const priorityOptions = [ + { value: 'low', label: 'Nízká' }, + { value: 'normal', label: 'Běžná' }, + { value: 'high', label: 'Vysoká' }, + { value: 'critical', label: 'Kritická' }, +]; + +export const ticketServices: Service[] = [ + { + id: 'ticket', + name: 'Tickety', + category: 'obecne', + description: + 'Servicedesk. Požadavek od zákazníka, který má svého řešitele a dohledatelný průběh.', + icon: 'LifeBuoy', + status: 'available', + general: true, + appId: null, + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [ + { + id: 'created', + name: 'Založen ticket', + description: + 'Spustí se při každém novém ticketu, ať vznikl z kanálu nebo ručně. ' + + 'Podle parametru knownCustomer se pozná, jestli se firma dohledala v CRM.', + providedFields: [ + { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, + { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, + { id: 'ticket.body', name: 'body', type: 'string', required: false }, + { id: 'ticket.channel', name: 'channel', type: 'string', required: true }, + { id: 'ticket.company', name: 'company', type: 'string', required: false }, + { id: 'ticket.contact', name: 'contact', type: 'string', required: false }, + { id: 'ticket.priority', name: 'priority', type: 'string', required: true }, + { id: 'ticket.knownCustomer', name: 'knownCustomer', type: 'boolean', required: true }, + { id: 'ticket.assigned', name: 'assigned', type: 'boolean', required: true }, + ], + }, + { + id: 'unknown-customer', + name: 'Ticket bez zákazníka', + description: + 'Spustí se, když se k ticketu nepodařilo dohledat firmu. Sem patří založení ' + + 'obchodního případu nebo dotaz zpět na zadavatele.', + providedFields: [ + { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, + { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, + { id: 'ticket.body', name: 'body', type: 'string', required: false }, + { id: 'ticket.channel', name: 'channel', type: 'string', required: true }, + { id: 'ticket.contact', name: 'contact', type: 'string', required: false }, + { id: 'ticket.reply', name: 'reply', type: 'string', required: true }, + ], + }, + { + id: 'assigned', + name: 'Ticket přiřazen řešiteli', + description: 'Spustí se, jakmile ticket dostane konkrétního člověka.', + providedFields: [ + { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, + { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, + { id: 'ticket.assignee', name: 'assignee', type: 'string', required: true }, + { id: 'ticket.assigneeEmail', name: 'assigneeEmail', type: 'string', required: true }, + { id: 'ticket.priority', name: 'priority', type: 'string', required: true }, + ], + }, + { + id: 'changed', + name: 'Ticket vznikl nebo se změnil', + description: + 'Spustí se při každé změně ticketu, včetně vzniku. Na tomhle stojí ' + + 'automatické přidělování práce: podle typu a štítku se rozhodne, kdo to dostane.', + providedFields: [ + { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, + { id: 'ticket.externalId', name: 'externalId', type: 'string', required: false }, + { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, + { id: 'ticket.status', name: 'status', type: 'string', required: true }, + { id: 'ticket.priority', name: 'priority', type: 'string', required: true }, + { id: 'ticket.typeId', name: 'typeId', type: 'string', required: false }, + { id: 'ticket.closed', name: 'closed', type: 'boolean', required: true }, + { id: 'ticket.tags', name: 'tags', type: 'list', required: false }, + { id: 'ticket.assigneeId', name: 'assigneeId', type: 'string', required: false }, + { id: 'ticket.company', name: 'company', type: 'string', required: false }, + ], + }, + { + id: 'status-changed', + name: 'Změna stavu ticketu', + description: 'Spustí se při přechodu do jiného stavu, včetně vyřešení.', + providedFields: [ + { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, + { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, + { id: 'ticket.status', name: 'status', type: 'string', required: true }, + { id: 'ticket.previousStatus', name: 'previousStatus', type: 'string', required: true }, + { id: 'ticket.assignee', name: 'assignee', type: 'string', required: false }, + ], + }, + ], + actions: [ + { + id: 'upsert', + name: 'Založit nebo doplnit ticket', + description: + 'Podle externího ID buď založí nový ticket, nebo na existující navěsí událost. ' + + 'Externí ID je unikátní v rámci firmy, takže druhá zpráva o téže objednávce ' + + 'skončí na jednom místě. Co je tady vyplněné, zapíše se i na existující ticket - ' + + 'prázdná hodnota nikdy nic nesmaže, takže data můžou chodit po částech.', + implementation: 'script', + inputs: [ + { + id: 'externalId', + label: 'Externí ID', + kind: 'text', + required: false, + hint: 'ID u odesílatele, typicky číslo objednávky. Bez něj vznikne vždy nový ticket.', + }, + { id: 'subject', label: 'Předmět', kind: 'text', required: false }, + { + id: 'body', + label: 'Obsah', + kind: 'longtext', + required: false, + hint: 'Text může dorazit až druhou zprávou, doplní se i na existující ticket.', + }, + { + id: 'typeId', + label: 'Typ ticketu', + kind: 'lookup', + optionsFrom: 'ticketTypes', + required: false, + hint: + 'Vyberte typ ze seznamu, nebo hodnotu dosaďte z dat, například ' + + '{{data.typ}}. Za typem stojí vlastní pole, ze kterých pak akce čerpají.', + }, + { + id: 'tags', + label: 'Štítky', + kind: 'text', + required: false, + hint: 'Oddělené čárkou. Přidají se, existující se nemažou.', + }, + { + id: 'priority', + label: 'Priorita', + kind: 'choice', + required: false, + options: [ + { value: 'low', label: 'Nízká' }, + { value: 'normal', label: 'Běžná' }, + { value: 'high', label: 'Vysoká' }, + { value: 'critical', label: 'Kritická' }, + ], + }, + { + id: 'status', + label: 'Stav', + kind: 'text', + required: false, + hint: 'Cokoliv chcete, například ringing nebo Připraveno k expedici.', + }, + { + id: 'closed', + label: 'Vyřízený', + kind: 'choice', + required: false, + options: [ + { value: 'true', label: 'Ano' }, + { value: 'false', label: 'Ne' }, + ], + hint: 'Prázdné = nechat, jak je. Podle tohohle se počítá fronta a statistiky.', + }, + { + id: 'fields', + label: 'Vlastní pole typu', + kind: 'json', + required: false, + hint: + 'JSON s klíči podle typu ticketu, například {"orderNumber":"{{orderId}}"}. ' + + 'Klíče se sčítají: co přinesla minulá zpráva, zůstane.', + }, + { + id: 'company', + label: 'Zákazník: firma', + kind: 'text', + required: false, + hint: 'Prázdná hodnota jméno nesmaže, takže zpráva bez firmy nic nepokazí.', + }, + { id: 'contact', label: 'Zákazník: kontakt', kind: 'text', required: false }, + { + id: 'reply', + label: 'Zákazník: kam odpovídat', + kind: 'text', + required: false, + hint: 'E-mail nebo číslo, odkud to přišlo.', + }, + { + id: 'channel', + label: 'Odkud požadavek přišel', + kind: 'choice', + required: false, + hint: + 'Jen pro filtrování a ikonu v seznamu ticketů. Na chování ' + + 'automatizace to nemá vliv, vyplňovat se nemusí.', + options: [ + { value: 'whatsapp', label: 'WhatsApp' }, + { value: 'facebook', label: 'Facebook Messenger' }, + { value: 'instagram', label: 'Instagram' }, + { value: 'email', label: 'E-mail' }, + { value: 'voice', label: 'Hlasová linka' }, + { value: 'form', label: 'Webový formulář' }, + { value: 'portal', label: 'Portál' }, + ], + }, + { + id: 'sourceRef', + label: 'Odkaz na zdroj', + kind: 'text', + required: false, + hint: 'ID zprávy u odesílatele, ať je dohledatelná.', + }, + { + id: 'assigneeId', + label: 'Řešitel', + kind: 'choice', + required: false, + options: [], + optionsFrom: 'people', + hint: 'Když víte rovnou, komu to patří. Jinak použijte samostatný krok.', + }, + { + id: 'groupId', + label: 'Skupina', + kind: 'choice', + required: false, + options: [], + optionsFrom: 'groups', + }, + { id: 'event', label: 'Typ události', kind: 'text', required: false }, + { id: 'label', label: 'Popisek do časové osy', kind: 'text', required: false }, + ], + outputFields: [ + { id: 'ticketId', name: 'ticketId', type: 'string', required: true }, + { id: 'created', name: 'created', type: 'boolean', required: true }, + ], + }, + { + id: 'assign-group', + name: 'Předat skupině', + description: + 'Ticket se objeví ve frontě skupiny a kdo má čas, si ho převezme. ' + + 'Když zaškrtnete rovnou přiřadit, dostane ho hned ten, kdo má nejmíň práce - ' + + 'to se hodí tam, kde se čeká na rychlou reakci.', + implementation: 'script', + inputs: [ + { + id: 'groupId', + label: 'Skupina', + kind: 'choice', + required: true, + options: [], + optionsFrom: 'groups', + }, + { + id: 'autoAssign', + label: 'Rovnou přiřadit nejvolnějšímu', + kind: 'choice', + required: false, + options: [ + { value: 'true', label: 'Ano' }, + { value: 'false', label: 'Ne, nechat ve frontě skupiny' }, + ], + hint: 'Prázdné = nechat ve frontě, ať si to lidé vezmou sami.', + }, + { id: 'ticketId', label: 'Ticket', kind: 'text', required: false }, + ], + outputFields: [ + { id: 'groupId', name: 'groupId', type: 'string', required: true }, + { id: 'groupName', name: 'groupName', type: 'string', required: true }, + ], + }, + { + id: 'assign-least-busy', + name: 'Předat nejvolnějšímu ze skupiny', + description: + 'Najde ve skupině toho, kdo má nejmíň nevyřízených ticketů, a předá mu to. ' + + 'Při shodě rozhoduje podíl ke kapacitě. Vypnutí lidé se přeskočí.', + implementation: 'script', + inputs: [ + { + id: 'groupId', + label: 'Skupina', + kind: 'choice', + required: true, + options: [], + optionsFrom: 'groups', + hint: 'Například sklad nebo IT. Skupiny se spravují v Nastavení.', + }, + { + id: 'ticketId', + label: 'Ticket', + kind: 'text', + required: false, + hint: 'Prázdné = ticket, kvůli kterému běh vznikl.', + }, + ], + outputFields: [ + { id: 'assigneeId', name: 'assigneeId', type: 'string', required: true }, + { id: 'assigneeName', name: 'assigneeName', type: 'string', required: true }, + ], + }, + { + id: 'assign-by-external', + name: 'Předat podle ID z cizí aplikace', + description: + 'Najde řešitele, který má u sebe uvedené externí ID, a předá mu ticket. ' + + 'Typicky voicebotId nebo klapka. Vazba se nastavuje u řešitele, takže ' + + 'při změně člověka se opravuje na jednom místě.', + implementation: 'script', + inputs: [ + { + id: 'value', + label: 'Hodnota', + kind: 'text', + required: true, + hint: 'Například {{voicebotId}}.', + }, + { + id: 'fallbackGroupId', + label: 'Náhradní skupina', + kind: 'text', + required: false, + hint: 'Když se nikdo nenajde, předá se nejvolnějšímu z této skupiny.', + }, + { id: 'ticketId', label: 'Ticket', kind: 'text', required: false }, + ], + outputFields: [ + { id: 'assigneeId', name: 'assigneeId', type: 'string', required: true }, + { id: 'assigneeName', name: 'assigneeName', type: 'string', required: true }, + ], + }, + { + id: 'set-type', + name: 'Nastavit typ ticketu', + description: 'Za typem stojí vlastní pole a podle typu se ukazují akce.', + implementation: 'script', + inputs: [ + { + id: 'typeId', + label: 'Typ ticketu', + kind: 'lookup', + optionsFrom: 'ticketTypes', + required: true, + hint: 'Vyberte ze seznamu, nebo dosaďte z dat.', + }, + { id: 'ticketId', label: 'Ticket', kind: 'text', required: false }, + ], + }, + { + id: 'add-tags', + name: 'Přidat štítky', + description: 'Existující štítky zůstanou, jinak by se dva kroky přebíjely.', + implementation: 'script', + inputs: [ + { id: 'tags', label: 'Štítky', kind: 'text', required: true, hint: 'Oddělené čárkou.' }, + { id: 'ticketId', label: 'Ticket', kind: 'text', required: false }, + ], + }, + /* + * "Posunout do dalsi faze" tady bylo, ale fazi nema ani ticket, ani typ + * ticketu - v modelu nikdy nevznikla. Krok nemel co vykonat a pole Faze + * u zalozeni ticketu se tise zahazovalo. To, co mela faze delat, uz umi + * stav: je prave jeden, je to volny retezec a typ ticketu si k nemu muze + * nabidnout svoje hodnoty. + */ + { + id: 'set-status', + name: 'Změnit stav ticketu', + description: + 'Stav je libovolný text, žádný číselník. Jestli je ticket vyřízený, ' + + 'říká samostatné pole - podle něj se počítá fronta a statistiky.', + implementation: 'script', + inputs: [ + { + id: 'status', + label: 'Stav', + kind: 'text', + required: true, + hint: 'Cokoliv chcete, například completed nebo Předáno dopravci.', + }, + { + id: 'closed', + label: 'Vyřízený', + kind: 'choice', + required: false, + options: [ + { value: 'true', label: 'Ano' }, + { value: 'false', label: 'Ne' }, + ], + hint: 'Prázdné = nechat, jak je.', + }, + { id: 'ticketId', label: 'Ticket', kind: 'text', required: false }, + ], + }, + { + id: 'create', + name: 'Založit ticket', + description: 'Vytvoří požadavek. Co se kam uloží, určíte v nastavení kroku.', + inputs: [ + { + id: 'subject', + label: 'Předmět', + kind: 'text', + required: true, + hint: 'Krátké shrnutí. Typicky předmět e-mailu nebo začátek zprávy.', + }, + { + id: 'body', + label: 'Obsah', + kind: 'longtext', + required: false, + hint: 'Celý text požadavku. Sem patří tělo e-mailu nebo zpráva z WhatsApp.', + }, + { id: 'company', label: 'Firma', kind: 'text', required: false }, + { id: 'contact', label: 'Kontakt', kind: 'text', required: false }, + { + id: 'reply', + label: 'Adresa pro odpověď', + kind: 'text', + required: false, + hint: 'E-mail nebo telefon, odkud to přišlo.', + }, + { + id: 'priority', + label: 'Priorita', + kind: 'choice', + required: true, + options: priorityOptions, + }, + { + id: 'assigneeId', + label: 'Řešitel', + kind: 'choice', + required: false, + options: assigneeOptions, + optionsFrom: 'people', + }, + ], + outputFields: [{ id: 'ticket.newId', name: 'newTicketId', type: 'string', required: true }], + }, + { + id: 'assign', + name: 'Přiřadit řešiteli', + description: + 'Předá ticket konkrétnímu člověku. Objeví se mu mezi jeho tickety a dostane ' + + 'upozornění. Když nechcete vybírat ručně, použijte Předat nejvolnějšímu ze skupiny.', + implementation: 'script', + inputs: [ + { + id: 'ticketId', + label: 'ID ticketu', + kind: 'text', + required: true, + hint: 'Obvykle {{ticketId}} ze spouštěče.', + }, + { + id: 'assigneeId', + label: 'Řešitel', + kind: 'choice', + required: true, + options: assigneeOptions, + optionsFrom: 'people', + }, + ], + }, + /* + * 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', + description: 'Doplní ticketu firmu z CRM. Používá se poté, co se zákazník dohledá.', + inputs: [ + { id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true }, + { id: 'companyId', label: 'ID firmy v CRM', kind: 'text', required: true }, + ], + }, + { + id: 'comment', + name: 'Přidat komentář', + description: 'Zapíše komentář do logu ticketu, aby byl na stejné časové ose jako běh.', + inputs: [ + { id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true }, + { id: 'author', label: 'Autor', kind: 'text', required: false }, + { id: 'text', label: 'Text', kind: 'longtext', required: true }, + ], + }, + ], + }, +]; diff --git a/src/data/services/catalog/tools.ts b/src/data/services/catalog/tools.ts new file mode 100644 index 0000000..b774bb3 --- /dev/null +++ b/src/data/services/catalog/tools.ts @@ -0,0 +1,153 @@ +/** + * Obecne nastroje: HTTP pozadavek, transformace dat, pauza, zapis do logu. + */ + +import type { Service } from '../../../shared/services.js'; + +export const toolServices: Service[] = [ + { + id: 'http', + name: 'HTTP požadavek', + category: 'obecne', + description: 'Zavolá libovolné API, které nemá vlastní konektor.', + icon: 'Globe', + status: 'available', + general: true, + appId: null, + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [], + actions: [ + { + id: 'request', + name: 'Zavolat API', + description: 'Odešle HTTP požadavek a vrátí odpověď dalším krokům.', + fields: ['Metoda', 'URL', 'Hlavičky', 'Tělo'], + }, + ], + }, + { + id: 'transform', + name: 'Transformace dat', + category: 'transformace', + description: 'Přemapování polí, formátování a čištění dat mezi kroky.', + icon: 'Shuffle', + status: 'available', + general: true, + appId: null, + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [], + actions: [ + { + id: 'map-fields', + name: 'Přemapovat pole', + description: 'Přeloží data z jednoho tvaru do druhého.', + fields: ['Mapování polí'], + }, + { + id: 'deduplicate', + name: 'Odstranit duplicity', + description: 'Vyřadí záznamy, které už systémem prošly.', + fields: ['Klíč pro srovnání'], + }, + { + id: 'custom', + name: 'Vlastní skript', + description: + 'Převod dat napsaný v JavaScriptu. Hodí se, když je pravidel tolik, ' + + 'že je kód čitelnější než jejich seznam.', + fields: ['Skript', 'Zdrojová data'], + inputs: [ + { + id: 'scriptId', + label: 'Skript', + kind: 'choice', + required: true, + optionsFrom: 'scripts', + hint: 'Skripty firmy se píšou v záložce Skripty.', + }, + { + id: 'source', + label: 'Zdrojová data', + kind: 'object', + required: true, + hint: 'Objekt, který skript dostane jako input. Třeba {{_body}}.', + }, + { + id: 'extra', + label: 'Co přidat ke vstupu', + kind: 'json', + required: false, + hint: + 'Hodnoty z předchozích kroků, například ' + + '{"partnerId": "{{st_kontakt.contactId}}"}. Skript je najde v input.extra.', + }, + ], + outputFields: [ + { id: 'transform.result', name: 'result', type: 'object', required: true }, + ], + }, + ], + }, + { + id: 'delay', + name: 'Pauza', + category: 'obecne', + description: 'Pozdrží běh o daný čas nebo do konkrétního okamžiku.', + icon: 'Timer', + status: 'available', + general: true, + appId: null, + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [], + actions: [ + { + id: 'wait', + name: 'Počkat', + description: 'Pozastaví běh na zadanou dobu.', + fields: ['Doba čekání'], + inputs: [ + { + id: 'seconds', + label: 'Sekund', + kind: 'text', + required: true, + hint: 'Nejvýš 60. Delší čekání patří do plánovače, ne do běhu.', + }, + ], + }, + ], + }, + { + id: 'log', + name: 'Zápis do logu', + category: 'obecne', + description: 'Uloží zprávu do provozního logu automatizace.', + icon: 'ScrollText', + status: 'available', + general: true, + appId: null, + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [], + actions: [ + { + id: 'write', + name: 'Zapsat zprávu', + description: 'Přidá záznam do historie běhu, užitečné při ladění.', + fields: ['Zpráva'], + inputs: [ + { + id: 'message', + label: 'Zpráva', + kind: 'text', + required: true, + hint: 'Může obsahovat odkazy, například {{data.order.code}}.', + }, + ], + }, + ], + }, +]; diff --git a/src/data/services/catalog/triggers.ts b/src/data/services/catalog/triggers.ts new file mode 100644 index 0000000..f3e0d0a --- /dev/null +++ b/src/data/services/catalog/triggers.ts @@ -0,0 +1,92 @@ +/** + * Obecne spoustece: webhook, planovac, rucni spusteni, formular. + */ + +import type { Service } from '../../../shared/services.js'; + +export const triggerServices: Service[] = [ + { + id: 'webhook', + name: 'Webhook', + category: 'obecne', + description: 'Spustí automatizaci příchozím HTTP požadavkem z libovolného systému.', + icon: 'Webhook', + status: 'available', + general: true, + appId: null, + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [ + { + id: 'received', + name: 'Přijat požadavek', + description: + 'Portál vygeneruje neodhadnutelnou adresu. Vy určíte, jaké parametry na ni budou přicházet.', + customPayload: true, + }, + ], + actions: [], + }, + { + id: 'scheduler', + name: 'Plánovač', + category: 'obecne', + description: 'Spouštění podle času, každou hodinu, denně, nebo podle cron výrazu.', + icon: 'Clock', + status: 'available', + general: true, + appId: null, + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [ + { + id: 'interval', + name: 'V pravidelném intervalu', + description: 'Například každých 15 minut nebo každý den v 6:00.', + fields: ['Interval / cron výraz', 'Časová zóna'], + }, + ], + actions: [], + }, + { + id: 'manual', + name: 'Ruční spuštění', + category: 'obecne', + description: 'Automatizaci spustí člověk tlačítkem v portálu. Vhodné pro testování.', + icon: 'MousePointerClick', + status: 'available', + general: true, + appId: null, + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [ + { + id: 'button', + name: 'Spuštěno z portálu', + description: 'Spustí se stiskem tlačítka na detailu automatizace.', + }, + ], + actions: [], + }, + { + id: 'form', + name: 'Webový formulář', + category: 'obecne', + description: 'Odeslání formuláře z webu: poptávka, registrace, reklamace.', + icon: 'FileInput', + status: 'available', + general: true, + appId: null, + visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, + credentials: [], + triggers: [ + { + id: 'submitted', + name: 'Formulář odeslán', + description: 'Spustí se po odeslání formuláře. Pole formuláře si definujete sami.', + customPayload: true, + }, + ], + actions: [], + }, +]; diff --git a/src/data/services/index.ts b/src/data/services/index.ts new file mode 100644 index 0000000..6f57067 --- /dev/null +++ b/src/data/services/index.ts @@ -0,0 +1,334 @@ +/** + * Katalog SLUZEB = zdroj pravdy o tom, co lze v automatizaci a v akcich pouzit. + * + * Pozor na dve veci, ktere se snadno pletou: + * - **Sluzba** je to, co umime. iDoklad, Shoptet, tickety, HTTP pozadavek. + * Definujeme ji my, ma svoje operace a rika, co je potreba k napojeni. + * - **Konektor** je napojeni jedne firmy na jednu sluzbu vcetne jejich + * pristupovych udaju. Zaklada si ho firma, uloziste je `connectorStore.ts`. + * + * Sluzba tedy rika "iDoklad potrebuje X-ClientId a X-ClientSecret", + * konektor rika "a tohle jsou nase". + * + * Kazda sluzba ma: + * - triggers: udalosti, kterymi muze automatizace ZACIT (spoustec) + * - actions: co se s ni da UDELAT uprostred behu + * + * Sluzba muze mit jen triggery (webhook), jen akce (odeslani e-mailu), nebo obojí. + * Jak pridat sluzbu: documentation/12-sluzby-a-konektory.md + * + * Katalog samotny je ve slozce `catalog/` po skupinach, tady je to, co se + * z nej pocita: hledani operaci, prekryvy ze skriptu a z MCP, viditelnost. + * Zvenku se importuje pres `data/services.ts`. + */ + +import type { User } from '../../types.js'; +import { isMcpService } from '../../mcp/dialect.js'; +import { tenantHasService } from '../tenantFeatures.js'; +import { services } from './catalog/index.js'; + +import type { + OperationField, + ProvidedField, + Service, + ServiceCategory, + ServiceCategoryEntry, + ServiceCredentialField, + ServiceOperation, + ServiceStatus, + ServiceVisibility, +} from '../../shared/services.js'; + +/** + * Tvar sluzby je sdileny s webem, viz src/shared/services.ts. Tady je katalog + * samotny a to, co se z nej pocita. + */ +export type { + OperationField, + ProvidedField, + Service, + ServiceCategory, + ServiceCategoryEntry, + ServiceCredentialField, + ServiceOperation, + ServiceStatus, + ServiceVisibility, +}; + +export { serviceCategories, services } from './catalog/index.js'; + +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 + +/** + * Vidi uzivatel tuhle sluzbu? + * + * Obecne sluzby vidi vzdy vsichni - webhook, pauza nebo transformace dat nejsou + * nic, co by se komu odepiralo. Spravce platformy vidi vzdy vsechno. + * + * Sluzba, kterou uzivatel nevidi, se **nevraci vubec**, ne se stavem "nemate + * pravo". Firma nema z odpovedi poznat, ze taková sluzba existuje - stejne + * pravidlo jako u ticketu v documentation/07-firmy-a-prava.md. + */ +export function canSeeService(service: Service, user: User, tenantId: string | null): boolean { + if (service.general) return true; + + switch (service.visibility.mode) { + case 'everyone': + return true; + case 'admin': + return user.platformAdmin; + case 'restricted': { + // Zakazkova integrace pro konkretniho cloveka, at uz je prepnuty kamkoliv. + if (service.visibility.userIds.includes(user.id)) return true; + + /* + * **Rozhoduje firma, ne clovek.** Spravce platformy driv videl vsechny + * sluzby vzdycky, i po prepnuti do firmy, ktera je nema - takze si mohl + * do jeji automatizace vybrat zakazkovou integraci jineho klienta. + * Kdyz je firma vybrana, plati jeji seznam; bez vybrane firmy spravuje + * spravce platformy katalog a vidi vsechno. + */ + if (tenantId === null) return user.platformAdmin; + return service.visibility.tenantIds.includes(tenantId) || tenantHasService(tenantId, service.id); + } + default: + return false; + } +} + +export function visibleServices(user: User, tenantId: string | null): Service[] { + return services.filter((service) => canSeeService(service, user, tenantId)); +} + +// ------------------------------------------------------- akce ze skriptu + +/** + * Akce domerene ze skriptu sluzby. Plni to `src/scripts/registry.ts` + * pri kazdem nacteni skriptu. + * + * Je to prekryv, ne zapis do `services`. Dva duvody: staticky katalog zustane + * citelny a z operace jde poznat, odkud je (`implementation`). + * + * Prekryv je zamerne tady, ne ve zvlastnim modulu. Vsechno ostatni v aplikaci + * uz se pta pres `findOperation`, takze tim se skripty naraz objevi ve validaci + * stromu, ve vypoctu scope i v sablonach - bez toho, aby se to psalo trikrat. + */ +const scriptActions = new Map(); + +/** Nahradi cely prekryv. Volani je idempotentni, poradi nezalezi. */ +export function setScriptActions(byService: Map): void { + scriptActions.clear(); + for (const [serviceId, operations] of byService) { + scriptActions.set(serviceId, operations); + } +} + +/** + * Nastroje MCP serveru. + * + * Druhy prekryv katalogu, a jineho druhu nez skripty. Skript je nas kod, takze + * je znamy pri prekladu. Nastroj MCP je **cizi a zjisti se az od serveru**, + * proto s sebou nese firmu: co ma jedna firma na svem serveru, druhe do + * katalogu nepatri. + * + * Plni to `src/data/mcpTools.ts`. + */ +let mcpOperations: Array<{ tenantId: string; serviceId: string; operation: ServiceOperation }> = []; + +/** Nahradi cely seznam nastroju. */ +export function setMcpOperations( + items: Array<{ tenantId: string; serviceId: string; operation: ServiceOperation }>, +): void { + mcpOperations = items; +} + +const byName = (a: ServiceOperation, b: ServiceOperation): number => + a.name.localeCompare(b.name, 'cs'); + +/** + * Nastroje **jedne firmy**. `null` znamena zadne, ne vsechny. + * + * Firma bez vybrane firmy v adrese nema videt nastroje cizich serveru, a to ani + * jmenem. Nazev nastroje umi prozradit dost: `zrus_objednavku_v_soap_bridge` + * rekne o cizi firme vic, nez by melo. + */ +export function mcpActionsFor(tenantId: string | null, serviceId: string): ServiceOperation[] { + if (tenantId === null) return []; + return mcpOperations + .filter((item) => item.tenantId === tenantId && item.serviceId === serviceId) + .map((item) => item.operation) + .sort(byName); +} + +/** + * Nastroje napric firmami. + * + * Jen pro vnitrni dohledani operace (`findOperation`, dosazovani sablon). + * Ven se to neposila - od toho je `mcpActionsFor`. + */ +function allMcpActions(serviceId: string): ServiceOperation[] { + return mcpOperations + .filter((item) => item.serviceId === serviceId) + .map((item) => item.operation) + .sort(byName); +} + +/** + * Akce sluzby vcetne tech ze skriptu. + * Kdyz skript nese ID operace, ktera uz v katalogu je, **skript vyhrava**. + * Staticky zapis je popis toho, co umime, skript je to, co se opravdu stane. + */ +export function actionsFor(serviceId: string): ServiceOperation[] { + const service = findService(serviceId); + if (!service) return []; + + // MCP nema skripty, ma nastroje serveru. Napric firmami, viz `allMcpActions`. + if (isMcpService(serviceId)) return [...service.actions, ...allMcpActions(serviceId)]; + + const fromScripts = scriptActions.get(serviceId); + if (!fromScripts || fromScripts.length === 0) return service.actions; + + const replaced = new Set(fromScripts.map((operation) => operation.id)); + return [ + ...service.actions.filter((action) => !replaced.has(action.id)), + ...fromScripts, + ].sort((a, b) => a.name.localeCompare(b.name, 'cs')); +} + +/** + * Doplni nabidky, ktere se nedaji zapsat do katalogu. + * + * Katalog vznika pri importu modulu, ale resitele a skupiny se nacitaji + * z uloziste az pozdeji. Pole s `optionsFrom` proto dostane hodnoty az tady. + */ +export function withRuntimeOptions( + items: Service[], + options: { + people: Array<{ id: string; name: string }>; + groups: Array<{ id: string; name: string }>; + scripts?: Array<{ id: string; name: string }>; + /** Typy ticketu **teto firmy**. Kazda firma ma svoje, ID se nemaji opisovat. */ + ticketTypes?: Array<{ id: string; name: string }>; + }, +): Service[] { + /** Prazdna volba nahore. U kazdeho zdroje znamena neco jineho. */ + const empty: Record, string> = { + people: 'Nechat ve frontě', + groups: 'Bez skupiny', + scripts: '- vyberte skript -', + ticketTypes: 'Bez typu', + }; + + const fill = (field: OperationField): OperationField => { + if (!field.optionsFrom) return field; + const source = + field.optionsFrom === 'people' + ? options.people + : field.optionsFrom === 'groups' + ? options.groups + : field.optionsFrom === 'ticketTypes' + ? (options.ticketTypes ?? []) + : (options.scripts ?? []); + return { + ...field, + options: [ + { value: '', label: empty[field.optionsFrom] }, + ...source.map((item) => ({ value: item.id, label: item.name })), + ], + }; + }; + + const fillOperation = (operation: ServiceOperation): ServiceOperation => + operation.inputs ? { ...operation, inputs: operation.inputs.map(fill) } : operation; + + return items.map((service) => ({ + ...service, + triggers: service.triggers.map(fillOperation), + actions: service.actions.map(fillOperation), + })); +} + +/** + * Katalog sluzeb vcetne akci ze skriptu. Nemodifikuje `services`. + * + * `tenantId` je potreba kvuli MCP: nastroje jsou vlastnost napojeni jedne + * firmy, ne sluzby. Bez nej se zadne nevraci, coz je spravna vychozi hodnota - + * zapomenuty argument tak neznamena "vsechny". + */ +export function serviceCatalog(tenantId: string | null = null): Service[] { + return services.map((service) => { + if (isMcpService(service.id)) { + return { ...service, actions: [...service.actions, ...mcpActionsFor(tenantId, service.id)] }; + } + return scriptActions.has(service.id) ? { ...service, actions: actionsFor(service.id) } : service; + }); +} + +/** + * Overi, ze sluzba existuje a ma danou operaci pozadovaneho druhu. + * Pouziva se pri ukladani stromu, aby se do nej nedostaly neexistujici kroky. + */ +export function findOperation( + serviceId: string, + operationId: string, + type: 'trigger' | 'action', +): ServiceOperation | undefined { + const service = findService(serviceId); + if (!service) return undefined; + const pool = type === 'trigger' ? service.triggers : actionsFor(serviceId); + return pool.find((operation) => operation.id === operationId); +} + +/** + * Parametry, ktere spoustec predava sam. `undefined` znamena, ze si je + * deklaruje uzivatel (webhook, formular) - katalog do toho nemluvi. + */ +export function providedFieldsFor( + serviceId: string, + operationId: string, +): ProvidedField[] | undefined { + return findOperation(serviceId, operationId, 'trigger')?.providedFields; +} + +/** Nastavitelna pole akce. Prazdne pole = akci zatim nejde konfigurovat. */ +export function actionInputsFor(serviceId: string, operationId: string): OperationField[] { + return findOperation(serviceId, operationId, 'action')?.inputs ?? []; +} + +/** Co akce vrati dalsim krokum. Prazdne pole = nic, na co by se dalo ptat. */ +export function actionOutputsFor(serviceId: string, operationId: string): ProvidedField[] { + return findOperation(serviceId, operationId, 'action')?.outputFields ?? []; +} diff --git a/src/data/store/local.ts b/src/data/store/local.ts index 2126596..62cf1b9 100644 --- a/src/data/store/local.ts +++ b/src/data/store/local.ts @@ -91,13 +91,15 @@ export function createLocalStore( async update(id, patch, listOptions) { const index = rows.findIndex((item) => item.id === id); - if (index === -1 || !isVisible(rows[index], listOptions)) return undefined; + const current = index === -1 ? undefined : rows[index]; + if (current === undefined || !isVisible(current, listOptions)) return undefined; // ID ani cas vzniku se prepsat nesmi, i kdyby prisly v patchi. const { id: _id, createdAt: _createdAt, ...rest } = patch as Partial; - rows[index] = { ...rows[index], ...(rest as Partial), updatedAt: nowIso() }; + const updated = { ...current, ...(rest as Partial), updatedAt: nowIso() }; + rows[index] = updated; persist(); - return copy(rows[index]); + return copy(updated); }, async updateMany(ids, patch, listOptions) { @@ -116,7 +118,8 @@ export function createLocalStore( async remove(id, listOptions) { const index = rows.findIndex((item) => item.id === id); - if (index === -1 || !isVisible(rows[index], listOptions)) return false; + const current = index === -1 ? undefined : rows[index]; + if (current === undefined || !isVisible(current, listOptions)) return false; rows.splice(index, 1); persist(); return true; diff --git a/src/data/templates.ts b/src/data/templates.ts index 701aa55..cfa3e96 100644 --- a/src/data/templates.ts +++ b/src/data/templates.ts @@ -18,7 +18,7 @@ * Server je autorita, kopie na klientovi existuje jen kvuli napovede v UI. */ -import { getPath } from '../scripts/mapping.js'; +import { getPath } from '../runtime/scripts/mapping.js'; /** * `{{nazev}}` nebo `{{cesta.do.struktury}}` i s indexy `{{items[0].name}}`. diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 4634a63..03fb3c9 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -1,1851 +1,7 @@ /** - * Uloziste ticketu. Zmeny posilaji udalost na sbernici, takze se projevi - * v dashboardu okamzite bez obnoveni stranky. - * - * Ticket je prichozi pozadavek odkudkoliv (WhatsApp, e-mail, hlasova linka, - * formular, portal). NENI to bug ani wish - vyvojarska agenda ma vlastni - * evidenci a s ticketem se plete jen v hlave. - * - * Dve veci, na kterych model stoji: - * - ticket ma vzdy jednoho resitele (nebo zadneho), aby slo rict "mas to u sebe", - * - ticket si nese strom zaznamu o tom, co se s nim delo a co ktera sluzba vratila. - * - * Data se drzi v pameti a po kazde zmene se cely ticket zapise do uloziste - * (`withMirror`). Pri startu se cte uloziste, ukazkova sada nize se pouzije jen - * kdyz je prazdne. Kam se zapisuje - databaze, soubor, nebo nikam - rozhoduje - * `data/store/index.ts`, tady se to neresi. + * Fasada nad slozkou `tickets/`. Puvodni modul se rozrostl na skoro dva + * tisice radku, rozdelil se podle odpovednosti a tenhle soubor zustava, + * aby se nemusely menit importy jinde. */ -import { config } from '../config.js'; -import { publish } from '../events/bus.js'; -import { currentRun } from '../runtime/context.js'; -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, 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'; - -/** - * 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 { - 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; -/** - * Tvar v ulozisti. - * - * Nova pole jsou nepovinna zamerne: vychozi sada ticketu je psana jako literaly - * a doplnovat do kazdeho `tags: []` by byl sum. Chybejici hodnotu dosadi - * `toTicket`, takze navenek je `Ticket` uplny. - */ -interface StoredTicket - extends Omit< - Ticket, - | 'assignee' - | 'typeId' - | 'fields' - | 'tags' - | 'assigneeGroupId' - | 'externalId' - | 'closed' - | 'externalSource' - | 'firstResponseAt' - | 'resolvedAt' - | 'resolvedById' - | 'reopenCount' - | 'helpdeskSourceId' - | 'createdById' - > { - assigneeId: string | null; - helpdeskSourceId?: string | null; - assigneeGroupId?: string | null; - typeId?: string | null; - fields?: Record; - tags?: string[]; - closed?: boolean; - externalId?: string | null; - externalSource?: string | null; - firstResponseAt?: string | null; - resolvedAt?: string | null; - resolvedById?: string | null; - reopenCount?: number; - createdById?: string | null; - events?: TicketEvent[]; -} - -export const channelLabels: Record = { - whatsapp: 'WhatsApp', - facebook: 'Facebook Messenger', - instagram: 'Instagram', - email: 'E-mail', - voice: 'Hlasová linka', - form: 'Webový formulář', - portal: 'Portál', -}; - -// ------------------------------------------------------------------- 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 - * pri kazdem kroku automatizace a moznost, ze jeden z nich selze. - */ -interface PersistedTicket extends StoredTicket { - trace: TicketTraceEntry[]; -} - -/** Udalosti drzime u ticketu stejne jako log - jina zivotnost, stejny zaznam. */ -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 { - 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) ?? [], - }); - } -} - -/** - * Oznaci ticket jako zmeneny a ulozi ho. - * - * Kazda zmena jde skrz tohle, aby neslo upravit ticket a zapomenout na - * `updatedAt` nebo na zapis. Pary "prirad radek, uloz" se jinak rozejdou. - */ -function touch(ticket: StoredTicket): void { - ticket.updatedAt = new Date().toISOString(); - persist(ticket); - // Automatizace navazane na zmenu ticketu. Necekana se a chyby nevyhazuje, - // jinak by rozbita fronta rozbila ukladani ticketu. - onTicketChanged('ticket.updated', toTicket(ticket)); -} - -/** - * Nacte tickety z uloziste. Vola se pri startu, viz data/bootstrap.ts. - * - * Kdyz uloziste nic nema, ulozi se ukazkova sada, ktera je v tuhle chvili - * v pameti. Po prvnim startu je tedy uloziste jediny zdroj pravdy. - */ -export async function initTickets(): Promise { - const rows = await mirror.load(() => - tickets.map((ticket) => ({ ...ticket, trace: traces.get(ticket.id) ?? [] })), - ); - - 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 - * se doplni pri nacteni - jinak by se v portalu ukazovalo "undefinedx". - */ - events.set( - row.id, - (rowEvents ?? []).map((event) => ({ - ...event, - repeats: event.repeats ?? 1, - lastAt: event.lastAt ?? event.at, - })), - ); - } - - // Citac musi pokracovat za nejvyssim ulozenym cislem, jinak by nove tickety - // prepisovaly stare. - 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); -} - -/** - * Prepise ID resitelu podle mapy stare -> nove. Vraci pocet zmenenych ticketu. - * - * Jen pro migraci (data/migratePeople.ts): resitel byval vlastni zaznam - * `ppl_...`, dnes je to ID uctu. Meni se jen odkazy, `updatedAt` ani hooky - * se nespousteji - ticket se vecne nezmenil, jen se preznacil. - */ -export function remapPersonIds(map: Map): number { - let changed = 0; - for (const ticket of tickets) { - let touched = false; - if (ticket.assigneeId && map.has(ticket.assigneeId)) { - ticket.assigneeId = map.get(ticket.assigneeId)!; - touched = true; - } - if (ticket.resolvedById && map.has(ticket.resolvedById)) { - ticket.resolvedById = map.get(ticket.resolvedById)!; - touched = true; - } - if (touched) { - changed += 1; - persist(ticket); - } - } - return changed; -} - -let ticketCounter = 4_821; -let traceCounter = 0; - -function nextTraceId(): string { - traceCounter += 1; - return `tr_${traceCounter}`; -} - -/** Zaznam v logu tak, jak se zapisuje - strom je zanoreny, ulozeni ho zplosti. */ -export interface TraceInput { - kind: TraceKind; - label: string; - status: TraceStatus; - serviceId?: string | null; - operationId?: string | null; - response?: string | null; - durationMs?: number | null; - /** Posun proti "ted" v minutach. Pouziva jen ukazkova data. */ - agoMinutes?: number; - children?: TraceInput[]; -} - -/** - * Zplosti zanoreny zapis do seznamu s `parentId`. - * Poradi se zachovava, aby se strom dal vykreslit jednim pruchodem. - */ -function flattenTrace( - inputs: TraceInput[], - parentId: string | null, - into: TicketTraceEntry[], -): TicketTraceEntry[] { - for (const input of inputs) { - const entry: TicketTraceEntry = { - id: nextTraceId(), - parentId, - kind: input.kind, - serviceId: input.serviceId ?? null, - operationId: input.operationId ?? null, - label: input.label, - status: input.status, - response: input.response ?? null, - durationMs: input.durationMs ?? null, - at: minutesAgo(input.agoMinutes ?? 0), - }; - into.push(entry); - if (input.children && input.children.length > 0) { - flattenTrace(input.children, entry.id, into); - } - } - return into; -} - -/** Prida zaznamy do logu ticketu. Vraci, kolik radku pribylo. */ -export function appendTrace(ticketId: string, inputs: TraceInput[]): number { - const existing = traces.get(ticketId); - if (!existing) { - console.warn(`[tickets] zapis do logu neexistujiciho ticketu: ${ticketId}`); - return 0; - } - const before = existing.length; - /* - * Kdyz zapis patri behu automatizace, radi se pod jeho radek udalosti. - * Diky tomu je v logu videt "prislo tohle -> zmenilo to tohle" misto dvou - * vet vedle sebe, u kterych se jen hada, jestli spolu souvisi. - */ - flattenTrace(inputs, currentRun()?.traceParent.id ?? null, existing); - - // Log je soucast ulozeneho ticketu, takze zapis do logu je zmena ticketu. - const ticket = ticketsById.get(ticketId); - if (ticket) persist(ticket); - - return existing.length - before; -} - -// --------------------------------------------------------------- ukazkova data - -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`**, 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( - { - id: 'TK-4821', - tenantId: 'tnt_automia', - subject: 'Voicebot neodpovídá na volání po 18:00', - body: - 'Dobrý den, po šesté hodině to nikdo nebere. Zkoušeli jsme to včera i dnes, ' + - 'linka jen vyzvání a pak to spadne. Přes den to funguje normálně.', - sourceRef: 'cl_88213', - channel: 'voice', - customer: { - id: 'crm_1042', - company: 'Firma s.r.o.', - contact: 'Petra Klientová', - reply: '+420 601 118 224', - }, - status: 'V řešení', - priority: 'high', - assigneeId: 'usr_novakova', - automationId: 'AUT-02', - createdAt: minutesAgo(310), - updatedAt: minutesAgo(42), - }, - [ - { - kind: 'trigger', - serviceId: 'voicebot', - operationId: 'call-received', - label: 'Příchozí hovor na linku 800 100 200', - status: 'ok', - response: '{ "callId": "cl_88213", "from": "+420601118224", "durationSec": 96 }', - durationMs: 120, - agoMinutes: 310, - }, - { - kind: 'action', - serviceId: 'transcription', - operationId: 'transcribe', - label: 'Přepis nahrávky', - status: 'ok', - response: - '{ "language": "cs", "confidence": 0.94, "text": "Dobrý den, po šesté hodině to nikdo nebere..." }', - durationMs: 4_180, - agoMinutes: 309, - }, - { - kind: 'action', - serviceId: 'openai', - operationId: 'chat', - label: 'Zařazení do kategorie', - status: 'ok', - response: '{ "category": "porucha", "priority": "high", "confidence": 0.88 }', - durationMs: 910, - agoMinutes: 309, - }, - { - kind: 'action', - serviceId: 'raynet', - operationId: 'upsert-contact', - label: 'Dohledání firmy podle telefonu', - status: 'ok', - response: '{ "companyId": "crm_1042", "name": "Firma s.r.o.", "matchedBy": "phone" }', - durationMs: 640, - agoMinutes: 309, - }, - { - kind: 'condition', - label: 'knownCustomer je splněno', - status: 'ok', - response: 'true, pokračuje větev ANO', - agoMinutes: 309, - children: [ - { - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - label: 'Založení ticketu', - status: 'ok', - response: '{ "ticketId": "TK-4821", "priority": "high" }', - durationMs: 85, - agoMinutes: 309, - }, - { - kind: 'action', - serviceId: 'ticket', - operationId: 'assign', - label: 'Přiřazení řešitele podle služby', - status: 'ok', - response: '{ "assignee": "Eva Nováková", "rule": "voicebot -> voiceboti" }', - durationMs: 40, - agoMinutes: 309, - }, - ], - }, - { - kind: 'action', - serviceId: 'microsoft365', - operationId: 'post-teams', - label: 'Upozornění do Teams', - status: 'error', - response: 'HTTP 429 Too Many Requests, kanál "Servicedesk" překročil limit, zpráva neodešla', - durationMs: 2_400, - agoMinutes: 308, - }, - { - kind: 'note', - label: 'Eva Nováková: Reprodukováno, chyba je v nočním režimu scénáře.', - status: 'info', - agoMinutes: 42, - }, - ], - ); - - seed( - { - id: 'TK-4820', - tenantId: 'tnt_automia', - subject: 'Přidat pole IČO do synchronizace CRM a fakturace', - body: - 'Zdravím, potřebovali bychom, aby se při synchronizaci přenášelo i IČO. ' + - 'Teď ho musíme do faktur doplňovat ručně a občas se na to zapomene. ' + - 'Kolik by to bylo práce?', - sourceRef: '<9f21c4@nordis.cz>', - channel: 'email', - customer: { - id: 'crm_2210', - company: 'Nordis a.s.', - contact: 'Tomáš Beran', - reply: 'tomas.beran@nordis.cz', - }, - status: 'Čeká na klienta', - priority: 'normal', - assigneeId: 'usr_3', - automationId: 'AUT-03', - createdAt: minutesAgo(1_180), - updatedAt: minutesAgo(190), - }, - [ - { - kind: 'trigger', - serviceId: 'email', - operationId: 'received', - label: 'Přijat e-mail do schránky podpora@', - status: 'ok', - response: '{ "from": "tomas.beran@nordis.cz", "subject": "IČO v synchronizaci", "attachments": 0 }', - durationMs: 60, - agoMinutes: 1_180, - }, - { - kind: 'action', - serviceId: 'openai', - operationId: 'chat', - label: 'Vytažení údajů z textu', - status: 'ok', - response: '{ "type": "pozadavek na zmenu", "system": "CRM + iDoklad", "urgent": false }', - durationMs: 1_120, - agoMinutes: 1_180, - }, - { - kind: 'action', - serviceId: 'raynet', - operationId: 'upsert-contact', - label: 'Dohledání firmy podle e-mailu', - status: 'ok', - response: '{ "companyId": "crm_2210", "name": "Nordis a.s.", "matchedBy": "emailDomain" }', - durationMs: 520, - agoMinutes: 1_180, - }, - { - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - label: 'Založení ticketu', - status: 'ok', - response: '{ "ticketId": "TK-4820", "priority": "normal" }', - durationMs: 74, - agoMinutes: 1_179, - }, - { - kind: 'action', - serviceId: 'email', - operationId: 'send', - label: 'Potvrzení zadavateli', - status: 'ok', - response: '{ "messageId": "", "to": "tomas.beran@nordis.cz" }', - durationMs: 380, - agoMinutes: 1_179, - }, - { - kind: 'note', - label: 'Martin Kříž: Čekáme na potvrzení rozsahu od zákazníka.', - status: 'info', - agoMinutes: 190, - }, - ], - ); - - seed( - { - id: 'TK-4819', - tenantId: 'tnt_automia', - subject: 'Chybí denní report objednávek v e-mailu', - body: 'Dobrý den, už třetí den nechodí ranní report. Můžete se na to prosím podívat?', - sourceRef: 'wamid.HBgLNDIwNzc0OTAyMzMx', - channel: 'whatsapp', - customer: { - id: null, - company: 'Neznámá firma', - contact: 'Bistro Kolektiv', - reply: '+420 774 902 331', - }, - status: 'Nový', - priority: 'normal', - assigneeId: null, - automationId: 'AUT-01', - createdAt: minutesAgo(95), - updatedAt: minutesAgo(95), - }, - [ - { - kind: 'trigger', - serviceId: 'whatsapp', - operationId: 'message-received', - label: 'Přijata zpráva z WhatsApp', - status: 'ok', - response: - '{ "from": "+420774902331", "profileName": "Bistro Kolektiv", "text": "Dobrý den, už třetí den nechodí ranní report." }', - durationMs: 55, - agoMinutes: 95, - }, - { - kind: 'action', - serviceId: 'openai', - operationId: 'chat', - label: 'Zařazení do kategorie', - status: 'ok', - response: '{ "category": "vypadek reportu", "priority": "normal", "confidence": 0.79 }', - durationMs: 870, - agoMinutes: 95, - }, - { - kind: 'action', - serviceId: 'raynet', - operationId: 'upsert-contact', - label: 'Dohledání firmy podle telefonu', - status: 'error', - response: '{ "matches": 0, "searchedBy": "phone", "value": "+420774902331" }', - durationMs: 610, - agoMinutes: 95, - }, - { - kind: 'condition', - label: 'knownCustomer není splněno', - status: 'skipped', - response: 'false, pokračuje větev NE', - agoMinutes: 95, - children: [ - { - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - label: 'Založení ticketu bez napojení na firmu', - status: 'ok', - response: '{ "ticketId": "TK-4819", "customerId": null }', - durationMs: 68, - agoMinutes: 95, - }, - { - kind: 'action', - serviceId: 'raynet', - operationId: 'create-lead', - label: 'Založení obchodního případu k dohledání', - status: 'ok', - response: '{ "leadId": "lead_7781", "stage": "k overeni" }', - durationMs: 940, - agoMinutes: 95, - }, - { - kind: 'action', - serviceId: 'ticket', - operationId: 'assign', - label: 'Přiřazení řešitele', - status: 'skipped', - response: 'Přeskočeno, bez známé firmy nelze určit garanta a ticket zůstal nepřiřazený', - agoMinutes: 95, - }, - ], - }, - ], - ); - - 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: 'usr_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: 'usr_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(); - -// ------------------------------------------------------------------ prevody - -function toTicket(stored: StoredTicket): Ticket { - const { assigneeId, events: _events, ...rest } = stored; - // Chybejici nova pole dostanou vychozi hodnotu, aby navenek byl Ticket uplny. - const base = { - ...rest, - assigneeGroupId: stored.assigneeGroupId ?? null, - typeId: stored.typeId ?? null, - fields: stored.fields ?? {}, - tags: stored.tags ?? [], - closed: stored.closed ?? false, - externalId: stored.externalId ?? null, - externalSource: stored.externalSource ?? null, - firstResponseAt: stored.firstResponseAt ?? null, - resolvedAt: stored.resolvedAt ?? null, - resolvedById: stored.resolvedById ?? null, - reopenCount: stored.reopenCount ?? 0, - helpdeskSourceId: stored.helpdeskSourceId ?? null, - createdById: stored.createdById ?? null, - }; - - if (!assigneeId) return { ...base, assignee: null }; - - const person = findPerson(assigneeId, stored.tenantId); - if (!person) { - // Resitel uz neni clenem firmy - ticket nesmi spadnout, ale chceme o tom vedet. - console.warn(`[tickets] ${stored.id}: resitel ${assigneeId} uz ve firme neni`); - return { ...base, assignee: null }; - } - return { ...base, assignee: { id: person.id, name: person.name } }; -} - -// -------------------------------------------------------------------- dotazy - -export interface TicketFilter { - /** - * Firmy, ze kterych se smi vracet. Povinne - kdyby to slo vynechat, - * driv nebo pozdeji nekdo zapomene a endpoint vrati cizi data. - */ - tenantIds: string[]; - /** - * Strop viditelnosti uvnitr firmy. **Povinny ze stejneho duvodu** jako - * `tenantIds`: nepovinny filtr na prava je filtr, ktery jednou nekde chybi. - * - * Poklada ho `resolveScope`, viz data/access.ts. Kdo ma videt vsechno, - * posila `{ kind: 'all' }` - vyslovne, ne vynechanim. - */ - visibility: Visibility; - /** - * Firmy, ze kterych pozadavek prisel pres helpdesk. - * - * Vyplnene **nahrazuje** filtr podle vlastnika: zadavatel vlastnikem neni, - * takze by mu jinak nezbylo nic. Prazdne pole tady znamena "nefiltrovat - * podle zdroje", ne "nic" - vlastnicky filtr plati dal. - */ - helpdeskSourceIds?: string[]; - /** ID resitele, nebo 'unassigned' pro nepridelene. */ - assignee?: string; - /** - * Ucet, ktery ticket zalozil. Pouziva **jen helpdesk**: tam je "moje" to, - * co jsem poslal, ne to, co mam prirazene. - */ - createdById?: string; - status?: TicketStatus; - channel?: TicketChannel; - /** Typ ticketu. `none` = tickety bez typu. */ - typeId?: string; - /** Jeden tag. `none` = tickety bez tagu. */ - tag?: string; - /** Skupina resitelu. `none` = bez skupiny. */ - groupId?: string; -} - -/** - * Vejde se ticket do stropu? - * - * Sjednoceni, ne prunik: bud ho ma u sebe nekdo, na koho vidim, nebo lezi - * ve fronte sekce, kterou vedu. Fronta bez resitele patri do druhe podminky - - * bez ni by vedouci nemel co rozdelovat. - */ -function withinVisibility(ticket: StoredTicket, visibility: Visibility): boolean { - if (visibility.kind === 'all') return true; - /* - * **Nezarazene vidi kazdy ve firme.** Prvni verze stropu je schovavala - * a byla to diera v provozu: prichozi ticket, ktery jeste nikdo nesmeroval, - * nepatri do zadne sekce, takze by ho nevidel nikdo krome vedeni - a nikdo - * by si ho nevzal. Fronta je spolecna, prave proto je to fronta. - */ - if (ticket.assigneeId === null) return true; - if (visibility.personIds.includes(ticket.assigneeId)) return true; - if (ticket.assigneeGroupId && visibility.groupIds.includes(ticket.assigneeGroupId)) return true; - return false; -} - -export function listTickets(filter: TicketFilter): Ticket[] { - const sources = filter.helpdeskSourceIds; - const selected = tickets.filter((ticket) => { - if (sources && sources.length > 0) { - // Pohled zadavatele: vidi svoje pozadavky bez ohledu na to, kdo je resi. - if (!ticket.helpdeskSourceId || !sources.includes(ticket.helpdeskSourceId)) return false; - } else if (!filter.tenantIds.includes(ticket.tenantId)) { - return false; - } else if (!withinVisibility(ticket, filter.visibility)) { - /* - * Strop se pta jen u vlastnickeho pohledu. Pohled zadavatele nad nim - * neni: pozadavek posila firma dodavateli, zadavatel u nej neni resitel - * ani clen zadne skupiny, takze by mu strop vzal i to, co sam poslal. - */ - return false; - } - if (filter.status && ticket.status !== filter.status) return false; - if (filter.channel && ticket.channel !== filter.channel) return false; - if (filter.typeId === 'none' ? ticket.typeId : filter.typeId && ticket.typeId !== filter.typeId) - return false; - if (filter.tag === 'none') { - if ((ticket.tags ?? []).length > 0) return false; - } else if (filter.tag && !(ticket.tags ?? []).includes(filter.tag)) { - return false; - } - if ( - filter.groupId === 'none' - ? ticket.assigneeGroupId - : filter.groupId && ticket.assigneeGroupId !== filter.groupId - ) { - return false; - } - if (filter.createdById && ticket.createdById !== filter.createdById) return false; - if (filter.assignee === 'unassigned') return ticket.assigneeId === null; - if (filter.assignee && ticket.assigneeId !== filter.assignee) return false; - return true; - }); - - // Nejdriv nevyrizene, uvnitr od nejnovejsi upravy. - return selected - .sort((a, b) => { - // Hotove dolu. Ptame se na priznak, ne na text stavu - ten je volny. - if (a.closed && !b.closed) return 1; - if (b.closed && !a.closed) return -1; - return b.updatedAt.localeCompare(a.updatedAt); - }) - .map(toTicket); -} - -/** - * Vraci ticket jen z povolenych firem. Cizi se tvari jako neexistujici. - * - * `helpdeskSourceIds` je druha cesta dovnitr: firma, ktera pozadavek poslala, - * ho smi cist, i kdyz ho nevlastni. Bez toho by zadavatel videl v seznamu - * pozadavek, ktery si nemuze otevrit. - */ -export function getTicket( - id: string, - tenantIds: string[], - helpdeskSourceIds: string[] = [], - visibility: Visibility = { kind: 'all' }, -): TicketDetail | undefined { - const stored = ticketsById.get(id); - if (!stored) return undefined; - - const owns = tenantIds.includes(stored.tenantId); - const asked = - stored.helpdeskSourceId !== null && - stored.helpdeskSourceId !== undefined && - helpdeskSourceIds.includes(stored.helpdeskSourceId); - - if (!owns && !asked) { - console.warn(`[tickets] pokus o cteni ticketu ${id} mimo povolene firmy`); - return undefined; - } - - /* - * Strop plati i na jeden ticket, ne jen na seznam. Bez toho by staciloa - * znat ID: seznam by ho neukazal, ale adresa detailu by ho vydala. - * - * Pohledu zadavatele se to netyka, ten stoji na `helpdeskSourceIds` - viz - * `listTickets`. - */ - if (owns && !asked && !withinVisibility(stored, visibility)) { - console.warn(`[tickets] pokus o cteni ticketu ${id} mimo strop viditelnosti`); - return undefined; - } - return { - ...toTicket(stored), - trace: traces.get(id) ?? [], - events: events.get(id) ?? [], - }; -} - -/** - * Vejde se ten ticket do stropu? - * - * Pouziva detail a prevzeti, kde se firma ticketu pozna az po nalezeni - - * odkaz z pohledu "vse" muze vest do jine firmy uzivatele a strop se pocita - * za firmu ticketu, ne za prave prepnutou. - */ -export function ticketWithinVisibility(id: string, visibility: Visibility): boolean { - const stored = ticketsById.get(id); - return stored ? withinVisibility(stored, visibility) : false; -} - -/** Prvni nevyrizeny ticket. */ -export function firstOpenTicket(tenantIds: string[]): Ticket | undefined { - const stored = tickets.find( - (t) => !t.closed && tenantIds.includes(t.tenantId), - ); - return stored ? toTicket(stored) : undefined; -} - -export function getWorkload( - everyone: Person[], - tenantIds: string[], - visibility: Visibility, -): Workload { - // Pres listTickets, aby strop platil i tady. Driv se sahalo primo do pole - // 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 = byAssignee.get(person.id) ?? []; - const open = mine.filter((t) => !t.closed); - - return { - person, - open: open.length, - total: mine.length, - critical: open.filter((t) => t.priority === 'critical').length, - oldestOpenAt: oldestCreatedAt(open), - overloaded: open.length > person.capacity, - }; - }); - - // Nejvytizenejsi nahoru - prehled ma odpovedet na "kdo toho ma nejvic". - rows.sort((a, b) => b.open - a.open || a.person.name.localeCompare(b.person.name, 'cs')); - - return { - rows, - unassigned: visible.filter((t) => t.assignee === null && !t.closed).length, - openTotal: visible.filter((t) => !t.closed).length, - }; -} - -/** 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. - * - * Je to cas, kdy zakaznik prestal cekat. Kdyby se prepisoval pri kazde zmene, - * merilo by to posledni dotek, coz je uplne jina velicina. - */ -function markResponded(ticket: StoredTicket): void { - if (!ticket.firstResponseAt) ticket.firstResponseAt = new Date().toISOString(); -} - -/** Ticket podle naseho ID, jen z povolenych firem. */ -export function findTicket(id: string, tenantIds: string[]): Ticket | undefined { - 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 = ticketsByExternal.get(externalKey(tenantId, externalId)); - return stored ? toTicket(stored) : undefined; -} - -/** - * Co jde na ticketu nastavit prichozi udalosti. - * - * Zakaznik je rozepsany po polozkach schvalne. Kdyby se predaval cely, prepsala - * by zprava, ktera zna jen telefon, i jmeno firmy - prazdnou hodnotou. - */ -export interface TicketApply { - subject?: string; - body?: string; - typeId?: string; - priority?: TicketPriority; - channel?: TicketChannel; - sourceRef?: string; - company?: string; - contact?: string; - reply?: string; - assigneeId?: string; - assigneeGroupId?: string; -} - -export interface IntakeInput { - tenantId: string; - /** Bez nej se navazani nema o co oprit a zalozi se novy ticket. */ - externalId: string | null; - externalSource?: string | null; - /** Typ udalosti od odesilatele, napr. `order.created`. */ - type: string; - /** Popisek do casove osy. Bez nej se pouzije typ udalosti. */ - label?: string; - /** Cela prijata data. */ - payload?: Record; - /** - * Hodnoty, ktere maji smysl **jen pri vzniku** ticketu. - * - * Zbylo jich malo a je to zamer: predmet jako zaloha, kdyz ho odesilatel - * neposlal, a vychozi stav. Vsechno ostatni patri do `apply`. - */ - create?: Partial>; - /** - * Hodnoty, ktere se zapisi **pri kazde udalosti**, ne jen pri zalozeni. - * - * Driv bylo skoro vsechno jen pro vznik ticketu a nedava to smysl: krok se - * jmenuje "zalozit **nebo doplnit**" a data casto nechodi najednou. Prvni - * zprava jen oznami, ze se neco deje - u hovoru nese cislo a `in-progress`, - * a prave ta ticket zaklada. Predmet, obsah, typ i zakaznik dorazi az tou - * posledni, kdy uz ticket existoval, a tise se zahazovaly. - * - * **Prazdna hodnota nikdy nemaze**, co uz na ticketu je. To je ta pojistka, - * kvuli ktere se drive zapisovalo jen pri zalozeni: pozdejsi zprava bez - * jmena zakaznika je bezna a smazat kvuli ni jmeno by bylo horsi nez ho - * nedoplnit. Prepise se jen to, co odesilatel opravdu poslal. - */ - apply?: TicketApply; - /** Vlastni pole, ktera se doplni i na existujici ticket. */ - fields?: Record; - /** Tagy, ktere se **pridaji**. Existujici se nemazou. */ - addTags?: string[]; -} - -export interface IntakeResult { - ticket: Ticket; - /** true = ticket teprve ted vznikl, false = udalost se navesila na existujici. */ - created: boolean; - event: TicketEvent; - /** - * true = prislo presne totez co posledne, takze se jen pricetlo k pocitadlu. - * Ticket se **nemenil**: nesmi se kvuli tomu prepsat `updatedAt`, ani - * rozeslat zmena - jinak by kazdy duplikat rozblikal dashboard a mohl - * spustit automatizaci navazanou na zmenu ticketu. - */ - repeated: boolean; -} - -/** - * Prijem udalosti zvenku. - * - * Tohle je vstup do ticketovaciho systemu: **jakakoliv udalost se muze stat - * ticketem**. Kdyz uz ticket se stejnym externim ID ve **stejne firme** je, - * udalost se na nej navesi misto zalozeni druheho. Diky tomu muze odesilatel - * poslat "objednavka 3 vznikla" a za hodinu "objednavka 3 vyfakturovana" - * a obojí skonci na jednom miste. - * - * Bez externiho ID se vzdy zaklada novy ticket - nemame podle ceho navazovat - * a hadat podle predmetu by slucovalo veci, ktere spolu nesouvisi. - */ -export function intakeEvent(input: IntakeInput): IntakeResult { - const timestamp = new Date().toISOString(); - const event: TicketEvent = { - id: nextEventId(), - type: input.type, - source: input.externalSource ?? 'webhook', - label: input.label ?? input.type, - payload: input.payload ?? {}, - at: timestamp, - repeats: 1, - lastAt: timestamp, - }; - - const existing = input.externalId - ? ticketsByExternal.get(externalKey(input.tenantId, input.externalId)) - : undefined; - - if (existing) { - /* - * Prislo presne totez co posledne? Pak se to jen pricte. Zadny novy radek, - * zadny zapis do logu, zadna zmena ticketu - nic se totiz nestalo. - */ - const repeat = sameAsLast(existing.id, event); - if (repeat) { - repeat.repeats += 1; - repeat.lastAt = event.at; - persist(existing); - return { ticket: toTicket(existing), created: false, event: repeat, repeated: true }; - } - - appendEvent(existing, event); - - if (input.fields) existing.fields = mergeFields(existing.fields, input.fields); - if (input.addTags && input.addTags.length > 0) { - existing.tags = [...new Set([...(existing.tags ?? []), ...input.addTags])]; - } - applyValues(existing, input.apply); - touch(existing); - - /* - * Rodic musi byt prazdny, jinak by se radek udalosti zaradil pod udalost - * predchozi - `appendTrace` doplnuje rodice z behu. - */ - const run = currentRun(); - const parent = run?.traceParent; - if (parent) parent.id = null; - - appendTrace(existing.id, [ - { - kind: 'trigger', - status: 'info', - label: `Přijata událost: ${event.label}${originOf(run, event)}`, - response: describePayload(event.payload), - }, - ]); - - // Dalsi zapisy tohohle behu uz patri pod tenhle radek. - if (parent) parent.id = lastTraceId(existing.id); - 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 }; - } - - /* - * Pri zalozeni plati totez co u doplneni, jen se navic dosadi zaloha tam, - * kde odesilatel nic neposlal. Dva ruzne seznamy poli by se rozesly a zase - * by nekde neco chybelo. - */ - const apply = input.apply ?? {}; - const created = createTicket({ - tenantId: input.tenantId, - externalId: input.externalId, - externalSource: input.externalSource ?? null, - subject: apply.subject || input.create?.subject || event.label, - /* - * Stav uz pri vzniku. Volajici ho posilal, ale sem se nepredaval, takze - * ticket vznikl s vychozim "Nový" a hned se prepsal - v logu pak stalo - * "stav Nový -> completed" u ticketu, ktery v nem nikdy nebyl. - */ - status: input.create?.status, - body: apply.body ?? '', - sourceRef: apply.sourceRef ?? null, - channel: apply.channel ?? 'form', - customer: { - id: null, - company: apply.company ?? '', - contact: apply.contact ?? '', - reply: apply.reply ?? '', - }, - priority: apply.priority ?? 'normal', - assigneeId: apply.assigneeId ?? null, - assigneeGroupId: apply.assigneeGroupId ?? null, - typeId: apply.typeId ?? null, - fields: mergeFields(input.create?.fields, input.fields ?? {}), - tags: [...new Set([...(input.create?.tags ?? []), ...(input.addTags ?? [])])], - automationId: input.create?.automationId ?? null, - trace: [ - { - kind: 'trigger', - status: 'info', - label: `Ticket vznikl z události: ${event.label}${originOf(currentRun(), event)}`, - response: describePayload(event.payload), - }, - ], - }); - - const stored = ticketsById.get(created.id); - if (stored) { - appendEvent(stored, event); - persist(stored); - } - - // Zmeny, ktere beh udela dal, patri pod radek o vzniku ticketu. - const parent = currentRun()?.traceParent; - if (parent) parent.id = traces.get(created.id)?.[0]?.id ?? null; - - return { - ticket: toTicket(stored ?? (created as unknown as StoredTicket)), - created: true, - event, - repeated: false, - }; -} - -/** - * Slouci vlastni pole typu ticketu. - * - * Klice se **scitaji**: kdyz prvni zprava prinese `data` a druha `data2`, ma - * ticket obe. Odesilatel nemusi posilat vsechno pokazde a nemusi se predem - * dohodnout, co vsechno posle. - * - * **Prazdny retezec nemaze.** Sablona, ktera na nic neukazuje, se dosadi - * prazdnem, takze `{"vysledek":"{{result}}"}` u zpravy bez vysledku posle - * prazdno - a to by prepsalo hodnotu z minule zpravy. Vymazat pole jde - * poslanim `null`: to uz je zamer, ne vedlejsi ucinek nevyplnene sablony. - */ -function mergeFields( - current: Record | undefined, - incoming: Record, -): Record { - const merged = { ...(current ?? {}) }; - for (const [key, value] of Object.entries(incoming)) { - if (value === '') continue; - merged[key] = value; - } - return merged; -} - -/** - * Zapise na ticket to, co prinesla udalost. - * - * Jedno pravidlo pro vsechna pole: **neprazdna hodnota prepise, prazdna nemaze**. - * Pozdejsi zprava o teze veci je upresneni, ne druhy zaznam - a zaroven nesmi - * smazat to, co uz na ticketu je, jen proto, ze o tom nic nevi. - * - * Stav tudy zamerne nechodi. Ma svoje `updateTicketStatus`, ktere resi i priznak - * vyrizeni, cas vyreseni a pocet znovuotevreni - obejit ho by ta cisla rozbilo. - */ -function applyValues(ticket: StoredTicket, apply: TicketApply | undefined): void { - if (!apply) return; - - if (apply.subject) ticket.subject = apply.subject; - if (apply.body) ticket.body = apply.body; - if (apply.typeId) ticket.typeId = apply.typeId; - if (apply.priority) ticket.priority = apply.priority; - if (apply.channel) ticket.channel = apply.channel; - if (apply.sourceRef) ticket.sourceRef = apply.sourceRef; - if (apply.assigneeGroupId) ticket.assigneeGroupId = apply.assigneeGroupId; - - // Stejne jako pri zalozeni: mrtvy odkaz na resitele radeji nez ulozit. - if (apply.assigneeId) { - if (findPerson(apply.assigneeId, ticket.tenantId)) ticket.assigneeId = apply.assigneeId; - else console.warn(`[tickets] neznamy resitel ${apply.assigneeId}, ticket zustava jak byl`); - } - - // Po polozkach, at zprava, ktera zna jen telefon, neprepise jmeno firmy. - const customer = ticket.customer; - if (apply.company) customer.company = apply.company; - if (apply.contact) customer.contact = apply.contact; - if (apply.reply) customer.reply = apply.reply; -} - -/** Kolik udalosti se u jednoho ticketu drzi. Starsi se odmazavaji. */ -const MAX_EVENTS = 200; - -function appendEvent(ticket: StoredTicket, event: TicketEvent): void { - const list = events.get(ticket.id) ?? []; - list.push(event); - // Nekonecne rostouci ticket by pri kazdem zapisu prepisoval vic a vic dat. - if (list.length > MAX_EVENTS) list.splice(0, list.length - MAX_EVENTS); - events.set(ticket.id, list); -} - -/** - * Je to totez, co prislo naposledy? - * - * Porovnava se **jen s posledni** udalosti, ne s celou historii. "Objednavka - * pripravena" muze legitimne prijit znovu za hodinu, kdyz se mezitim stalo - * neco jineho - to je novy fakt. Dvacet stejnych zprav v rade uz ne. - * - * Data se srovnavaji pres JSON, protoze na poradi klicu v prijate zprave - * nezalezi jen vyjimecne a levnejsi porovnani neni potreba. - */ -function sameAsLast(ticketId: string, event: TicketEvent): TicketEvent | null { - const list = events.get(ticketId); - const last = list && list.length > 0 ? list[list.length - 1] : undefined; - if (!last) return null; - - if (last.type !== event.type || last.source !== event.source || last.label !== event.label) { - return null; - } - if (JSON.stringify(last.payload) !== JSON.stringify(event.payload)) return null; - return last; -} - -let eventCounter = 0; - -function nextEventId(): string { - eventCounter += 1; - return `tev_${eventCounter.toString(36)}`; -} - -/** - * Kdo udalost prinesl. - * - * V logu je to to nejdulezitejsi: kdyz se ticket zmenil, prvni otazka je "kdo - * mi do toho sahl". Jmeno automatizace na to odpovi, `webhook` uz ne. - */ -function originOf(run: { automationName: string } | undefined, event: TicketEvent): string { - if (run) return ` (automatizace ${run.automationName})`; - if (event.source && event.source !== 'webhook') return ` (${event.source})`; - return ''; -} - -/** ID posledniho radku logu. Pod nej se radi zmeny, ktere z udalosti plynou. */ -function lastTraceId(ticketId: string): string | null { - const list = traces.get(ticketId); - return list && list.length > 0 ? list[list.length - 1].id : null; -} - -/** Kratky popis dat do logu. Cela data zustavaji v udalosti. */ -function describePayload(payload: Record): string | null { - const keys = Object.keys(payload); - if (keys.length === 0) return null; - const text = JSON.stringify(payload); - return text.length > 2_000 ? `${text.slice(0, 2_000)}...` : text; -} - -// ---------------------------------------------------------------- statistiky - -/** Median, ne prumer: jeden ticket zapomenuty pres dovolenou jinak prebije vsechno. */ -function median(values: number[]): number | null { - if (values.length === 0) return null; - const sorted = [...values].sort((a, b) => a - b); - const middle = Math.floor(sorted.length / 2); - return sorted.length % 2 === 1 - ? sorted[middle] - : Math.round((sorted[middle - 1] + sorted[middle]) / 2); -} - -/** - * Vykon resitelu za obdobi. - * - * `since` je hranice pro **vyresene** tickety, tedy "kolik toho odbavil za - * poslednich 30 dni". Nevyrizene se pocitaji vzdy vsechny - fronta neni - * vec obdobi, lezi tam bez ohledu na to, na co se zrovna divame. - */ -export function getAgentStats( - everyone: Person[], - tenantIds: string[], - since: number | null, - visibility: Visibility, -): 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 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 = mine - .filter((t) => at(t.firstResponseAt) !== null) - .map((t) => (at(t.firstResponseAt) ?? 0) - (at(t.createdAt) ?? 0)) - .filter((value) => value >= 0); - - const oldest = oldestCreatedAt(open); - - return { - personId: person.id, - name: person.name, - resolved: resolved.length, - open: open.length, - critical: open.filter((t) => t.priority === 'critical').length, - medianResolveMs: median(resolveTimes), - medianResponseMs: median(responseTimes), - reopened: resolved.reduce((sum, t) => sum + (t.reopenCount ?? 0), 0), - oldestOpenAt: oldest, - overloaded: open.length > person.capacity, - }; - }); - - // Nejvic odbavenych nahoru. Prehled ma odpovedet na "kdo toho udelal nejvic". - rows.sort((a, b) => b.resolved - a.resolved || a.name.localeCompare(b.name, 'cs')); - return rows; -} - -// --------------------------------------------------------------------- zmeny - -export interface CreateTicketInput { - tenantId: string; - /** ID u odesilatele. Unikatni za firmu, viz `Ticket.externalId`. */ - externalId?: string | null; - externalSource?: string | null; - subject: string; - body?: string; - sourceRef?: string | null; - channel: TicketChannel; - /** - * Zakaznik je **nepovinny**. U pozadavku z helpdesku dava smysl vedet, kdo - * ho poslal, u ticketu zalozeneho rucne casto nikdo takovy neni - je to - * ukol, ne pozadavek od nekoho zvenku. - */ - customer?: TicketCustomer; - /** Firma, ktera pozadavek poslala pres helpdesk. Vlastnikem je ta, ktera resi. */ - helpdeskSourceId?: string | null; - priority: TicketPriority; - /** Vychozi stav, kdyz se nezada. Volny retezec, ne ciselnik. */ - status?: string; - /** Je uz vyrizeny? Vychozi ne. */ - closed?: boolean; - assigneeId?: string | null; - assigneeGroupId?: string | null; - typeId?: string | null; - fields?: Record; - tags?: string[]; - automationId?: string | null; - /** Ucet, ktery ho zaklada rucne. null u automatizace a prijmu zvenku. */ - createdById?: string | null; - /** Log toho, jak ticket vznikl. Bez nej je ticket nedohledatelny. */ - trace?: TraceInput[]; -} - -export function createTicket(input: CreateTicketInput): Ticket { - ticketCounter += 1; - const now = new Date().toISOString(); - - // Neexistujiciho resitele radeji zahodime, nez abychom ulozili mrtvy odkaz. - let assigneeId = input.assigneeId ?? null; - if (assigneeId && !findPerson(assigneeId, input.tenantId)) { - console.warn(`[tickets] neznamy resitel ${assigneeId}, ticket zustava neprirazeny`); - assigneeId = null; - } - - const stored: StoredTicket = { - id: `TK-${ticketCounter}`, - tenantId: input.tenantId, - externalId: input.externalId ?? null, - externalSource: input.externalSource ?? null, - firstResponseAt: null, - resolvedAt: null, - resolvedById: null, - reopenCount: 0, - subject: input.subject, - body: input.body ?? '', - sourceRef: input.sourceRef ?? null, - channel: input.channel, - customer: input.customer ?? { id: null, company: '', contact: '', reply: '' }, - helpdeskSourceId: input.helpdeskSourceId ?? null, - // Vychozi stav je jen doporuceni. Kdo posle vlastni, ma vlastni. - status: input.status ?? defaultStatuses[0], - priority: input.priority, - assigneeId, - // Tyhle ctyri se driv zahazovaly: vstup je nabizel, ale zaznam je nemel. - // Ticket zalozeny s typem tak zustaval bez typu a bez vlastnich poli. - assigneeGroupId: input.assigneeGroupId ?? null, - typeId: input.typeId ?? null, - fields: input.fields ?? {}, - tags: input.tags ?? [], - closed: input.closed ?? false, - automationId: input.automationId ?? null, - createdById: input.createdById ?? null, - createdAt: now, - updatedAt: now, - }; - tickets.unshift(stored); - index(stored); - traces.set(stored.id, flattenTrace(input.trace ?? [], null, [])); - events.set(stored.id, []); - persist(stored); - - onTicketChanged('ticket.created', toTicket(stored)); - - publish('ticket.created', `Nový ticket ${stored.id}: ${stored.subject}`, { - ticketId: stored.id, - 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 { - return writableOrWarn(ticketsById.get(id), id, tenantIds, 'tickets'); -} - -/** - * Zmeni stav, pripadne i priznak vyrizeni. - * - * Stav je **volny retezec** a neoveruje se proti nicemu. Jestli je ticket - * vyrizeny, rika `closed` - vyslovne, ne odvozene ze jmena stavu. Kdyz se - * nepreda, priznak zustava, jak byl: zmena textu stavu sama o sobe neznamena, - * ze je hotovo. - */ -export function updateTicketStatus( - id: string, - status: TicketStatus, - tenantIds: string[], - closed?: boolean, -): Ticket | undefined { - const ticket = findWritable(id, tenantIds); - if (!ticket) { - console.warn(`[tickets] zmena stavu nedostupneho ticketu: ${id}`); - return undefined; - } - - const previous = ticket.status; - const wasClosed = ticket.closed ?? false; - ticket.status = status; - if (closed !== undefined) ticket.closed = closed; - - if (ticket.closed && !wasClosed) { - ticket.resolvedAt = new Date().toISOString(); - // Vyresil ten, kdo ho mel u sebe. Kdyz nikdo, zustane to nekomu nepripsane - - // radeji nez pripsat vyreseni cloveku, ktery s tim nic nemel. - ticket.resolvedById = ticket.assigneeId; - } else if (!ticket.closed && wasClosed) { - // Navrat z vyreseno je nejlepsi ukazatel toho, ze hotovo nebylo. - ticket.reopenCount = (ticket.reopenCount ?? 0) + 1; - ticket.resolvedAt = null; - ticket.resolvedById = null; - } - - markResponded(ticket); - touch(ticket); - - /* - * Poznamka jen kdyz se neco zmenilo. "Stav zmenen z completed na completed" - * je rada, ktera se ctenari pise do historie u kazde prichozi zpravy, a po - * dvaceti takovych se v logu neda nic najit. - */ - const statusChanged = previous !== status; - const closedChanged = closed !== undefined && closed !== wasClosed; - - if (statusChanged || closedChanged) { - const parts: string[] = []; - if (statusChanged) parts.push(`stav ${previous} -> ${status}`); - if (closedChanged) parts.push(ticket.closed ? 'oznaceno jako vyrizene' : 'znovu otevreno'); - appendTrace(id, [{ kind: 'note', label: parts.join(', '), status: 'info' }]); - } - - 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); -} - -/** Prirazeni resitele. `null` ticket vrati zpatky do fronty. */ -export function assignTicket( - id: string, - assigneeId: string | null, - tenantIds: string[], -): Ticket | undefined { - const ticket = findWritable(id, tenantIds); - if (!ticket) { - console.warn(`[tickets] prirazeni nedostupneho ticketu: ${id}`); - return undefined; - } - - // Resitel musi byt clenem firmy ticketu. Jinak by ticket zmizel z prehledu - // firmy a objevil se nekomu, kdo do ni nepatri. Necleny `findPerson` nevrati. - const person = assigneeId ? findPerson(assigneeId, ticket.tenantId) : null; - if (assigneeId && !person) { - console.warn(`[tickets] ${id}: prirazeni na ${assigneeId}, ktery neni clenem firmy`); - return undefined; - } - - const previousAssignee = ticket.assigneeId; - ticket.assigneeId = person?.id ?? null; - if (person) markResponded(ticket); - touch(ticket); - - // Komu ticket prisel, ten se to musi dozvedet. Znovu prirazeni tomu samemu - // cloveku upozorneni negeneruje, jinak by mu chodilo pri kazde drobnosti. - if (person && person.id !== previousAssignee) { - notify({ - tenantId: ticket.tenantId, - userId: person.id, - kind: 'ticket.assigned', - title: `Máte nový ticket ${ticket.id}: ${ticket.subject}`, - href: `/dashboard/tickety/${ticket.id}`, - ticketId: ticket.id, - }); - } - - appendTrace(id, [ - { - kind: 'note', - label: person ? `Ticket přiřazen: ${person.name}.` : 'Ticket vrácen do fronty.', - status: 'info', - }, - ]); - - publish( - 'ticket.assigned', - person - ? `Ticket ${ticket.id} přiřazen: ${person.name}` - : `Ticket ${ticket.id} vrácen do fronty`, - { ticketId: ticket.id, assigneeId: ticket.assigneeId, ticket: toTicket(ticket) }, - ticket.tenantId, - ); - return toTicket(ticket); -} - -/** Komentar je jen dalsi radek logu - at je vsechno na jedne casove ose. */ -/** - * Zmena typu ticketu. - * - * Vlastni pole se **nezahazuji**, jen prestanou byt videt. Kdyby se mazala, - * omylem prepnuty typ by znamenal ztratu dat bez cesty zpatky. - */ -export function setTicketType( - id: string, - typeId: string | null, - fields: Record | undefined, - tenantIds: string[], -): Ticket | undefined { - const ticket = findWritable(id, tenantIds); - if (!ticket) return undefined; - - ticket.typeId = typeId; - if (fields) ticket.fields = { ...(ticket.fields ?? {}), ...fields }; - touch(ticket); - - 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, ticket: toTicket(ticket) }, ticket.tenantId); - return toTicket(ticket); -} - -/** Tagy se prepisuji cele. Prirustkova zmena by u vic lidi naraz kolidovala. */ -export function setTicketTags(id: string, tags: string[], tenantIds: string[]): Ticket | undefined { - const ticket = findWritable(id, tenantIds); - if (!ticket) return undefined; - - 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, ticket: toTicket(ticket) }, ticket.tenantId); - return toTicket(ticket); -} - -/** - * Prehozeni na skupinu. - * - * Prirazeni cloveku se **zrusi**: kdyby zustalo, ticket by byl ve fronte skupiny - * i u konkretniho cloveka a nikdo by nevedel, kdo to ma resit. - */ -export function assignTicketGroup( - id: string, - groupId: string | null, - tenantIds: string[], - /** false = nechat resitele, jak je. Pouziva to prevzeti ticketu. */ - clearAssignee = true, -): Ticket | undefined { - const ticket = findWritable(id, tenantIds); - if (!ticket) return undefined; - - ticket.assigneeGroupId = groupId; - if (groupId && clearAssignee) ticket.assigneeId = null; - touch(ticket); - - appendTrace(id, [ - { - kind: 'note', - status: 'info', - label: groupId ? `Přehozeno na skupinu ${groupId}` : 'Odebráno ze skupiny', - }, - ]); - publish('ticket.assigned', `Ticket ${ticket.id} přehozen na skupinu`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId); - return toTicket(ticket); -} - -/** - * Prevzeti ticketu. - * - * Clovek si vezme praci sam, misto aby cekal, az mu ji nekdo prideli. Smi to - * jen u ticketu **ze sve skupiny**, nebo u takoveho, ktery nema nikoho - - * brat cizi rozdelanou praci by znamenalo, ze o ni prijde ten, kdo ji resi. - * - * Vraci `undefined`, kdyz to nejde; duvod rekne volajici, ktery zna kontext. - */ -export function claimTicket( - id: string, - personId: string, - tenantIds: string[], -): Ticket | undefined { - const ticket = findWritable(id, tenantIds); - if (!ticket) return undefined; - - // Necleny firmy `findPerson` nevrati, takze tohle je i kontrola firmy. - const person = findPerson(personId, ticket.tenantId); - if (!person) return undefined; - - ticket.assigneeId = person.id; - markResponded(ticket); - touch(ticket); - - appendTrace(id, [ - { kind: 'note', status: 'info', label: `${person.name} si ticket převzal` }, - ]); - 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 = ticketsById.get(id); - if (!ticket || !tenantIds.includes(ticket.tenantId)) return undefined; - return ticket.assigneeId; -} - -/** - * Komentar k ticketu. - * - * `helpdeskSourceIds` pusti ke slovu i zadavatele z helpdesku. Je to jedina - * zmena, kterou nad cizim ticketem smi - doplnit, co zapomnel napsat, je presne - * to, kvuli cemu se pozadavek otevira. Prehazovat resitele nebo menit stav uz - * ne, na to se ho nikdo neptal. - */ -export function addComment( - id: string, - author: string, - text: string, - tenantIds: string[], - helpdeskSourceIds: string[] = [], -): Ticket | undefined { - const found = ticketsById.get(id); - const asked = - found?.helpdeskSourceId !== null && - found?.helpdeskSourceId !== undefined && - helpdeskSourceIds.includes(found.helpdeskSourceId); - - const ticket = asked ? found : findWritable(id, tenantIds); - if (!ticket) { - console.warn(`[tickets] komentar k nedostupnemu ticketu: ${id}`); - return undefined; - } - - markResponded(ticket); - touch(ticket); - appendTrace(id, [{ kind: 'note', label: `${author}: ${text}`, status: 'info' }]); - - publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId); - return toTicket(ticket); -} +export * from './tickets/index.js'; diff --git a/src/data/tickets/index.ts b/src/data/tickets/index.ts new file mode 100644 index 0000000..a7ef51e --- /dev/null +++ b/src/data/tickets/index.ts @@ -0,0 +1,71 @@ +/** + * Uloziste ticketu. Zmeny posilaji udalost na sbernici, takze se projevi + * v dashboardu okamzite bez obnoveni stranky. + * + * Ticket je prichozi pozadavek odkudkoliv (WhatsApp, e-mail, hlasova linka, + * formular, portal). NENI to bug ani wish - vyvojarska agenda ma vlastni + * evidenci a s ticketem se plete jen v hlave. + * + * Dve veci, na kterych model stoji: + * - ticket ma vzdy jednoho resitele (nebo zadneho), aby slo rict "mas to u sebe", + * - ticket si nese strom zaznamu o tom, co se s nim delo a co ktera sluzba vratila. + * + * Data se drzi v pameti a po kazde zmene se cely ticket zapise do uloziste + * (`withMirror`). Pri startu se cte uloziste, ukazkova sada nize se pouzije jen + * kdyz je prazdne. Kam se zapisuje - databaze, soubor, nebo nikam - rozhoduje + * `data/store/index.ts`, tady se to neresi. + * + * Slozka je rozdelena podle odpovednosti: `model` (tvar), `state` (pamet), + * `persist` (zapis a nacteni), `trace` (log), `queries` (cteni), `store` + * (zapisy), `intake` (prijem udalosti), `stats`, `seed` a `remap`. Zvenku + * se importuje jen tenhle soubor, a to pres `data/ticketStore.ts`. + */ + +import { config } from '../../config.js'; +import { seedDemoTickets } from './seed.js'; + +export type { + AgentStatsRow, + Ticket, + TicketAssignee, + TicketChannel, + TicketCustomer, + TicketDetail, + TicketEvent, + TicketPriority, + TicketStatus, + TicketTraceEntry, + TraceKind, + TraceStatus, + Workload, + WorkloadRow, +} from './model.js'; +export { channelLabels, defaultStatuses } from './model.js'; +export { initTickets } from './persist.js'; +export { appendTrace, type TraceInput } from './trace.js'; +export { + findByExternalId, + findTicket, + firstOpenTicket, + getTicket, + listTickets, + ticketWithinVisibility, + type TicketFilter, +} from './queries.js'; +export { getAgentStats, getWorkload } from './stats.js'; +export { intakeEvent, type IntakeInput, type IntakeResult, type TicketApply } from './intake.js'; +export { + addComment, + assignTicket, + assignTicketGroup, + claimTicket, + createTicket, + setTicketTags, + setTicketType, + ticketAssignee, + updateTicketStatus, + type CreateTicketInput, +} from './store.js'; +export { remapPersonIds } from './remap.js'; + +if (config.seedDemo) seedDemoTickets(); diff --git a/src/data/tickets/intake.ts b/src/data/tickets/intake.ts new file mode 100644 index 0000000..fea855d --- /dev/null +++ b/src/data/tickets/intake.ts @@ -0,0 +1,332 @@ +/** + * Prijem udalosti zvenku. + * + * Vstup do ticketovaciho systemu: jakakoliv udalost se muze stat ticketem, + * nebo se navesit na existujici podle externiho ID. + */ + +import { publish } from '../../events/bus.js'; +import { currentRun } from '../../runtime/context.js'; +import { findPerson } from '../people.js'; +import type { Ticket, TicketChannel, TicketEvent, TicketPriority } from '../../shared/tickets.js'; +import { toTicket, type StoredTicket } from './model.js'; +import { persist, touch } from './persist.js'; +import { events, externalKey, nextEventId, ticketsByExternal, ticketsById, traces } from './state.js'; +import { createTicket, type CreateTicketInput } from './store.js'; +import { appendTrace, describePayload, lastTraceId } from './trace.js'; + +/** + * Co jde na ticketu nastavit prichozi udalosti. + * + * Zakaznik je rozepsany po polozkach schvalne. Kdyby se predaval cely, prepsala + * by zprava, ktera zna jen telefon, i jmeno firmy - prazdnou hodnotou. + */ +export interface TicketApply { + subject?: string; + body?: string; + typeId?: string; + priority?: TicketPriority; + channel?: TicketChannel; + sourceRef?: string; + company?: string; + contact?: string; + reply?: string; + assigneeId?: string; + assigneeGroupId?: string; +} + +export interface IntakeInput { + tenantId: string; + /** Bez nej se navazani nema o co oprit a zalozi se novy ticket. */ + externalId: string | null; + externalSource?: string | null; + /** Typ udalosti od odesilatele, napr. `order.created`. */ + type: string; + /** Popisek do casove osy. Bez nej se pouzije typ udalosti. */ + label?: string; + /** Cela prijata data. */ + payload?: Record; + /** + * Hodnoty, ktere maji smysl **jen pri vzniku** ticketu. + * + * Zbylo jich malo a je to zamer: predmet jako zaloha, kdyz ho odesilatel + * neposlal, a vychozi stav. Vsechno ostatni patri do `apply`. + */ + create?: Partial>; + /** + * Hodnoty, ktere se zapisi **pri kazde udalosti**, ne jen pri zalozeni. + * + * Driv bylo skoro vsechno jen pro vznik ticketu a nedava to smysl: krok se + * jmenuje "zalozit **nebo doplnit**" a data casto nechodi najednou. Prvni + * zprava jen oznami, ze se neco deje - u hovoru nese cislo a `in-progress`, + * a prave ta ticket zaklada. Predmet, obsah, typ i zakaznik dorazi az tou + * posledni, kdy uz ticket existoval, a tise se zahazovaly. + * + * **Prazdna hodnota nikdy nemaze**, co uz na ticketu je. To je ta pojistka, + * kvuli ktere se drive zapisovalo jen pri zalozeni: pozdejsi zprava bez + * jmena zakaznika je bezna a smazat kvuli ni jmeno by bylo horsi nez ho + * nedoplnit. Prepise se jen to, co odesilatel opravdu poslal. + */ + apply?: TicketApply; + /** Vlastni pole, ktera se doplni i na existujici ticket. */ + fields?: Record; + /** Tagy, ktere se **pridaji**. Existujici se nemazou. */ + addTags?: string[]; +} + +export interface IntakeResult { + ticket: Ticket; + /** true = ticket teprve ted vznikl, false = udalost se navesila na existujici. */ + created: boolean; + event: TicketEvent; + /** + * true = prislo presne totez co posledne, takze se jen pricetlo k pocitadlu. + * Ticket se **nemenil**: nesmi se kvuli tomu prepsat `updatedAt`, ani + * rozeslat zmena - jinak by kazdy duplikat rozblikal dashboard a mohl + * spustit automatizaci navazanou na zmenu ticketu. + */ + repeated: boolean; +} + +/** + * Prijem udalosti zvenku. + * + * Tohle je vstup do ticketovaciho systemu: **jakakoliv udalost se muze stat + * ticketem**. Kdyz uz ticket se stejnym externim ID ve **stejne firme** je, + * udalost se na nej navesi misto zalozeni druheho. Diky tomu muze odesilatel + * poslat "objednavka 3 vznikla" a za hodinu "objednavka 3 vyfakturovana" + * a obojí skonci na jednom miste. + * + * Bez externiho ID se vzdy zaklada novy ticket - nemame podle ceho navazovat + * a hadat podle predmetu by slucovalo veci, ktere spolu nesouvisi. + */ +export function intakeEvent(input: IntakeInput): IntakeResult { + const timestamp = new Date().toISOString(); + const event: TicketEvent = { + id: nextEventId(), + type: input.type, + source: input.externalSource ?? 'webhook', + label: input.label ?? input.type, + payload: input.payload ?? {}, + at: timestamp, + repeats: 1, + lastAt: timestamp, + }; + + const existing = input.externalId + ? ticketsByExternal.get(externalKey(input.tenantId, input.externalId)) + : undefined; + + if (existing) { + /* + * Prislo presne totez co posledne? Pak se to jen pricte. Zadny novy radek, + * zadny zapis do logu, zadna zmena ticketu - nic se totiz nestalo. + */ + const repeat = sameAsLast(existing.id, event); + if (repeat) { + repeat.repeats += 1; + repeat.lastAt = event.at; + persist(existing); + return { ticket: toTicket(existing), created: false, event: repeat, repeated: true }; + } + + appendEvent(existing, event); + + if (input.fields) existing.fields = mergeFields(existing.fields, input.fields); + if (input.addTags && input.addTags.length > 0) { + existing.tags = [...new Set([...(existing.tags ?? []), ...input.addTags])]; + } + applyValues(existing, input.apply); + touch(existing); + + /* + * Rodic musi byt prazdny, jinak by se radek udalosti zaradil pod udalost + * predchozi - `appendTrace` doplnuje rodice z behu. + */ + const run = currentRun(); + const parent = run?.traceParent; + if (parent) parent.id = null; + + appendTrace(existing.id, [ + { + kind: 'trigger', + status: 'info', + label: `Přijata událost: ${event.label}${originOf(run, event)}`, + response: describePayload(event.payload), + }, + ]); + + // Dalsi zapisy tohohle behu uz patri pod tenhle radek. + if (parent) parent.id = lastTraceId(existing.id); + 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 }; + } + + /* + * Pri zalozeni plati totez co u doplneni, jen se navic dosadi zaloha tam, + * kde odesilatel nic neposlal. Dva ruzne seznamy poli by se rozesly a zase + * by nekde neco chybelo. + */ + const apply = input.apply ?? {}; + const created = createTicket({ + tenantId: input.tenantId, + externalId: input.externalId, + externalSource: input.externalSource ?? null, + subject: apply.subject || input.create?.subject || event.label, + /* + * Stav uz pri vzniku. Volajici ho posilal, ale sem se nepredaval, takze + * ticket vznikl s vychozim "Nový" a hned se prepsal - v logu pak stalo + * "stav Nový -> completed" u ticketu, ktery v nem nikdy nebyl. + */ + status: input.create?.status, + body: apply.body ?? '', + sourceRef: apply.sourceRef ?? null, + channel: apply.channel ?? 'form', + customer: { + id: null, + company: apply.company ?? '', + contact: apply.contact ?? '', + reply: apply.reply ?? '', + }, + priority: apply.priority ?? 'normal', + assigneeId: apply.assigneeId ?? null, + assigneeGroupId: apply.assigneeGroupId ?? null, + typeId: apply.typeId ?? null, + fields: mergeFields(input.create?.fields, input.fields ?? {}), + tags: [...new Set([...(input.create?.tags ?? []), ...(input.addTags ?? [])])], + automationId: input.create?.automationId ?? null, + trace: [ + { + kind: 'trigger', + status: 'info', + label: `Ticket vznikl z události: ${event.label}${originOf(currentRun(), event)}`, + response: describePayload(event.payload), + }, + ], + }); + + const stored = ticketsById.get(created.id); + if (stored) { + appendEvent(stored, event); + persist(stored); + } + + // Zmeny, ktere beh udela dal, patri pod radek o vzniku ticketu. + const parent = currentRun()?.traceParent; + if (parent) parent.id = traces.get(created.id)?.[0]?.id ?? null; + + return { + ticket: toTicket(stored ?? (created as unknown as StoredTicket)), + created: true, + event, + repeated: false, + }; +} + +/** + * Slouci vlastni pole typu ticketu. + * + * Klice se **scitaji**: kdyz prvni zprava prinese `data` a druha `data2`, ma + * ticket obe. Odesilatel nemusi posilat vsechno pokazde a nemusi se predem + * dohodnout, co vsechno posle. + * + * **Prazdny retezec nemaze.** Sablona, ktera na nic neukazuje, se dosadi + * prazdnem, takze `{"vysledek":"{{result}}"}` u zpravy bez vysledku posle + * prazdno - a to by prepsalo hodnotu z minule zpravy. Vymazat pole jde + * poslanim `null`: to uz je zamer, ne vedlejsi ucinek nevyplnene sablony. + */ +function mergeFields( + current: Record | undefined, + incoming: Record, +): Record { + const merged = { ...(current ?? {}) }; + for (const [key, value] of Object.entries(incoming)) { + if (value === '') continue; + merged[key] = value; + } + return merged; +} + +/** + * Zapise na ticket to, co prinesla udalost. + * + * Jedno pravidlo pro vsechna pole: **neprazdna hodnota prepise, prazdna nemaze**. + * Pozdejsi zprava o teze veci je upresneni, ne druhy zaznam - a zaroven nesmi + * smazat to, co uz na ticketu je, jen proto, ze o tom nic nevi. + * + * Stav tudy zamerne nechodi. Ma svoje `updateTicketStatus`, ktere resi i priznak + * vyrizeni, cas vyreseni a pocet znovuotevreni - obejit ho by ta cisla rozbilo. + */ +function applyValues(ticket: StoredTicket, apply: TicketApply | undefined): void { + if (!apply) return; + + if (apply.subject) ticket.subject = apply.subject; + if (apply.body) ticket.body = apply.body; + if (apply.typeId) ticket.typeId = apply.typeId; + if (apply.priority) ticket.priority = apply.priority; + if (apply.channel) ticket.channel = apply.channel; + if (apply.sourceRef) ticket.sourceRef = apply.sourceRef; + if (apply.assigneeGroupId) ticket.assigneeGroupId = apply.assigneeGroupId; + + // Stejne jako pri zalozeni: mrtvy odkaz na resitele radeji nez ulozit. + if (apply.assigneeId) { + if (findPerson(apply.assigneeId, ticket.tenantId)) ticket.assigneeId = apply.assigneeId; + else console.warn(`[tickets] neznamy resitel ${apply.assigneeId}, ticket zustava jak byl`); + } + + // Po polozkach, at zprava, ktera zna jen telefon, neprepise jmeno firmy. + const customer = ticket.customer; + if (apply.company) customer.company = apply.company; + if (apply.contact) customer.contact = apply.contact; + if (apply.reply) customer.reply = apply.reply; +} + +/** Kolik udalosti se u jednoho ticketu drzi. Starsi se odmazavaji. */ +const MAX_EVENTS = 200; + +function appendEvent(ticket: StoredTicket, event: TicketEvent): void { + const list = events.get(ticket.id) ?? []; + list.push(event); + // Nekonecne rostouci ticket by pri kazdem zapisu prepisoval vic a vic dat. + if (list.length > MAX_EVENTS) list.splice(0, list.length - MAX_EVENTS); + events.set(ticket.id, list); +} + +/** + * Je to totez, co prislo naposledy? + * + * Porovnava se **jen s posledni** udalosti, ne s celou historii. "Objednavka + * pripravena" muze legitimne prijit znovu za hodinu, kdyz se mezitim stalo + * neco jineho - to je novy fakt. Dvacet stejnych zprav v rade uz ne. + * + * Data se srovnavaji pres JSON, protoze na poradi klicu v prijate zprave + * nezalezi jen vyjimecne a levnejsi porovnani neni potreba. + */ +function sameAsLast(ticketId: string, event: TicketEvent): TicketEvent | null { + const list = events.get(ticketId); + const last = list && list.length > 0 ? list[list.length - 1] : undefined; + if (!last) return null; + + if (last.type !== event.type || last.source !== event.source || last.label !== event.label) { + return null; + } + if (JSON.stringify(last.payload) !== JSON.stringify(event.payload)) return null; + return last; +} + +/** + * Kdo udalost prinesl. + * + * V logu je to to nejdulezitejsi: kdyz se ticket zmenil, prvni otazka je "kdo + * mi do toho sahl". Jmeno automatizace na to odpovi, `webhook` uz ne. + */ +function originOf(run: { automationName: string } | undefined, event: TicketEvent): string { + if (run) return ` (automatizace ${run.automationName})`; + if (event.source && event.source !== 'webhook') return ` (${event.source})`; + return ''; +} diff --git a/src/data/tickets/model.ts b/src/data/tickets/model.ts new file mode 100644 index 0000000..50624d7 --- /dev/null +++ b/src/data/tickets/model.ts @@ -0,0 +1,144 @@ +/** + * Tvar ticketu v ulozisti a prevod navenek. + * + * Tvar ticketu je sdileny s webem, viz src/shared/tickets.ts. Tady zustava + * to, co web nevidi: jak vypada zaznam v ulozisti a jak se z nej sklada + * uplny `Ticket` s doplnenymi vychozimi hodnotami. + */ + +import { findPerson } from '../people.js'; + +import type { + AgentStatsRow, + Ticket, + TicketAssignee, + TicketChannel, + TicketCustomer, + TicketDetail, + TicketEvent, + TicketPriority, + TicketStatus, + TicketTraceEntry, + TraceKind, + TraceStatus, + Workload, + WorkloadRow, +} from '../../shared/tickets.js'; + +/** + * 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 { + 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; +/** + * Tvar v ulozisti. + * + * Nova pole jsou nepovinna zamerne: vychozi sada ticketu je psana jako literaly + * a doplnovat do kazdeho `tags: []` by byl sum. Chybejici hodnotu dosadi + * `toTicket`, takze navenek je `Ticket` uplny. + */ +export interface StoredTicket + extends Omit< + Ticket, + | 'assignee' + | 'typeId' + | 'fields' + | 'tags' + | 'assigneeGroupId' + | 'externalId' + | 'closed' + | 'externalSource' + | 'firstResponseAt' + | 'resolvedAt' + | 'resolvedById' + | 'reopenCount' + | 'helpdeskSourceId' + | 'createdById' + > { + assigneeId: string | null; + helpdeskSourceId?: string | null; + assigneeGroupId?: string | null; + typeId?: string | null; + fields?: Record; + tags?: string[]; + closed?: boolean; + externalId?: string | null; + externalSource?: string | null; + firstResponseAt?: string | null; + resolvedAt?: string | null; + resolvedById?: string | null; + reopenCount?: number; + createdById?: string | null; + events?: TicketEvent[]; +} + +export const channelLabels: Record = { + whatsapp: 'WhatsApp', + facebook: 'Facebook Messenger', + instagram: 'Instagram', + email: 'E-mail', + voice: 'Hlasová linka', + form: 'Webový formulář', + portal: 'Portál', +}; + +/** + * 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 + * pri kazdem kroku automatizace a moznost, ze jeden z nich selze. + */ +export interface PersistedTicket extends StoredTicket { + trace: TicketTraceEntry[]; +} + +// ------------------------------------------------------------------ prevody + +export function toTicket(stored: StoredTicket): Ticket { + const { assigneeId, events: _events, ...rest } = stored; + // Chybejici nova pole dostanou vychozi hodnotu, aby navenek byl Ticket uplny. + const base = { + ...rest, + assigneeGroupId: stored.assigneeGroupId ?? null, + typeId: stored.typeId ?? null, + fields: stored.fields ?? {}, + tags: stored.tags ?? [], + closed: stored.closed ?? false, + externalId: stored.externalId ?? null, + externalSource: stored.externalSource ?? null, + firstResponseAt: stored.firstResponseAt ?? null, + resolvedAt: stored.resolvedAt ?? null, + resolvedById: stored.resolvedById ?? null, + reopenCount: stored.reopenCount ?? 0, + helpdeskSourceId: stored.helpdeskSourceId ?? null, + createdById: stored.createdById ?? null, + }; + + if (!assigneeId) return { ...base, assignee: null }; + + const person = findPerson(assigneeId, stored.tenantId); + if (!person) { + // Resitel uz neni clenem firmy - ticket nesmi spadnout, ale chceme o tom vedet. + console.warn(`[tickets] ${stored.id}: resitel ${assigneeId} uz ve firme neni`); + return { ...base, assignee: null }; + } + return { ...base, assignee: { id: person.id, name: person.name } }; +} + diff --git a/src/data/tickets/persist.ts b/src/data/tickets/persist.ts new file mode 100644 index 0000000..a3fa30b --- /dev/null +++ b/src/data/tickets/persist.ts @@ -0,0 +1,104 @@ +/** + * Zapis ticketu do uloziste a nacteni pri startu. + * + * Po kazde zmene se cely ticket vcetne logu a udalosti zapise do uloziste + * (`withMirror`). Kam se zapisuje - databaze, soubor, nebo nikam - rozhoduje + * `data/store/index.ts`, tady se to neresi. + */ + +import { defineStore } from '../store/index.js'; +import { withMirror } from '../store/mirror.js'; +import { onTicketChanged } from '../ticketHooks.js'; +import { toTicket, type PersistedTicket, type StoredTicket } from './model.js'; +import { + continueCounters, + events, + index, + tickets, + ticketsByExternal, + ticketsById, + traces, +} from './state.js'; + +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. */ +export function persist(ticket: StoredTicket): void { + 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) ?? [], + }); + } +} + +/** + * Oznaci ticket jako zmeneny a ulozi ho. + * + * Kazda zmena jde skrz tohle, aby neslo upravit ticket a zapomenout na + * `updatedAt` nebo na zapis. Pary "prirad radek, uloz" se jinak rozejdou. + */ +export function touch(ticket: StoredTicket): void { + ticket.updatedAt = new Date().toISOString(); + persist(ticket); + // Automatizace navazane na zmenu ticketu. Necekana se a chyby nevyhazuje, + // jinak by rozbita fronta rozbila ukladani ticketu. + onTicketChanged('ticket.updated', toTicket(ticket)); +} + +/** + * Nacte tickety z uloziste. Vola se pri startu, viz data/bootstrap.ts. + * + * Kdyz uloziste nic nema, ulozi se ukazkova sada, ktera je v tuhle chvili + * v pameti. Po prvnim startu je tedy uloziste jediny zdroj pravdy. + */ +export async function initTickets(): Promise { + const rows = await mirror.load(() => + tickets.map((ticket) => ({ ...ticket, trace: traces.get(ticket.id) ?? [] })), + ); + + 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 + * se doplni pri nacteni - jinak by se v portalu ukazovalo "undefinedx". + */ + events.set( + row.id, + (rowEvents ?? []).map((event) => ({ + ...event, + repeats: event.repeats ?? 1, + lastAt: event.lastAt ?? event.at, + })), + ); + } + + continueCounters(); +} diff --git a/src/data/tickets/queries.ts b/src/data/tickets/queries.ts new file mode 100644 index 0000000..61aa142 --- /dev/null +++ b/src/data/tickets/queries.ts @@ -0,0 +1,198 @@ +/** + * Cteni ticketu: seznam, detail, hledani podle ID. + * + * Kazdy dotaz bere povolene firmy a strop viditelnosti. Oboji je povinne, + * viz `TicketFilter` - nepovinny filtr na prava je filtr, ktery jednou chybi. + */ + +import type { Visibility } from '../access.js'; +import type { Ticket, TicketChannel, TicketDetail, TicketStatus } from '../../shared/tickets.js'; +import { toTicket, type StoredTicket } from './model.js'; +import { events, externalKey, tickets, ticketsByExternal, ticketsById, traces } from './state.js'; + +export interface TicketFilter { + /** + * Firmy, ze kterych se smi vracet. Povinne - kdyby to slo vynechat, + * driv nebo pozdeji nekdo zapomene a endpoint vrati cizi data. + */ + tenantIds: string[]; + /** + * Strop viditelnosti uvnitr firmy. **Povinny ze stejneho duvodu** jako + * `tenantIds`: nepovinny filtr na prava je filtr, ktery jednou nekde chybi. + * + * Poklada ho `resolveScope`, viz data/access.ts. Kdo ma videt vsechno, + * posila `{ kind: 'all' }` - vyslovne, ne vynechanim. + */ + visibility: Visibility; + /** + * Firmy, ze kterych pozadavek prisel pres helpdesk. + * + * Vyplnene **nahrazuje** filtr podle vlastnika: zadavatel vlastnikem neni, + * takze by mu jinak nezbylo nic. Prazdne pole tady znamena "nefiltrovat + * podle zdroje", ne "nic" - vlastnicky filtr plati dal. + */ + helpdeskSourceIds?: string[]; + /** ID resitele, nebo 'unassigned' pro nepridelene. */ + assignee?: string; + /** + * Ucet, ktery ticket zalozil. Pouziva **jen helpdesk**: tam je "moje" to, + * co jsem poslal, ne to, co mam prirazene. + */ + createdById?: string; + status?: TicketStatus; + channel?: TicketChannel; + /** Typ ticketu. `none` = tickety bez typu. */ + typeId?: string; + /** Jeden tag. `none` = tickety bez tagu. */ + tag?: string; + /** Skupina resitelu. `none` = bez skupiny. */ + groupId?: string; +} + +/** + * Vejde se ticket do stropu? + * + * Sjednoceni, ne prunik: bud ho ma u sebe nekdo, na koho vidim, nebo lezi + * ve fronte sekce, kterou vedu. Fronta bez resitele patri do druhe podminky - + * bez ni by vedouci nemel co rozdelovat. + */ +function withinVisibility(ticket: StoredTicket, visibility: Visibility): boolean { + if (visibility.kind === 'all') return true; + /* + * **Nezarazene vidi kazdy ve firme.** Prvni verze stropu je schovavala + * a byla to diera v provozu: prichozi ticket, ktery jeste nikdo nesmeroval, + * nepatri do zadne sekce, takze by ho nevidel nikdo krome vedeni - a nikdo + * by si ho nevzal. Fronta je spolecna, prave proto je to fronta. + */ + if (ticket.assigneeId === null) return true; + if (visibility.personIds.includes(ticket.assigneeId)) return true; + if (ticket.assigneeGroupId && visibility.groupIds.includes(ticket.assigneeGroupId)) return true; + return false; +} + +export function listTickets(filter: TicketFilter): Ticket[] { + const sources = filter.helpdeskSourceIds; + const selected = tickets.filter((ticket) => { + if (sources && sources.length > 0) { + // Pohled zadavatele: vidi svoje pozadavky bez ohledu na to, kdo je resi. + if (!ticket.helpdeskSourceId || !sources.includes(ticket.helpdeskSourceId)) return false; + } else if (!filter.tenantIds.includes(ticket.tenantId)) { + return false; + } else if (!withinVisibility(ticket, filter.visibility)) { + /* + * Strop se pta jen u vlastnickeho pohledu. Pohled zadavatele nad nim + * neni: pozadavek posila firma dodavateli, zadavatel u nej neni resitel + * ani clen zadne skupiny, takze by mu strop vzal i to, co sam poslal. + */ + return false; + } + if (filter.status && ticket.status !== filter.status) return false; + if (filter.channel && ticket.channel !== filter.channel) return false; + if (filter.typeId === 'none' ? ticket.typeId : filter.typeId && ticket.typeId !== filter.typeId) + return false; + if (filter.tag === 'none') { + if ((ticket.tags ?? []).length > 0) return false; + } else if (filter.tag && !(ticket.tags ?? []).includes(filter.tag)) { + return false; + } + if ( + filter.groupId === 'none' + ? ticket.assigneeGroupId + : filter.groupId && ticket.assigneeGroupId !== filter.groupId + ) { + return false; + } + if (filter.createdById && ticket.createdById !== filter.createdById) return false; + if (filter.assignee === 'unassigned') return ticket.assigneeId === null; + if (filter.assignee && ticket.assigneeId !== filter.assignee) return false; + return true; + }); + + // Nejdriv nevyrizene, uvnitr od nejnovejsi upravy. + return selected + .sort((a, b) => { + // Hotove dolu. Ptame se na priznak, ne na text stavu - ten je volny. + if (a.closed && !b.closed) return 1; + if (b.closed && !a.closed) return -1; + return b.updatedAt.localeCompare(a.updatedAt); + }) + .map(toTicket); +} + +/** + * Vraci ticket jen z povolenych firem. Cizi se tvari jako neexistujici. + * + * `helpdeskSourceIds` je druha cesta dovnitr: firma, ktera pozadavek poslala, + * ho smi cist, i kdyz ho nevlastni. Bez toho by zadavatel videl v seznamu + * pozadavek, ktery si nemuze otevrit. + */ +export function getTicket( + id: string, + tenantIds: string[], + helpdeskSourceIds: string[] = [], + visibility: Visibility = { kind: 'all' }, +): TicketDetail | undefined { + const stored = ticketsById.get(id); + if (!stored) return undefined; + + const owns = tenantIds.includes(stored.tenantId); + const asked = + stored.helpdeskSourceId !== null && + stored.helpdeskSourceId !== undefined && + helpdeskSourceIds.includes(stored.helpdeskSourceId); + + if (!owns && !asked) { + console.warn(`[tickets] pokus o cteni ticketu ${id} mimo povolene firmy`); + return undefined; + } + + /* + * Strop plati i na jeden ticket, ne jen na seznam. Bez toho by staciloa + * znat ID: seznam by ho neukazal, ale adresa detailu by ho vydala. + * + * Pohledu zadavatele se to netyka, ten stoji na `helpdeskSourceIds` - viz + * `listTickets`. + */ + if (owns && !asked && !withinVisibility(stored, visibility)) { + console.warn(`[tickets] pokus o cteni ticketu ${id} mimo strop viditelnosti`); + return undefined; + } + return { + ...toTicket(stored), + trace: traces.get(id) ?? [], + events: events.get(id) ?? [], + }; +} + +/** + * Vejde se ten ticket do stropu? + * + * Pouziva detail a prevzeti, kde se firma ticketu pozna az po nalezeni - + * odkaz z pohledu "vse" muze vest do jine firmy uzivatele a strop se pocita + * za firmu ticketu, ne za prave prepnutou. + */ +export function ticketWithinVisibility(id: string, visibility: Visibility): boolean { + const stored = ticketsById.get(id); + return stored ? withinVisibility(stored, visibility) : false; +} + +/** Prvni nevyrizeny ticket. */ +export function firstOpenTicket(tenantIds: string[]): Ticket | undefined { + const stored = tickets.find( + (t) => !t.closed && tenantIds.includes(t.tenantId), + ); + return stored ? toTicket(stored) : undefined; +} + +/** Ticket podle naseho ID, jen z povolenych firem. */ +export function findTicket(id: string, tenantIds: string[]): Ticket | undefined { + 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 = ticketsByExternal.get(externalKey(tenantId, externalId)); + return stored ? toTicket(stored) : undefined; +} diff --git a/src/data/tickets/remap.ts b/src/data/tickets/remap.ts new file mode 100644 index 0000000..ae2a90e --- /dev/null +++ b/src/data/tickets/remap.ts @@ -0,0 +1,33 @@ +/** + * Preznaceni resitelu pri migraci, viz data/migratePeople.ts. + */ + +import { persist } from './persist.js'; +import { tickets } from './state.js'; + +/** + * Prepise ID resitelu podle mapy stare -> nove. Vraci pocet zmenenych ticketu. + * + * Jen pro migraci (data/migratePeople.ts): resitel byval vlastni zaznam + * `ppl_...`, dnes je to ID uctu. Meni se jen odkazy, `updatedAt` ani hooky + * se nespousteji - ticket se vecne nezmenil, jen se preznacil. + */ +export function remapPersonIds(map: Map): number { + let changed = 0; + for (const ticket of tickets) { + let touched = false; + if (ticket.assigneeId && map.has(ticket.assigneeId)) { + ticket.assigneeId = map.get(ticket.assigneeId)!; + touched = true; + } + if (ticket.resolvedById && map.has(ticket.resolvedById)) { + ticket.resolvedById = map.get(ticket.resolvedById)!; + touched = true; + } + if (touched) { + changed += 1; + persist(ticket); + } + } + return changed; +} diff --git a/src/data/tickets/seed.ts b/src/data/tickets/seed.ts new file mode 100644 index 0000000..85ba6d6 --- /dev/null +++ b/src/data/tickets/seed.ts @@ -0,0 +1,433 @@ +/** + * Ukazkove tickety. + * + * Nasypou se jen se `SEED_DEMO=1`, o tom rozhoduje `index.ts`. Tady je jen + * sada samotna a zapis do pameti bez uloziste - uloziste se plni az pri + * `initTickets`, kdyz je prazdne. + */ + +import { minutesAgo } from '../store/index.js'; +import type { StoredTicket } from './model.js'; +import { index, tickets, traces } from './state.js'; +import { flattenTrace, type TraceInput } from './trace.js'; + +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`**, 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. + */ +export function seedDemoTickets(): void { + seed( + { + id: 'TK-4821', + tenantId: 'tnt_automia', + subject: 'Voicebot neodpovídá na volání po 18:00', + body: + 'Dobrý den, po šesté hodině to nikdo nebere. Zkoušeli jsme to včera i dnes, ' + + 'linka jen vyzvání a pak to spadne. Přes den to funguje normálně.', + sourceRef: 'cl_88213', + channel: 'voice', + customer: { + id: 'crm_1042', + company: 'Firma s.r.o.', + contact: 'Petra Klientová', + reply: '+420 601 118 224', + }, + status: 'V řešení', + priority: 'high', + assigneeId: 'usr_novakova', + automationId: 'AUT-02', + createdAt: minutesAgo(310), + updatedAt: minutesAgo(42), + }, + [ + { + kind: 'trigger', + serviceId: 'voicebot', + operationId: 'call-received', + label: 'Příchozí hovor na linku 800 100 200', + status: 'ok', + response: '{ "callId": "cl_88213", "from": "+420601118224", "durationSec": 96 }', + durationMs: 120, + agoMinutes: 310, + }, + { + kind: 'action', + serviceId: 'transcription', + operationId: 'transcribe', + label: 'Přepis nahrávky', + status: 'ok', + response: + '{ "language": "cs", "confidence": 0.94, "text": "Dobrý den, po šesté hodině to nikdo nebere..." }', + durationMs: 4_180, + agoMinutes: 309, + }, + { + kind: 'action', + serviceId: 'openai', + operationId: 'chat', + label: 'Zařazení do kategorie', + status: 'ok', + response: '{ "category": "porucha", "priority": "high", "confidence": 0.88 }', + durationMs: 910, + agoMinutes: 309, + }, + { + kind: 'action', + serviceId: 'raynet', + operationId: 'upsert-contact', + label: 'Dohledání firmy podle telefonu', + status: 'ok', + response: '{ "companyId": "crm_1042", "name": "Firma s.r.o.", "matchedBy": "phone" }', + durationMs: 640, + agoMinutes: 309, + }, + { + kind: 'condition', + label: 'knownCustomer je splněno', + status: 'ok', + response: 'true, pokračuje větev ANO', + agoMinutes: 309, + children: [ + { + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + label: 'Založení ticketu', + status: 'ok', + response: '{ "ticketId": "TK-4821", "priority": "high" }', + durationMs: 85, + agoMinutes: 309, + }, + { + kind: 'action', + serviceId: 'ticket', + operationId: 'assign', + label: 'Přiřazení řešitele podle služby', + status: 'ok', + response: '{ "assignee": "Eva Nováková", "rule": "voicebot -> voiceboti" }', + durationMs: 40, + agoMinutes: 309, + }, + ], + }, + { + kind: 'action', + serviceId: 'microsoft365', + operationId: 'post-teams', + label: 'Upozornění do Teams', + status: 'error', + response: 'HTTP 429 Too Many Requests, kanál "Servicedesk" překročil limit, zpráva neodešla', + durationMs: 2_400, + agoMinutes: 308, + }, + { + kind: 'note', + label: 'Eva Nováková: Reprodukováno, chyba je v nočním režimu scénáře.', + status: 'info', + agoMinutes: 42, + }, + ], + ); + + seed( + { + id: 'TK-4820', + tenantId: 'tnt_automia', + subject: 'Přidat pole IČO do synchronizace CRM a fakturace', + body: + 'Zdravím, potřebovali bychom, aby se při synchronizaci přenášelo i IČO. ' + + 'Teď ho musíme do faktur doplňovat ručně a občas se na to zapomene. ' + + 'Kolik by to bylo práce?', + sourceRef: '<9f21c4@nordis.cz>', + channel: 'email', + customer: { + id: 'crm_2210', + company: 'Nordis a.s.', + contact: 'Tomáš Beran', + reply: 'tomas.beran@nordis.cz', + }, + status: 'Čeká na klienta', + priority: 'normal', + assigneeId: 'usr_3', + automationId: 'AUT-03', + createdAt: minutesAgo(1_180), + updatedAt: minutesAgo(190), + }, + [ + { + kind: 'trigger', + serviceId: 'email', + operationId: 'received', + label: 'Přijat e-mail do schránky podpora@', + status: 'ok', + response: '{ "from": "tomas.beran@nordis.cz", "subject": "IČO v synchronizaci", "attachments": 0 }', + durationMs: 60, + agoMinutes: 1_180, + }, + { + kind: 'action', + serviceId: 'openai', + operationId: 'chat', + label: 'Vytažení údajů z textu', + status: 'ok', + response: '{ "type": "pozadavek na zmenu", "system": "CRM + iDoklad", "urgent": false }', + durationMs: 1_120, + agoMinutes: 1_180, + }, + { + kind: 'action', + serviceId: 'raynet', + operationId: 'upsert-contact', + label: 'Dohledání firmy podle e-mailu', + status: 'ok', + response: '{ "companyId": "crm_2210", "name": "Nordis a.s.", "matchedBy": "emailDomain" }', + durationMs: 520, + agoMinutes: 1_180, + }, + { + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + label: 'Založení ticketu', + status: 'ok', + response: '{ "ticketId": "TK-4820", "priority": "normal" }', + durationMs: 74, + agoMinutes: 1_179, + }, + { + kind: 'action', + serviceId: 'email', + operationId: 'send', + label: 'Potvrzení zadavateli', + status: 'ok', + response: '{ "messageId": "", "to": "tomas.beran@nordis.cz" }', + durationMs: 380, + agoMinutes: 1_179, + }, + { + kind: 'note', + label: 'Martin Kříž: Čekáme na potvrzení rozsahu od zákazníka.', + status: 'info', + agoMinutes: 190, + }, + ], + ); + + seed( + { + id: 'TK-4819', + tenantId: 'tnt_automia', + subject: 'Chybí denní report objednávek v e-mailu', + body: 'Dobrý den, už třetí den nechodí ranní report. Můžete se na to prosím podívat?', + sourceRef: 'wamid.HBgLNDIwNzc0OTAyMzMx', + channel: 'whatsapp', + customer: { + id: null, + company: 'Neznámá firma', + contact: 'Bistro Kolektiv', + reply: '+420 774 902 331', + }, + status: 'Nový', + priority: 'normal', + assigneeId: null, + automationId: 'AUT-01', + createdAt: minutesAgo(95), + updatedAt: minutesAgo(95), + }, + [ + { + kind: 'trigger', + serviceId: 'whatsapp', + operationId: 'message-received', + label: 'Přijata zpráva z WhatsApp', + status: 'ok', + response: + '{ "from": "+420774902331", "profileName": "Bistro Kolektiv", "text": "Dobrý den, už třetí den nechodí ranní report." }', + durationMs: 55, + agoMinutes: 95, + }, + { + kind: 'action', + serviceId: 'openai', + operationId: 'chat', + label: 'Zařazení do kategorie', + status: 'ok', + response: '{ "category": "vypadek reportu", "priority": "normal", "confidence": 0.79 }', + durationMs: 870, + agoMinutes: 95, + }, + { + kind: 'action', + serviceId: 'raynet', + operationId: 'upsert-contact', + label: 'Dohledání firmy podle telefonu', + status: 'error', + response: '{ "matches": 0, "searchedBy": "phone", "value": "+420774902331" }', + durationMs: 610, + agoMinutes: 95, + }, + { + kind: 'condition', + label: 'knownCustomer není splněno', + status: 'skipped', + response: 'false, pokračuje větev NE', + agoMinutes: 95, + children: [ + { + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + label: 'Založení ticketu bez napojení na firmu', + status: 'ok', + response: '{ "ticketId": "TK-4819", "customerId": null }', + durationMs: 68, + agoMinutes: 95, + }, + { + kind: 'action', + serviceId: 'raynet', + operationId: 'create-lead', + label: 'Založení obchodního případu k dohledání', + status: 'ok', + response: '{ "leadId": "lead_7781", "stage": "k overeni" }', + durationMs: 940, + agoMinutes: 95, + }, + { + kind: 'action', + serviceId: 'ticket', + operationId: 'assign', + label: 'Přiřazení řešitele', + status: 'skipped', + response: 'Přeskočeno, bez známé firmy nelze určit garanta a ticket zůstal nepřiřazený', + agoMinutes: 95, + }, + ], + }, + ], + ); + + 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: 'usr_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: 'usr_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, + }, + ], + ); +} + diff --git a/src/data/tickets/state.ts b/src/data/tickets/state.ts new file mode 100644 index 0000000..9dd7e43 --- /dev/null +++ b/src/data/tickets/state.ts @@ -0,0 +1,70 @@ +/** + * Pamet ticketu: pole, indexy, log, udalosti a citace. + * + * Vsechno, co ostatni moduly slozky sdileji. Zadna logika, jen data a to + * nejnutnejsi kolem nich - indexy a pridelovani dalsich ID. + */ + +import { highestNumber } from '../store/index.js'; +import type { TicketEvent, TicketTraceEntry } from '../../shared/tickets.js'; +import type { StoredTicket } from './model.js'; + +export 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. + */ +export const ticketsById = new Map(); +export const ticketsByExternal = new Map(); +/** Log ticketu drzime zvlast - je to jina zivotnost i jiny objem dat. */ +export const traces = new Map(); + +/** Klic externiho ID: unikatni je v ramci firmy, ne globalne. */ +export function externalKey(tenantId: string, externalId: string): string { + return `${tenantId}:${externalId}`; +} + +/** Zaradi ticket do indexu. Vola se vsude, kde ticket pribyva do pole. */ +export function index(ticket: StoredTicket): void { + ticketsById.set(ticket.id, ticket); + if (ticket.externalId) ticketsByExternal.set(externalKey(ticket.tenantId, ticket.externalId), ticket); +} + +/** Udalosti drzime u ticketu stejne jako log - jina zivotnost, stejny zaznam. */ +export const events = new Map(); + +// -------------------------------------------------------------------- citace + +/** Prvni cislo ticketu. Ukazkova sada konci na TK-4821, nove tickety pokracuji za ni. */ +const FIRST_TICKET_NUMBER = 4_821; + +let ticketCounter = FIRST_TICKET_NUMBER; +let traceCounter = 0; +let eventCounter = 0; + +export function nextTicketId(): string { + ticketCounter += 1; + return `TK-${ticketCounter}`; +} + +export function nextTraceId(): string { + traceCounter += 1; + return `tr_${traceCounter}`; +} + +export function nextEventId(): string { + eventCounter += 1; + return `tev_${eventCounter.toString(36)}`; +} + +/** + * Po nacteni z uloziste. Citac musi pokracovat za nejvyssim ulozenym cislem, + * jinak by nove tickety prepisovaly stare. + */ +export function continueCounters(): void { + 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); +} diff --git a/src/data/tickets/stats.ts b/src/data/tickets/stats.ts new file mode 100644 index 0000000..3bfdd2d --- /dev/null +++ b/src/data/tickets/stats.ts @@ -0,0 +1,152 @@ +/** + * Statistiky nad tickety: vytizeni tymu a vykon resitelu. + * + * Pocita se vzdy pres `listTickets`, aby strop viditelnosti platil i tady. + */ + +import type { Visibility } from '../access.js'; +import type { Person } from '../people.js'; +import type { AgentStatsRow, Ticket, Workload } from '../../shared/tickets.js'; +import { listTickets } from './queries.js'; + +export function getWorkload( + everyone: Person[], + tenantIds: string[], + visibility: Visibility, +): Workload { + // Pres listTickets, aby strop platil i tady. Driv se sahalo primo do pole + // 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 = byAssignee.get(person.id) ?? []; + const open = mine.filter((t) => !t.closed); + + return { + person, + open: open.length, + total: mine.length, + critical: open.filter((t) => t.priority === 'critical').length, + oldestOpenAt: oldestCreatedAt(open), + overloaded: open.length > person.capacity, + }; + }); + + // Nejvytizenejsi nahoru - prehled ma odpovedet na "kdo toho ma nejvic". + rows.sort((a, b) => b.open - a.open || a.person.name.localeCompare(b.person.name, 'cs')); + + return { + rows, + unassigned: visible.filter((t) => t.assignee === null && !t.closed).length, + openTotal: visible.filter((t) => !t.closed).length, + }; +} + +/** 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, + ); +} + +/** Median, ne prumer: jeden ticket zapomenuty pres dovolenou jinak prebije vsechno. */ +function median(values: number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + const upper = sorted[middle]; + const lower = sorted[middle - 1]; + // Seznam neni prazdny, stredni prvek tedy existuje. Kontrola je jen kvuli typum. + if (upper === undefined) return null; + if (sorted.length % 2 === 1 || lower === undefined) return upper; + return Math.round((lower + upper) / 2); +} + +/** + * Vykon resitelu za obdobi. + * + * `since` je hranice pro **vyresene** tickety, tedy "kolik toho odbavil za + * poslednich 30 dni". Nevyrizene se pocitaji vzdy vsechny - fronta neni + * vec obdobi, lezi tam bez ohledu na to, na co se zrovna divame. + */ +export function getAgentStats( + everyone: Person[], + tenantIds: string[], + since: number | null, + visibility: Visibility, +): 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 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 = mine + .filter((t) => at(t.firstResponseAt) !== null) + .map((t) => (at(t.firstResponseAt) ?? 0) - (at(t.createdAt) ?? 0)) + .filter((value) => value >= 0); + + const oldest = oldestCreatedAt(open); + + return { + personId: person.id, + name: person.name, + resolved: resolved.length, + open: open.length, + critical: open.filter((t) => t.priority === 'critical').length, + medianResolveMs: median(resolveTimes), + medianResponseMs: median(responseTimes), + reopened: resolved.reduce((sum, t) => sum + (t.reopenCount ?? 0), 0), + oldestOpenAt: oldest, + overloaded: open.length > person.capacity, + }; + }); + + // Nejvic odbavenych nahoru. Prehled ma odpovedet na "kdo toho udelal nejvic". + rows.sort((a, b) => b.resolved - a.resolved || a.name.localeCompare(b.name, 'cs')); + return rows; +} + diff --git a/src/data/tickets/store.ts b/src/data/tickets/store.ts new file mode 100644 index 0000000..b211815 --- /dev/null +++ b/src/data/tickets/store.ts @@ -0,0 +1,405 @@ +/** + * Zapisy do ticketu: zalozeni, stav, resitel, typ, tagy, skupina, komentar. + * + * Kazda zmena jde pres `touch`, aby neslo zapomenout na `updatedAt` a zapis. + * Zmeny posilaji udalost na sbernici, takze se projevi v dashboardu okamzite + * bez obnoveni stranky. + */ + +import { publish } from '../../events/bus.js'; +import { notify } from '../notifications.js'; +import { findPerson } from '../people.js'; +import { writableOrWarn } from '../store/index.js'; +import { onTicketChanged } from '../ticketHooks.js'; +import type { + Ticket, + TicketChannel, + TicketCustomer, + TicketPriority, + TicketStatus, +} from '../../shared/tickets.js'; +import { defaultStatuses, toTicket, type StoredTicket } from './model.js'; +import { persist, touch } from './persist.js'; +import { events, index, nextTicketId, tickets, ticketsById, traces } from './state.js'; +import { appendTrace, flattenTrace, type TraceInput } from './trace.js'; + +/** + * Prvni reakce se zapisuje jednou a uz se neprepisuje. + * + * Je to cas, kdy zakaznik prestal cekat. Kdyby se prepisoval pri kazde zmene, + * merilo by to posledni dotek, coz je uplne jina velicina. + */ +function markResponded(ticket: StoredTicket): void { + if (!ticket.firstResponseAt) ticket.firstResponseAt = new Date().toISOString(); +} + +export interface CreateTicketInput { + tenantId: string; + /** ID u odesilatele. Unikatni za firmu, viz `Ticket.externalId`. */ + externalId?: string | null; + externalSource?: string | null; + subject: string; + body?: string; + sourceRef?: string | null; + channel: TicketChannel; + /** + * Zakaznik je **nepovinny**. U pozadavku z helpdesku dava smysl vedet, kdo + * ho poslal, u ticketu zalozeneho rucne casto nikdo takovy neni - je to + * ukol, ne pozadavek od nekoho zvenku. + */ + customer?: TicketCustomer; + /** Firma, ktera pozadavek poslala pres helpdesk. Vlastnikem je ta, ktera resi. */ + helpdeskSourceId?: string | null; + priority: TicketPriority; + /** Vychozi stav, kdyz se nezada. Volny retezec, ne ciselnik. */ + status?: string; + /** Je uz vyrizeny? Vychozi ne. */ + closed?: boolean; + assigneeId?: string | null; + assigneeGroupId?: string | null; + typeId?: string | null; + fields?: Record; + tags?: string[]; + automationId?: string | null; + /** Ucet, ktery ho zaklada rucne. null u automatizace a prijmu zvenku. */ + createdById?: string | null; + /** Log toho, jak ticket vznikl. Bez nej je ticket nedohledatelny. */ + trace?: TraceInput[]; +} + +export function createTicket(input: CreateTicketInput): Ticket { + const now = new Date().toISOString(); + + // Neexistujiciho resitele radeji zahodime, nez abychom ulozili mrtvy odkaz. + let assigneeId = input.assigneeId ?? null; + if (assigneeId && !findPerson(assigneeId, input.tenantId)) { + console.warn(`[tickets] neznamy resitel ${assigneeId}, ticket zustava neprirazeny`); + assigneeId = null; + } + + const stored: StoredTicket = { + id: nextTicketId(), + tenantId: input.tenantId, + externalId: input.externalId ?? null, + externalSource: input.externalSource ?? null, + firstResponseAt: null, + resolvedAt: null, + resolvedById: null, + reopenCount: 0, + subject: input.subject, + body: input.body ?? '', + sourceRef: input.sourceRef ?? null, + channel: input.channel, + customer: input.customer ?? { id: null, company: '', contact: '', reply: '' }, + helpdeskSourceId: input.helpdeskSourceId ?? null, + // Vychozi stav je jen doporuceni. Kdo posle vlastni, ma vlastni. + status: input.status ?? defaultStatuses[0], + priority: input.priority, + assigneeId, + // Tyhle ctyri se driv zahazovaly: vstup je nabizel, ale zaznam je nemel. + // Ticket zalozeny s typem tak zustaval bez typu a bez vlastnich poli. + assigneeGroupId: input.assigneeGroupId ?? null, + typeId: input.typeId ?? null, + fields: input.fields ?? {}, + tags: input.tags ?? [], + closed: input.closed ?? false, + automationId: input.automationId ?? null, + createdById: input.createdById ?? null, + createdAt: now, + updatedAt: now, + }; + tickets.unshift(stored); + index(stored); + traces.set(stored.id, flattenTrace(input.trace ?? [], null, [])); + events.set(stored.id, []); + persist(stored); + + onTicketChanged('ticket.created', toTicket(stored)); + + publish('ticket.created', `Nový ticket ${stored.id}: ${stored.subject}`, { + ticketId: stored.id, + 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 { + return writableOrWarn(ticketsById.get(id), id, tenantIds, 'tickets'); +} + +/** + * Zmeni stav, pripadne i priznak vyrizeni. + * + * Stav je **volny retezec** a neoveruje se proti nicemu. Jestli je ticket + * vyrizeny, rika `closed` - vyslovne, ne odvozene ze jmena stavu. Kdyz se + * nepreda, priznak zustava, jak byl: zmena textu stavu sama o sobe neznamena, + * ze je hotovo. + */ +export function updateTicketStatus( + id: string, + status: TicketStatus, + tenantIds: string[], + closed?: boolean, +): Ticket | undefined { + const ticket = findWritable(id, tenantIds); + if (!ticket) { + console.warn(`[tickets] zmena stavu nedostupneho ticketu: ${id}`); + return undefined; + } + + const previous = ticket.status; + const wasClosed = ticket.closed ?? false; + ticket.status = status; + if (closed !== undefined) ticket.closed = closed; + + if (ticket.closed && !wasClosed) { + ticket.resolvedAt = new Date().toISOString(); + // Vyresil ten, kdo ho mel u sebe. Kdyz nikdo, zustane to nekomu nepripsane - + // radeji nez pripsat vyreseni cloveku, ktery s tim nic nemel. + ticket.resolvedById = ticket.assigneeId; + } else if (!ticket.closed && wasClosed) { + // Navrat z vyreseno je nejlepsi ukazatel toho, ze hotovo nebylo. + ticket.reopenCount = (ticket.reopenCount ?? 0) + 1; + ticket.resolvedAt = null; + ticket.resolvedById = null; + } + + markResponded(ticket); + touch(ticket); + + /* + * Poznamka jen kdyz se neco zmenilo. "Stav zmenen z completed na completed" + * je rada, ktera se ctenari pise do historie u kazde prichozi zpravy, a po + * dvaceti takovych se v logu neda nic najit. + */ + const statusChanged = previous !== status; + const closedChanged = closed !== undefined && closed !== wasClosed; + + if (statusChanged || closedChanged) { + const parts: string[] = []; + if (statusChanged) parts.push(`stav ${previous} -> ${status}`); + if (closedChanged) parts.push(ticket.closed ? 'oznaceno jako vyrizene' : 'znovu otevreno'); + appendTrace(id, [{ kind: 'note', label: parts.join(', '), status: 'info' }]); + } + + 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); +} + +/** Prirazeni resitele. `null` ticket vrati zpatky do fronty. */ +export function assignTicket( + id: string, + assigneeId: string | null, + tenantIds: string[], +): Ticket | undefined { + const ticket = findWritable(id, tenantIds); + if (!ticket) { + console.warn(`[tickets] prirazeni nedostupneho ticketu: ${id}`); + return undefined; + } + + // Resitel musi byt clenem firmy ticketu. Jinak by ticket zmizel z prehledu + // firmy a objevil se nekomu, kdo do ni nepatri. Necleny `findPerson` nevrati. + const person = assigneeId ? findPerson(assigneeId, ticket.tenantId) : null; + if (assigneeId && !person) { + console.warn(`[tickets] ${id}: prirazeni na ${assigneeId}, ktery neni clenem firmy`); + return undefined; + } + + const previousAssignee = ticket.assigneeId; + ticket.assigneeId = person?.id ?? null; + if (person) markResponded(ticket); + touch(ticket); + + // Komu ticket prisel, ten se to musi dozvedet. Znovu prirazeni tomu samemu + // cloveku upozorneni negeneruje, jinak by mu chodilo pri kazde drobnosti. + if (person && person.id !== previousAssignee) { + notify({ + tenantId: ticket.tenantId, + userId: person.id, + kind: 'ticket.assigned', + title: `Máte nový ticket ${ticket.id}: ${ticket.subject}`, + href: `/dashboard/tickety/${ticket.id}`, + ticketId: ticket.id, + }); + } + + appendTrace(id, [ + { + kind: 'note', + label: person ? `Ticket přiřazen: ${person.name}.` : 'Ticket vrácen do fronty.', + status: 'info', + }, + ]); + + publish( + 'ticket.assigned', + person + ? `Ticket ${ticket.id} přiřazen: ${person.name}` + : `Ticket ${ticket.id} vrácen do fronty`, + { ticketId: ticket.id, assigneeId: ticket.assigneeId, ticket: toTicket(ticket) }, + ticket.tenantId, + ); + return toTicket(ticket); +} + +/** Komentar je jen dalsi radek logu - at je vsechno na jedne casove ose. */ +/** + * Zmena typu ticketu. + * + * Vlastni pole se **nezahazuji**, jen prestanou byt videt. Kdyby se mazala, + * omylem prepnuty typ by znamenal ztratu dat bez cesty zpatky. + */ +export function setTicketType( + id: string, + typeId: string | null, + fields: Record | undefined, + tenantIds: string[], +): Ticket | undefined { + const ticket = findWritable(id, tenantIds); + if (!ticket) return undefined; + + ticket.typeId = typeId; + if (fields) ticket.fields = { ...(ticket.fields ?? {}), ...fields }; + touch(ticket); + + 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, ticket: toTicket(ticket) }, ticket.tenantId); + return toTicket(ticket); +} + +/** Tagy se prepisuji cele. Prirustkova zmena by u vic lidi naraz kolidovala. */ +export function setTicketTags(id: string, tags: string[], tenantIds: string[]): Ticket | undefined { + const ticket = findWritable(id, tenantIds); + if (!ticket) return undefined; + + 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, ticket: toTicket(ticket) }, ticket.tenantId); + return toTicket(ticket); +} + +/** + * Prehozeni na skupinu. + * + * Prirazeni cloveku se **zrusi**: kdyby zustalo, ticket by byl ve fronte skupiny + * i u konkretniho cloveka a nikdo by nevedel, kdo to ma resit. + */ +export function assignTicketGroup( + id: string, + groupId: string | null, + tenantIds: string[], + /** false = nechat resitele, jak je. Pouziva to prevzeti ticketu. */ + clearAssignee = true, +): Ticket | undefined { + const ticket = findWritable(id, tenantIds); + if (!ticket) return undefined; + + ticket.assigneeGroupId = groupId; + if (groupId && clearAssignee) ticket.assigneeId = null; + touch(ticket); + + appendTrace(id, [ + { + kind: 'note', + status: 'info', + label: groupId ? `Přehozeno na skupinu ${groupId}` : 'Odebráno ze skupiny', + }, + ]); + publish('ticket.assigned', `Ticket ${ticket.id} přehozen na skupinu`, { ticketId: ticket.id, ticket: toTicket(ticket) }, ticket.tenantId); + return toTicket(ticket); +} + +/** + * Prevzeti ticketu. + * + * Clovek si vezme praci sam, misto aby cekal, az mu ji nekdo prideli. Smi to + * jen u ticketu **ze sve skupiny**, nebo u takoveho, ktery nema nikoho - + * brat cizi rozdelanou praci by znamenalo, ze o ni prijde ten, kdo ji resi. + * + * Vraci `undefined`, kdyz to nejde; duvod rekne volajici, ktery zna kontext. + */ +export function claimTicket( + id: string, + personId: string, + tenantIds: string[], +): Ticket | undefined { + const ticket = findWritable(id, tenantIds); + if (!ticket) return undefined; + + // Necleny firmy `findPerson` nevrati, takze tohle je i kontrola firmy. + const person = findPerson(personId, ticket.tenantId); + if (!person) return undefined; + + ticket.assigneeId = person.id; + markResponded(ticket); + touch(ticket); + + appendTrace(id, [ + { kind: 'note', status: 'info', label: `${person.name} si ticket převzal` }, + ]); + 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 = ticketsById.get(id); + if (!ticket || !tenantIds.includes(ticket.tenantId)) return undefined; + return ticket.assigneeId; +} + +/** + * Komentar k ticketu. + * + * `helpdeskSourceIds` pusti ke slovu i zadavatele z helpdesku. Je to jedina + * zmena, kterou nad cizim ticketem smi - doplnit, co zapomnel napsat, je presne + * to, kvuli cemu se pozadavek otevira. Prehazovat resitele nebo menit stav uz + * ne, na to se ho nikdo neptal. + */ +export function addComment( + id: string, + author: string, + text: string, + tenantIds: string[], + helpdeskSourceIds: string[] = [], +): Ticket | undefined { + const found = ticketsById.get(id); + const asked = + found?.helpdeskSourceId !== null && + found?.helpdeskSourceId !== undefined && + helpdeskSourceIds.includes(found.helpdeskSourceId); + + const ticket = asked ? found : findWritable(id, tenantIds); + if (!ticket) { + console.warn(`[tickets] komentar k nedostupnemu ticketu: ${id}`); + return undefined; + } + + markResponded(ticket); + touch(ticket); + appendTrace(id, [{ kind: 'note', label: `${author}: ${text}`, status: 'info' }]); + + 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/tickets/trace.ts b/src/data/tickets/trace.ts new file mode 100644 index 0000000..f14073e --- /dev/null +++ b/src/data/tickets/trace.ts @@ -0,0 +1,95 @@ +/** + * Log prubehu ticketu. + * + * Ticket si nese strom zaznamu o tom, co se s nim delo a co ktera sluzba + * vratila. Zapisuje se zanorene, uklada se zplostele s `parentId`. + */ + +import { currentRun } from '../../runtime/context.js'; +import { minutesAgo } from '../store/index.js'; +import type { TicketTraceEntry, TraceKind, TraceStatus } from '../../shared/tickets.js'; +import { persist } from './persist.js'; +import { nextTraceId, ticketsById, traces } from './state.js'; + +/** Zaznam v logu tak, jak se zapisuje - strom je zanoreny, ulozeni ho zplosti. */ +export interface TraceInput { + kind: TraceKind; + label: string; + status: TraceStatus; + serviceId?: string | null; + operationId?: string | null; + response?: string | null; + durationMs?: number | null; + /** Posun proti "ted" v minutach. Pouziva jen ukazkova data. */ + agoMinutes?: number; + children?: TraceInput[]; +} + +/** + * Zplosti zanoreny zapis do seznamu s `parentId`. + * Poradi se zachovava, aby se strom dal vykreslit jednim pruchodem. + */ +export function flattenTrace( + inputs: TraceInput[], + parentId: string | null, + into: TicketTraceEntry[], +): TicketTraceEntry[] { + for (const input of inputs) { + const entry: TicketTraceEntry = { + id: nextTraceId(), + parentId, + kind: input.kind, + serviceId: input.serviceId ?? null, + operationId: input.operationId ?? null, + label: input.label, + status: input.status, + response: input.response ?? null, + durationMs: input.durationMs ?? null, + at: minutesAgo(input.agoMinutes ?? 0), + }; + into.push(entry); + if (input.children && input.children.length > 0) { + flattenTrace(input.children, entry.id, into); + } + } + return into; +} + +/** Prida zaznamy do logu ticketu. Vraci, kolik radku pribylo. */ +export function appendTrace(ticketId: string, inputs: TraceInput[]): number { + const existing = traces.get(ticketId); + if (!existing) { + console.warn(`[tickets] zapis do logu neexistujiciho ticketu: ${ticketId}`); + return 0; + } + const before = existing.length; + /* + * Kdyz zapis patri behu automatizace, radi se pod jeho radek udalosti. + * Diky tomu je v logu videt "prislo tohle -> zmenilo to tohle" misto dvou + * vet vedle sebe, u kterych se jen hada, jestli spolu souvisi. + */ + flattenTrace(inputs, currentRun()?.traceParent.id ?? null, existing); + + // Log je soucast ulozeneho ticketu, takze zapis do logu je zmena ticketu. + const ticket = ticketsById.get(ticketId); + if (ticket) persist(ticket); + + return existing.length - before; +} + +/** ID posledniho radku logu. Pod nej se radi zmeny, ktere z udalosti plynou. */ +export function lastTraceId(ticketId: string): string | null { + return traces.get(ticketId)?.at(-1)?.id ?? null; +} + +/** Kolik znaku dat se ukaze v logu. Cela data zustavaji v udalosti. */ +const MAX_PAYLOAD_PREVIEW_CHARS = 2_000; + +/** Kratky popis dat do logu. Cela data zustavaji v udalosti. */ +export function describePayload(payload: Record): string | null { + const keys = Object.keys(payload); + if (keys.length === 0) return null; + const text = JSON.stringify(payload); + return text.length > MAX_PAYLOAD_PREVIEW_CHARS ? `${text.slice(0, MAX_PAYLOAD_PREVIEW_CHARS)}...` : text; +} + diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 365e662..9f2de6c 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -99,7 +99,7 @@ export async function runMigrations(): Promise { await client.query('ROLLBACK').catch(() => undefined); // Rozbita migrace nesmi projit potichu. Bez schematu nema smysl bezet. const message = err instanceof Error ? err.message : String(err); - throw new Error(`Migrace ${file} selhala: ${message}`); + throw new Error(`Migrace ${file} selhala: ${message}`, { cause: err }); } applied.push(file); diff --git a/src/index.ts b/src/index.ts index 567f0bd..cdae752 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,273 +1,22 @@ -import cors from 'cors'; -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'; -import swaggerUi from 'swagger-ui-express'; +/** + * Start serveru: nacteni dat, worker, `listen` a ukonceni. Nic jineho. + * Aplikace sama se sklada v `app.ts`, aby sla postavit i v testu. + */ + +import { createApp } from './app.js'; import { config } from './config.js'; -import { buildOpenApiDocument } from './openapi.js'; -import { authRouter } from './routes/auth.js'; -import { contactRouter } from './routes/contact.js'; -import { dashboardRouter } from './routes/dashboard.js'; -import { publicInviteRouter } from './routes/invites.js'; -import { webhookRouter } from './routes/webhook.js'; 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'; -import { startWorker, stopWorker } from './runtime/worker.js'; +import { closeDatabase, isDatabaseEnabled } from './db/pool.js'; +import { ensureLoaded, scriptsDir } from './runtime/scripts/registry.js'; import { startScheduler, stopScheduler } from './runtime/triggers.js'; +import { startWorker, stopWorker } from './runtime/worker.js'; -const here = path.dirname(fileURLToPath(import.meta.url)); -/** Zbuildovana SPA. Vite ji zapisuje do dist/public, viz vite.config.ts. */ -const webRoot = path.join(here, 'public'); - -const app = express(); -/* - * 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({ - origin(origin, callback) { - // Bez Origin (curl, server-to-server) i stejna domena projdou vzdy. - if (!origin || isOriginAllowed(origin)) return callback(null, true); - console.warn(`[cors] zablokovan origin: ${origin}`); - return callback(null, false); - }, - credentials: true, - }), -); -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} ${maskSecretsInUrl(req.originalUrl)}`); - next(); -}); - -function isOriginAllowed(origin: string): boolean { - if (config.corsOrigins.includes(origin)) return true; - // V dev rezimu si Vite pri obsazenem portu vezme jiny, proto cely localhost. - if (!config.isProduction && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) { - return true; - } - return false; -} - -// ---------------------------------------------------------------- API router - -/** - * 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 = safeRouter({ strict: true }); - -/** - * Liveness. Zamerne **nezavisi na databazi**: kratky vypadek DB by jinak vedl - * k restartovani containeru, coz nic nespravi (AGENTS.md). - */ -api.get('/health', (_req, res) => { - res.json({ status: 'ok', uptimeSec: Math.round(process.uptime()) }); -}); - -/** - * Readiness. Tady uz databaze zalezi, a proto je to zvlast. - * Vysledek se par sekund cachuje, aby monitoring nedelal dotaz pri kazdem pingu. - */ -api.get('/health/ready', async (_req, res) => { - const database = await databaseHealth(); - const storage = storageStatus(); - const ready = !database.enabled || database.ok; - - res.status(ready ? 200 : 503).json({ - status: ready ? 'ok' : 'degraded', - database, - storage, - }); -}); - -/** - * Jak nas vidi ten, kdo nam vola. - * - * Vraci volajicimu jeho vlastni adresu tak, jak dorazila k serveru. Zni to - * zbytecne, ale je to jediny zpusob, jak zmerit, **s jakou zdrojovou adresou - * doruci reverse proxy volani, ktere vyslo z naseho containeru**. Container - * sam to nevidi, echo sluzba na internetu odpovi verejnou adresu, jenze - * volani na vlastni domenu se otaci zpatky na tentyz stroj a proxy pak muze - * videt adresu docker bridge, ne tu verejnou. A prave to rozhoduje o tom, - * jestli nas seznam povolenych IP pusti. - * - * Bez prihlaseni zamerne: neprozradi to nic, co by volajici uz nevedel, - * dostane svoji vlastni adresu. Stejne jako kterakoliv echo sluzba. - */ -api.get('/whoami', (req, res) => { - res.json({ - // `req.ip` uz je po `trust proxy`, tedy hodnota z X-Forwarded-For. - ip: req.ip ?? null, - // Surove, aby bylo videt i to, co proxy pripsala nebo nepripsala. - forwardedFor: req.headers['x-forwarded-for'] ?? null, - remoteAddress: req.socket.remoteAddress ?? null, - }); -}); - -/** - * Swagger UI. Cesta bez lomitka presmerujeme na variantu s lomitkem, - * jinak by se relativni odkazy na CSS a JS skladaly o uroven vys - * a za reverse proxy by se nenacetly. - */ -const openApiDocument = buildOpenApiDocument(); -const swaggerOptions: swaggerUi.SwaggerUiOptions = { - customSiteTitle: `${config.brandName} API`, - swaggerOptions: { persistAuthorization: true }, -}; - -api.get('/docs', (_req, res) => res.redirect(`${config.rootPath}/docs/`)); -api.get('/openapi.json', (_req, res) => res.json(openApiDocument)); -api.use( - '/docs', - swaggerUi.serveFiles(openApiDocument, swaggerOptions), - swaggerUi.setup(openApiDocument, swaggerOptions), -); - -api.use('/api/auth', authRouter); -api.use('/api/dashboard', dashboardRouter); -// Verejne: kdo dostal odkaz na pozvanku, neni jeste prihlaseny. -api.use('/api/invites', publicInviteRouter); -api.use('/api/admin', adminRouter); -api.use('/api/contact', contactRouter); -api.use('/webhook', webhookRouter); - -// Mount na koren i na prefix proxy. Caddy prefix pres handle_path odstranuje, -// ale takhle aplikace funguje i kdyby ho nechal - a lokalne bez proxy taky. -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 (apiPathPattern.test(req.path)) { - console.warn(`[404] ${req.method} ${req.originalUrl}`); - return res.status(404).json({ error: 'not_found', message: 'Endpoint neexistuje.' }); - } - return next(); -}); - -// ---------------------------------------------------------------- SPA - -/** - * Do index.html se za behu vklada base pro prohlizec. - * - * Prohlizec vidi adresu /apps//..., ale Vite build ma relativni cesty. - * Bez by se soubory na vnorenych cestach hledaly ve spatne slozce. - * Prefix se bere z ROOT_PATH, nikdy neni v kodu natvrdo. - */ -function renderIndexHtml(): string { - const file = path.join(webRoot, 'index.html'); - const html = fs.readFileSync(file, 'utf8'); - const base = `${config.rootPath}/`; - const injected = - `\n` + - ` `; - return html.replace('', `\n ${injected}`); -} - -let cachedIndexHtml: string | null = null; -const hasWebBuild = fs.existsSync(path.join(webRoot, 'index.html')); - -if (hasWebBuild) { - const serveStatic = express.static(webRoot, { - index: false, - // Soubory maji hash v nazvu, muzou se cachovat dlouho. index.html ne. - setHeaders(res, filePath) { - if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache'); - else res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); - }, - }); - - app.use(serveStatic); - if (config.rootPath) app.use(config.rootPath, serveStatic); - - // Vsechny ostatni cesty obsluhuje SPA, routovani si resi React Router. - app.get('*', (_req, res) => { - if (!cachedIndexHtml) cachedIndexHtml = renderIndexHtml(); - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.setHeader('Cache-Control', 'no-cache'); - res.send(cachedIndexHtml); - }); -} else { - // Bez buildu webu nesmi aplikace tise vracet prazdno. - console.warn(`[start] build webu nenalezen v ${webRoot}, bezi jen API`); - app.get('/', (_req, res) => { - res.json({ - name: 'csbot-prototype', - status: 'ok', - note: 'Build webu chybi, dostupne je jen API a /docs.', - }); - }); -} - -// Centralni error handler - nic nesmi propadnout bez logu. -app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => { - /* - * Rozbite telo pozadavku neni nase chyba, je to spatne polozeny dotaz. - * - * `express.json` na nej vyhodi vyjimku, ta propadla sem a uzivatel videl - * "Interni chyba serveru" - hlasku, ktera rika, ze je neco spatne u nas, - * a poslala ho hledat na spatnou stranu. Stalo to jedno odpoledne. - */ - const status = (err as { status?: number } | null)?.status; - const type = (err as { type?: string } | null)?.type; - if (status === 400 && typeof type === 'string' && type.startsWith('entity.')) { - console.warn(`[error] ${req.method} ${req.path}: neplatne telo pozadavku (${type})`); - return res.status(400).json({ - error: 'bad_request', - message: 'Tělo požadavku není platný JSON objekt.', - }); - } - - console.error('[error]', err); - const message = err instanceof Error ? err.message : 'Neznama chyba.'; - res.status(500).json({ - error: 'internal_error', - message: config.isProduction ? 'Interni chyba serveru.' : message, - }); -}); +// Aplikace se sklada pred nactenim dat, stejne jako driv: routery na datech +// pri sestaveni nezavisi, ctou je az za requestu. +const app = createApp(); /** * Skripty konektoru se nactou jeste pred prijimanim provozu, protoze doplnuji @@ -324,7 +73,9 @@ const server = app.listen(config.port, '0.0.0.0', () => { console.info(`[start] health: ${config.rootPath}/health, docs: ${config.rootPath}/docs`); console.info(`[start] skripty konektoru: ${scriptsDir()}`); const storage = storageStatus(); - console.info(`[start] uloziste konektoru: ${storage.mode}${storage.location ? ` (${storage.location})` : ''}`); + console.info( + `[start] uloziste konektoru: ${storage.mode}${storage.location ? ` (${storage.location})` : ''}`, + ); }); // Pool se pri ukonceni zavre, at se spojeni neopousti otevrena. @@ -354,7 +105,10 @@ for (const signal of ['SIGTERM', 'SIGINT'] as const) { * ho nastartuje znovu. */ process.on('unhandledRejection', (reason: unknown) => { - console.error('[fatal] neosetreny odmitnuty promise:', reason instanceof Error ? reason.stack : reason); + console.error( + '[fatal] neosetreny odmitnuty promise:', + reason instanceof Error ? reason.stack : reason, + ); }); process.on('uncaughtException', (err: Error) => { diff --git a/src/mail/smtp.ts b/src/mail/smtp.ts index e44ffe3..947af8e 100644 --- a/src/mail/smtp.ts +++ b/src/mail/smtp.ts @@ -16,9 +16,9 @@ import nodemailer from 'nodemailer'; import { config } from '../config.js'; -import type { ResolvedTarget } from '../scripts/connections.js'; +import type { ResolvedTarget } from '../runtime/scripts/connections.js'; import { hostProblem } from '../net/guard.js'; -import { createRedactor, truncate } from '../scripts/util.js'; +import { createRedactor, truncate } from '../runtime/scripts/util.js'; export interface SmtpSettings { host: string; @@ -70,7 +70,9 @@ export function smtpTargetUrl(settings: SmtpSettings): string { * Vraci `null` a duvod, kdyz udaje nedavaji smysl. Padat na tom nemuzeme: * spatne vyplneny konektor je bezny stav, ne chyba aplikace. */ -export function smtpSettings(target: ResolvedTarget): { settings: SmtpSettings } | { error: string } { +export function smtpSettings( + target: ResolvedTarget, +): { settings: SmtpSettings } | { error: string } { const value = (key: string): string => (target.serviceConfig[key] ?? '').trim(); const host = value('host'); @@ -159,7 +161,12 @@ function failure(err: unknown, settings: SmtpSettings, what: string): MailResult // Redaguje se heslo, ne uzivatel. Uzivatel je adresa schranky a prave ta // v hlasce pomaha - skrtnout ji by z vety udelalo hadanku. const redact = createRedactor([settings.password]); - const error = err as { code?: string; responseCode?: number; response?: string; message?: string }; + const error = err as { + code?: string; + responseCode?: number; + response?: string; + message?: string; + }; const code = error?.code ?? ''; const status = typeof error?.responseCode === 'number' ? error.responseCode : null; @@ -265,15 +272,13 @@ export async function sendMail(target: ResolvedTarget, message: MailMessage): Pr try { const info = await transport.sendMail({ - from: settings.fromName - ? { name: settings.fromName, address: settings.from } - : settings.from, + from: settings.fromName ? { name: settings.fromName, address: settings.from } : settings.from, to: message.to, ...(message.cc ? { cc: message.cc } : {}), ...(message.bcc ? { bcc: message.bcc } : {}), // Krok smi adresu pro odpovedi prebit: odpoved casto ma zamirit do // ticketu, ne do schranky, ze ktere se odeslalo. - ...(message.replyTo ?? settings.replyTo + ...((message.replyTo ?? settings.replyTo) ? { replyTo: message.replyTo ?? settings.replyTo ?? undefined } : {}), subject: message.subject, diff --git a/src/mcp/auth.ts b/src/mcp/auth.ts index ebee66c..0d1f706 100644 --- a/src/mcp/auth.ts +++ b/src/mcp/auth.ts @@ -23,8 +23,8 @@ import { config } from '../config.js'; import { describeFetchError, readJsonLimited } from '../net/guard.js'; -import type { ResolvedTarget } from '../scripts/connections.js'; -import { jwtExpiry, pickText, truncate } from '../scripts/util.js'; +import type { ResolvedTarget } from '../runtime/scripts/connections.js'; +import { jwtExpiry, pickText, truncate } from '../runtime/scripts/util.js'; import { dialectFor, normalizeServerUrl } from './dialect.js'; import { ensureAccess, forgetSession, type EasyWebCredentials } from './easyweb/session.js'; import { AuthFailure } from './errors.js'; @@ -54,10 +54,7 @@ const CLIENT_INFO = 'worknuke/1.0'; /** Jak se portal prihlasil. Jde to do hlasky u konektoru. */ export type AuthMethod = - | 'bez přihlášení' - | 'vyplněný token' - | 'OAuth jako aplikace' - | 'klíč zařízení, EasyWeb'; + 'bez přihlášení' | 'vyplněný token' | 'OAuth jako aplikace' | 'klíč zařízení, EasyWeb'; export interface Authorization { headers: Record; @@ -130,7 +127,9 @@ function credentialsOf(target: ResolvedTarget): Credentials { * a sezeni jedne nesmi obslouzit volani druhe. */ function cacheKey(target: ResolvedTarget, credentials: Credentials): string { - return target.connectorId ?? `${credentials.serverUrl}|${credentials.username}${credentials.clientId}`; + return ( + target.connectorId ?? `${credentials.serverUrl}|${credentials.username}${credentials.clientId}` + ); } /** @@ -207,7 +206,11 @@ async function readMetadata( signal: AbortSignal, ): Promise | null> { try { - const response = await fetch(url, { method: 'GET', signal, headers: { Accept: 'application/json' } }); + const response = await fetch(url, { + method: 'GET', + signal, + headers: { Accept: 'application/json' }, + }); if (!response.ok) return null; const body = await readJsonLimited(response, config.scriptMaxResponseBytes); const parsed = body.json; @@ -241,7 +244,10 @@ function wellKnown(base: URL, suffix: string): string[] { * volani navic, ale bez toho by nesel napojit server, ktery si metadata dal * jinam a oznamuje je jen timhle zpusobem. */ -async function challengeMetadataUrl(serverUrl: string, signal: AbortSignal): Promise { +async function challengeMetadataUrl( + serverUrl: string, + signal: AbortSignal, +): Promise { try { const response = await fetch(serverUrl, { method: 'POST', @@ -253,7 +259,7 @@ async function challengeMetadataUrl(serverUrl: string, signal: AbortSignal): Pro const challenge = response.headers.get('www-authenticate') ?? ''; const match = /resource_metadata\s*=\s*"([^"]+)"/i.exec(challenge); - return match ? match[1] : null; + return match?.[1] ?? null; } catch { return null; } diff --git a/src/mcp/client.ts b/src/mcp/client.ts index a292080..73f7d31 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -29,8 +29,8 @@ import { readJsonLimited, tooLargeMessage, } from '../net/guard.js'; -import { targetSecrets, type ResolvedTarget } from '../scripts/connections.js'; -import { createRedactor, pick, truncate } from '../scripts/util.js'; +import { targetSecrets, type ResolvedTarget } from '../runtime/scripts/connections.js'; +import { createRedactor, pick, truncate } from '../runtime/scripts/util.js'; import { AuthFailure, authorize, type AuthMethod } from './auth.js'; import { dialectFor, normalizeServerUrl, type McpDialect } from './dialect.js'; @@ -235,7 +235,8 @@ function messageFromBlock(block: string): Record | null { const message = parsed as Record; // Notifikace o prubehu nas nezajimaji, ceka se na vysledek. - if (typeof message.method === 'string' && message.method.startsWith('notifications/')) return null; + if (typeof message.method === 'string' && message.method.startsWith('notifications/')) + return null; return 'result' in message || 'error' in message ? message : null; } @@ -586,7 +587,8 @@ async function attempt( }; const signal = controller.signal; // Posledni sestavene sezeni. Jeho redakce zna i token, ne jen udaje konektoru. - let session: Session | null = null; + // Bez pocatecni hodnoty: prvni prirazeni je v cyklu, chyba pred nim ho necha prazdne. + let session: Session | undefined; try { let force = false; @@ -737,7 +739,10 @@ function delay(ms: number, signal: AbortSignal): Promise { } /** Prevede odpoved serveru na vysledek kroku. */ -function toCallResult(result: Record | null, taskId: string | null): McpCallResult { +function toCallResult( + result: Record | null, + taskId: string | null, +): McpCallResult { const content = result?.content; const text = Array.isArray(content) ? content @@ -847,9 +852,12 @@ export function callTool( await ensureInitialized(session, signal); try { - const result = (await rpc(session, 'tools/call', { name, arguments: args }, signal)) as - | Record - | null; + const result = (await rpc( + session, + 'tools/call', + { name, arguments: args }, + signal, + )) as Record | null; return toCallResult(result, null); } catch (err) { /* diff --git a/src/mcp/dialect.ts b/src/mcp/dialect.ts index 30c60d7..0914445 100644 --- a/src/mcp/dialect.ts +++ b/src/mcp/dialect.ts @@ -53,7 +53,8 @@ export interface McpDialect { useSessionHeader: boolean; } -const dialects: Record = { +// Oficialni chovani je v typu povinne: je to zaloha pro vsechny ostatni sluzby. +const dialects: Record & Record = { [MCP_SERVICE_ID]: { auth: 'oauth', protocolVersion: '2025-06-18', diff --git a/src/mcp/easyweb/crypto.ts b/src/mcp/easyweb/crypto.ts index 1a77b95..d663332 100644 --- a/src/mcp/easyweb/crypto.ts +++ b/src/mcp/easyweb/crypto.ts @@ -35,7 +35,10 @@ export interface DeviceKey { /** Vyrobi novy par klicu. Dela se **jednou za konektor**, pak uz se jen nacita. */ export function generateDeviceKey(): { key: DeviceKey; jwk: string } { const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); - return { key: fromPrivateKey(privateKey), jwk: JSON.stringify(privateKey.export({ format: 'jwk' })) }; + return { + key: fromPrivateKey(privateKey), + jwk: JSON.stringify(privateKey.export({ format: 'jwk' })), + }; } /** diff --git a/src/mcp/easyweb/device.ts b/src/mcp/easyweb/device.ts index b58b807..ff17c48 100644 --- a/src/mcp/easyweb/device.ts +++ b/src/mcp/easyweb/device.ts @@ -73,7 +73,10 @@ export async function deviceFor(connectorId: string, tenantId: string): Promise< * nemelo tentyz otisk jako to stare. Server by jinak videl znamy otisk * s jinym klicem, a to je presne obraz pokusu o podvrzeni. */ - const suffix = key.thumbprint.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8).toLowerCase(); + const suffix = key.thumbprint + .replace(/[^a-zA-Z0-9]/g, '') + .slice(0, 8) + .toLowerCase(); const fingerprint = `${FINGERPRINT_PREFIX}-${connectorId}-${suffix}`; await setManagedValues( diff --git a/src/mcp/easyweb/session.ts b/src/mcp/easyweb/session.ts index c0be31b..a50faab 100644 --- a/src/mcp/easyweb/session.ts +++ b/src/mcp/easyweb/session.ts @@ -26,7 +26,7 @@ import { config } from '../../config.js'; import { describeFetchError, readBodyLimited } from '../../net/guard.js'; -import { jwtExpiry, pickText, truncate } from '../../scripts/util.js'; +import { jwtExpiry, pickText, truncate } from '../../runtime/scripts/util.js'; import { AuthFailure } from '../errors.js'; import { KEY_REGISTRATION_CONTEXT, @@ -143,7 +143,11 @@ async function post( response = await fetch(url, { method: 'POST', signal, - headers: { 'Content-Type': 'application/json; charset=utf-8', Accept: 'application/json', ...headers }, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Accept: 'application/json', + ...headers, + }, body: JSON.stringify(body), }); } catch (err) { @@ -188,7 +192,8 @@ function tokensFrom( accessToken, accessExpiresAt: accessExpiry(accessToken), refreshToken: (body ? pickText(body, 'RefreshToken') : null) ?? previous?.refreshToken ?? '', - refreshNonce: (body ? pickText(body, 'RefreshTokenNonce') : null) ?? previous?.refreshNonce ?? '', + refreshNonce: + (body ? pickText(body, 'RefreshTokenNonce') : null) ?? previous?.refreshNonce ?? '', deviceToken: (body ? pickText(body, 'DeviceToken') : null) ?? previous?.deviceToken ?? '', deviceNonce: (body ? pickText(body, 'DeviceTokenNonce') : null) ?? previous?.deviceNonce ?? '', }; @@ -237,7 +242,7 @@ async function login( throw new EasyWebAuthError( result.status === 401 || result.status === 400 ? 'Server přihlášení nepřijal. Ověřte jméno a heslo a to, že účet na tomhle ' + - 'serveru existuje a smí zakládat zařízení.' + 'serveru existuje a smí zakládat zařízení.' : `Přihlášení na ${url} vrátilo HTTP ${result.status}.`, result.status, result.detail === '' ? null : result.detail, diff --git a/src/mcp/schema.ts b/src/mcp/schema.ts index 85c371c..4b6d3f0 100644 --- a/src/mcp/schema.ts +++ b/src/mcp/schema.ts @@ -21,7 +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'; +import { parseBool } from '../runtime/scripts/util.js'; /** * Vystupy, ktere ma **kazdy** nastroj bez ohledu na schema. @@ -49,7 +49,9 @@ function propertiesOf(schema: JsonSchema | undefined): Array<[string, JsonSchema function requiredOf(schema: JsonSchema | undefined): Set { const required = schema?.required; - return new Set(Array.isArray(required) ? required.filter((item) => typeof item === 'string') : []); + return new Set( + Array.isArray(required) ? required.filter((item) => typeof item === 'string') : [], + ); } /** @@ -153,7 +155,10 @@ export function fieldsFromSchema(schema: JsonSchema | undefined): OperationField return { id: name, label, kind: 'text', required: isRequired, hint }; }); - return [...fields.filter((field) => field.required), ...fields.filter((field) => !field.required)]; + return [ + ...fields.filter((field) => field.required), + ...fields.filter((field) => !field.required), + ]; } /** Typ pole pro podminky. Seznam je `list`, zbytek se mapuje primo. */ @@ -315,7 +320,10 @@ export function argumentsFrom( issues.push(`${name}: má to být seznam, tedy [...]`); continue; } - if (type === 'object' && (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))) { + if ( + type === 'object' && + (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) + ) { issues.push(`${name}: má to být objekt, tedy {...}`); continue; } diff --git a/src/middleware/asyncHandler.ts b/src/middleware/asyncHandler.ts index f58a092..61211cb 100644 --- a/src/middleware/asyncHandler.ts +++ b/src/middleware/asyncHandler.ts @@ -6,7 +6,14 @@ * 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'; +import { + Router, + type NextFunction, + type Request, + type RequestHandler, + type Response, + type RouterOptions, +} from 'express'; type AnyHandler = (req: Request, res: Response, next: NextFunction) => unknown; @@ -38,6 +45,7 @@ export function safeRouter(options?: RouterOptions): Router { for (const method of METHODS) { const original = target[method]; + if (original === undefined) throw new Error(`Router nema metodu ${method}, nejde ji obalit.`); target[method] = (...args: unknown[]) => original.apply(router, args.map(wrapArgument)); } diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index e60d38e..0debd77 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -62,7 +62,9 @@ export function requireAuth(req: Request, res: Response, next: NextFunction) { return next(); } catch (err) { console.warn('[auth] neplatny token:', err instanceof Error ? err.message : err); - return res.status(401).json({ error: 'unauthorized', message: 'Neplatný nebo expirovaný token.' }); + return res + .status(401) + .json({ error: 'unauthorized', message: 'Neplatný nebo expirovaný token.' }); } } diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index 73d3f06..d8c6ae7 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -42,7 +42,9 @@ export function rateLimit(options: RateLimitOptions): RequestHandler { 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); + // Nejstarsi pokus v okne. Bez pokusu (max = 0) plati cele okno. + const oldest = stamps[0] ?? now; + const retryAfterSec = Math.ceil((oldest + 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({ diff --git a/src/middleware/tenant.ts b/src/middleware/tenant.ts index 622d47d..4741cfd 100644 --- a/src/middleware/tenant.ts +++ b/src/middleware/tenant.ts @@ -75,7 +75,9 @@ export function tenantOrDeny(req: Request, res: Response): string | 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.' }); + res + .status(404) + .json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' }); return null; } return tenantId; @@ -97,7 +99,9 @@ export function optionalTenantOrDeny( 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.' }); + res + .status(404) + .json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' }); return null; } return { tenantId }; diff --git a/src/net/guard.ts b/src/net/guard.ts index d0d7315..5b157f0 100644 --- a/src/net/guard.ts +++ b/src/net/guard.ts @@ -68,10 +68,13 @@ export interface FetchErrorInfo { */ export function describeFetchError(err: unknown, where: string): FetchErrorInfo { const code = - err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).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 cause = + err instanceof Error && err.cause instanceof Error ? `\nPříčina: ${err.cause.message}` : ''; const timedOut = name === 'AbortError' || name === 'TimeoutError'; return { diff --git a/src/openapi.ts b/src/openapi.ts deleted file mode 100644 index f638a67..0000000 --- a/src/openapi.ts +++ /dev/null @@ -1,3370 +0,0 @@ -import { config } from './config.js'; - -/** - * Sprava zaznamu ma u kazde entity stejnou petici endpointu, protoze ji na - * serveru dela jedna fabrika (`routes/crud.ts`). Popisovat ji devetkrat rucne - * by znamenalo devet mist, ktere se casem rozejdou. - */ -function crudPaths(entity: { - /** Cast cesty, napr. `roles`. */ - path: string; - /** Jak se o tom mluvi v popisu, napr. `roli`. */ - label: string; - /** Pravo, ktere je na zapis potreba. */ - permission: string; -}) { - const id = { name: 'id', in: 'path', required: true, schema: { type: 'string' } }; - const body = { - required: true, - content: { 'application/json': { schema: { type: 'object' } } }, - }; - const record = { - description: 'Zaznam', - content: { 'application/json': { schema: { type: 'object' } } }, - }; - const denied = { '403': { description: `Chybi pravo ${entity.permission}` } }; - const base = `/api/dashboard/settings/${entity.path}`; - - return { - [base]: { - get: { - tags: ['Nastaveni'], - summary: `Seznam - ${entity.label}`, - description: 'Vraci jen zaznamy firem, do kterych volajici patri.', - security: [{ bearerAuth: [] }], - responses: { - '200': { - description: 'Seznam', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { items: { type: 'array', items: { type: 'object' } } }, - }, - }, - }, - }, - ...denied, - }, - }, - post: { - tags: ['Nastaveni'], - summary: `Vytvorit - ${entity.label}`, - security: [{ bearerAuth: [] }], - requestBody: body, - responses: { '201': record, '400': { description: 'Neplatny vstup' }, ...denied }, - }, - }, - [`${base}/{id}`]: { - get: { - tags: ['Nastaveni'], - summary: `Detail - ${entity.label}`, - security: [{ bearerAuth: [] }], - parameters: [id], - responses: { '200': record, '404': { description: 'Neexistuje' }, ...denied }, - }, - patch: { - tags: ['Nastaveni'], - summary: `Upravit - ${entity.label}`, - description: 'Posilaji se jen menena pole. ID a cas vzniku se prepsat nedaji.', - security: [{ bearerAuth: [] }], - parameters: [id], - requestBody: body, - responses: { - '200': record, - '400': { description: 'Neplatny vstup' }, - '404': { description: 'Neexistuje' }, - ...denied, - }, - }, - delete: { - tags: ['Nastaveni'], - summary: `Smazat - ${entity.label}`, - security: [{ bearerAuth: [] }], - parameters: [id], - responses: { - '204': { description: 'Smazano' }, - '404': { description: 'Neexistuje' }, - ...denied, - }, - }, - }, - }; -} - -/** Entity, ktere se spravuji v Nastaveni. Jeden radek na entitu. */ -const settingsEntities = [ - { path: 'tenants', label: 'firmy', permission: 'tenant.manage' }, - { path: 'users', label: 'uzivatele', permission: 'user.manage' }, - { path: 'roles', label: 'role a prava', permission: 'role.manage' }, - // Resitele maji vlastni popis v `additionalPaths`: pod endpointy jsou ucty. - { path: 'people', label: 'resitele', permission: 'people.manage' }, - { path: 'groups', label: 'skupiny resitelu', permission: 'group.manage' }, - { 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' }, - // `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 = { - /* - * Resitel je clenstvi uctu ve firme, ID resitele je ID uctu. Endpointy - * a pravo zustavaji, ale zalozeni zaklada ucet (nebo prida clenstvi uz - * existujicimu) a smazani odebira clenstvi. Prepisuje obecny popis z - * `settingsEntities`, protoze telo je jine nez u ostatnich entit. - */ - '/api/dashboard/settings/people': { - get: { - tags: ['Nastaveni'], - summary: 'Seznam - resitele', - description: 'Clenove vybrane firmy vcetne vypnutych uctu, jeden pohled na clenstvi.', - security: bearer, - parameters: [tenantParam], - responses: { - '200': jsonResponse('Resitele', { - type: 'object', - properties: { items: { type: 'array', items: { $ref: '#/components/schemas/Person' } } }, - }), - }, - }, - post: { - tags: ['Nastaveni'], - summary: 'Vytvorit - resitele', - description: - 'Zalozi ucet s clenstvim ve vybrane firme. Kdyz ucet s tim e-mailem uz existuje ' + - 'a ve firme neni, prida se mu jen clenstvi (jmeno, heslo a zapnuti se neprepisuji). ' + - 'Bez hesla dostane nahodne. Role musi byt teto firmy nebo systemove, vychozi role_agent.', - security: bearer, - parameters: [tenantParam], - requestBody: jsonBody({ - type: 'object', - required: ['name', 'email'], - properties: { - name: { type: 'string' }, - email: { type: 'string', format: 'email' }, - password: { type: 'string', format: 'password', description: 'Nepovinne, jinak nahodne.' }, - roleIds: { type: 'array', items: { type: 'string' }, default: ['role_agent'] }, - role: { type: 'string', description: 'Popisek, cim se v tymu zabyva.' }, - capacity: { type: 'integer', default: 8 }, - externalIds: { type: 'array', items: { type: 'string' } }, - enabled: { type: 'boolean', default: true }, - }, - }), - responses: { - '201': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }), - '400': { description: 'Neplatny vstup, neznama role, nebo uz je clenem firmy' }, - '403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' }, - }, - }, - }, - '/api/dashboard/settings/people/{id}': { - get: { - tags: ['Nastaveni'], - summary: 'Detail - resitele', - security: bearer, - parameters: [idParam, tenantParam], - responses: { - '200': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }), - '404': { description: 'Neni clenem firmy' }, - }, - }, - patch: { - tags: ['Nastaveni'], - summary: 'Upravit - resitele', - description: - 'Jmeno, e-mail a zapnuti meni ucet (plati ve vsech firmach), role, popisek, kapacita ' + - 'a externi ID meni clenstvi v teto firme. E-mail musi zustat unikatni.', - security: bearer, - parameters: [idParam, tenantParam], - requestBody: jsonBody({ - type: 'object', - properties: { - name: { type: 'string' }, - email: { type: 'string', format: 'email' }, - enabled: { type: 'boolean' }, - roleIds: { type: 'array', items: { type: 'string' } }, - role: { type: 'string' }, - capacity: { type: 'integer' }, - externalIds: { type: 'array', items: { type: 'string' } }, - }, - }), - responses: { - '200': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }), - '400': { description: 'Neplatny vstup, neznama role, nebo obsazeny e-mail' }, - '403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' }, - '404': { description: 'Neni clenem firmy' }, - }, - }, - delete: { - tags: ['Nastaveni'], - summary: 'Smazat - resitele', - description: - 'Odebere clenstvi ve vybrane firme. Ucet zustava; bez jedineho clenstvi se vypne ' + - '(spravce platformy ne).', - security: bearer, - parameters: [idParam, tenantParam], - responses: { - '204': { description: 'Clenstvi odebrano' }, - '403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' }, - '404': { description: 'Neni clenem firmy' }, - }, - }, - }, - '/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 clenem firmy' }, - '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', description: 'Stary priznak, ignoruje se: resitel je kazdy clen firmy.' }, - }, - }), - 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 s roli spravce firmy ' + - 'a nahodnym heslem; funkce z rejstriku jde do popisku clenstvi. 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.' }, - roles: { - type: 'array', - items: { type: 'string' }, - description: 'Funkce z rejstriku, jde do popisku clenstvi.', - }, - }, - }, - }, - }, - }), - 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 (totez co seznam)', - 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. - * - * `servers` MUSI obsahovat prefix reverse proxy, jinak Swagger "Try it out" - * vola endpointy na root domene a dostane 404 (viz AGENTS.md). - * Prefix se bere z ROOT_PATH, nikdy se nehardcoduje. - */ -export function buildOpenApiDocument() { - const server = config.rootPath === '' ? '/' : config.rootPath; - - return { - openapi: '3.0.3', - info: { - title: `${config.brandName} - portal a API`, - version: '1.0.0', - description: - 'Webova prezentace a klientsky portal. Automatizace, voiceboti, integrace, ' + - 'tickety a incidenty. Aplikace bezi za reverse proxy AppFactory.', - }, - servers: [{ url: server, description: 'Verejna adresa vcetne prefixu proxy' }], - tags: [ - { name: 'Provoz', description: 'Health a zakladni informace' }, - { name: 'Autentizace', description: 'Prihlaseni do portalu' }, - { name: 'Dashboard', description: 'Data klientskeho portalu' }, - { name: 'Tickety', description: 'Pozadavky, jejich resitele a log prubehu' }, - { name: 'Automatizace', description: 'Sprava automatizaci a stromu akci' }, - { name: 'Sluzby', description: 'Katalog toho, co umime napojit' }, - { name: 'Konektory', description: 'Napojeni firmy na sluzbu vcetne pristupovych udaju' }, - { 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 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: { - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'Token z POST /api/auth/login. Vlozte samotny token bez slova Bearer.', - }, - }, - schemas: { - AresCompany: { - type: 'object', - properties: { - ico: { type: 'string', example: '27074358' }, - name: { type: 'string', example: 'Asseco Central Europe, a.s.' }, - dic: { type: 'string', nullable: true, example: 'CZ27074358' }, - address: { type: 'string', example: 'Budejovicka 778/3a, Michle, 14000 Praha 4' }, - legalFormCode: { type: 'string', example: '121' }, - legalForm: { type: 'string', example: 'Akciova spolecnost' }, - existingTenantId: { - type: 'string', - nullable: true, - description: 'ID firmy v portalu, kdyz uz je zalozena.', - }, - }, - }, - Tenant: { - type: 'object', - properties: { - id: { type: 'string', example: 'tnt_automia' }, - name: { type: 'string' }, - note: { type: 'string' }, - enabled: { type: 'boolean' }, - helpdeskProviderId: { type: 'string', nullable: true }, - ico: { type: 'string', nullable: true }, - dic: { type: 'string', nullable: true }, - address: { type: 'string', nullable: true }, - legalForm: { type: 'string', nullable: true }, - createdAt: { type: 'string', format: 'date-time' }, - updatedAt: { type: 'string', format: 'date-time' }, - }, - }, - Error: { - type: 'object', - properties: { - error: { type: 'string', example: 'validation_error' }, - message: { type: 'string', example: 'Zadejte platny e-mail.' }, - issues: { - type: 'array', - description: - 'Jen u validation_error: vsechny problemy vstupu. `field` je cesta ' + - 'k poli spojena teckou, prazdna u chyby celeho tela.', - items: { - type: 'object', - properties: { - field: { type: 'string', example: 'memberships.0.roleIds' }, - message: { type: 'string' }, - }, - }, - }, - }, - }, - User: { - type: 'object', - description: - 'Uzivatel muze patrit do vic firem. Role je vzdy az uvnitr firmy, ' + - 'pristup napric firmami je zvlast jako platformAdmin.', - properties: { - id: { type: 'string', example: 'usr_1' }, - email: { type: 'string', example: 'admin@automia.cz' }, - name: { type: 'string', example: 'Jiri Uhlir' }, - platformAdmin: { - type: 'boolean', - description: 'Vidi napric vsemi firmami a muze mezi nimi prepinat.', - }, - memberships: { - type: 'array', - items: { - type: 'object', - properties: { - tenantId: { type: 'string', example: 'tnt_automia' }, - role: { type: 'string', enum: ['admin', 'agent'] }, - }, - }, - }, - }, - }, - Access: { - type: 'object', - description: 'Co uzivatel smi. Klient podle toho kresli prepinac pohledu.', - properties: { - scopes: { - type: 'array', - items: { type: 'string', enum: ['all', 'tenant', 'mine'] }, - }, - tenants: { - type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'string', example: 'tnt_automia' }, - name: { type: 'string', example: 'Automia' }, - }, - }, - }, - defaultTenantId: { type: 'string', nullable: true }, - canAssignOthers: { - type: 'boolean', - description: 'Smi prehazovat tickety mezi lidmi, ne jen brat na sebe.', - }, - personId: { type: 'string', nullable: true }, - permissions: { - type: 'array', - items: { type: 'string' }, - description: 'Efektivni prava ve vybrane firme. Klient podle nich kresli tlacitka.', - }, - roleNames: { - type: 'array', - items: { type: 'string' }, - example: ['Spravce firmy'], - description: - 'Nazvy roli uzivatele ve vybrane firme. Tohle se ukazuje jako popis uctu, ' + - 'ne odhad z poctu prav.', - }, - nav: { type: 'array', items: { type: 'object' }, description: 'Zalozky, ktere ma videt.' }, - platformAdmin: { type: 'boolean' }, - seesOthers: { type: 'boolean', description: 'Vidi i cizi tickety, ne jen svoje.' }, - visibleGroups: { - type: 'array', - items: { - type: 'object', - properties: { id: { type: 'string' }, name: { type: 'string' } }, - }, - }, - }, - }, - Connector: { - type: 'object', - description: - 'Napojeni firmy na jednu sluzbu. Hodnoty pristupovych udaju tady zamerne ' + - 'nejsou a nikdy nebudou - secrets se z beznych endpointu nevraci.', - properties: { - id: { type: 'string', example: 'con_1a2b3c4d' }, - tenantId: { type: 'string', example: 'tnt_automia' }, - serviceId: { type: 'string', example: 'idoklad' }, - name: { type: 'string', example: 'iDoklad Automia' }, - baseUrl: { type: 'string', nullable: true }, - enabled: { type: 'boolean' }, - status: { type: 'string', enum: ['untested', 'ok', 'error'] }, - lastCheckAt: { type: 'string', format: 'date-time', nullable: true }, - lastError: { type: 'string', nullable: true }, - checkCount: { - type: 'integer', - description: - 'Kolik zaznamu o overeni je v historii. Samotna historie se cte pres ' + - '/api/dashboard/connectors/{id}/checks - v seznamu by to byla tela odpovedi navic.', - }, - isDefault: { - type: 'boolean', - description: 'Krok stromu bez vybraneho konektoru pouzije tenhle.', - }, - filled: { - type: 'array', - items: { type: 'string' }, - description: 'ID poli, ktera jsou vyplnena. Hodnoty se nevraci.', - }, - missing: { - type: 'array', - items: { type: 'string' }, - description: 'ID povinnych poli, ktera chybi.', - }, - config: { - type: 'object', - additionalProperties: { type: 'string' }, - description: 'Necitliva nastaveni. Tajna pole tu nejsou vubec.', - }, - ready: { type: 'boolean' }, - }, - }, - ScriptField: { - type: 'object', - description: - 'Parametr skriptu. Stejny tvar pro vstup i vystup - kontrola je pak ' + - 'jedna funkce, ne dve skoro stejne.', - required: ['id', 'label', 'type', 'required'], - properties: { - id: { - type: 'string', - example: 'invoiceId', - description: 'Pouziva se v sablonach jako {{invoiceId}}.', - }, - label: { type: 'string', example: 'ID faktury v iDokladu' }, - type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] }, - required: { type: 'boolean' }, - hint: { type: 'string' }, - options: { - type: 'array', - description: 'Vyber z hodnot. Jina hodnota neprojde kontrolou.', - items: { - type: 'object', - properties: { value: { type: 'string' }, label: { type: 'string' } }, - }, - }, - pattern: { type: 'string', description: 'Jen u typu string.' }, - multiline: { type: 'boolean', description: 'Jen u typu string.' }, - default: { description: 'Dosadi se, kdyz hodnota chybi a parametr neni povinny.' }, - }, - }, - ScriptManifest: { - type: 'object', - description: 'Co skript umi. Podle nej s nim umi pracovat strom automatizace.', - properties: { - id: { - type: 'string', - example: 'idoklad.get-issued-invoice', - description: 'Tvar sluzba.operace. Nazev souboru musi byt .js.', - }, - serviceId: { type: 'string', example: 'idoklad' }, - serviceName: { type: 'string', example: 'iDoklad' }, - operationId: { type: 'string', example: 'get-issued-invoice' }, - name: { type: 'string', example: 'Získat vydanou fakturu' }, - description: { type: 'string' }, - inputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } }, - outputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } }, - timeoutMs: { type: 'integer', example: 15000 }, - }, - }, - ScriptProblem: { - type: 'object', - description: - 'Rozbity skript. Nesmi shodit ostatni ani tise zmizet, proto se vraci sem.', - properties: { - file: { type: 'string', example: 'idoklad.get-issued-invoice.js' }, - scriptId: { type: 'string', nullable: true }, - message: { type: 'string' }, - issues: { - type: 'array', - items: { - type: 'object', - properties: { field: { type: 'string' }, message: { type: 'string' } }, - }, - }, - }, - }, - ConnectionStatus: { - type: 'object', - description: - 'Stav napojeni konektoru. Hodnoty pristupovych udaju se nevraci nikdy, ' + - 'jen jmena promennych, ktere chybi.', - properties: { - connectorId: { type: 'string', example: 'idoklad' }, - baseUrl: { type: 'string', example: 'https://services.csbot.cz/apps/idoklad' }, - ready: { type: 'boolean' }, - missing: { - type: 'array', - items: { type: 'string' }, - example: ['IDOKLAD_CLIENT_SECRET'], - }, - headers: { type: 'array', items: { type: 'string' }, example: ['X-ClientId'] }, - }, - }, - ScriptRunResult: { - type: 'object', - description: - 'Vysledek behu skriptu. `retryable` rika, jestli ma smysl zkusit to znovu - ' + - 'timeout ano, spatny vstup ne.', - properties: { - ok: { type: 'boolean' }, - scriptId: { type: 'string' }, - outputs: { - type: 'object', - additionalProperties: true, - description: 'Prazdne, kdyz beh selhal.', - }, - logs: { - type: 'array', - items: { - type: 'object', - properties: { - at: { type: 'string', format: 'date-time' }, - message: { type: 'string' }, - detail: { type: 'string' }, - }, - }, - }, - durationMs: { type: 'integer' }, - httpCalls: { type: 'integer' }, - error: { - type: 'object', - nullable: true, - properties: { - kind: { - type: 'string', - enum: [ - 'not_found', - 'config', - 'validation', - 'output', - 'retryable', - 'terminal', - 'timeout', - 'internal', - ], - }, - message: { type: 'string' }, - retryable: { type: 'boolean' }, - status: { type: 'integer' }, - detail: { type: 'string' }, - issues: { - type: 'array', - items: { - type: 'object', - properties: { field: { type: 'string' }, message: { type: 'string' } }, - }, - }, - }, - }, - }, - }, - LoginRequest: { - type: 'object', - required: ['email', 'password'], - properties: { - email: { type: 'string', format: 'email', example: 'admin@automia.cz' }, - password: { type: 'string', format: 'password', example: 'demo1234' }, - }, - }, - LoginResponse: { - type: 'object', - properties: { - token: { type: 'string' }, - user: { $ref: '#/components/schemas/User' }, - }, - }, - Person: { - type: 'object', - description: - 'Resitel ticketu = clen firmy. Neni to vlastni zaznam: `id` je ID uctu, `tenantId` ' + - 'firma clenstvi. Jmeno a e-mail jsou z uctu, role, kapacita a externi ID z clenstvi.', - properties: { - id: { type: 'string', example: 'usr_2', description: 'ID uctu.' }, - tenantId: { type: 'string', example: 'tnt_automia' }, - name: { type: 'string', example: 'Karel Vomacka' }, - email: { type: 'string', format: 'email' }, - role: { type: 'string', example: 'Servicedesk', description: 'Popisek, nic nerozhoduje.' }, - capacity: { - type: 'integer', - description: 'Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez.', - }, - enabled: { type: 'boolean', description: 'Zapnute clenstvi v teto firme. Vypnuty se nenabizi k prirazeni, ucet jinde bezi dal.' }, - externalIds: { type: 'array', items: { type: 'string' } }, - roleIds: { - type: 'array', - items: { type: 'string' }, - description: 'Role clenstvi v teto firme.', - }, - }, - }, - TicketCustomer: { - type: 'object', - properties: { - id: { - type: 'string', - nullable: true, - description: 'ID firmy v CRM. null = zakaznika se nepodarilo dohledat.', - example: 'crm_1042', - }, - company: { type: 'string', example: 'Firma s.r.o.' }, - contact: { type: 'string', example: 'Petra Klientova' }, - reply: { - type: 'string', - description: 'Adresa nebo cislo, odkud pozadavek prisel a kam se odpovida.', - }, - }, - }, - Ticket: { - type: 'object', - properties: { - id: { type: 'string', example: 'TK-4821' }, - subject: { type: 'string' }, - body: { - type: 'string', - description: - 'Cely text pozadavku. Prazdny retezec = krok "Zalozit ticket" obsah nenaplnil.', - }, - sourceRef: { - type: 'string', - nullable: true, - description: 'Odkaz na zdrojovou zpravu u poskytovatele.', - example: 'wamid.HBgLNDIwNzc0OTAyMzMx', - }, - channel: { - type: 'string', - enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'], - description: 'Odkud pozadavek prisel.', - }, - customer: { $ref: '#/components/schemas/TicketCustomer' }, - status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] }, - priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] }, - assignee: { - type: 'object', - nullable: true, - description: 'Kdo ma ticket u sebe. null = ceka ve fronte.', - properties: { - id: { type: 'string', example: 'usr_2', description: 'ID uctu resitele.' }, - name: { type: 'string', example: 'Karel Vomacka' }, - }, - }, - automationId: { - type: 'string', - nullable: true, - description: 'Automatizace, ktera ticket zalozila. null = zalozeno rucne.', - }, - createdAt: { type: 'string', format: 'date-time' }, - updatedAt: { type: 'string', format: 'date-time' }, - }, - }, - TicketTraceEntry: { - type: 'object', - description: - 'Jeden radek logu ticketu. Strom se sklada pres parentId - vetev podminky ' + - 'visi na zaznamu te podminky.', - properties: { - id: { type: 'string', example: 'tr_12' }, - parentId: { - type: 'string', - nullable: true, - description: 'null = zaznam v hlavni sekvenci.', - }, - kind: { type: 'string', enum: ['trigger', 'action', 'condition', 'note'] }, - connectorId: { type: 'string', nullable: true, example: 'raynet' }, - operationId: { type: 'string', nullable: true, example: 'upsert-contact' }, - label: { type: 'string' }, - status: { type: 'string', enum: ['ok', 'error', 'skipped', 'info'] }, - response: { - type: 'string', - nullable: true, - description: 'Co sluzba vratila. Kvuli tomuhle log existuje.', - }, - durationMs: { type: 'integer', nullable: true }, - at: { type: 'string', format: 'date-time' }, - }, - }, - TicketDetail: { - allOf: [ - { $ref: '#/components/schemas/Ticket' }, - { - type: 'object', - properties: { - trace: { - type: 'array', - items: { $ref: '#/components/schemas/TicketTraceEntry' }, - }, - }, - }, - ], - }, - Workload: { - type: 'object', - description: 'Prehled nad firmou - kdo ma kolik ticketu u sebe.', - properties: { - rows: { - type: 'array', - items: { - type: 'object', - properties: { - person: { $ref: '#/components/schemas/Person' }, - open: { type: 'integer', description: 'Nevyresene tickety.' }, - total: { type: 'integer' }, - critical: { type: 'integer' }, - oldestOpenAt: { type: 'string', format: 'date-time', nullable: true }, - overloaded: { type: 'boolean' }, - }, - }, - }, - unassigned: { type: 'integer', description: 'Nevyresene tickety bez resitele.' }, - openTotal: { type: 'integer' }, - }, - }, - Incident: { - type: 'object', - properties: { - id: { type: 'string', example: 'INC-231' }, - title: { type: 'string' }, - service: { type: 'string' }, - severity: { type: 'string', enum: ['sev1', 'sev2', 'sev3'] }, - status: { - type: 'string', - enum: ['investigating', 'identified', 'monitoring', 'resolved'], - }, - startedAt: { type: 'string', format: 'date-time' }, - resolvedAt: { type: 'string', format: 'date-time', nullable: true }, - }, - }, - TriggerField: { - type: 'object', - required: ['id', 'name', 'type', 'required'], - properties: { - id: { type: 'string', example: 'f_42' }, - name: { - type: 'string', - example: 'score', - description: 'Klic v prichozich datech, pismena, cislice a podtrzitko.', - }, - type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] }, - required: { type: 'boolean' }, - }, - }, - FlowStep: { - type: 'object', - description: - 'Krok stromu. Bud akce nad konektorem, nebo podminka se dvema vetvemi.', - properties: { - id: { type: 'string' }, - kind: { type: 'string', enum: ['action', 'condition'] }, - connectorId: { type: 'string', example: 'email' }, - operationId: { type: 'string', example: 'send' }, - inputs: { - type: 'object', - additionalProperties: { type: 'string' }, - description: - 'Nastaveni akce. Klic je ID pole z katalogu, hodnota je sablona - ' + - '{{nazev}} se nahradi parametrem spoustece. Neznamy klic vraci 400.', - example: { subject: 'Reklamace od {{profileName}}', body: '{{text}}' }, - }, - fieldId: { type: 'string', example: 'f_42' }, - operator: { - type: 'string', - enum: [ - 'eq', - 'neq', - 'gt', - 'gte', - 'lt', - 'lte', - 'contains', - 'startsWith', - 'isEmpty', - 'isNotEmpty', - 'isTrue', - 'isFalse', - ], - }, - value: { type: 'string', example: '15' }, - yes: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } }, - no: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } }, - }, - }, - AutomationFlow: { - type: 'object', - properties: { - trigger: { - type: 'object', - nullable: true, - properties: { - connectorId: { type: 'string', example: 'webhook' }, - operationId: { type: 'string', example: 'received' }, - fields: { - type: 'array', - items: { $ref: '#/components/schemas/TriggerField' }, - }, - webhookToken: { - type: 'string', - readOnly: true, - description: 'Generuje vyhradne server, hodnota od klienta se ignoruje.', - }, - }, - }, - steps: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } }, - }, - }, - Automation: { - type: 'object', - properties: { - id: { type: 'string', example: 'AUT-01' }, - name: { type: 'string' }, - kind: { type: 'string', enum: ['workflow', 'voicebot', 'integrace', 'report'] }, - enabled: { type: 'boolean' }, - runsToday: { type: 'integer' }, - runsYesterday: { type: 'integer' }, - runsTotal: { type: 'integer' }, - successRate: { type: 'number' }, - avgDurationMs: { type: 'integer' }, - lastRunAt: { type: 'string', format: 'date-time' }, - stepCount: { type: 'integer' }, - configured: { type: 'boolean' }, - issues: { - type: 'array', - items: { type: 'string' }, - description: 'Co chybi k zapnuti. Prazdne pole znamena hotovo.', - }, - }, - }, - AutomationDetail: { - allOf: [ - { $ref: '#/components/schemas/Automation' }, - { - type: 'object', - properties: { - flow: { $ref: '#/components/schemas/AutomationFlow' }, - createdAt: { type: 'string', format: 'date-time' }, - updatedAt: { type: 'string', format: 'date-time' }, - }, - }, - ], - }, - }, - }, - paths: { - // Petice endpointu za kazdou entitu v Nastaveni, viz `crudPaths` vyse. - ...settingsEntities.reduce( - (all, entity) => ({ ...all, ...crudPaths(entity) }), - {} as Record, - ), - ...additionalPaths, - '/health': { - get: { - tags: ['Provoz'], - summary: 'Health check', - description: 'Vraci 200, pokud je aplikace schopna prijimat provoz.', - responses: { - '200': { - description: 'Aplikace bezi', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - status: { type: 'string', example: 'ok' }, - uptimeSec: { type: 'integer', example: 42 }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/auth/login': { - post: { - tags: ['Autentizace'], - summary: 'Prihlaseni', - requestBody: { - required: true, - content: { - 'application/json': { schema: { $ref: '#/components/schemas/LoginRequest' } }, - }, - }, - responses: { - '200': { - description: 'Token a udaje uzivatele', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } }, - }, - }, - '401': { - description: 'Nespravny e-mail nebo heslo', - content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, - }, - ...tooMany, - }, - }, - }, - '/api/auth/me': { - get: { - tags: ['Autentizace'], - summary: 'Prihlaseny uzivatel', - security: [{ bearerAuth: [] }], - responses: { - '200': { - description: 'Udaje uzivatele', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { user: { $ref: '#/components/schemas/User' } }, - }, - }, - }, - }, - '401': { description: 'Chybi nebo neplatny token' }, - }, - }, - }, - '/api/auth/logout': { - post: { - tags: ['Autentizace'], - summary: 'Odhlaseni', - security: [{ bearerAuth: [] }], - responses: { '204': { description: 'Odhlaseno' } }, - }, - }, - '/api/dashboard/summary': { - get: { - tags: ['Dashboard'], - summary: 'Souhrn pro prehled', - security: [{ bearerAuth: [] }], - responses: { '200': { description: 'Souhrnne metriky a casova rada' } }, - }, - }, - '/api/dashboard/widgets': { - get: { - tags: ['Dashboard'], - summary: 'Katalog widgetu prehledu', - description: 'Co jde polozit na dashboard vcetne povolenych sirek.', - security: [{ bearerAuth: [] }], - responses: { '200': { description: 'Widgety' } }, - }, - }, - '/api/dashboard/layout': { - get: { - tags: ['Dashboard'], - summary: 'Rozlozeni dashboardu', - description: - 'Uklada se pro dvojici uzivatel a firma. `custom: false` znamena, ' + - 'ze uzivatel kouka na vychozi rozlozeni.', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'tenantId', - in: 'query', - schema: { type: 'string' }, - description: 'Firma. Bez ni se pouzije prvni, do ktere uzivatel patri.', - }, - ], - responses: { - '200': { description: 'Rozlozeni' }, - '403': { description: 'Ucet nepatri do zadne firmy' }, - '404': { description: 'Firma neexistuje, nebo do ni uzivatel nepatri' }, - }, - }, - put: { - tags: ['Dashboard'], - summary: 'Ulozit rozlozeni', - description: 'Overuje se proti katalogu. Neznamy widget nebo sirka vraci 400.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'tenantId', in: 'query', schema: { type: 'string' } }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['items'], - properties: { - items: { - type: 'array', - items: { - type: 'object', - required: ['id', 'widgetId', 'size'], - properties: { - id: { type: 'string', example: 'w1' }, - widgetId: { type: 'string', example: 'stat.openTickets' }, - size: { type: 'string', enum: ['third', 'half', 'full'] }, - }, - }, - }, - }, - }, - }, - }, - }, - responses: { - '200': { description: 'Ulozeno' }, - '400': { description: 'Neplatne rozlozeni' }, - }, - }, - delete: { - tags: ['Dashboard'], - summary: 'Vratit na vychozi rozlozeni', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'tenantId', in: 'query', schema: { type: 'string' } }], - responses: { '200': { description: 'Vychozi rozlozeni' } }, - }, - }, - '/api/dashboard/access': { - get: { - tags: ['Dashboard'], - summary: 'Co uzivatel smi videt', - description: - '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', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/Access' } }, - }, - }, - }, - }, - }, - '/api/dashboard/people': { - get: { - tags: ['Tickety'], - summary: 'Seznam resitelu', - description: - 'Clenove firmy, na ktere jde ticket priradit. `meId` je ID prihlaseneho uctu, ' + - 'nebo null, kdyz ve firme neni clenem.', - security: [{ bearerAuth: [] }], - responses: { - '200': { - description: 'Resitele', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - items: { type: 'array', items: { $ref: '#/components/schemas/Person' } }, - meId: { type: 'string', nullable: true }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/dashboard/tickets': { - get: { - tags: ['Tickety'], - summary: 'Seznam ticketu', - description: 'Neznama hodnota filtru se ignoruje a zaloguje, seznam se nezuzi.', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'scope', - in: 'query', - schema: { type: 'string', enum: ['all', 'tenant', 'mine'] }, - description: - '`all` napric firmami (jen platformni admin), `tenant` cela firma, ' + - '`mine` jen moje tickety. Nepovoleny pohled vraci 403, nikdy se tise nezuzi.', - }, - { - name: 'tenantId', - in: 'query', - schema: { type: 'string' }, - description: 'Firma u pohledu `tenant` a `mine`. Bez clenstvi vraci 404.', - example: 'tnt_automia', - }, - { - name: 'assignee', - in: 'query', - schema: { type: 'string' }, - description: - 'ID resitele nebo `unassigned` pro frontu. U pohledu `mine` se ignoruje.', - }, - { - name: 'status', - in: 'query', - schema: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] }, - }, - { - name: 'channel', - in: 'query', - 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 }, - }, - }, - }, - }, - }, - }, - }, - 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: { - tags: ['Tickety'], - summary: 'Kdo co ma u sebe', - description: 'Prehled zateze pres cely tym vcetne poctu ticketu ve fronte.', - security: [{ bearerAuth: [] }], - responses: { - '200': { - description: 'Vytizeni resitelu', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/Workload' } }, - }, - }, - }, - }, - }, - '/api/dashboard/tickets/{id}': { - get: { - tags: ['Tickety'], - summary: 'Detail ticketu vcetne logu', - description: 'Log obsahuje i to, co ktera volana sluzba vratila.', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string' }, - example: 'TK-4821', - }, - ], - responses: { - '200': { - description: 'Detail', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/TicketDetail' } }, - }, - }, - '404': { description: 'Neexistuje' }, - }, - }, - }, - '/api/dashboard/tickets/{id}/assign': { - post: { - tags: ['Tickety'], - summary: 'Priradit resitele', - 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: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['assigneeId'], - properties: { - assigneeId: { type: 'string', nullable: true, example: 'usr_2', description: 'ID uctu clena firmy.' }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Prirazeno', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, - }, - }, - '400': { description: 'Chybi assigneeId' }, - '403': { description: 'Chybi pravo ticket.assign.self nebo ticket.assign.others' }, - '404': { description: 'Ticket nebo resitel neexistuje' }, - }, - }, - }, - '/api/dashboard/tickets/{id}/status': { - 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: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['status'], - properties: { - status: { type: 'string', maxLength: 60, example: 'open' }, - closed: { type: 'boolean' }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Zmeneno', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, - }, - }, - '400': { description: 'Neplatny stav' }, - '403': { description: 'Chybi pravo ticket.status.change' }, - '404': { description: 'Neexistuje' }, - }, - }, - }, - '/api/dashboard/tickets/{id}/comment': { - post: { - tags: ['Tickety'], - summary: 'Pridat komentar', - description: 'Komentar je dalsi radek logu, aby bylo vse na jedne casove ose.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['text'], - properties: { text: { type: 'string', minLength: 2 } }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Zapsano', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, - }, - }, - '400': { description: 'Prazdny komentar' }, - '403': { description: 'Chybi pravo ticket.comment' }, - '404': { description: 'Neexistuje' }, - }, - }, - }, - '/api/dashboard/tickets/{id}/type': { - post: { - tags: ['Tickety'], - summary: 'Nastavit typ ticketu', - description: - 'Typ rozhoduje, ktera vlastni pole ticket ma a ktere akce se na nem ukazou. ' + - 'Pri zmene typu se hodnoty poli **nemazou**, jen prestanou byt videt.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['typeId'], - properties: { - typeId: { type: 'string', nullable: true, example: 'tt_order' }, - fields: { - type: 'object', - description: 'Hodnoty vlastnich poli. Klic je klic pole z typu ticketu.', - additionalProperties: true, - }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Ulozeno', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, - }, - }, - '403': { description: 'Chybi pravo ticket.type.change' }, - '404': { description: 'Ticket nebo typ neexistuje' }, - }, - }, - }, - '/api/dashboard/tickets/{id}/tags': { - post: { - tags: ['Tickety'], - summary: 'Nastavit tagy', - description: 'Tagy se prepisuji cele. Prirustkova zmena by u vic lidi naraz kolidovala.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['tags'], - properties: { - tags: { type: 'array', maxItems: 20, items: { type: 'string', maxLength: 40 } }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Ulozeno', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, - }, - }, - '403': { description: 'Chybi pravo ticket.tag' }, - '404': { description: 'Neexistuje' }, - }, - }, - }, - '/api/dashboard/tickets/{id}/group': { - post: { - tags: ['Tickety'], - summary: 'Prehodit na skupinu resitelu', - description: - 'Prirazeni konkretnimu cloveku se **zrusi**. Kdyby zustalo, ticket by byl ' + - 've fronte skupiny i u cloveka a nikdo by nevedel, kdo to resi.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['groupId'], - properties: { groupId: { type: 'string', nullable: true } }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Ulozeno', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, - }, - }, - '403': { description: 'Chybi pravo ticket.assign.group' }, - '404': { description: 'Neexistuje' }, - }, - }, - }, - '/api/dashboard/tickets/{id}/actions': { - get: { - tags: ['Tickety'], - summary: 'Akce dostupne k ticketu', - description: - 'Vraci **jen akce, ktere v teto situaci opravdu jdou spustit**: sedi typ nebo ' + - 'tag, projdou podminky a volajici na ne ma pravo. Klient nefiltruje nic.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - responses: { - '200': { - description: 'Akce', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - items: { - type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'string' }, - label: { type: 'string', example: 'Odeslat do iDokladu' }, - icon: { type: 'string' }, - style: { type: 'string', enum: ['primary', 'default', 'danger'] }, - confirm: { type: 'string', nullable: true }, - form: { type: 'array', items: { type: 'object' } }, - }, - }, - }, - }, - }, - }, - }, - }, - '404': { description: 'Ticket neexistuje' }, - }, - }, - }, - '/api/dashboard/tickets/{id}/actions/{actionId}': { - post: { - tags: ['Tickety'], - summary: 'Spustit akci', - description: - 'Vraci 200 **i kdyz akce selhala** - selhani akce neni chyba API. Cely prubeh ' + - 'vcetne toho, co sluzba vratila, se zapise do logu ticketu.', - security: [{ bearerAuth: [] }], - parameters: [ - { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, - { name: 'actionId', in: 'path', required: true, schema: { type: 'string' } }, - ], - requestBody: { - required: false, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - form: { - type: 'object', - description: 'Hodnoty poli, ktera si akce vyzada.', - additionalProperties: { type: 'string' }, - }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Akce probehla nebo selhala, viz ok', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - ok: { type: 'boolean' }, - summary: { type: 'string' }, - detail: { - type: 'string', - nullable: true, - description: 'Cele chybove hlaseni. Nikdy se nezkracuje.', - }, - durationMs: { type: 'integer' }, - }, - }, - }, - }, - }, - '403': { description: 'Chybi pravo na tuto akci' }, - '404': { description: 'Ticket nebo akce neexistuje' }, - }, - }, - }, - '/api/dashboard/widget-data': { - post: { - tags: ['Dashboard'], - summary: 'Data vlastnich widgetu', - description: - 'Jeden request na cely prehled. Deset dlazdic nesmi znamenat deset dotazu.', - security: [{ bearerAuth: [] }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['widgetIds'], - properties: { widgetIds: { type: 'array', items: { type: 'string' } } }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Data po widgetech', - content: { 'application/json': { schema: { type: 'object' } } }, - }, - }, - }, - }, - '/api/dashboard/widget-data/options': { - get: { - tags: ['Dashboard'], - summary: 'Co jde ve vlastnim widgetu nastavit', - description: 'Zdroje dat, mozna seskupeni a sirky. Aby to klient nemel v kodu.', - security: [{ bearerAuth: [] }], - responses: { - '200': { - description: 'Moznosti', - content: { 'application/json': { schema: { type: 'object' } } }, - }, - }, - }, - }, - '/api/dashboard/settings/catalog': { - get: { - tags: ['Nastaveni'], - summary: 'Katalog prav a modulu', - description: - 'Seznam vsech prav a zalozek. Formular role tak nema seznam prav v kodu klienta.', - security: [{ bearerAuth: [] }], - responses: { - '200': { - description: 'Katalog', - content: { 'application/json': { schema: { type: 'object' } } }, - }, - }, - }, - }, - '/api/dashboard/settings/users/{id}/password': { - patch: { - tags: ['Nastaveni'], - summary: 'Zmenit heslo', - description: - 'Svoje heslo si zmeni kazdy, cizi jen spravce platformy. Hash se nikdy nevraci.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['password'], - properties: { password: { type: 'string', minLength: 8 } }, - }, - }, - }, - }, - responses: { - '204': { description: 'Zmeneno' }, - '400': { description: 'Kratke heslo' }, - '403': { description: 'Cizi heslo bez prava' }, - }, - }, - }, - '/api/admin/impersonate': { - post: { - tags: ['Sprava platformy'], - summary: 'Prepnout se na jiny ucet', - description: - 'Vraci novy token s narokem `act`. Bez `writes` projde **jen GET**, cokoliv ' + - 'jineho vrati 403. Prepnuti i jeho ukonceni je v auditu.', - security: [{ bearerAuth: [] }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['userId'], - properties: { - userId: { type: 'string' }, - allowWrites: { - type: 'boolean', - default: false, - description: 'true = i zapis. Musi se zapnout vedome.', - }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Token na cizi ucet', - content: { 'application/json': { schema: { type: 'object' } } }, - }, - '403': { description: 'Neni spravce platformy' }, - '404': { description: 'Ucet neexistuje' }, - }, - }, - }, - '/api/admin/impersonate/stop': { - post: { - tags: ['Sprava platformy'], - summary: 'Ukoncit prepnuti', - description: - 'Jen zaznam do auditu. Svuj puvodni token si drzi klient, server o nem nevi.', - security: [{ bearerAuth: [] }], - responses: { '204': { description: 'Zapsano' } }, - }, - }, - '/api/admin/impersonate/candidates': { - get: { - tags: ['Sprava platformy'], - summary: 'Koho lze prepnout', - description: 'Spravci platformy se nenabizeji.', - security: [{ bearerAuth: [] }], - responses: { - '200': { - description: 'Ucty', - content: { 'application/json': { schema: { type: 'object' } } }, - }, - '403': { description: 'Neni spravce platformy' }, - }, - }, - }, - '/api/admin/audit': { - get: { - tags: ['Sprava platformy'], - summary: 'Audit', - description: - 'Kdo co udelal, vcetne odepreni a vcetne toho, kdo se za koho vydaval. ' + - 'Nejnovejsi nahore.', - security: [{ bearerAuth: [] }], - parameters: [ - { name: 'action', in: 'query', schema: { type: 'string' } }, - { name: 'result', in: 'query', schema: { type: 'string', enum: ['ok', 'denied'] } }, - { name: 'limit', in: 'query', schema: { type: 'integer', maximum: 500, default: 200 } }, - ], - responses: { - '200': { - description: 'Zaznamy', - content: { 'application/json': { schema: { type: 'object' } } }, - }, - '403': { description: 'Neni spravce platformy' }, - }, - }, - }, - '/api/dashboard/incidents': { - get: { - tags: ['Dashboard'], - summary: 'Seznam incidentu', - security: [{ bearerAuth: [] }], - responses: { - '200': { - description: 'Incidenty', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - items: { type: 'array', items: { $ref: '#/components/schemas/Incident' } }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/dashboard/incidents/{id}': { - get: { - tags: ['Dashboard'], - summary: 'Detail incidentu', - description: - 'Incident firmy nebo platformni. `detail` (diagnostika) dostane jen spravce platformy, ' + - 'ostatni maji null. Cizi incident je 404.', - security: [{ bearerAuth: [] }], - parameters: [idParam, tenantParam], - responses: { - '200': jsonResponse('Incident', { $ref: '#/components/schemas/Incident' }), - '404': { description: 'Incident neexistuje nebo patri jine firme' }, - }, - }, - }, - '/api/dashboard/incidents/{id}/status': { - patch: { - tags: ['Dashboard'], - summary: 'Posunout incident do dalsiho stavu', - description: - 'Pravo incident.manage za firmu incidentu; platformni incident (bez firmy) meni jen ' + - 'spravce platformy. Stav resolved nastavi resolvedAt.', - security: [{ bearerAuth: [] }], - parameters: [idParam, tenantParam], - requestBody: jsonBody({ - type: 'object', - required: ['status'], - properties: { - status: { - type: 'string', - enum: ['investigating', 'identified', 'monitoring', 'resolved'], - }, - }, - }), - responses: { - '200': jsonResponse('Incident', { $ref: '#/components/schemas/Incident' }), - '400': { description: 'Neznamy stav' }, - '403': { description: 'Chybi pravo incident.manage' }, - '404': { description: 'Incident neexistuje nebo patri jine firme' }, - }, - }, - }, - '/api/dashboard/stream': { - get: { - tags: ['Dashboard'], - summary: 'Zivy stream zmen (SSE)', - description: - 'Server-Sent Events. Drzi otevrene spojeni a posila udalosti, jakmile nastanou. ' + - 'Swagger UI streamovanou odpoved nezobrazi rozumne, testujte prohlizecem nebo curl.', - security: [{ bearerAuth: [] }], - responses: { '200': { description: 'Proud udalosti text/event-stream' } }, - }, - }, - '/health/ready': { - get: { - tags: ['Provoz'], - summary: 'Readiness vcetne databaze', - description: - 'Vraci 503, kdyz je databaze nastavena a nedostupna. `/health` na databazi ' + - 'zamerne nezavisi - kratky vypadek DB by jinak vedl k restartovani containeru.', - responses: { - '200': { description: 'Aplikace je pripravena' }, - '503': { description: 'Databaze je nastavena, ale nedostupna' }, - }, - }, - }, - '/api/dashboard/storage': { - get: { - tags: ['Dashboard'], - summary: 'Kam se uklada', - description: - 'mode postgres nebo memory. `ephemeral: true` znamena, ze restart procesu ' + - 'data smaze. Portal to musi umet rict nahlas.', - security: [{ bearerAuth: [] }], - responses: { - '200': { - description: 'Rezim uloziste', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - mode: { type: 'string', enum: ['postgres', 'memory'] }, - reason: { type: 'string', nullable: true }, - ephemeral: { type: 'boolean' }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/dashboard/services': { - get: { - tags: ['Sluzby'], - summary: 'Katalog sluzeb pro builder', - description: - 'Vraci jen sluzby, ktere uzivatel vidi. Neviditelna sluzba v odpovedi neni ' + - 'vubec, ne se stavem "nemate pravo". Operace, ktere obsluhuje skript, nesou ' + - 'implementation: script a maji skutecne inputs a outputFields.', - security: [{ bearerAuth: [] }], - parameters: [tenantParam], - responses: { - '200': { description: 'Sluzby, kategorie a operatory podminek' }, - '404': { description: 'Firma neexistuje, nebo do ni volajici nepatri' }, - }, - }, - }, - '/api/dashboard/connectors/services': { - get: { - tags: ['Sluzby'], - summary: 'Katalog sluzeb ocima firmy', - description: - 'Jako /services, navic connectorCount, tedy kolik konektoru na sluzbu firma ma. ' + - 'Podle toho se rozlisi napojeno od muzete si napojit. Neni to vlastnost sluzby, ' + - 'ale te firmy.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'tenantId', in: 'query', schema: { type: 'string' } }], - responses: { '200': { description: 'Sluzby vcetne pouziti ve firme' } }, - }, - }, - '/api/dashboard/connectors': { - get: { - tags: ['Konektory'], - summary: 'Konektory firmy', - description: - 'Hodnoty pristupovych udaju se NIKDY nevraci, jen filled (co je vyplnene) ' + - 'a missing (ktera povinna pole chybi).', - security: [{ bearerAuth: [] }], - parameters: [ - { name: 'tenantId', in: 'query', schema: { type: 'string' } }, - { name: 'serviceId', in: 'query', schema: { type: 'string' } }, - ], - responses: { - '200': { - description: 'Konektory firmy', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - items: { - type: 'array', - items: { $ref: '#/components/schemas/Connector' }, - }, - tenantId: { type: 'string' }, - }, - }, - }, - }, - }, - }, - }, - post: { - tags: ['Konektory'], - summary: 'Zalozit konektor', - description: - 'Pristupove udaje se posilaji ve values s klici podle Service.credentials. ' + - 'Nevyplnene povinne pole neni chyba, konektor se ulozi a jen nepujde pouzit.', - security: [{ bearerAuth: [] }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['serviceId', 'name'], - properties: { - serviceId: { type: 'string', example: 'idoklad' }, - name: { type: 'string', example: 'iDoklad Celo' }, - baseUrl: { type: 'string', nullable: true }, - values: { - type: 'object', - additionalProperties: { type: 'string' }, - }, - }, - }, - }, - }, - }, - responses: { - '201': { - description: 'Zalozeno', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/Connector' } }, - }, - }, - '400': { description: 'Obecna sluzba konektor nepotrebuje, nebo nezname pole' }, - '403': { description: 'Chybi pravo connector.manage' }, - '404': { description: 'Sluzba neexistuje nebo ji uzivatel nevidi' }, - }, - }, - }, - '/api/dashboard/connectors/{id}': { - get: { - tags: ['Konektory'], - summary: 'Detail konektoru', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - responses: { '200': { description: 'Konektor' }, '404': { description: 'Neexistuje' } }, - }, - patch: { - tags: ['Konektory'], - summary: 'Upravit konektor', - description: - 'Ve values staci poslat jen to, co se meni. PRAZDNY RETEZEC hodnotu smaze, ' + - 'chybejici klic ji nechava - diky tomu jde ulozit formular, ktery tajne hodnoty ' + - 'neposila. Zmena udaju vzdy zrusi predchozi overeni.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - name: { type: 'string' }, - baseUrl: { type: 'string', nullable: true }, - values: { type: 'object', additionalProperties: { type: 'string' } }, - enabled: { type: 'boolean' }, - isDefault: { type: 'boolean', enum: [true] }, - }, - }, - }, - }, - }, - 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. Chce pravo connector.manage.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - responses: { - '204': { description: 'Smazano' }, - '403': { description: 'Chybi pravo connector.manage' }, - '404': { description: 'Neexistuje' }, - }, - }, - }, - '/whoami': { - get: { - tags: ['Provoz'], - summary: 'Jak nas vidi ten, kdo nam vola', - description: - 'Vraci volajicimu jeho vlastni adresu tak, jak dorazila k serveru. Zni to ' + - 'zbytecne, ale je to jediny zpusob, jak zmerit, s jakou zdrojovou adresou ' + - 'doruci reverse proxy volani, ktere vyslo z naseho containeru. Bez prihlaseni ' + - 'zamerne - volajici dostane svoji vlastni adresu, nic navic.', - responses: { - '200': { - description: 'Adresa volajiciho', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - ip: { type: 'string', nullable: true }, - forwardedFor: { type: 'string', nullable: true }, - remoteAddress: { type: 'string', nullable: true }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/dashboard/connectors/egress-ip': { - get: { - tags: ['Konektory'], - summary: 'Odchozi IP adresa portalu', - description: - 'Adresa, kterou vidi volana sluzba, tedy ta, ktera musi byt na jejim seznamu ' + - 'povolenych IP. Z containeru videt neni, zjistuje se echo sluzbou podle ' + - 'EGRESS_IP_URL a vysledek se drzi v pameti po EGRESS_IP_TTL_MS. Neni to ' + - 'tajemstvi: kazda volana sluzba tuhle adresu stejne vidi.', - security: [{ bearerAuth: [] }], - responses: { - '200': { - description: 'Odchozi adresa, nebo duvod, proc se nezjistila', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - ip: { type: 'string', nullable: true }, - source: { type: 'string' }, - checkedAt: { type: 'string', format: 'date-time' }, - error: { type: 'string' }, - viaProxy: { - type: 'object', - nullable: true, - description: - 'Jak nas vidi nase vlastni reverse proxy. Zmeri se volanim na ' + - 'vlastni verejnou adresu (PUBLIC_ORIGIN + ROOT_PATH + /whoami), ' + - 'ktere se otoci zpatky na tentyz stroj. Byva jina nez ta verejna ' + - 'a prave ji porovnava seznam povolenych IP u sluzeb za toutez proxy.', - properties: { - ip: { type: 'string', nullable: true }, - forwardedFor: { type: 'string', nullable: true }, - remoteAddress: { - type: 'string', - nullable: true, - description: - '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.', - }, - suggestedRange: { - type: 'string', - nullable: true, - description: - 'CIDR rozsah, ktery tu adresu pokryje cely (napr. 172.16.0.0/12 ' + - 'nebo 127.0.0.0/8). Do seznamu povolenych patri on, ne jedna ' + - 'adresa: docker prideluje z bloku a pri prekresleni site se cisla ' + - 'meni. null = adresa je verejna, zadny blok se nenabizi.', - }, - url: { type: 'string' }, - error: { type: 'string' }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/dashboard/connectors/{id}/test': { - post: { - tags: ['Konektory'], - summary: 'Overit napojeni', - description: - 'Zavola verifyPath sluzby, coz je zamerne cteci volani vyzadujici autorizaci. ' + - 'Kdyz ho sluzba nema, overi se jen /health a odpoved to v checked rekne - aby ' + - 'si nikdo nemyslel, ze jsou overene i pristupove udaje. Neuspesne overeni neni ' + - 'chyba API, vraci se 200 s ok: false.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - responses: { - '200': { - description: 'Vysledek overeni', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - ok: { type: 'boolean' }, - checked: { type: 'string' }, - status: { type: 'integer' }, - message: { type: 'string' }, - baseUrl: { - type: 'string', - description: - 'Kam konektor miri. Vraci se i pri uspechu - zaklad adresy je ' + - 'z konfigurace a konektor ho smi prepsat, takze bez nej nerika ' + - 'kod odpovedi nic o tom, jestli se to trefilo na spravny stroj.', - }, - }, - }, - }, - }, - }, - '403': { description: 'Chybi pravo connector.manage' }, - '404': { description: 'Konektor neexistuje' }, - }, - }, - }, - '/api/dashboard/client-crash': { - 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 ' + - 'nekdo nahlasil. Incident nese title a impact pro zakaznika a detail pro spravce ' + - 'platformy: hlaska, misto v kodu, strom komponent, adresa stranky a verze buildu. ' + - 'Tentyz pad na tomtez miste zalozi incident nejvys jednou za deset minut - pad pri ' + - 'vykreslovani se opakuje pri kazdem prekresleni a jinak by z jedne chyby vzniklo ' + - 'padesat incidentu. Volá to pojistka v klientovi, ne clovek.', - security: [{ bearerAuth: [] }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['message'], - properties: { - message: { type: 'string' }, - stack: { type: 'string' }, - componentStack: { type: 'string' }, - path: { type: 'string', description: 'Kde v portalu se to stalo.' }, - build: { type: 'string', description: 'Verze nasazeneho klienta.' }, - }, - }, - }, - }, - }, - responses: { - '201': { - description: 'Incident zalozen', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - created: { type: 'boolean' }, - incidentId: { type: 'string' }, - }, - }, - }, - }, - }, - '202': { description: 'Stejny pad uz je hlaseny, incident se nezaklada' }, - '400': { description: 'Neplatny vstup' }, - '404': { description: 'Firma neexistuje, nebo do ni volajici nepatri' }, - }, - }, - }, - '/api/dashboard/connectors/{id}/mcp/tools': { - post: { - tags: ['Konektory'], - summary: 'Nacist nastroje MCP serveru', - description: - 'Zepta se MCP serveru na tools/list a ulozi vysledek ke konektoru. Plati pro obe ' + - 'sluzby MCP (obecnou i EasyWeb) - jsou to jedine sluzby, u kterych seznam operaci ' + - 'neurcuje katalog, ale az sam server - teprve tim ' + - 'vzniknou kroky, ktere jde davat do automatizaci, vcetne toho, jake promenne ' + - 'prijimaji a jake vraceji. Zaroven to je overeni konektoru, proto se zapisuje do ' + - 'historie: kdyz server odpovi seznamem, adresa i token sedi. Cteci volani, nic ' + - 'nemeni. Prazdny vysledek se ulozi (server uz nastroje nenabizi), chyba nemeni nic ' + - '- vypadek serveru nesmi vymazat kroky z hotovych automatizaci. Neuspech neni ' + - 'chyba API, vraci se 200 s ok: false.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - responses: { - '200': { - description: 'Vysledek nacteni', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - ok: { type: 'boolean' }, - checked: { type: 'string' }, - message: { type: 'string' }, - status: { type: 'integer' }, - detail: { type: 'string' }, - server: { type: 'string', description: 'Jak se server predstavil.' }, - protocolVersion: { type: 'string' }, - tools: { - type: 'array', - items: { - type: 'object', - properties: { - name: { type: 'string' }, - label: { type: 'string' }, - description: { type: 'string' }, - inputs: { - type: 'array', - items: { type: 'string' }, - description: 'Nazvy parametru, povinne s hvezdickou na konci.', - }, - outputs: { type: 'array', items: { type: 'string' } }, - }, - }, - }, - }, - }, - }, - }, - }, - '400': { description: 'Sluzba neni MCP server' }, - '403': { description: 'Chybi pravo connector.manage' }, - '404': { description: 'Konektor neexistuje' }, - }, - }, - }, - '/api/dashboard/connectors/{id}/checks': { - get: { - tags: ['Konektory'], - summary: 'Historie overeni konektoru', - description: - 'Poslednich pet overeni, nejnovejsi prvni. U neuspechu nese zaznam cele telo ' + - 'odpovedi sluzby v poli detail - prave tam sluzba pise, co ji vadilo, a bez ' + - 'toho se neda rozlisit spatny udaj od zakazane IP adresy. Texty jsou uz ' + - 'zredigovane, pristupovy udaj v nich neni. Historie je zvlast a ne v seznamu ' + - 'konektoru proto, ze telo odpovedi byva o rady velikosti vetsi nez zbytek radku.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - responses: { - '200': { - description: 'Zaznamy o overeni', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - limit: { type: 'integer' }, - items: { - type: 'array', - items: { - type: 'object', - properties: { - at: { type: 'string', format: 'date-time' }, - ok: { type: 'boolean' }, - checked: { type: 'string' }, - status: { type: 'integer', nullable: true }, - message: { type: 'string' }, - detail: { type: 'string', nullable: true }, - request: { - type: 'object', - nullable: true, - description: - 'url je cela adresa vcetne serveru, bez query - v query muze byt tajemstvi.', - properties: { - method: { type: 'string' }, - path: { type: 'string' }, - url: { type: 'string' }, - }, - }, - responseHeaders: { - type: 'object', - nullable: true, - additionalProperties: { type: 'string' }, - description: - 'Vybrane hlavicky odpovedi (server, via, content-type, ' + - 'www-authenticate, retry-after, x-request-id, date). Rikaji, kdo ' + - 'odpoved vydal - aplikace, nebo proxy pred ni. U kodu bez tela ' + - 'je to jedina stopa, ktera zbyde. Allowlist, ne vsechno: ' + - 'Set-Cookie a podobne do zaznamu nepatri.', - }, - egressIp: { - type: 'string', - nullable: true, - description: - 'Odchozi IP adresa portalu ve chvili volani. Vyplnena jen ' + - 'u odmitnuteho pristupu (401, 403) - tam je to prvni otazka, ' + - 'jinde nema co rict.', - }, - }, - }, - }, - }, - }, - }, - }, - }, - '404': { description: 'Konektor neexistuje' }, - }, - }, - }, - '/api/dashboard/scripts': { - get: { - tags: ['Skripty'], - summary: 'Seznam skriptu konektoru', - description: - 'Manifesty vsech nactenych skriptu, rozbite skripty v `problems` ' + - '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', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - items: { - type: 'array', - items: { $ref: '#/components/schemas/ScriptManifest' }, - }, - problems: { - type: 'array', - items: { $ref: '#/components/schemas/ScriptProblem' }, - }, - connections: { - type: 'array', - items: { $ref: '#/components/schemas/ConnectionStatus' }, - }, - directory: { - type: 'string', - nullable: true, - example: '/app/scripts', - description: 'Jen pro spravce platformy, ostatnim null.', - }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/dashboard/scripts/reload': { - post: { - tags: ['Skripty'], - summary: 'Znovu nacist skripty ze slozky', - description: - 'Skripty se nacitaji samy podle casu zmeny souboru. Tenhle endpoint ' + - 'to jen vynuti hned, bez cekani.', - security: [{ bearerAuth: [] }], - responses: { - '200': { description: 'Skripty po nacteni' }, - '403': { description: 'Jen spravce platformy' }, - }, - }, - }, - '/api/dashboard/scripts/{id}': { - get: { - tags: ['Skripty'], - summary: 'Manifest a kod skriptu', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string' }, - example: 'idoklad.get-issued-invoice', - }, - ], - responses: { - '200': { - description: 'Kod se vraci vzdy. `manifest` je null, kdyz je skript rozbity.', - }, - '400': { description: 'Neplatne ID skriptu' }, - '404': { description: 'Skript neexistuje' }, - }, - }, - put: { - tags: ['Skripty'], - summary: 'Ulozit kod skriptu', - description: - 'Nejdriv se kod nacte a overi, az pak prepise soubor. Rozbita uprava ' + - 'se neulozi a puvodni skript dal funguje.', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string' }, - example: 'idoklad.get-issued-invoice', - }, - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['code'], - properties: { - code: { - type: 'string', - description: 'Cely obsah souboru vcetne exportu manifest a run.', - }, - }, - }, - }, - }, - }, - responses: { - '200': { description: 'Ulozeno, vraci se overeny manifest' }, - '400': { - description: 'Kod nebo manifest neprosel, v `issues` je co opravit', - content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, - }, - '403': { description: 'Jen spravce platformy' }, - }, - }, - }, - '/api/dashboard/scripts/{id}/test': { - post: { - tags: ['Skripty'], - summary: 'Zkusebni spusteni skriptu', - description: - 'POZOR: vola opravdovou sluzbu. Vystavena faktura opravdu vznikne. ' + - 'Chyba skriptu neni chyba API, vraci se 200 s popisem v `error`.', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string' }, - example: 'idoklad.get-issued-invoice', - }, - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - inputs: { - type: 'object', - additionalProperties: true, - example: { invoiceId: 12345 }, - }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Vysledek behu', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/ScriptRunResult' } }, - }, - }, - '400': { description: 'Neplatne ID nebo vstupy' }, - '403': { description: 'Jen spravce platformy' }, - }, - }, - }, - '/api/dashboard/automations': { - get: { - tags: ['Automatizace'], - summary: 'Seznam automatizaci', - security: [{ bearerAuth: [] }], - responses: { - '200': { - description: 'Automatizace', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - items: { type: 'array', items: { $ref: '#/components/schemas/Automation' } }, - }, - }, - }, - }, - }, - }, - }, - post: { - tags: ['Automatizace'], - summary: 'Zalozit automatizaci', - description: 'Do prave prepnute firmy. Chce pravo automation.edit.', - security: [{ bearerAuth: [] }], - parameters: [tenantParam], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['name'], - properties: { name: { type: 'string', minLength: 3 } }, - }, - }, - }, - }, - responses: { - '201': { - description: 'Vytvoreno', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } }, - }, - }, - '400': { description: 'Neplatny nazev' }, - '403': { description: 'Chybi pravo automation.edit' }, - }, - }, - }, - '/api/dashboard/automations/{id}': { - parameters: [ - { name: 'id', in: 'path', required: true, schema: { type: 'string' }, example: 'AUT-01' }, - ], - get: { - tags: ['Automatizace'], - summary: 'Detail vcetne stromu akci', - security: [{ bearerAuth: [] }], - responses: { - '200': { - description: 'Detail', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } }, - }, - }, - '404': { description: 'Neexistuje' }, - }, - }, - put: { - tags: ['Automatizace'], - summary: 'Ulozit automatizaci', - description: - 'Validuje strom proti katalogu konektoru. Nedokoncenou automatizaci server ' + - 'nezapne ani pri enabled=true, duvody vraci v poli issues.', - security: [{ bearerAuth: [] }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - name: { type: 'string', minLength: 3 }, - enabled: { type: 'boolean' }, - flow: { $ref: '#/components/schemas/AutomationFlow' }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Ulozeno', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } }, - }, - }, - '400': { description: 'Neplatny strom' }, - '403': { description: 'Chybi pravo automation.edit' }, - '404': { description: 'Neexistuje' }, - }, - }, - delete: { - tags: ['Automatizace'], - summary: 'Smazat automatizaci', - security: [{ bearerAuth: [] }], - 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. Chce pravo automation.edit.', - security: [{ bearerAuth: [] }], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - responses: { - '200': { - description: 'Novy token', - content: { - 'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } }, - }, - }, - '403': { description: 'Chybi pravo automation.edit' }, - '404': { description: 'Neexistuje nebo spoustecem neni webhook' }, - }, - }, - }, - '/api/dashboard/notifications': { - get: { - tags: ['Dashboard'], - summary: 'Upozorneni prihlaseneho', - description: - 'Cislo u zalozky Tickety a hlasky o pridelene praci. Upozorneni jsou ulozena, ' + - 'takze je najde i ten, kdo mel portal zavreny.', - security: [{ bearerAuth: [] }], - responses: { - '200': { - description: 'Upozorneni', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - items: { type: 'array', items: { type: 'object' } }, - unread: { type: 'integer' }, - mine: { type: 'integer', description: 'Kolik ticketu ma volajici u sebe.' }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/dashboard/notifications/read': { - post: { - tags: ['Dashboard'], - summary: 'Oznacit upozorneni jako prectena', - security: [{ bearerAuth: [] }], - requestBody: { - required: false, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - ids: { - type: 'array', - items: { type: 'string' }, - description: 'Bez seznamu se oznaci vsechna.', - }, - }, - }, - }, - }, - }, - responses: { '200': { description: 'Oznaceno' } }, - }, - }, - '/api/dashboard/runs': { - get: { - tags: ['Automatizace'], - 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. ' + - 'Bez `limit` poslednich 50.', - security: [{ bearerAuth: [] }], - parameters: [tenantParam, ...pagingParams], - responses: { - '200': { - description: 'Fronta a posledni behy', - headers: totalCountHeader, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - stats: { - type: 'object', - properties: { - pending: { type: 'integer' }, - running: { type: 'integer' }, - done: { type: 'integer' }, - failed: { type: 'integer' }, - oldestPendingAt: { type: 'string', nullable: true }, - }, - }, - items: { type: 'array', items: { type: 'object' } }, - }, - }, - }, - }, - }, - }, - }, - }, - '/webhook/ticket/{token}': { - post: { - tags: ['Webhook'], - summary: 'Prijem udalosti do ticketu', - description: - 'VEREJNY endpoint, autorizuje token firmy v adrese. Se stejnym externalId se ' + - 'udalost navesi na existujici ticket, jinak vznikne novy. externalId je ' + - 'unikatni v ramci firmy.', - parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - externalId: { oneOf: [{ type: 'string' }, { type: 'number' }] }, - source: { type: 'string', example: 'eshop' }, - event: { type: 'string', example: 'order.created' }, - subject: { type: 'string' }, - typeId: { type: 'string' }, - tags: { type: 'array', items: { type: 'string' } }, - fields: { type: 'object', additionalProperties: true }, - }, - }, - }, - }, - }, - responses: { - '201': { description: 'Ticket vznikl' }, - '200': { description: 'Udalost se navesila na existujici ticket' }, - '400': { description: 'Neplatna data' }, - '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: { - tags: ['Webhook'], - summary: 'Prijem dat do automatizace', - description: - 'VEREJNY endpoint. **Odpovi hned** (202) a strom vykona worker na pozadi - ' + - 'cizi sluzba muze odpovidat pomalu a odesilateli by vyprsel timeout. ' + - 'Telo se kontroluje proti kontraktu spoustece, vcetne vnorenych cest.', - parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { type: 'object', additionalProperties: true }, - }, - }, - }, - responses: { - '202': { - description: 'Prijato, zpracuje se na pozadi', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - accepted: { type: 'boolean' }, - automationId: { type: 'string' }, - runId: { type: 'string', nullable: true }, - }, - }, - }, - }, - }, - '400': { description: 'Telo neodpovida kontraktu spoustece' }, - '404': { description: 'Neznamy token' }, - '409': { description: 'Automatizace je pozastavena' }, - }, - }, - get: { - tags: ['Webhook'], - summary: 'Napoveda: co se v tele ceka', - description: 'Vraci metodu, seznam parametru vcetne cest a ukazku tela.', - parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], - responses: { - '200': { description: 'Kontrakt' }, - '404': { description: 'Neznamy token' }, - }, - }, - }, - '/api/contact': { - post: { - tags: ['Kontakt'], - summary: 'Odeslat poptavku z webu', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['name', 'email', 'topic', 'message'], - properties: { - name: { type: 'string', minLength: 2 }, - email: { type: 'string', format: 'email' }, - company: { type: 'string' }, - phone: { type: 'string' }, - topic: { - type: 'string', - enum: [ - 'automatizace', - 'voicebot', - 'integrace', - 'dashboard', - 'podpora', - 'jine', - ], - }, - message: { type: 'string', minLength: 10 }, - }, - }, - }, - }, - }, - responses: { - '202': { description: 'Prijato' }, - '400': { description: 'Neplatny vstup' }, - ...tooMany, - }, - }, - }, - }, - }; -} diff --git a/src/openapi/components.ts b/src/openapi/components.ts new file mode 100644 index 0000000..e95e492 --- /dev/null +++ b/src/openapi/components.ts @@ -0,0 +1,612 @@ +/** Schemata a zabezpeceni. Jen popis tvaru dat, zadna logika. */ + +export const components = { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Token z POST /api/auth/login. Vlozte samotny token bez slova Bearer.', + }, + }, + schemas: { + AresCompany: { + type: 'object', + properties: { + ico: { type: 'string', example: '27074358' }, + name: { type: 'string', example: 'Asseco Central Europe, a.s.' }, + dic: { type: 'string', nullable: true, example: 'CZ27074358' }, + address: { type: 'string', example: 'Budejovicka 778/3a, Michle, 14000 Praha 4' }, + legalFormCode: { type: 'string', example: '121' }, + legalForm: { type: 'string', example: 'Akciova spolecnost' }, + existingTenantId: { + type: 'string', + nullable: true, + description: 'ID firmy v portalu, kdyz uz je zalozena.', + }, + }, + }, + Tenant: { + type: 'object', + properties: { + id: { type: 'string', example: 'tnt_automia' }, + name: { type: 'string' }, + note: { type: 'string' }, + enabled: { type: 'boolean' }, + helpdeskProviderId: { type: 'string', nullable: true }, + ico: { type: 'string', nullable: true }, + dic: { type: 'string', nullable: true }, + address: { type: 'string', nullable: true }, + legalForm: { type: 'string', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + Error: { + type: 'object', + properties: { + error: { type: 'string', example: 'validation_error' }, + message: { type: 'string', example: 'Zadejte platny e-mail.' }, + issues: { + type: 'array', + description: + 'Jen u validation_error: vsechny problemy vstupu. `field` je cesta ' + + 'k poli spojena teckou, prazdna u chyby celeho tela.', + items: { + type: 'object', + properties: { + field: { type: 'string', example: 'memberships.0.roleIds' }, + message: { type: 'string' }, + }, + }, + }, + }, + }, + User: { + type: 'object', + description: + 'Uzivatel muze patrit do vic firem. Role je vzdy az uvnitr firmy, ' + + 'pristup napric firmami je zvlast jako platformAdmin.', + properties: { + id: { type: 'string', example: 'usr_1' }, + email: { type: 'string', example: 'admin@automia.cz' }, + name: { type: 'string', example: 'Jiri Uhlir' }, + platformAdmin: { + type: 'boolean', + description: 'Vidi napric vsemi firmami a muze mezi nimi prepinat.', + }, + memberships: { + type: 'array', + items: { + type: 'object', + properties: { + tenantId: { type: 'string', example: 'tnt_automia' }, + role: { type: 'string', enum: ['admin', 'agent'] }, + }, + }, + }, + }, + }, + Access: { + type: 'object', + description: 'Co uzivatel smi. Klient podle toho kresli prepinac pohledu.', + properties: { + scopes: { + type: 'array', + items: { type: 'string', enum: ['all', 'tenant', 'mine'] }, + }, + tenants: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', example: 'tnt_automia' }, + name: { type: 'string', example: 'Automia' }, + }, + }, + }, + defaultTenantId: { type: 'string', nullable: true }, + canAssignOthers: { + type: 'boolean', + description: 'Smi prehazovat tickety mezi lidmi, ne jen brat na sebe.', + }, + personId: { type: 'string', nullable: true }, + permissions: { + type: 'array', + items: { type: 'string' }, + description: 'Efektivni prava ve vybrane firme. Klient podle nich kresli tlacitka.', + }, + roleNames: { + type: 'array', + items: { type: 'string' }, + example: ['Spravce firmy'], + description: + 'Nazvy roli uzivatele ve vybrane firme. Tohle se ukazuje jako popis uctu, ' + + 'ne odhad z poctu prav.', + }, + nav: { type: 'array', items: { type: 'object' }, description: 'Zalozky, ktere ma videt.' }, + platformAdmin: { type: 'boolean' }, + seesOthers: { type: 'boolean', description: 'Vidi i cizi tickety, ne jen svoje.' }, + visibleGroups: { + type: 'array', + items: { + type: 'object', + properties: { id: { type: 'string' }, name: { type: 'string' } }, + }, + }, + }, + }, + Connector: { + type: 'object', + description: + 'Napojeni firmy na jednu sluzbu. Hodnoty pristupovych udaju tady zamerne ' + + 'nejsou a nikdy nebudou - secrets se z beznych endpointu nevraci.', + properties: { + id: { type: 'string', example: 'con_1a2b3c4d' }, + tenantId: { type: 'string', example: 'tnt_automia' }, + serviceId: { type: 'string', example: 'idoklad' }, + name: { type: 'string', example: 'iDoklad Automia' }, + baseUrl: { type: 'string', nullable: true }, + enabled: { type: 'boolean' }, + status: { type: 'string', enum: ['untested', 'ok', 'error'] }, + lastCheckAt: { type: 'string', format: 'date-time', nullable: true }, + lastError: { type: 'string', nullable: true }, + checkCount: { + type: 'integer', + description: + 'Kolik zaznamu o overeni je v historii. Samotna historie se cte pres ' + + '/api/dashboard/connectors/{id}/checks - v seznamu by to byla tela odpovedi navic.', + }, + isDefault: { + type: 'boolean', + description: 'Krok stromu bez vybraneho konektoru pouzije tenhle.', + }, + filled: { + type: 'array', + items: { type: 'string' }, + description: 'ID poli, ktera jsou vyplnena. Hodnoty se nevraci.', + }, + missing: { + type: 'array', + items: { type: 'string' }, + description: 'ID povinnych poli, ktera chybi.', + }, + config: { + type: 'object', + additionalProperties: { type: 'string' }, + description: 'Necitliva nastaveni. Tajna pole tu nejsou vubec.', + }, + ready: { type: 'boolean' }, + }, + }, + ScriptField: { + type: 'object', + description: + 'Parametr skriptu. Stejny tvar pro vstup i vystup - kontrola je pak ' + + 'jedna funkce, ne dve skoro stejne.', + required: ['id', 'label', 'type', 'required'], + properties: { + id: { + type: 'string', + example: 'invoiceId', + description: 'Pouziva se v sablonach jako {{invoiceId}}.', + }, + label: { type: 'string', example: 'ID faktury v iDokladu' }, + type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] }, + required: { type: 'boolean' }, + hint: { type: 'string' }, + options: { + type: 'array', + description: 'Vyber z hodnot. Jina hodnota neprojde kontrolou.', + items: { + type: 'object', + properties: { value: { type: 'string' }, label: { type: 'string' } }, + }, + }, + pattern: { type: 'string', description: 'Jen u typu string.' }, + multiline: { type: 'boolean', description: 'Jen u typu string.' }, + default: { description: 'Dosadi se, kdyz hodnota chybi a parametr neni povinny.' }, + }, + }, + ScriptManifest: { + type: 'object', + description: 'Co skript umi. Podle nej s nim umi pracovat strom automatizace.', + properties: { + id: { + type: 'string', + example: 'idoklad.get-issued-invoice', + description: 'Tvar sluzba.operace. Nazev souboru musi byt .js.', + }, + serviceId: { type: 'string', example: 'idoklad' }, + serviceName: { type: 'string', example: 'iDoklad' }, + operationId: { type: 'string', example: 'get-issued-invoice' }, + name: { type: 'string', example: 'Získat vydanou fakturu' }, + description: { type: 'string' }, + inputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } }, + outputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } }, + timeoutMs: { type: 'integer', example: 15000 }, + }, + }, + ScriptProblem: { + type: 'object', + description: 'Rozbity skript. Nesmi shodit ostatni ani tise zmizet, proto se vraci sem.', + properties: { + file: { type: 'string', example: 'idoklad.get-issued-invoice.js' }, + scriptId: { type: 'string', nullable: true }, + message: { type: 'string' }, + issues: { + type: 'array', + items: { + type: 'object', + properties: { field: { type: 'string' }, message: { type: 'string' } }, + }, + }, + }, + }, + ConnectionStatus: { + type: 'object', + description: + 'Stav napojeni konektoru. Hodnoty pristupovych udaju se nevraci nikdy, ' + + 'jen jmena promennych, ktere chybi.', + properties: { + connectorId: { type: 'string', example: 'idoklad' }, + baseUrl: { type: 'string', example: 'https://services.csbot.cz/apps/idoklad' }, + ready: { type: 'boolean' }, + missing: { + type: 'array', + items: { type: 'string' }, + example: ['IDOKLAD_CLIENT_SECRET'], + }, + headers: { type: 'array', items: { type: 'string' }, example: ['X-ClientId'] }, + }, + }, + ScriptRunResult: { + type: 'object', + description: + 'Vysledek behu skriptu. `retryable` rika, jestli ma smysl zkusit to znovu - ' + + 'timeout ano, spatny vstup ne.', + properties: { + ok: { type: 'boolean' }, + scriptId: { type: 'string' }, + outputs: { + type: 'object', + additionalProperties: true, + description: 'Prazdne, kdyz beh selhal.', + }, + logs: { + type: 'array', + items: { + type: 'object', + properties: { + at: { type: 'string', format: 'date-time' }, + message: { type: 'string' }, + detail: { type: 'string' }, + }, + }, + }, + durationMs: { type: 'integer' }, + httpCalls: { type: 'integer' }, + error: { + type: 'object', + nullable: true, + properties: { + kind: { + type: 'string', + enum: [ + 'not_found', + 'config', + 'validation', + 'output', + 'retryable', + 'terminal', + 'timeout', + 'internal', + ], + }, + message: { type: 'string' }, + retryable: { type: 'boolean' }, + status: { type: 'integer' }, + detail: { type: 'string' }, + issues: { + type: 'array', + items: { + type: 'object', + properties: { field: { type: 'string' }, message: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + LoginRequest: { + type: 'object', + required: ['email', 'password'], + properties: { + email: { type: 'string', format: 'email', example: 'admin@automia.cz' }, + password: { type: 'string', format: 'password', example: 'demo1234' }, + }, + }, + LoginResponse: { + type: 'object', + properties: { + token: { type: 'string' }, + user: { $ref: '#/components/schemas/User' }, + }, + }, + Person: { + type: 'object', + description: + 'Resitel ticketu = clen firmy. Neni to vlastni zaznam: `id` je ID uctu, `tenantId` ' + + 'firma clenstvi. Jmeno a e-mail jsou z uctu, role, kapacita a externi ID z clenstvi.', + properties: { + id: { type: 'string', example: 'usr_2', description: 'ID uctu.' }, + tenantId: { type: 'string', example: 'tnt_automia' }, + name: { type: 'string', example: 'Karel Vomacka' }, + email: { type: 'string', format: 'email' }, + role: { type: 'string', example: 'Servicedesk', description: 'Popisek, nic nerozhoduje.' }, + capacity: { + type: 'integer', + description: 'Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez.', + }, + enabled: { + type: 'boolean', + description: + 'Zapnute clenstvi v teto firme. Vypnuty se nenabizi k prirazeni, ucet jinde bezi dal.', + }, + externalIds: { type: 'array', items: { type: 'string' } }, + roleIds: { + type: 'array', + items: { type: 'string' }, + description: 'Role clenstvi v teto firme.', + }, + }, + }, + TicketCustomer: { + type: 'object', + properties: { + id: { + type: 'string', + nullable: true, + description: 'ID firmy v CRM. null = zakaznika se nepodarilo dohledat.', + example: 'crm_1042', + }, + company: { type: 'string', example: 'Firma s.r.o.' }, + contact: { type: 'string', example: 'Petra Klientova' }, + reply: { + type: 'string', + description: 'Adresa nebo cislo, odkud pozadavek prisel a kam se odpovida.', + }, + }, + }, + Ticket: { + type: 'object', + properties: { + id: { type: 'string', example: 'TK-4821' }, + subject: { type: 'string' }, + body: { + type: 'string', + description: + 'Cely text pozadavku. Prazdny retezec = krok "Zalozit ticket" obsah nenaplnil.', + }, + sourceRef: { + type: 'string', + nullable: true, + description: 'Odkaz na zdrojovou zpravu u poskytovatele.', + example: 'wamid.HBgLNDIwNzc0OTAyMzMx', + }, + channel: { + type: 'string', + enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'], + description: 'Odkud pozadavek prisel.', + }, + customer: { $ref: '#/components/schemas/TicketCustomer' }, + status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] }, + priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] }, + assignee: { + type: 'object', + nullable: true, + description: 'Kdo ma ticket u sebe. null = ceka ve fronte.', + properties: { + id: { type: 'string', example: 'usr_2', description: 'ID uctu resitele.' }, + name: { type: 'string', example: 'Karel Vomacka' }, + }, + }, + automationId: { + type: 'string', + nullable: true, + description: 'Automatizace, ktera ticket zalozila. null = zalozeno rucne.', + }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + TicketTraceEntry: { + type: 'object', + description: + 'Jeden radek logu ticketu. Strom se sklada pres parentId - vetev podminky ' + + 'visi na zaznamu te podminky.', + properties: { + id: { type: 'string', example: 'tr_12' }, + parentId: { + type: 'string', + nullable: true, + description: 'null = zaznam v hlavni sekvenci.', + }, + kind: { type: 'string', enum: ['trigger', 'action', 'condition', 'note'] }, + connectorId: { type: 'string', nullable: true, example: 'raynet' }, + operationId: { type: 'string', nullable: true, example: 'upsert-contact' }, + label: { type: 'string' }, + status: { type: 'string', enum: ['ok', 'error', 'skipped', 'info'] }, + response: { + type: 'string', + nullable: true, + description: 'Co sluzba vratila. Kvuli tomuhle log existuje.', + }, + durationMs: { type: 'integer', nullable: true }, + at: { type: 'string', format: 'date-time' }, + }, + }, + TicketDetail: { + allOf: [ + { $ref: '#/components/schemas/Ticket' }, + { + type: 'object', + properties: { + trace: { + type: 'array', + items: { $ref: '#/components/schemas/TicketTraceEntry' }, + }, + }, + }, + ], + }, + Workload: { + type: 'object', + description: 'Prehled nad firmou - kdo ma kolik ticketu u sebe.', + properties: { + rows: { + type: 'array', + items: { + type: 'object', + properties: { + person: { $ref: '#/components/schemas/Person' }, + open: { type: 'integer', description: 'Nevyresene tickety.' }, + total: { type: 'integer' }, + critical: { type: 'integer' }, + oldestOpenAt: { type: 'string', format: 'date-time', nullable: true }, + overloaded: { type: 'boolean' }, + }, + }, + }, + unassigned: { type: 'integer', description: 'Nevyresene tickety bez resitele.' }, + openTotal: { type: 'integer' }, + }, + }, + Incident: { + type: 'object', + properties: { + id: { type: 'string', example: 'INC-231' }, + title: { type: 'string' }, + service: { type: 'string' }, + severity: { type: 'string', enum: ['sev1', 'sev2', 'sev3'] }, + status: { + type: 'string', + enum: ['investigating', 'identified', 'monitoring', 'resolved'], + }, + startedAt: { type: 'string', format: 'date-time' }, + resolvedAt: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + TriggerField: { + type: 'object', + required: ['id', 'name', 'type', 'required'], + properties: { + id: { type: 'string', example: 'f_42' }, + name: { + type: 'string', + example: 'score', + description: 'Klic v prichozich datech, pismena, cislice a podtrzitko.', + }, + type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] }, + required: { type: 'boolean' }, + }, + }, + FlowStep: { + type: 'object', + description: 'Krok stromu. Bud akce nad konektorem, nebo podminka se dvema vetvemi.', + properties: { + id: { type: 'string' }, + kind: { type: 'string', enum: ['action', 'condition'] }, + connectorId: { type: 'string', example: 'email' }, + operationId: { type: 'string', example: 'send' }, + inputs: { + type: 'object', + additionalProperties: { type: 'string' }, + description: + 'Nastaveni akce. Klic je ID pole z katalogu, hodnota je sablona - ' + + '{{nazev}} se nahradi parametrem spoustece. Neznamy klic vraci 400.', + example: { subject: 'Reklamace od {{profileName}}', body: '{{text}}' }, + }, + fieldId: { type: 'string', example: 'f_42' }, + operator: { + type: 'string', + enum: [ + 'eq', + 'neq', + 'gt', + 'gte', + 'lt', + 'lte', + 'contains', + 'startsWith', + 'isEmpty', + 'isNotEmpty', + 'isTrue', + 'isFalse', + ], + }, + value: { type: 'string', example: '15' }, + yes: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } }, + no: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } }, + }, + }, + AutomationFlow: { + type: 'object', + properties: { + trigger: { + type: 'object', + nullable: true, + properties: { + connectorId: { type: 'string', example: 'webhook' }, + operationId: { type: 'string', example: 'received' }, + fields: { + type: 'array', + items: { $ref: '#/components/schemas/TriggerField' }, + }, + webhookToken: { + type: 'string', + readOnly: true, + description: 'Generuje vyhradne server, hodnota od klienta se ignoruje.', + }, + }, + }, + steps: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } }, + }, + }, + Automation: { + type: 'object', + properties: { + id: { type: 'string', example: 'AUT-01' }, + name: { type: 'string' }, + kind: { type: 'string', enum: ['workflow', 'voicebot', 'integrace', 'report'] }, + enabled: { type: 'boolean' }, + runsToday: { type: 'integer' }, + runsYesterday: { type: 'integer' }, + runsTotal: { type: 'integer' }, + successRate: { type: 'number' }, + avgDurationMs: { type: 'integer' }, + lastRunAt: { type: 'string', format: 'date-time' }, + stepCount: { type: 'integer' }, + configured: { type: 'boolean' }, + issues: { + type: 'array', + items: { type: 'string' }, + description: 'Co chybi k zapnuti. Prazdne pole znamena hotovo.', + }, + }, + }, + AutomationDetail: { + allOf: [ + { $ref: '#/components/schemas/Automation' }, + { + type: 'object', + properties: { + flow: { $ref: '#/components/schemas/AutomationFlow' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + ], + }, + }, +}; diff --git a/src/openapi/helpers.ts b/src/openapi/helpers.ts new file mode 100644 index 0000000..23f972d --- /dev/null +++ b/src/openapi/helpers.ts @@ -0,0 +1,140 @@ +/** + * Sdilene kousky popisu: fabrika na CRUD petici a opakujici se parametry, + * tela a odpovedi. Jedno misto, aby se popisy v paths/** nerozesly. + */ + +/** + * Sprava zaznamu ma u kazde entity stejnou petici endpointu, protoze ji na + * serveru dela jedna fabrika (`routes/crud.ts`). Popisovat ji devetkrat rucne + * by znamenalo devet mist, ktere se casem rozejdou. + */ +export function crudPaths(entity: { + /** Cast cesty, napr. `roles`. */ + path: string; + /** Jak se o tom mluvi v popisu, napr. `roli`. */ + label: string; + /** Pravo, ktere je na zapis potreba. */ + permission: string; +}) { + const id = { name: 'id', in: 'path', required: true, schema: { type: 'string' } }; + const body = { + required: true, + content: { 'application/json': { schema: { type: 'object' } } }, + }; + const record = { + description: 'Zaznam', + content: { 'application/json': { schema: { type: 'object' } } }, + }; + const denied = { '403': { description: `Chybi pravo ${entity.permission}` } }; + const base = `/api/dashboard/settings/${entity.path}`; + + return { + [base]: { + get: { + tags: ['Nastaveni'], + summary: `Seznam - ${entity.label}`, + description: 'Vraci jen zaznamy firem, do kterych volajici patri.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Seznam', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { items: { type: 'array', items: { type: 'object' } } }, + }, + }, + }, + }, + ...denied, + }, + }, + post: { + tags: ['Nastaveni'], + summary: `Vytvorit - ${entity.label}`, + security: [{ bearerAuth: [] }], + requestBody: body, + responses: { '201': record, '400': { description: 'Neplatny vstup' }, ...denied }, + }, + }, + [`${base}/{id}`]: { + get: { + tags: ['Nastaveni'], + summary: `Detail - ${entity.label}`, + security: [{ bearerAuth: [] }], + parameters: [id], + responses: { '200': record, '404': { description: 'Neexistuje' }, ...denied }, + }, + patch: { + tags: ['Nastaveni'], + summary: `Upravit - ${entity.label}`, + description: 'Posilaji se jen menena pole. ID a cas vzniku se prepsat nedaji.', + security: [{ bearerAuth: [] }], + parameters: [id], + requestBody: body, + responses: { + '200': record, + '400': { description: 'Neplatny vstup' }, + '404': { description: 'Neexistuje' }, + ...denied, + }, + }, + delete: { + tags: ['Nastaveni'], + summary: `Smazat - ${entity.label}`, + security: [{ bearerAuth: [] }], + parameters: [id], + responses: { + '204': { description: 'Smazano' }, + '404': { description: 'Neexistuje' }, + ...denied, + }, + }, + }, + }; +} + +export const bearer = [{ bearerAuth: [] }]; +export const idParam = { name: 'id', in: 'path', required: true, schema: { type: 'string' } }; +export 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. */ +export 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.', + }, +]; +export const totalCountHeader = { + 'X-Total-Count': { + schema: { type: 'integer' }, + description: 'Pocet polozek pred strankovanim.', + }, +}; +export const jsonBody = (schema: Record, required = true) => ({ + required, + content: { 'application/json': { schema } }, +}); +export const jsonResponse = ( + description: string, + schema: Record = { type: 'object' }, +) => ({ + description, + content: { 'application/json': { schema } }, +}); +export const tooMany = { + '429': { description: 'Prilis mnoho pokusu z jedne adresy, viz Retry-After' }, +}; diff --git a/src/openapi/index.ts b/src/openapi/index.ts new file mode 100644 index 0000000..3768209 --- /dev/null +++ b/src/openapi/index.ts @@ -0,0 +1,72 @@ +import { config } from '../config.js'; +import { components } from './components.js'; +import { opsPaths } from './paths/ops.js'; +import { authPaths } from './paths/auth.js'; +import { dashboardPaths } from './paths/dashboard.js'; +import { ticketsPaths } from './paths/tickets.js'; +import { automationsPaths } from './paths/automations.js'; +import { settingsPaths } from './paths/settings.js'; +import { adminPaths } from './paths/admin.js'; +import { connectorsPaths } from './paths/connectors.js'; +import { scriptsPaths } from './paths/scripts.js'; +import { webhookPaths } from './paths/webhook.js'; +import { helpdeskPaths } from './paths/helpdesk.js'; +import { invitesPaths } from './paths/invites.js'; +import { contactPaths } from './paths/contact.js'; + +/** + * OpenAPI popis API. + * + * `servers` MUSI obsahovat prefix reverse proxy, jinak Swagger "Try it out" + * vola endpointy na root domene a dostane 404 (viz AGENTS.md). + * Prefix se bere z ROOT_PATH, nikdy se nehardcoduje. + */ +export function buildOpenApiDocument() { + const server = config.rootPath === '' ? '/' : config.rootPath; + + return { + openapi: '3.0.3', + info: { + title: `${config.brandName} - portal a API`, + version: '1.0.0', + description: + 'Webova prezentace a klientsky portal. Automatizace, voiceboti, integrace, ' + + 'tickety a incidenty. Aplikace bezi za reverse proxy AppFactory.', + }, + servers: [{ url: server, description: 'Verejna adresa vcetne prefixu proxy' }], + tags: [ + { name: 'Provoz', description: 'Health a zakladni informace' }, + { name: 'Autentizace', description: 'Prihlaseni do portalu' }, + { name: 'Dashboard', description: 'Data klientskeho portalu' }, + { name: 'Tickety', description: 'Pozadavky, jejich resitele a log prubehu' }, + { name: 'Automatizace', description: 'Sprava automatizaci a stromu akci' }, + { name: 'Sluzby', description: 'Katalog toho, co umime napojit' }, + { name: 'Konektory', description: 'Napojeni firmy na sluzbu vcetne pristupovych udaju' }, + { 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 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, + paths: { + // Poradi urcuje, jak jdou endpointy za sebou v Swagger UI. + ...opsPaths, + ...authPaths, + ...dashboardPaths, + ...ticketsPaths, + ...automationsPaths, + ...settingsPaths, + ...adminPaths, + ...connectorsPaths, + ...scriptsPaths, + ...webhookPaths, + ...helpdeskPaths, + ...invitesPaths, + ...contactPaths, + }, + }; +} diff --git a/src/openapi/paths/admin.ts b/src/openapi/paths/admin.ts new file mode 100644 index 0000000..4785f3b --- /dev/null +++ b/src/openapi/paths/admin.ts @@ -0,0 +1,87 @@ +/** Sprava platformy: audit a prepnuti na jiny ucet. */ + +export const adminPaths: Record = { + '/api/admin/impersonate': { + post: { + tags: ['Sprava platformy'], + summary: 'Prepnout se na jiny ucet', + description: + 'Vraci novy token s narokem `act`. Bez `writes` projde **jen GET**, cokoliv ' + + 'jineho vrati 403. Prepnuti i jeho ukonceni je v auditu.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['userId'], + properties: { + userId: { type: 'string' }, + allowWrites: { + type: 'boolean', + default: false, + description: 'true = i zapis. Musi se zapnout vedome.', + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Token na cizi ucet', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + '403': { description: 'Neni spravce platformy' }, + '404': { description: 'Ucet neexistuje' }, + }, + }, + }, + '/api/admin/impersonate/stop': { + post: { + tags: ['Sprava platformy'], + summary: 'Ukoncit prepnuti', + description: 'Jen zaznam do auditu. Svuj puvodni token si drzi klient, server o nem nevi.', + security: [{ bearerAuth: [] }], + responses: { '204': { description: 'Zapsano' } }, + }, + }, + '/api/admin/impersonate/candidates': { + get: { + tags: ['Sprava platformy'], + summary: 'Koho lze prepnout', + description: 'Spravci platformy se nenabizeji.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Ucty', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + '403': { description: 'Neni spravce platformy' }, + }, + }, + }, + '/api/admin/audit': { + get: { + tags: ['Sprava platformy'], + summary: 'Audit', + description: + 'Kdo co udelal, vcetne odepreni a vcetne toho, kdo se za koho vydaval. ' + + 'Nejnovejsi nahore.', + security: [{ bearerAuth: [] }], + parameters: [ + { name: 'action', in: 'query', schema: { type: 'string' } }, + { name: 'result', in: 'query', schema: { type: 'string', enum: ['ok', 'denied'] } }, + { name: 'limit', in: 'query', schema: { type: 'integer', maximum: 500, default: 200 } }, + ], + responses: { + '200': { + description: 'Zaznamy', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + '403': { description: 'Neni spravce platformy' }, + }, + }, + }, +}; diff --git a/src/openapi/paths/auth.ts b/src/openapi/paths/auth.ts new file mode 100644 index 0000000..ef60f04 --- /dev/null +++ b/src/openapi/paths/auth.ts @@ -0,0 +1,60 @@ +/** Autentizace: prihlaseni a token. */ + +import { tooMany } from '../helpers.js'; + +export const authPaths: Record = { + '/api/auth/login': { + post: { + tags: ['Autentizace'], + summary: 'Prihlaseni', + requestBody: { + required: true, + content: { + 'application/json': { schema: { $ref: '#/components/schemas/LoginRequest' } }, + }, + }, + responses: { + '200': { + description: 'Token a udaje uzivatele', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } }, + }, + }, + '401': { + description: 'Nespravny e-mail nebo heslo', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + ...tooMany, + }, + }, + }, + '/api/auth/me': { + get: { + tags: ['Autentizace'], + summary: 'Prihlaseny uzivatel', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Udaje uzivatele', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { user: { $ref: '#/components/schemas/User' } }, + }, + }, + }, + }, + '401': { description: 'Chybi nebo neplatny token' }, + }, + }, + }, + '/api/auth/logout': { + post: { + tags: ['Autentizace'], + summary: 'Odhlaseni', + security: [{ bearerAuth: [] }], + responses: { '204': { description: 'Odhlaseno' } }, + }, + }, +}; diff --git a/src/openapi/paths/automations.ts b/src/openapi/paths/automations.ts new file mode 100644 index 0000000..f9a93ae --- /dev/null +++ b/src/openapi/paths/automations.ts @@ -0,0 +1,177 @@ +/** Automatizace: strom akci, webhook a fronta behu. */ + +import { tenantParam, pagingParams, totalCountHeader } from '../helpers.js'; + +export const automationsPaths: Record = { + '/api/dashboard/automations': { + get: { + tags: ['Automatizace'], + summary: 'Seznam automatizaci', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Automatizace', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { type: 'array', items: { $ref: '#/components/schemas/Automation' } }, + }, + }, + }, + }, + }, + }, + }, + post: { + tags: ['Automatizace'], + summary: 'Zalozit automatizaci', + description: 'Do prave prepnute firmy. Chce pravo automation.edit.', + security: [{ bearerAuth: [] }], + parameters: [tenantParam], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string', minLength: 3 } }, + }, + }, + }, + }, + responses: { + '201': { + description: 'Vytvoreno', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } }, + }, + }, + '400': { description: 'Neplatny nazev' }, + '403': { description: 'Chybi pravo automation.edit' }, + }, + }, + }, + '/api/dashboard/automations/{id}': { + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' }, example: 'AUT-01' }, + ], + get: { + tags: ['Automatizace'], + summary: 'Detail vcetne stromu akci', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Detail', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } }, + }, + }, + '404': { description: 'Neexistuje' }, + }, + }, + put: { + tags: ['Automatizace'], + summary: 'Ulozit automatizaci', + description: + 'Validuje strom proti katalogu konektoru. Nedokoncenou automatizaci server ' + + 'nezapne ani pri enabled=true, duvody vraci v poli issues.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 }, + enabled: { type: 'boolean' }, + flow: { $ref: '#/components/schemas/AutomationFlow' }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Ulozeno', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } }, + }, + }, + '400': { description: 'Neplatny strom' }, + '403': { description: 'Chybi pravo automation.edit' }, + '404': { description: 'Neexistuje' }, + }, + }, + delete: { + tags: ['Automatizace'], + summary: 'Smazat automatizaci', + security: [{ bearerAuth: [] }], + 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. Chce pravo automation.edit.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Novy token', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } }, + }, + }, + '403': { description: 'Chybi pravo automation.edit' }, + '404': { description: 'Neexistuje nebo spoustecem neni webhook' }, + }, + }, + }, + '/api/dashboard/runs': { + get: { + tags: ['Automatizace'], + 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. ' + + 'Bez `limit` poslednich 50.', + security: [{ bearerAuth: [] }], + parameters: [tenantParam, ...pagingParams], + responses: { + '200': { + description: 'Fronta a posledni behy', + headers: totalCountHeader, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + stats: { + type: 'object', + properties: { + pending: { type: 'integer' }, + running: { type: 'integer' }, + done: { type: 'integer' }, + failed: { type: 'integer' }, + oldestPendingAt: { type: 'string', nullable: true }, + }, + }, + items: { type: 'array', items: { type: 'object' } }, + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/src/openapi/paths/connectors.ts b/src/openapi/paths/connectors.ts new file mode 100644 index 0000000..19bd193 --- /dev/null +++ b/src/openapi/paths/connectors.ts @@ -0,0 +1,396 @@ +/** Sluzby a konektory: katalog, napojeni firmy, testy a MCP. */ + +import { tenantParam } from '../helpers.js'; + +export const connectorsPaths: Record = { + '/api/dashboard/services': { + get: { + tags: ['Sluzby'], + summary: 'Katalog sluzeb pro builder', + description: + 'Vraci jen sluzby, ktere uzivatel vidi. Neviditelna sluzba v odpovedi neni ' + + 'vubec, ne se stavem "nemate pravo". Operace, ktere obsluhuje skript, nesou ' + + 'implementation: script a maji skutecne inputs a outputFields.', + security: [{ bearerAuth: [] }], + parameters: [tenantParam], + responses: { + '200': { description: 'Sluzby, kategorie a operatory podminek' }, + '404': { description: 'Firma neexistuje, nebo do ni volajici nepatri' }, + }, + }, + }, + '/api/dashboard/connectors/services': { + get: { + tags: ['Sluzby'], + summary: 'Katalog sluzeb ocima firmy', + description: + 'Jako /services, navic connectorCount, tedy kolik konektoru na sluzbu firma ma. ' + + 'Podle toho se rozlisi napojeno od muzete si napojit. Neni to vlastnost sluzby, ' + + 'ale te firmy.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'tenantId', in: 'query', schema: { type: 'string' } }], + responses: { '200': { description: 'Sluzby vcetne pouziti ve firme' } }, + }, + }, + '/api/dashboard/connectors': { + get: { + tags: ['Konektory'], + summary: 'Konektory firmy', + description: + 'Hodnoty pristupovych udaju se NIKDY nevraci, jen filled (co je vyplnene) ' + + 'a missing (ktera povinna pole chybi).', + security: [{ bearerAuth: [] }], + parameters: [ + { name: 'tenantId', in: 'query', schema: { type: 'string' } }, + { name: 'serviceId', in: 'query', schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'Konektory firmy', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { + type: 'array', + items: { $ref: '#/components/schemas/Connector' }, + }, + tenantId: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + post: { + tags: ['Konektory'], + summary: 'Zalozit konektor', + description: + 'Pristupove udaje se posilaji ve values s klici podle Service.credentials. ' + + 'Nevyplnene povinne pole neni chyba, konektor se ulozi a jen nepujde pouzit.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['serviceId', 'name'], + properties: { + serviceId: { type: 'string', example: 'idoklad' }, + name: { type: 'string', example: 'iDoklad Celo' }, + baseUrl: { type: 'string', nullable: true }, + values: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + }, + }, + }, + }, + responses: { + '201': { + description: 'Zalozeno', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Connector' } }, + }, + }, + '400': { description: 'Obecna sluzba konektor nepotrebuje, nebo nezname pole' }, + '403': { description: 'Chybi pravo connector.manage' }, + '404': { description: 'Sluzba neexistuje nebo ji uzivatel nevidi' }, + }, + }, + }, + '/api/dashboard/connectors/{id}': { + get: { + tags: ['Konektory'], + summary: 'Detail konektoru', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { '200': { description: 'Konektor' }, '404': { description: 'Neexistuje' } }, + }, + patch: { + tags: ['Konektory'], + summary: 'Upravit konektor', + description: + 'Ve values staci poslat jen to, co se meni. PRAZDNY RETEZEC hodnotu smaze, ' + + 'chybejici klic ji nechava - diky tomu jde ulozit formular, ktery tajne hodnoty ' + + 'neposila. Zmena udaju vzdy zrusi predchozi overeni.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + baseUrl: { type: 'string', nullable: true }, + values: { type: 'object', additionalProperties: { type: 'string' } }, + enabled: { type: 'boolean' }, + isDefault: { type: 'boolean', enum: [true] }, + }, + }, + }, + }, + }, + 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. Chce pravo connector.manage.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '204': { description: 'Smazano' }, + '403': { description: 'Chybi pravo connector.manage' }, + '404': { description: 'Neexistuje' }, + }, + }, + }, + '/api/dashboard/connectors/egress-ip': { + get: { + tags: ['Konektory'], + summary: 'Odchozi IP adresa portalu', + description: + 'Adresa, kterou vidi volana sluzba, tedy ta, ktera musi byt na jejim seznamu ' + + 'povolenych IP. Z containeru videt neni, zjistuje se echo sluzbou podle ' + + 'EGRESS_IP_URL a vysledek se drzi v pameti po EGRESS_IP_TTL_MS. Neni to ' + + 'tajemstvi: kazda volana sluzba tuhle adresu stejne vidi.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Odchozi adresa, nebo duvod, proc se nezjistila', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ip: { type: 'string', nullable: true }, + source: { type: 'string' }, + checkedAt: { type: 'string', format: 'date-time' }, + error: { type: 'string' }, + viaProxy: { + type: 'object', + nullable: true, + description: + 'Jak nas vidi nase vlastni reverse proxy. Zmeri se volanim na ' + + 'vlastni verejnou adresu (PUBLIC_ORIGIN + ROOT_PATH + /whoami), ' + + 'ktere se otoci zpatky na tentyz stroj. Byva jina nez ta verejna ' + + 'a prave ji porovnava seznam povolenych IP u sluzeb za toutez proxy.', + properties: { + ip: { type: 'string', nullable: true }, + forwardedFor: { type: 'string', nullable: true }, + remoteAddress: { + type: 'string', + nullable: true, + description: + '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.', + }, + suggestedRange: { + type: 'string', + nullable: true, + description: + 'CIDR rozsah, ktery tu adresu pokryje cely (napr. 172.16.0.0/12 ' + + 'nebo 127.0.0.0/8). Do seznamu povolenych patri on, ne jedna ' + + 'adresa: docker prideluje z bloku a pri prekresleni site se cisla ' + + 'meni. null = adresa je verejna, zadny blok se nenabizi.', + }, + url: { type: 'string' }, + error: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/api/dashboard/connectors/{id}/test': { + post: { + tags: ['Konektory'], + summary: 'Overit napojeni', + description: + 'Zavola verifyPath sluzby, coz je zamerne cteci volani vyzadujici autorizaci. ' + + 'Kdyz ho sluzba nema, overi se jen /health a odpoved to v checked rekne - aby ' + + 'si nikdo nemyslel, ze jsou overene i pristupove udaje. Neuspesne overeni neni ' + + 'chyba API, vraci se 200 s ok: false.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Vysledek overeni', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { type: 'boolean' }, + checked: { type: 'string' }, + status: { type: 'integer' }, + message: { type: 'string' }, + baseUrl: { + type: 'string', + description: + 'Kam konektor miri. Vraci se i pri uspechu - zaklad adresy je ' + + 'z konfigurace a konektor ho smi prepsat, takze bez nej nerika ' + + 'kod odpovedi nic o tom, jestli se to trefilo na spravny stroj.', + }, + }, + }, + }, + }, + }, + '403': { description: 'Chybi pravo connector.manage' }, + '404': { description: 'Konektor neexistuje' }, + }, + }, + }, + '/api/dashboard/connectors/{id}/mcp/tools': { + post: { + tags: ['Konektory'], + summary: 'Nacist nastroje MCP serveru', + description: + 'Zepta se MCP serveru na tools/list a ulozi vysledek ke konektoru. Plati pro obe ' + + 'sluzby MCP (obecnou i EasyWeb) - jsou to jedine sluzby, u kterych seznam operaci ' + + 'neurcuje katalog, ale az sam server - teprve tim ' + + 'vzniknou kroky, ktere jde davat do automatizaci, vcetne toho, jake promenne ' + + 'prijimaji a jake vraceji. Zaroven to je overeni konektoru, proto se zapisuje do ' + + 'historie: kdyz server odpovi seznamem, adresa i token sedi. Cteci volani, nic ' + + 'nemeni. Prazdny vysledek se ulozi (server uz nastroje nenabizi), chyba nemeni nic ' + + '- vypadek serveru nesmi vymazat kroky z hotovych automatizaci. Neuspech neni ' + + 'chyba API, vraci se 200 s ok: false.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Vysledek nacteni', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { type: 'boolean' }, + checked: { type: 'string' }, + message: { type: 'string' }, + status: { type: 'integer' }, + detail: { type: 'string' }, + server: { type: 'string', description: 'Jak se server predstavil.' }, + protocolVersion: { type: 'string' }, + tools: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + label: { type: 'string' }, + description: { type: 'string' }, + inputs: { + type: 'array', + items: { type: 'string' }, + description: 'Nazvy parametru, povinne s hvezdickou na konci.', + }, + outputs: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + '400': { description: 'Sluzba neni MCP server' }, + '403': { description: 'Chybi pravo connector.manage' }, + '404': { description: 'Konektor neexistuje' }, + }, + }, + }, + '/api/dashboard/connectors/{id}/checks': { + get: { + tags: ['Konektory'], + summary: 'Historie overeni konektoru', + description: + 'Poslednich pet overeni, nejnovejsi prvni. U neuspechu nese zaznam cele telo ' + + 'odpovedi sluzby v poli detail - prave tam sluzba pise, co ji vadilo, a bez ' + + 'toho se neda rozlisit spatny udaj od zakazane IP adresy. Texty jsou uz ' + + 'zredigovane, pristupovy udaj v nich neni. Historie je zvlast a ne v seznamu ' + + 'konektoru proto, ze telo odpovedi byva o rady velikosti vetsi nez zbytek radku.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Zaznamy o overeni', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + limit: { type: 'integer' }, + items: { + type: 'array', + items: { + type: 'object', + properties: { + at: { type: 'string', format: 'date-time' }, + ok: { type: 'boolean' }, + checked: { type: 'string' }, + status: { type: 'integer', nullable: true }, + message: { type: 'string' }, + detail: { type: 'string', nullable: true }, + request: { + type: 'object', + nullable: true, + description: + 'url je cela adresa vcetne serveru, bez query - v query muze byt tajemstvi.', + properties: { + method: { type: 'string' }, + path: { type: 'string' }, + url: { type: 'string' }, + }, + }, + responseHeaders: { + type: 'object', + nullable: true, + additionalProperties: { type: 'string' }, + description: + 'Vybrane hlavicky odpovedi (server, via, content-type, ' + + 'www-authenticate, retry-after, x-request-id, date). Rikaji, kdo ' + + 'odpoved vydal - aplikace, nebo proxy pred ni. U kodu bez tela ' + + 'je to jedina stopa, ktera zbyde. Allowlist, ne vsechno: ' + + 'Set-Cookie a podobne do zaznamu nepatri.', + }, + egressIp: { + type: 'string', + nullable: true, + description: + 'Odchozi IP adresa portalu ve chvili volani. Vyplnena jen ' + + 'u odmitnuteho pristupu (401, 403) - tam je to prvni otazka, ' + + 'jinde nema co rict.', + }, + }, + }, + }, + }, + }, + }, + }, + }, + '404': { description: 'Konektor neexistuje' }, + }, + }, + }, +}; diff --git a/src/openapi/paths/contact.ts b/src/openapi/paths/contact.ts new file mode 100644 index 0000000..28f98c1 --- /dev/null +++ b/src/openapi/paths/contact.ts @@ -0,0 +1,39 @@ +/** Kontaktni formular z webu. */ + +import { tooMany } from '../helpers.js'; + +export const contactPaths: Record = { + '/api/contact': { + post: { + tags: ['Kontakt'], + summary: 'Odeslat poptavku z webu', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['name', 'email', 'topic', 'message'], + properties: { + name: { type: 'string', minLength: 2 }, + email: { type: 'string', format: 'email' }, + company: { type: 'string' }, + phone: { type: 'string' }, + topic: { + type: 'string', + enum: ['automatizace', 'voicebot', 'integrace', 'dashboard', 'podpora', 'jine'], + }, + message: { type: 'string', minLength: 10 }, + }, + }, + }, + }, + }, + responses: { + '202': { description: 'Prijato' }, + '400': { description: 'Neplatny vstup' }, + ...tooMany, + }, + }, + }, +}; diff --git a/src/openapi/paths/dashboard.ts b/src/openapi/paths/dashboard.ts new file mode 100644 index 0000000..ea0f9af --- /dev/null +++ b/src/openapi/paths/dashboard.ts @@ -0,0 +1,355 @@ +/** Dashboard: prehled, rozlozeni, incidenty, upozorneni a hlaseni padu klienta. */ + +import { idParam, tenantParam, jsonBody, jsonResponse } from '../helpers.js'; + +export const dashboardPaths: Record = { + '/api/dashboard/summary': { + get: { + tags: ['Dashboard'], + summary: 'Souhrn pro prehled', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Souhrnne metriky a casova rada' } }, + }, + }, + '/api/dashboard/widgets': { + get: { + tags: ['Dashboard'], + summary: 'Katalog widgetu prehledu', + description: 'Co jde polozit na dashboard vcetne povolenych sirek.', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Widgety' } }, + }, + }, + '/api/dashboard/layout': { + get: { + tags: ['Dashboard'], + summary: 'Rozlozeni dashboardu', + description: + 'Uklada se pro dvojici uzivatel a firma. `custom: false` znamena, ' + + 'ze uzivatel kouka na vychozi rozlozeni.', + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'tenantId', + in: 'query', + schema: { type: 'string' }, + description: 'Firma. Bez ni se pouzije prvni, do ktere uzivatel patri.', + }, + ], + responses: { + '200': { description: 'Rozlozeni' }, + '403': { description: 'Ucet nepatri do zadne firmy' }, + '404': { description: 'Firma neexistuje, nebo do ni uzivatel nepatri' }, + }, + }, + put: { + tags: ['Dashboard'], + summary: 'Ulozit rozlozeni', + description: 'Overuje se proti katalogu. Neznamy widget nebo sirka vraci 400.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'tenantId', in: 'query', schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['items'], + properties: { + items: { + type: 'array', + items: { + type: 'object', + required: ['id', 'widgetId', 'size'], + properties: { + id: { type: 'string', example: 'w1' }, + widgetId: { type: 'string', example: 'stat.openTickets' }, + size: { type: 'string', enum: ['third', 'half', 'full'] }, + }, + }, + }, + }, + }, + }, + }, + }, + responses: { + '200': { description: 'Ulozeno' }, + '400': { description: 'Neplatne rozlozeni' }, + }, + }, + delete: { + tags: ['Dashboard'], + summary: 'Vratit na vychozi rozlozeni', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'tenantId', in: 'query', schema: { type: 'string' } }], + responses: { '200': { description: 'Vychozi rozlozeni' } }, + }, + }, + '/api/dashboard/access': { + get: { + tags: ['Dashboard'], + summary: 'Co uzivatel smi videt', + description: + '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', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Access' } }, + }, + }, + }, + }, + }, + '/api/dashboard/widget-data': { + post: { + tags: ['Dashboard'], + summary: 'Data vlastnich widgetu', + description: 'Jeden request na cely prehled. Deset dlazdic nesmi znamenat deset dotazu.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['widgetIds'], + properties: { widgetIds: { type: 'array', items: { type: 'string' } } }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Data po widgetech', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + }, + }, + }, + '/api/dashboard/widget-data/options': { + get: { + tags: ['Dashboard'], + summary: 'Co jde ve vlastnim widgetu nastavit', + description: 'Zdroje dat, mozna seskupeni a sirky. Aby to klient nemel v kodu.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Moznosti', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + }, + }, + }, + '/api/dashboard/incidents': { + get: { + tags: ['Dashboard'], + summary: 'Seznam incidentu', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Incidenty', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { type: 'array', items: { $ref: '#/components/schemas/Incident' } }, + }, + }, + }, + }, + }, + }, + }, + }, + '/api/dashboard/incidents/{id}': { + get: { + tags: ['Dashboard'], + summary: 'Detail incidentu', + description: + 'Incident firmy nebo platformni. `detail` (diagnostika) dostane jen spravce platformy, ' + + 'ostatni maji null. Cizi incident je 404.', + security: [{ bearerAuth: [] }], + parameters: [idParam, tenantParam], + responses: { + '200': jsonResponse('Incident', { $ref: '#/components/schemas/Incident' }), + '404': { description: 'Incident neexistuje nebo patri jine firme' }, + }, + }, + }, + '/api/dashboard/incidents/{id}/status': { + patch: { + tags: ['Dashboard'], + summary: 'Posunout incident do dalsiho stavu', + description: + 'Pravo incident.manage za firmu incidentu; platformni incident (bez firmy) meni jen ' + + 'spravce platformy. Stav resolved nastavi resolvedAt.', + security: [{ bearerAuth: [] }], + parameters: [idParam, tenantParam], + requestBody: jsonBody({ + type: 'object', + required: ['status'], + properties: { + status: { + type: 'string', + enum: ['investigating', 'identified', 'monitoring', 'resolved'], + }, + }, + }), + responses: { + '200': jsonResponse('Incident', { $ref: '#/components/schemas/Incident' }), + '400': { description: 'Neznamy stav' }, + '403': { description: 'Chybi pravo incident.manage' }, + '404': { description: 'Incident neexistuje nebo patri jine firme' }, + }, + }, + }, + '/api/dashboard/stream': { + get: { + tags: ['Dashboard'], + summary: 'Zivy stream zmen (SSE)', + description: + 'Server-Sent Events. Drzi otevrene spojeni a posila udalosti, jakmile nastanou. ' + + 'Swagger UI streamovanou odpoved nezobrazi rozumne, testujte prohlizecem nebo curl.', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Proud udalosti text/event-stream' } }, + }, + }, + '/api/dashboard/storage': { + get: { + tags: ['Dashboard'], + summary: 'Kam se uklada', + description: + 'mode postgres nebo memory. `ephemeral: true` znamena, ze restart procesu ' + + 'data smaze. Portal to musi umet rict nahlas.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Rezim uloziste', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + mode: { type: 'string', enum: ['postgres', 'memory'] }, + reason: { type: 'string', nullable: true }, + ephemeral: { type: 'boolean' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/api/dashboard/client-crash': { + 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 ' + + 'nekdo nahlasil. Incident nese title a impact pro zakaznika a detail pro spravce ' + + 'platformy: hlaska, misto v kodu, strom komponent, adresa stranky a verze buildu. ' + + 'Tentyz pad na tomtez miste zalozi incident nejvys jednou za deset minut - pad pri ' + + 'vykreslovani se opakuje pri kazdem prekresleni a jinak by z jedne chyby vzniklo ' + + 'padesat incidentu. Volá to pojistka v klientovi, ne clovek.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['message'], + properties: { + message: { type: 'string' }, + stack: { type: 'string' }, + componentStack: { type: 'string' }, + path: { type: 'string', description: 'Kde v portalu se to stalo.' }, + build: { type: 'string', description: 'Verze nasazeneho klienta.' }, + }, + }, + }, + }, + }, + responses: { + '201': { + description: 'Incident zalozen', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + created: { type: 'boolean' }, + incidentId: { type: 'string' }, + }, + }, + }, + }, + }, + '202': { description: 'Stejny pad uz je hlaseny, incident se nezaklada' }, + '400': { description: 'Neplatny vstup' }, + '404': { description: 'Firma neexistuje, nebo do ni volajici nepatri' }, + }, + }, + }, + '/api/dashboard/notifications': { + get: { + tags: ['Dashboard'], + summary: 'Upozorneni prihlaseneho', + description: + 'Cislo u zalozky Tickety a hlasky o pridelene praci. Upozorneni jsou ulozena, ' + + 'takze je najde i ten, kdo mel portal zavreny.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Upozorneni', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, + unread: { type: 'integer' }, + mine: { type: 'integer', description: 'Kolik ticketu ma volajici u sebe.' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/api/dashboard/notifications/read': { + post: { + tags: ['Dashboard'], + summary: 'Oznacit upozorneni jako prectena', + security: [{ bearerAuth: [] }], + requestBody: { + required: false, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'string' }, + description: 'Bez seznamu se oznaci vsechna.', + }, + }, + }, + }, + }, + }, + responses: { '200': { description: 'Oznaceno' } }, + }, + }, +}; diff --git a/src/openapi/paths/helpdesk.ts b/src/openapi/paths/helpdesk.ts new file mode 100644 index 0000000..44a4862 --- /dev/null +++ b/src/openapi/paths/helpdesk.ts @@ -0,0 +1,71 @@ +/** Helpdesk: pozadavky firmy na jejiho dodavatele. */ + +import { bearer, idParam, tenantParam, jsonBody, jsonResponse } from '../helpers.js'; + +export const helpdeskPaths: Record = { + '/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' }, + }, + }, + }, +}; diff --git a/src/openapi/paths/invites.ts b/src/openapi/paths/invites.ts new file mode 100644 index 0000000..304f410 --- /dev/null +++ b/src/openapi/paths/invites.ts @@ -0,0 +1,108 @@ +/** Pozvanky do firmy a jejich prijeti. */ + +import { bearer, idParam, tenantParam, jsonBody, jsonResponse, tooMany } from '../helpers.js'; + +export const invitesPaths: Record = { + '/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', + description: 'Stary priznak, ignoruje se: resitel je kazdy clen firmy.', + }, + }, + }), + 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, + }, + }, + }, +}; diff --git a/src/openapi/paths/ops.ts b/src/openapi/paths/ops.ts new file mode 100644 index 0000000..f6edd04 --- /dev/null +++ b/src/openapi/paths/ops.ts @@ -0,0 +1,68 @@ +/** Provoz: health, readiness a echo adresy volajiciho. */ + +export const opsPaths: Record = { + '/health': { + get: { + tags: ['Provoz'], + summary: 'Health check', + description: 'Vraci 200, pokud je aplikace schopna prijimat provoz.', + responses: { + '200': { + description: 'Aplikace bezi', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', example: 'ok' }, + uptimeSec: { type: 'integer', example: 42 }, + }, + }, + }, + }, + }, + }, + }, + }, + '/health/ready': { + get: { + tags: ['Provoz'], + summary: 'Readiness vcetne databaze', + description: + 'Vraci 503, kdyz je databaze nastavena a nedostupna. `/health` na databazi ' + + 'zamerne nezavisi - kratky vypadek DB by jinak vedl k restartovani containeru.', + responses: { + '200': { description: 'Aplikace je pripravena' }, + '503': { description: 'Databaze je nastavena, ale nedostupna' }, + }, + }, + }, + '/whoami': { + get: { + tags: ['Provoz'], + summary: 'Jak nas vidi ten, kdo nam vola', + description: + 'Vraci volajicimu jeho vlastni adresu tak, jak dorazila k serveru. Zni to ' + + 'zbytecne, ale je to jediny zpusob, jak zmerit, s jakou zdrojovou adresou ' + + 'doruci reverse proxy volani, ktere vyslo z naseho containeru. Bez prihlaseni ' + + 'zamerne - volajici dostane svoji vlastni adresu, nic navic.', + responses: { + '200': { + description: 'Adresa volajiciho', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ip: { type: 'string', nullable: true }, + forwardedFor: { type: 'string', nullable: true }, + remoteAddress: { type: 'string', nullable: true }, + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/src/openapi/paths/scripts.ts b/src/openapi/paths/scripts.ts new file mode 100644 index 0000000..d3f414d --- /dev/null +++ b/src/openapi/paths/scripts.ts @@ -0,0 +1,249 @@ +/** Skripty konektoru a skripty firmy. */ + +import { bearer, idParam, tenantParam, jsonBody, jsonResponse } from '../helpers.js'; + +export const scriptsPaths: Record = { + '/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/scripts': { + get: { + tags: ['Skripty'], + summary: 'Seznam skriptu konektoru', + description: + 'Manifesty vsech nactenych skriptu, rozbite skripty v `problems` ' + + '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', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { + type: 'array', + items: { $ref: '#/components/schemas/ScriptManifest' }, + }, + problems: { + type: 'array', + items: { $ref: '#/components/schemas/ScriptProblem' }, + }, + connections: { + type: 'array', + items: { $ref: '#/components/schemas/ConnectionStatus' }, + }, + directory: { + type: 'string', + nullable: true, + example: '/app/scripts', + description: 'Jen pro spravce platformy, ostatnim null.', + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/api/dashboard/scripts/reload': { + post: { + tags: ['Skripty'], + summary: 'Znovu nacist skripty ze slozky', + description: + 'Skripty se nacitaji samy podle casu zmeny souboru. Tenhle endpoint ' + + 'to jen vynuti hned, bez cekani.', + security: [{ bearerAuth: [] }], + responses: { + '200': { description: 'Skripty po nacteni' }, + '403': { description: 'Jen spravce platformy' }, + }, + }, + }, + '/api/dashboard/scripts/{id}': { + get: { + tags: ['Skripty'], + summary: 'Manifest a kod skriptu', + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'idoklad.get-issued-invoice', + }, + ], + responses: { + '200': { + description: 'Kod se vraci vzdy. `manifest` je null, kdyz je skript rozbity.', + }, + '400': { description: 'Neplatne ID skriptu' }, + '404': { description: 'Skript neexistuje' }, + }, + }, + put: { + tags: ['Skripty'], + summary: 'Ulozit kod skriptu', + description: + 'Nejdriv se kod nacte a overi, az pak prepise soubor. Rozbita uprava ' + + 'se neulozi a puvodni skript dal funguje.', + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'idoklad.get-issued-invoice', + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['code'], + properties: { + code: { + type: 'string', + description: 'Cely obsah souboru vcetne exportu manifest a run.', + }, + }, + }, + }, + }, + }, + responses: { + '200': { description: 'Ulozeno, vraci se overeny manifest' }, + '400': { + description: 'Kod nebo manifest neprosel, v `issues` je co opravit', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + '403': { description: 'Jen spravce platformy' }, + }, + }, + }, + '/api/dashboard/scripts/{id}/test': { + post: { + tags: ['Skripty'], + summary: 'Zkusebni spusteni skriptu', + description: + 'POZOR: vola opravdovou sluzbu. Vystavena faktura opravdu vznikne. ' + + 'Chyba skriptu neni chyba API, vraci se 200 s popisem v `error`.', + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'idoklad.get-issued-invoice', + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + inputs: { + type: 'object', + additionalProperties: true, + example: { invoiceId: 12345 }, + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Vysledek behu', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/ScriptRunResult' } }, + }, + }, + '400': { description: 'Neplatne ID nebo vstupy' }, + '403': { description: 'Jen spravce platformy' }, + }, + }, + }, +}; diff --git a/src/openapi/paths/settings.ts b/src/openapi/paths/settings.ts new file mode 100644 index 0000000..6dee681 --- /dev/null +++ b/src/openapi/paths/settings.ts @@ -0,0 +1,372 @@ +/** Nastaveni: entity z fabriky crudRouter plus rucne psane endpointy. */ + +import { bearer, crudPaths, idParam, jsonBody, jsonResponse, tenantParam } from '../helpers.js'; + +/** Entity, ktere se spravuji v Nastaveni. Jeden radek na entitu. */ +const settingsEntities = [ + { path: 'tenants', label: 'firmy', permission: 'tenant.manage' }, + { path: 'users', label: 'uzivatele', permission: 'user.manage' }, + { path: 'roles', label: 'role a prava', permission: 'role.manage' }, + // Resitele maji vlastni popis nize: pod endpointy jsou ucty. + { path: 'people', label: 'resitele', permission: 'people.manage' }, + { path: 'groups', label: 'skupiny resitelu', permission: 'group.manage' }, + { 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' }, + // `features` tu neni: zalozky firmy maji jen GET a PUT, viz nize. +]; + +export const settingsPaths: Record = { + // Petice endpointu za kazdou entitu v Nastaveni, viz `crudPaths`. + ...settingsEntities.reduce( + (all, entity) => ({ ...all, ...crudPaths(entity) }), + {} as Record, + ), + /* + * Resitel je clenstvi uctu ve firme, ID resitele je ID uctu. Endpointy + * a pravo zustavaji, ale zalozeni zaklada ucet (nebo prida clenstvi uz + * existujicimu) a smazani odebira clenstvi. Prepisuje obecny popis z + * `settingsEntities`, protoze telo je jine nez u ostatnich entit. + */ + '/api/dashboard/settings/people': { + get: { + tags: ['Nastaveni'], + summary: 'Seznam - resitele', + description: 'Clenove vybrane firmy vcetne vypnutych uctu, jeden pohled na clenstvi.', + security: bearer, + parameters: [tenantParam], + responses: { + '200': jsonResponse('Resitele', { + type: 'object', + properties: { items: { type: 'array', items: { $ref: '#/components/schemas/Person' } } }, + }), + }, + }, + post: { + tags: ['Nastaveni'], + summary: 'Vytvorit - resitele', + description: + 'Zalozi ucet s clenstvim ve vybrane firme. Kdyz ucet s tim e-mailem uz existuje ' + + 'a ve firme neni, prida se mu jen clenstvi (jmeno, heslo a zapnuti se neprepisuji). ' + + 'Bez hesla dostane nahodne. Role musi byt teto firmy nebo systemove, vychozi role_agent.', + security: bearer, + parameters: [tenantParam], + requestBody: jsonBody({ + type: 'object', + required: ['name', 'email'], + properties: { + name: { type: 'string' }, + email: { type: 'string', format: 'email' }, + password: { + type: 'string', + format: 'password', + description: 'Nepovinne, jinak nahodne.', + }, + roleIds: { type: 'array', items: { type: 'string' }, default: ['role_agent'] }, + role: { type: 'string', description: 'Popisek, cim se v tymu zabyva.' }, + capacity: { type: 'integer', default: 8 }, + externalIds: { type: 'array', items: { type: 'string' } }, + enabled: { type: 'boolean', default: true }, + }, + }), + responses: { + '201': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }), + '400': { description: 'Neplatny vstup, neznama role, nebo uz je clenem firmy' }, + '403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' }, + }, + }, + }, + '/api/dashboard/settings/people/{id}': { + get: { + tags: ['Nastaveni'], + summary: 'Detail - resitele', + security: bearer, + parameters: [idParam, tenantParam], + responses: { + '200': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }), + '404': { description: 'Neni clenem firmy' }, + }, + }, + patch: { + tags: ['Nastaveni'], + summary: 'Upravit - resitele', + description: + 'Jmeno, e-mail a zapnuti meni ucet (plati ve vsech firmach), role, popisek, kapacita ' + + 'a externi ID meni clenstvi v teto firme. E-mail musi zustat unikatni.', + security: bearer, + parameters: [idParam, tenantParam], + requestBody: jsonBody({ + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string', format: 'email' }, + enabled: { type: 'boolean' }, + roleIds: { type: 'array', items: { type: 'string' } }, + role: { type: 'string' }, + capacity: { type: 'integer' }, + externalIds: { type: 'array', items: { type: 'string' } }, + }, + }), + responses: { + '200': jsonResponse('Pohled resitele', { $ref: '#/components/schemas/Person' }), + '400': { description: 'Neplatny vstup, neznama role, nebo obsazeny e-mail' }, + '403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' }, + '404': { description: 'Neni clenem firmy' }, + }, + }, + delete: { + tags: ['Nastaveni'], + summary: 'Smazat - resitele', + description: + 'Odebere clenstvi ve vybrane firme. Ucet zustava; bez jedineho clenstvi se vypne ' + + '(spravce platformy ne).', + security: bearer, + parameters: [idParam, tenantParam], + responses: { + '204': { description: 'Clenstvi odebrano' }, + '403': { description: 'Chybi pravo people.manage, nebo jde o spravce platformy' }, + '404': { description: 'Neni clenem firmy' }, + }, + }, + }, + '/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 s roli spravce firmy ' + + 'a nahodnym heslem; funkce z rejstriku jde do popisku clenstvi. 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.' }, + roles: { + type: 'array', + items: { type: 'string' }, + description: 'Funkce z rejstriku, jde do popisku clenstvi.', + }, + }, + }, + }, + }, + }), + 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 (totez co seznam)', + 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') }, + }, + }, + '/api/dashboard/settings/catalog': { + get: { + tags: ['Nastaveni'], + summary: 'Katalog prav a modulu', + description: + 'Seznam vsech prav a zalozek. Formular role tak nema seznam prav v kodu klienta.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Katalog', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + }, + }, + }, + '/api/dashboard/settings/users/{id}/password': { + patch: { + tags: ['Nastaveni'], + summary: 'Zmenit heslo', + description: 'Svoje heslo si zmeni kazdy, cizi jen spravce platformy. Hash se nikdy nevraci.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['password'], + properties: { password: { type: 'string', minLength: 8 } }, + }, + }, + }, + }, + responses: { + '204': { description: 'Zmeneno' }, + '400': { description: 'Kratke heslo' }, + '403': { description: 'Cizi heslo bez prava' }, + }, + }, + }, +}; diff --git a/src/openapi/paths/tickets.ts b/src/openapi/paths/tickets.ts new file mode 100644 index 0000000..ea81403 --- /dev/null +++ b/src/openapi/paths/tickets.ts @@ -0,0 +1,564 @@ +/** Tickety: seznam, detail, resitele a vestavene akce. */ + +import { + bearer, + idParam, + tenantParam, + pagingParams, + totalCountHeader, + jsonBody, + jsonResponse, +} from '../helpers.js'; + +export const ticketsPaths: 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/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 clenem firmy' }, + '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/people': { + get: { + tags: ['Tickety'], + summary: 'Seznam resitelu', + description: + 'Clenove firmy, na ktere jde ticket priradit. `meId` je ID prihlaseneho uctu, ' + + 'nebo null, kdyz ve firme neni clenem.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Resitele', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { type: 'array', items: { $ref: '#/components/schemas/Person' } }, + meId: { type: 'string', nullable: true }, + }, + }, + }, + }, + }, + }, + }, + }, + '/api/dashboard/tickets': { + get: { + tags: ['Tickety'], + summary: 'Seznam ticketu', + description: 'Neznama hodnota filtru se ignoruje a zaloguje, seznam se nezuzi.', + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'scope', + in: 'query', + schema: { type: 'string', enum: ['all', 'tenant', 'mine'] }, + description: + '`all` napric firmami (jen platformni admin), `tenant` cela firma, ' + + '`mine` jen moje tickety. Nepovoleny pohled vraci 403, nikdy se tise nezuzi.', + }, + { + name: 'tenantId', + in: 'query', + schema: { type: 'string' }, + description: 'Firma u pohledu `tenant` a `mine`. Bez clenstvi vraci 404.', + example: 'tnt_automia', + }, + { + name: 'assignee', + in: 'query', + schema: { type: 'string' }, + description: 'ID resitele nebo `unassigned` pro frontu. U pohledu `mine` se ignoruje.', + }, + { + name: 'status', + in: 'query', + schema: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] }, + }, + { + name: 'channel', + in: 'query', + 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 }, + }, + }, + }, + }, + }, + }, + }, + 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: { + tags: ['Tickety'], + summary: 'Kdo co ma u sebe', + description: 'Prehled zateze pres cely tym vcetne poctu ticketu ve fronte.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Vytizeni resitelu', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Workload' } }, + }, + }, + }, + }, + }, + '/api/dashboard/tickets/{id}': { + get: { + tags: ['Tickety'], + summary: 'Detail ticketu vcetne logu', + description: 'Log obsahuje i to, co ktera volana sluzba vratila.', + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'TK-4821', + }, + ], + responses: { + '200': { + description: 'Detail', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/TicketDetail' } }, + }, + }, + '404': { description: 'Neexistuje' }, + }, + }, + }, + '/api/dashboard/tickets/{id}/assign': { + post: { + tags: ['Tickety'], + summary: 'Priradit resitele', + 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: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['assigneeId'], + properties: { + assigneeId: { + type: 'string', + nullable: true, + example: 'usr_2', + description: 'ID uctu clena firmy.', + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Prirazeno', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, + }, + }, + '400': { description: 'Chybi assigneeId' }, + '403': { description: 'Chybi pravo ticket.assign.self nebo ticket.assign.others' }, + '404': { description: 'Ticket nebo resitel neexistuje' }, + }, + }, + }, + '/api/dashboard/tickets/{id}/status': { + 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: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['status'], + properties: { + status: { type: 'string', maxLength: 60, example: 'open' }, + closed: { type: 'boolean' }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Zmeneno', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, + }, + }, + '400': { description: 'Neplatny stav' }, + '403': { description: 'Chybi pravo ticket.status.change' }, + '404': { description: 'Neexistuje' }, + }, + }, + }, + '/api/dashboard/tickets/{id}/comment': { + post: { + tags: ['Tickety'], + summary: 'Pridat komentar', + description: 'Komentar je dalsi radek logu, aby bylo vse na jedne casove ose.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['text'], + properties: { text: { type: 'string', minLength: 2 } }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Zapsano', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, + }, + }, + '400': { description: 'Prazdny komentar' }, + '403': { description: 'Chybi pravo ticket.comment' }, + '404': { description: 'Neexistuje' }, + }, + }, + }, + '/api/dashboard/tickets/{id}/type': { + post: { + tags: ['Tickety'], + summary: 'Nastavit typ ticketu', + description: + 'Typ rozhoduje, ktera vlastni pole ticket ma a ktere akce se na nem ukazou. ' + + 'Pri zmene typu se hodnoty poli **nemazou**, jen prestanou byt videt.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['typeId'], + properties: { + typeId: { type: 'string', nullable: true, example: 'tt_order' }, + fields: { + type: 'object', + description: 'Hodnoty vlastnich poli. Klic je klic pole z typu ticketu.', + additionalProperties: true, + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Ulozeno', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, + }, + }, + '403': { description: 'Chybi pravo ticket.type.change' }, + '404': { description: 'Ticket nebo typ neexistuje' }, + }, + }, + }, + '/api/dashboard/tickets/{id}/tags': { + post: { + tags: ['Tickety'], + summary: 'Nastavit tagy', + description: 'Tagy se prepisuji cele. Prirustkova zmena by u vic lidi naraz kolidovala.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['tags'], + properties: { + tags: { type: 'array', maxItems: 20, items: { type: 'string', maxLength: 40 } }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Ulozeno', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, + }, + }, + '403': { description: 'Chybi pravo ticket.tag' }, + '404': { description: 'Neexistuje' }, + }, + }, + }, + '/api/dashboard/tickets/{id}/group': { + post: { + tags: ['Tickety'], + summary: 'Prehodit na skupinu resitelu', + description: + 'Prirazeni konkretnimu cloveku se **zrusi**. Kdyby zustalo, ticket by byl ' + + 've fronte skupiny i u cloveka a nikdo by nevedel, kdo to resi.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['groupId'], + properties: { groupId: { type: 'string', nullable: true } }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Ulozeno', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Ticket' } }, + }, + }, + '403': { description: 'Chybi pravo ticket.assign.group' }, + '404': { description: 'Neexistuje' }, + }, + }, + }, + '/api/dashboard/tickets/{id}/actions': { + get: { + tags: ['Tickety'], + summary: 'Akce dostupne k ticketu', + description: + 'Vraci **jen akce, ktere v teto situaci opravdu jdou spustit**: sedi typ nebo ' + + 'tag, projdou podminky a volajici na ne ma pravo. Klient nefiltruje nic.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Akce', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + label: { type: 'string', example: 'Odeslat do iDokladu' }, + icon: { type: 'string' }, + style: { type: 'string', enum: ['primary', 'default', 'danger'] }, + confirm: { type: 'string', nullable: true }, + form: { type: 'array', items: { type: 'object' } }, + }, + }, + }, + }, + }, + }, + }, + }, + '404': { description: 'Ticket neexistuje' }, + }, + }, + }, + '/api/dashboard/tickets/{id}/actions/{actionId}': { + post: { + tags: ['Tickety'], + summary: 'Spustit akci', + description: + 'Vraci 200 **i kdyz akce selhala** - selhani akce neni chyba API. Cely prubeh ' + + 'vcetne toho, co sluzba vratila, se zapise do logu ticketu.', + security: [{ bearerAuth: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'actionId', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: false, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + form: { + type: 'object', + description: 'Hodnoty poli, ktera si akce vyzada.', + additionalProperties: { type: 'string' }, + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Akce probehla nebo selhala, viz ok', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { type: 'boolean' }, + summary: { type: 'string' }, + detail: { + type: 'string', + nullable: true, + description: 'Cele chybove hlaseni. Nikdy se nezkracuje.', + }, + durationMs: { type: 'integer' }, + }, + }, + }, + }, + }, + '403': { description: 'Chybi pravo na tuto akci' }, + '404': { description: 'Ticket nebo akce neexistuje' }, + }, + }, + }, +}; diff --git a/src/openapi/paths/webhook.ts b/src/openapi/paths/webhook.ts new file mode 100644 index 0000000..5f3e6c6 --- /dev/null +++ b/src/openapi/paths/webhook.ts @@ -0,0 +1,132 @@ +/** Webhook: verejny prijem dat a adresa pro prijem udalosti do ticketu. */ + +import { bearer, tenantParam, jsonResponse } from '../helpers.js'; + +export const webhookPaths: Record = { + '/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' }, + }, + }, + }, + '/webhook/ticket/{token}': { + post: { + tags: ['Webhook'], + summary: 'Prijem udalosti do ticketu', + description: + 'VEREJNY endpoint, autorizuje token firmy v adrese. Se stejnym externalId se ' + + 'udalost navesi na existujici ticket, jinak vznikne novy. externalId je ' + + 'unikatni v ramci firmy.', + parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + externalId: { oneOf: [{ type: 'string' }, { type: 'number' }] }, + source: { type: 'string', example: 'eshop' }, + event: { type: 'string', example: 'order.created' }, + subject: { type: 'string' }, + typeId: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + fields: { type: 'object', additionalProperties: true }, + }, + }, + }, + }, + }, + responses: { + '201': { description: 'Ticket vznikl' }, + '200': { description: 'Udalost se navesila na existujici ticket' }, + '400': { description: 'Neplatna data' }, + '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: { + tags: ['Webhook'], + summary: 'Prijem dat do automatizace', + description: + 'VEREJNY endpoint. **Odpovi hned** (202) a strom vykona worker na pozadi - ' + + 'cizi sluzba muze odpovidat pomalu a odesilateli by vyprsel timeout. ' + + 'Telo se kontroluje proti kontraktu spoustece, vcetne vnorenych cest.', + parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { type: 'object', additionalProperties: true }, + }, + }, + }, + responses: { + '202': { + description: 'Prijato, zpracuje se na pozadi', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + accepted: { type: 'boolean' }, + automationId: { type: 'string' }, + runId: { type: 'string', nullable: true }, + }, + }, + }, + }, + }, + '400': { description: 'Telo neodpovida kontraktu spoustece' }, + '404': { description: 'Neznamy token' }, + '409': { description: 'Automatizace je pozastavena' }, + }, + }, + get: { + tags: ['Webhook'], + summary: 'Napoveda: co se v tele ceka', + description: 'Vraci metodu, seznam parametru vcetne cest a ukazku tela.', + parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { description: 'Kontrakt' }, + '404': { description: 'Neznamy token' }, + }, + }, + }, +}; diff --git a/src/routes/admin.ts b/src/routes/admin.ts index f9788d2..a295baa 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -57,60 +57,65 @@ const startSchema = z.object({ reason: z.string().trim().max(300).optional(), }); -adminRouter.post('/impersonate', requirePlatformAdmin, requirePlatformPermission('impersonate'), (req, res) => { - const parsed = startSchema.safeParse(req.body); - if (!parsed.success) return validationError(res, parsed.error, 'Vyberte uživatele.'); +adminRouter.post( + '/impersonate', + requirePlatformAdmin, + requirePlatformPermission('impersonate'), + (req, res) => { + const parsed = startSchema.safeParse(req.body); + if (!parsed.success) return validationError(res, parsed.error, 'Vyberte uživatele.'); - const target = findUserById(parsed.data.userId); - if (!target) { - return res.status(404).json({ error: 'not_found', message: 'Uživatel neexistuje.' }); - } - if (target.id === req.user!.id) { - return res.status(400).json({ error: 'validation_error', message: 'Tohle jste vy.' }); - } - // Prepnuti na jineho spravce platformy by obeslo cely tenhle mechanismus. - if (target.platformAdmin) { - return res.status(403).json({ - error: 'forbidden', - message: 'Na jiného správce platformy se přepnout nelze.', + const target = findUserById(parsed.data.userId); + if (!target) { + return res.status(404).json({ error: 'not_found', message: 'Uživatel neexistuje.' }); + } + if (target.id === req.user!.id) { + return res.status(400).json({ error: 'validation_error', message: 'Tohle jste vy.' }); + } + // Prepnuti na jineho spravce platformy by obeslo cely tenhle mechanismus. + if (target.platformAdmin) { + return res.status(403).json({ + error: 'forbidden', + message: 'Na jiného správce platformy se přepnout nelze.', + }); + } + + const token = jwt.sign( + { + sub: target.id, + email: target.email, + // `act` nese skutecneho cloveka. Podle nej audit pozna, kdo za tim byl. + act: req.user!.id, + actEmail: req.user!.email, + writes: parsed.data.allowWrites, + }, + config.jwtSecret, + { expiresIn: `${IMPERSONATION_MINUTES}m` }, + ); + + recordAudit({ + userId: target.id, + userEmail: target.email, + actedBy: req.user!.id, + tenantId: null, + action: 'impersonate.start', + target: target.id, + detail: { allowWrites: parsed.data.allowWrites, reason: parsed.data.reason ?? null }, }); - } - const token = jwt.sign( - { - sub: target.id, - email: target.email, - // `act` nese skutecneho cloveka. Podle nej audit pozna, kdo za tim byl. - act: req.user!.id, - actEmail: req.user!.email, - writes: parsed.data.allowWrites, - }, - config.jwtSecret, - { expiresIn: `${IMPERSONATION_MINUTES}m` }, - ); + console.warn( + `[admin] ${req.user!.email} se prepina na ${target.email}` + + `${parsed.data.allowWrites ? ' VCETNE ZAPISU' : ' jen pro cteni'}`, + ); - recordAudit({ - userId: target.id, - userEmail: target.email, - actedBy: req.user!.id, - tenantId: null, - action: 'impersonate.start', - target: target.id, - detail: { allowWrites: parsed.data.allowWrites, reason: parsed.data.reason ?? null }, - }); - - console.warn( - `[admin] ${req.user!.email} se prepina na ${target.email}` + - `${parsed.data.allowWrites ? ' VCETNE ZAPISU' : ' jen pro cteni'}`, - ); - - return res.json({ - token, - expiresInMinutes: IMPERSONATION_MINUTES, - user: { id: target.id, name: target.name, email: target.email }, - allowWrites: parsed.data.allowWrites, - }); -}); + return res.json({ + token, + expiresInMinutes: IMPERSONATION_MINUTES, + user: { id: target.id, name: target.name, email: target.email }, + allowWrites: parsed.data.allowWrites, + }); + }, +); /** * Konec impersonace. @@ -134,28 +139,38 @@ adminRouter.post('/impersonate/stop', (req, res) => { }); /** Koho lze prepnout. Spravci platformy se nenabizeji. */ -adminRouter.get('/impersonate/candidates', requirePlatformAdmin, requirePlatformPermission('impersonate'), (_req, res) => { - res.json({ - items: listAllUsers() - .filter((user) => !user.platformAdmin && user.enabled) - .map((user) => ({ id: user.id, name: user.name, email: user.email })), - }); -}); +adminRouter.get( + '/impersonate/candidates', + requirePlatformAdmin, + requirePlatformPermission('impersonate'), + (_req, res) => { + res.json({ + items: listAllUsers() + .filter((user) => !user.platformAdmin && user.enabled) + .map((user) => ({ id: user.id, name: user.name, email: user.email })), + }); + }, +); // ------------------------------------------------------------------- audit // 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), - action: typeof req.query.action === 'string' ? req.query.action : undefined, - result: req.query.result === 'denied' ? 'denied' : undefined, - limit: Number.isFinite(limit) ? Math.min(limit, 500) : 200, - }) - .then((items) => res.json({ items })) - .catch((err: unknown) => { - console.error('[admin] audit se nepodarilo precist:', err); - res.status(500).json({ error: 'internal_error', message: 'Audit se nepodařilo přečíst.' }); - }); -}); +adminRouter.get( + '/audit', + requirePlatformAdmin, + requirePlatformPermission('audit.view'), + (req, res) => { + const limit = Number(req.query.limit ?? 200); + return void listAudit({ + ...readScope(req), + action: typeof req.query.action === 'string' ? req.query.action : undefined, + result: req.query.result === 'denied' ? 'denied' : undefined, + limit: Number.isFinite(limit) ? Math.min(limit, 500) : 200, + }) + .then((items) => res.json({ items })) + .catch((err: unknown) => { + console.error('[admin] audit se nepodarilo precist:', err); + res.status(500).json({ error: 'internal_error', message: 'Audit se nepodařilo přečíst.' }); + }); + }, +); diff --git a/src/routes/ares.ts b/src/routes/ares.ts index de6c076..6415ae8 100644 --- a/src/routes/ares.ts +++ b/src/routes/ares.ts @@ -23,7 +23,13 @@ import { 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, refreshUsers, userStore, type StoredUser } from '../data/users.js'; +import { + hashPassword, + listAllUsers, + refreshUsers, + 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'; @@ -37,6 +43,9 @@ function aresStatus(err: AresError): number { return 502; } +/** Kolik shod podle nazvu se nabidne. Vic by v dialogu nikdo neprochazel. */ +const SEARCH_LIMIT = 10; + /** Firmy podle IC (presne) nebo casti nazvu. */ aresRouter.get('/companies', async (req, res) => { const query = String(req.query.query ?? '').trim(); @@ -45,14 +54,22 @@ aresRouter.get('/companies', async (req, res) => { } 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])); + const companies = + ico !== null + ? [await lookupCompany(ico)].filter((c) => c !== null) + : await searchCompanies(query, SEARCH_LIMIT); + 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 }); + if (err instanceof AresError) + return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message }); throw err; } }); @@ -60,20 +77,28 @@ aresRouter.get('/companies', async (req, res) => { /** 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.' }); + 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) })), + 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 }); + 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.'), + 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(), @@ -112,7 +137,8 @@ aresRouter.post('/tenants', async (req, res) => { try { company = await lookupCompany(ico); } catch (err) { - if (err instanceof AresError) return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message }); + if (err instanceof AresError) + return res.status(aresStatus(err)).json({ error: 'ares_error', message: err.message }); throw err; } if (company === null) { @@ -131,9 +157,14 @@ aresRouter.post('/tenants', async (req, res) => { // 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(), - ); + const persons = parsed.data.persons.map((person, index) => ({ + ...person, + email: (person.email && person.email !== '' + ? person.email + : placeholderEmail(ico, index + 1) + ).toLowerCase(), + })); + const emails = persons.map((person) => person.email); for (const email of emails) { if (taken.has(email)) { return res.status(409).json({ error: 'conflict', message: `E-mail ${email} už někdo má.` }); @@ -141,7 +172,9 @@ aresRouter.post('/tenants', async (req, res) => { 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.' }); + return res + .status(400) + .json({ error: 'validation_error', message: 'Dva lidé mají stejný e-mail.' }); } const timestamp = nowIso(); @@ -163,11 +196,11 @@ aresRouter.post('/tenants', async (req, res) => { const created = await tenantStore.create(tenant); const users: StoredUser[] = []; - for (const [index, person] of parsed.data.persons.entries()) { + for (const person of persons) { const user: StoredUser = { id: `usr_${randomUUID().slice(0, 8)}`, tenantId: null, - email: emails[index], + email: person.email, name: person.name, passwordHash: await hashPassword(randomBytes(18).toString('base64url')), platformAdmin: false, diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 297ea16..bc77550 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -19,7 +19,13 @@ const loginSchema = z.object({ * 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 }); +const LOGIN_WINDOW_MS = 15 * 60_000; +const LOGIN_MAX_ATTEMPTS = 20; +const loginLimiter = rateLimit({ + name: 'login', + windowMs: LOGIN_WINDOW_MS, + max: LOGIN_MAX_ATTEMPTS, +}); authRouter.post('/login', loginLimiter, async (req, res) => { const parsed = loginSchema.safeParse(req.body); diff --git a/src/routes/connectors.ts b/src/routes/connectors.ts index 439f722..97857b1 100644 --- a/src/routes/connectors.ts +++ b/src/routes/connectors.ts @@ -47,10 +47,10 @@ import { smtpSettings, smtpTargetUrl, verifySmtp } from '../mail/smtp.js'; import { listTools } from '../mcp/client.js'; import { forgetMcpTools, rememberMcpTools } from '../data/mcpTools.js'; import { isMcpService } from '../mcp/dialect.js'; -import { resolveTarget, serviceBaseUrl, targetSecrets } from '../scripts/connections.js'; -import { createHttp } from '../scripts/http.js'; -import { ScriptError } from '../scripts/types.js'; -import { createRedactor, describe, truncate } from '../scripts/util.js'; +import { resolveTarget, serviceBaseUrl, targetSecrets } from '../runtime/scripts/connections.js'; +import { createHttp } from '../runtime/scripts/http.js'; +import { ScriptError } from '../runtime/scripts/types.js'; +import { createRedactor, describe, truncate } from '../runtime/scripts/util.js'; import { safeRouter } from '../middleware/asyncHandler.js'; import { optionalTenantOrDeny, tenantOrDeny } from '../middleware/tenant.js'; import { validationError } from '../middleware/validation.js'; @@ -68,8 +68,12 @@ 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.' }); + 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; @@ -111,9 +115,7 @@ connectorsRouter.get('/services', async (req, res) => { const withScripts = new Map( serviceCatalog(tenantId ?? null).map((service) => [service.id, service]), ); - const counts = tenantId - ? await connectorCountsByService([tenantId]) - : new Map(); + const counts = tenantId ? await connectorCountsByService([tenantId]) : new Map(); const items = visible.map((service) => { const merged = withScripts.get(service.id) ?? service; @@ -202,8 +204,9 @@ connectorsRouter.post('/', async (req, res) => { } const issues = validateConnectorValues(service, parsed.data.values ?? {}); - if (issues.length > 0) { - return res.status(400).json({ error: 'validation_error', message: issues[0].message, issues }); + const firstIssue = issues[0]; + if (firstIssue !== undefined) { + return res.status(400).json({ error: 'validation_error', message: firstIssue.message, issues }); } const connector = await createConnector({ @@ -250,8 +253,11 @@ connectorsRouter.patch('/:id', async (req, res) => { if (parsed.data.values) { const issues = validateConnectorValues(service, parsed.data.values); - if (issues.length > 0) { - return res.status(400).json({ error: 'validation_error', message: issues[0].message, issues }); + const firstIssue = issues[0]; + if (firstIssue !== undefined) { + return res + .status(400) + .json({ error: 'validation_error', message: firstIssue.message, issues }); } } @@ -403,6 +409,9 @@ async function loadMcpTools(connectorId: string, tenantId: string) { * prepsat, takze "vratilo 403" bez serveru nerika, jestli se to vubec trefilo * na spravny stroj. Hadat to podle toho, kde je nasazeny portal, nejde. */ +/** Strop testu napojeni. Sluzba, ktera neodpovi do deseti sekund, je pro test nedostupna. */ +const TEST_TIMEOUT_MS = 10_000; + connectorsRouter.post('/:id/test', async (req, res) => { const tenantId = managedTenantOrDeny(req, res); if (!tenantId) return; @@ -498,11 +507,12 @@ connectorsRouter.post('/:id/test', async (req, res) => { const checked = service.verifyPath ? 'přístupové údaje' : 'jen dostupnost služby'; // Zaloha pro pripad, ze se k volani vubec nedoslo a chyba tedy `request` nema. // Query se odrizne stejne jako v `ScriptRequestInfo` - muze v ni byt tajemstvi. - const verifyUrl = `${target.baseUrl.replace(/\/+$/, '')}${path.split('?')[0]}`; + const pathWithoutQuery = path.split('?')[0] ?? path; + const verifyUrl = `${target.baseUrl.replace(/\/+$/, '')}${pathWithoutQuery}`; const redact = createRedactor(targetSecrets(target)); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 10_000); + const timer = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS); try { const http = createHttp({ @@ -518,7 +528,7 @@ connectorsRouter.post('/:id/test', async (req, res) => { const message = service.verifyPath ? 'Napojení funguje, přístupové údaje jsou platné.' : 'Služba odpovídá. Přístupové údaje se tímhle neověřily, služba na to nemá čtecí volání.'; - const request = { method: 'GET', path: path.split('?')[0], url: verifyUrl }; + const request = { method: 'GET', path: pathWithoutQuery, url: verifyUrl }; // Do historie patri i uspech. Bez nej se neda poznat, jestli konektor // nesel nikdy, nebo prestal jit ve chvili, kdy se sahlo na udaje. @@ -557,12 +567,17 @@ connectorsRouter.post('/:id/test', async (req, res) => { ? err.detail ? redact(err.detail) : undefined - : redact(truncate(String(err instanceof Error ? err.stack ?? err.message : err), config.errorDetailBytes)); + : redact( + truncate( + String(err instanceof Error ? (err.stack ?? err.message) : err), + config.errorDetailBytes, + ), + ); const request = isScriptError && err.request ? err.request - : { method: 'GET', path: path.split('?')[0], url: verifyUrl }; + : { method: 'GET', path: pathWithoutQuery, url: verifyUrl }; // Hlavicky rikaji, kdo odpoved vydal. U 403 bez tela je to vsechno, // co zbyde: `Server: Kestrel` je aplikace, `Via: 1.1 Caddy` proxy. const responseHeaders = isScriptError ? (err.responseHeaders ?? null) : null; diff --git a/src/routes/contact.ts b/src/routes/contact.ts index 4769e51..84f94c3 100644 --- a/src/routes/contact.ts +++ b/src/routes/contact.ts @@ -6,7 +6,13 @@ 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 CONTACT_WINDOW_MS = 60 * 60_000; +const CONTACT_MAX_PER_WINDOW = 5; +const contactLimiter = rateLimit({ + name: 'contact', + windowMs: CONTACT_WINDOW_MS, + max: CONTACT_MAX_PER_WINDOW, +}); const contactSchema = z.object({ name: z.string().min(2, 'Zadejte jméno.'), diff --git a/src/routes/crud.ts b/src/routes/crud.ts index 8a7c4ff..4e40384 100644 --- a/src/routes/crud.ts +++ b/src/routes/crud.ts @@ -25,7 +25,12 @@ import { type Request, type Response, type Router } from 'express'; import type { z } from 'zod'; 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'; +import { + nowIso, + type EntityStore, + type ListOptions, + type TenantEntity, +} from '../data/store/index.js'; import { safeRouter } from '../middleware/asyncHandler.js'; import { requestTenant, tenantOrDeny } from '../middleware/tenant.js'; import { validationError } from '../middleware/validation.js'; @@ -60,10 +65,7 @@ export interface CrudOptions { /** Pravo potrebne k zapisu. Cteni staci prihlaseni. */ writePermission: string; /** Sestavi novy zaznam z overeneho vstupu. Smi byt asynchronni (hash hesla). */ - build: ( - input: C, - tenantId: string, - ) => BuiltEntity | Promise>; + 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. */ @@ -111,7 +113,8 @@ export function crudRouter(options: CrudOptions(options: CrudOptions(options: CrudOptions, 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, - }; -} - -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(accessOf(req)); -}); - -dashboardRouter.get('/summary', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - return res.json(getSummary(scope.tenantIds)); -}); - -/** - * Kam se uklada a jestli to prezije restart. - * - * 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) => { - // Cesta na disku serveru je nase provozni informace, ne zakaznikova. - const { location, ...status } = storageStatus(); - res.json(req.user!.platformAdmin ? { ...status, location } : { ...status, location: null }); -}); - -/** - * Incidenty firmy plus platformni. - * - * Klient vidi `title` a `impact`, tedy co to pro nej znamena. `detail` s celym - * hlasenim, ID behu a daty na vstupu vidi **jen spravce platformy** - je to - * nase diagnostika, ne informace pro zakaznika. - */ -dashboardRouter.get('/incidents', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - - const forAdmin = req.user!.platformAdmin; - return res.json({ - items: listIncidents(scope.tenantIds).map((incident) => ({ - ...incident, - detail: forAdmin ? incident.detail : null, - })), - }); -}); - -/** Stejne pravidlo jako u seznamu: diagnostiku vidi jen spravce platformy. */ -function publicIncident(req: Request, incident: Incident): Incident { - return { ...incident, detail: req.user!.platformAdmin ? incident.detail : null }; -} - -dashboardRouter.get('/incidents/:id', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - - const incident = findIncident(req.params.id, scope.tenantIds); - if (!incident) return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' }); - return res.json(publicIncident(req, incident)); -}); - -const incidentStatusSchema = z.object({ - status: z.enum(['investigating', 'identified', 'monitoring', 'resolved']), -}); - -/** - * Posun incidentu do dalsiho stavu. - * - * Incident firmy meni, kdo ma v te firme `incident.manage`. Platformni incident - * (bez firmy) je nas a meni ho jen spravce platformy - klient by jinak mohl - * "vyresit" vypadek, ktery se tyka vsech. - */ -dashboardRouter.patch('/incidents/:id/status', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - - const incident = findIncident(req.params.id, scope.tenantIds); - if (!incident) return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' }); - - const allowed = - req.user!.platformAdmin || - (incident.tenantId !== null && hasPermission(req.user!, 'incident.manage', incident.tenantId)); - if (!allowed) { - return res.status(403).json({ error: 'forbidden', message: 'Stav incidentu nemůžete měnit.' }); - } - - const parsed = incidentStatusSchema.safeParse(req.body); - if (!parsed.success) return validationError(res, parsed.error); - - const updated = updateIncidentStatus(incident.id, parsed.data.status); - if (!updated) return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' }); - - recordAudit({ - userId: req.user!.id, - userEmail: req.user!.email, - tenantId: incident.tenantId, - action: 'incident.status', - target: incident.id, - detail: { status: parsed.data.status }, - }); - return res.json(publicIncident(req, updated)); -}); - -/** - * Hlaseni padu portalu. - * - * Kdyz v prohlizeci spadne vykreslovani, uzivatel to vidi, ale my ne. Bez - * tohohle endpointu je jedina stopa v jeho konzoli, kam se nikdo nedostane, - * takze o padu vime jen tehdy, kdyz ho nekdo nahlasi. To znamena, ze o vetsine - * padu nevime vubec. - * - * Incident nese dve casti a je to zamerne: - * - `title` a `impact` cte zakaznik, takze zadne stack trace, - * - `detail` cte spravce platformy a je v nem vsechno: hlaska, misto v kodu, - * strom komponent, adresa stranky a verze buildu. - * - * Cely `detail` je to, **jak se to stalo**. Bez adresy a stromu komponent je - * hlaska "Cannot create property" k nepouziti. - */ - -/** - * Kolik nejvys znaku se z jednoho pole prevezme. - * - * Strom komponent umi byt velmi dlouhy a do incidentu patri jeho zacatek, - * protoze prave nahore je komponenta, ktera spadla. - */ -const FIELD_LIMIT = 4_000; - -/** - * Jak dlouho se tentyz pad povazuje za jeden. - * - * Pad pri vykreslovani se opakuje pri kazdem stisku klavesy. Bez tohohle by - * z jedne chyby vzniklo padesat incidentu a ten pravy by v nich zapadl. - */ -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(), - componentStack: z.string().max(FIELD_LIMIT).optional(), - /** Kde v portalu se to stalo. Bez toho se to nema kde hledat. */ - path: z.string().max(500).optional(), - /** Verze nasazeneho klienta. Rika, jestli uz je v tom oprava. */ - build: z.string().max(200).optional(), -}); - -dashboardRouter.post('/client-crash', (req, res) => { - // 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 validationError(res, parsed.error); - - const crash = parsed.data; - const where = crash.path ?? 'neznámá stránka'; - - /* - * 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 = 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 = [ - `Stránka: ${where}`, - `Uživatel: ${req.user!.email}`, - `Verze klienta: ${crash.build ?? 'neznámá'}`, - `Prohlížeč: ${String(req.headers['user-agent'] ?? 'neznámý')}`, - '', - `Hláška: ${crash.message}`, - ...(crash.componentStack ? ['', 'Strom komponent:', crash.componentStack] : []), - ...(crash.stack ? ['', 'Zásobník volání:', crash.stack] : []), - ].join('\n'); - - const incident = createIncident({ - tenantId, - title: 'Část portálu se nepodařilo vykreslit', - service: 'Portál', - // Pad vykreslovani neni vypadek sluzby, ale uzivatel u toho nemuze - // pokracovat v praci. Prostredni zavaznost, ne nejvyssi. - severity: 'sev2', - impact: `Stránka ${where} se části uživatelů nezobrazila správně. Ostatní části portálu fungují.`, - detail, - source: 'portál', - }); - - console.error(`[ui] pad portalu na ${where}: ${crash.message} (incident ${incident.id})`); - return res.status(201).json({ created: true, incidentId: incident.id }); -}); - -// ------------------------------------------------------- rozlozeni dashboardu - -/** - * Katalog widgetu: pevne z kodu plus vlastni firmy. - * - * Vlastni widget je pro klienta tentyz tvar jako pevny, jen `custom: true`. - * Diky tomu se rozlozeni dashboardu nemuselo menit. - */ -dashboardRouter.get('/widgets', (req, res) => { - /* - * Katalog je za konkretni firmu, stejne jako rozlozeni. Kdyby vracel widgety - * vsech firem uzivatele, sla by polozit dlazdice Automie na dashboard - * Nordisu - v nabidce by byla, ale data by k ni nikdy neprisla. - */ - const tenantId = tenantOrDeny(req, res); - if (!tenantId) return; - - return res.json({ - 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. - */ -dashboardRouter.get('/layout', (req, res) => { - const tenantId = tenantOrDeny(req, res); - if (!tenantId) return; - - return res.json({ - tenantId, - items: getLayout(req.user!.id, tenantId, accessOf(req).personId !== null), - custom: hasCustomLayout(req.user!.id, tenantId), - }); -}); - -const layoutSchema = z.object({ - items: z.array( - z.object({ - id: z.string().min(1), - widgetId: z.string().min(1), - size: z.enum(['third', 'half', 'full']), - }), - ), -}); - -dashboardRouter.put('/layout', (req, res) => { - const tenantId = tenantOrDeny(req, res); - if (!tenantId) return; - - const parsed = layoutSchema.safeParse(req.body); - if (!parsed.success) return validationError(res, parsed.error, 'Neplatné rozložení.'); - - const problems = validateLayout( - parsed.data.items, - widgetCatalog([tenantId], req.user!.id, accessOf(req).personId !== null), - ); - if (problems.length > 0) { - console.warn(`[layout] ${req.user!.email}: neplatne rozlozeni - ${problems.join(' ')}`); - return res.status(400).json({ error: 'validation_error', message: problems[0] }); - } - - const items = saveLayout(req.user!.id, tenantId, parsed.data.items); - return res.json({ tenantId, items, custom: true }); -}); - -/** Vraceni na vychozi. Zamerne DELETE - je to smazani ulozeneho nastaveni. */ -dashboardRouter.delete('/layout', (req, res) => { - const tenantId = tenantOrDeny(req, res); - if (!tenantId) return; - - return res.json({ - tenantId, - items: resetLayout(req.user!.id, tenantId, accessOf(req).personId !== null), - custom: false, - }); -}); - -/** - * Detail resitele: kdo to je, jak mu to jde a co ma u sebe. - * - * Statistika i tickety chodi jednim requestem. Stranka o jednom cloveku by - * jinak delala tri dotazy na tri veci, ktere se pocitaji z tehoz seznamu. - */ -dashboardRouter.get('/people/:id', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - - const person = listPeople(scope.tenantIds).find((item) => item.id === req.params.id); - if (!person) { - // Cizi resitel se chova jako neexistujici, ne jako odepreni prava. - return res.status(404).json({ error: 'not_found', message: 'Řešitel neexistuje.' }); - } - - // 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; - const stats = getAgentStats([person], scope.tenantIds, since, scope.visibility)[0] ?? null; - - return res.json({ - person, - stats, - groups: listGroups(scope.tenantIds) - .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 && ticket.assignee?.id === person.id), - /** Co za posledni dobu vyresil, nejnovejsi nahore. */ - resolved: tickets - .filter((ticket) => ticket.closed && ticket.resolvedById === person.id) - .sort((a, b) => (b.resolvedAt ?? '').localeCompare(a.resolvedAt ?? '')) - .slice(0, 20), - }); -}); - -// ---------------------------------------------------- prijem udalosti zvenku - -/** - * Adresa pro prijem udalosti do ticketu. - * - * Token je pristupovy udaj, proto ho vidi jen ten, kdo spravuje napojeni. - * Kdo umi zalozit konektor, umi zaridit i prijem - je to tatáz prace. - */ -dashboardRouter.get('/intake', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - - if (!hasPermission(req.user!, 'connector.manage', scope.tenantId)) { - return res.status(403).json({ - error: 'forbidden', - message: 'Adresu pro příjem vidí ten, kdo spravuje napojení.', - }); - } - - const tenantId = scope.tenantIds[0]; - const tenant = tenantId ? findTenant(tenantId) : undefined; - if (!tenant) { - return res.status(404).json({ error: 'not_found', message: 'Firma neexistuje.' }); - } - - return res.json({ - tenantId: tenant.id, - tenantName: tenant.name, - url: `${publicBaseUrl()}/webhook/ticket/${tenant.intakeToken}`, - ticketTypes: listTicketTypes([tenant.id]).map((type) => ({ - id: type.id, - name: type.name, - fields: type.fields.map((field) => ({ - key: field.key, - label: field.label, - type: field.type, - required: field.required, - })), - })), - }); -}); - -/** Nova adresa. Stara okamzite prestane fungovat. */ -dashboardRouter.post('/intake/regenerate', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - - if (!hasPermission(req.user!, 'connector.manage', scope.tenantId)) { - return res.status(403).json({ - error: 'forbidden', - message: 'Adresu pro příjem mění ten, kdo spravuje napojení.', - }); - } - - const tenantId = scope.tenantIds[0]; - if (!tenantId) { - return res.status(400).json({ error: 'no_tenant', message: 'Vyberte firmu.' }); - } - - return void tenantStore - .update(tenantId, { intakeToken: generateIntakeToken() }, { tenantIds: [], includeGlobal: true }) - .then(async (updated) => { - if (!updated) { - return res.status(404).json({ error: 'not_found', message: 'Firma neexistuje.' }); - } - await refreshTenants(); - recordAudit({ - userId: req.user!.id, - userEmail: req.user!.email, - tenantId, - action: 'intake.regenerate', - target: tenantId, - }); - console.info(`[intake] ${tenantId}: adresa pregenerovana, stara neplati`); - return res.json({ url: `${publicBaseUrl()}/webhook/ticket/${updated.intakeToken}` }); - }) - .catch((err: unknown) => { - console.error('[intake] regenerace selhala:', err); - return res - .status(500) - .json({ error: 'internal_error', message: 'Adresu se nepodařilo změnit.' }); - }); -}); - -// ------------------------------------------------------------- upozorneni - -/** - * Upozorneni prihlaseneho. - * - * Cislo u zalozky "Moje tickety" a hlaska pri prirazeni. Nechodi to pres - * stream jako jedina cesta - kdo mel portal zavreny, musi to najit i po - * prihlaseni, proto jsou upozorneni ulozena. - */ -dashboardRouter.get('/notifications', (req, res) => { - const items = listNotifications(req.user!.id); - return res.json({ - items, - unread: unreadCount(req.user!.id), - /** Kolik ticketu ma prihlaseny u sebe. To je to cislo u zalozky. */ - mine: myOpenTickets(req), - }); -}); - -/** Oznaci prectene. Bez seznamu vsechny. */ -dashboardRouter.post('/notifications/read', (req, res) => { - const ids = Array.isArray(req.body?.ids) - ? (req.body.ids as unknown[]).filter((id): id is string => typeof id === 'string') - : undefined; - - return void markRead(req.user!.id, ids) - .then((count) => res.json({ marked: count, unread: unreadCount(req.user!.id) })) - .catch((err: unknown) => { - console.error('[upozorneni] oznaceni selhalo:', err); - return res.status(500).json({ error: 'internal_error', message: 'Nepodařilo se uložit.' }); - }); -}); - -/** Kolik nevyrizenych ma prihlaseny u sebe. */ -function myOpenTickets(req: Request): number { - const access = accessOf(req); - if (!access.personId) return 0; - - // Vlastni tickety jsou ve stropu vzdycky, ale posila se vyslovne - filtr - // na prava nesmi byt nepovinny. - return listTickets({ - tenantIds: access.tenants.map((tenant) => tenant.id), - visibility: { kind: 'scoped', personIds: [access.personId], groupIds: [] }, - assignee: access.personId, - }).filter((ticket) => !ticket.closed).length; -} - -// ------------------------------------------------------------------- fronta - -/** - * Stav fronty behu. - * - * Kdyz neco nefunguje, tohle je prvni misto, kam se clovek podiva: ceka - * fronta, nebo uz to nekolikrat selhalo a vzdalo se? - */ -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, - items: runs.map((run) => ({ - id: run.id, - automationId: run.automationId, - trigger: run.trigger, - status: run.status, - attempts: run.attempts, - ticketId: run.ticketId, - // Cele hlaseni. Zkratit ho tady znamena, ze se pricina uz nedozvime. - lastError: run.lastError, - nextAttemptAt: run.nextAttemptAt, - createdAt: run.createdAt, - finishedAt: run.finishedAt, - })), - }); -}); - -// ------------------------------------------------------------------- tickety - -/** - * Resitele vybrane firmy. Klient je potrebuje do nabidky prirazeni i do prehledu. - * - * Skupiny jdou stejnym endpointem zamerne: kdo smi prirazovat ticket, smi ho - * prirazit i skupine, a druhy request na dve polozky nema smysl. Sprava skupin - * je jina vec a ma vlastni pravo v nastaveni. - */ -dashboardRouter.get('/people', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - - return res.json({ - items: listPeople(scope.tenantIds), - groups: listGroups(scope.tenantIds).map((group) => ({ id: group.id, name: group.name })), - meId: accessOf(req).personId, - }); -}); - -/* - * Stav uz neni ciselnik. Filtr proto bere, co prijde - kdyz to na nic nesedi, - * vrati se prazdny seznam, coz je spravna odpoved na "ukaz mi stav, ktery - * nikdo nema". - */ -const ticketChannels: TicketChannel[] = [ - 'whatsapp', - 'facebook', - 'instagram', - 'email', - 'voice', - 'form', - 'portal', -]; - -/** - * Filtr ze query parametru. Nesmyslnou hodnotu zahodime a zalogujeme - - * je lepsi ukazat vic ticketu nez prazdny seznam bez vysvetleni. - */ -function ticketFilterFrom(query: Record, scope: ResolvedScope): TicketFilter { - const filter: TicketFilter = { tenantIds: scope.tenantIds, visibility: scope.visibility }; - - // Filtry, na ktere se odkazuje z widgetu. `none` znamena "bez toho". - const typeId = query.typeId; - if (typeof typeId === 'string' && typeId !== '') filter.typeId = typeId; - const tag = query.tag; - if (typeof tag === 'string' && tag !== '') filter.tag = tag; - const groupId = query.groupId; - if (typeof groupId === 'string' && groupId !== '') filter.groupId = groupId; - - // Pohled "moje" je silnejsi nez rucni filtr na resitele. - if (scope.scope === 'mine') { - filter.assignee = scope.personId ?? '__nikdo__'; - return applyRest(query, filter); - } - - const assignee = typeof query.assignee === 'string' ? query.assignee : undefined; - if (assignee) filter.assignee = assignee; - - return applyRest(query, filter); -} - -function applyRest(query: Record, filter: TicketFilter): TicketFilter { - /* - * Stav uz neni ciselnik, takze se nekontroluje proti seznamu. Kdyz hodnota - * na nic nesedi, vrati se prazdny seznam - to je spravna odpoved na dotaz - * po stavu, ktery nikdo nema. - */ - const status = typeof query.status === 'string' ? query.status : undefined; - if (status) filter.status = status; - - const channel = typeof query.channel === 'string' ? query.channel : undefined; - if (channel) { - if (ticketChannels.includes(channel as TicketChannel)) filter.channel = channel as TicketChannel; - else console.warn(`[tickets] neznamy kanal ve filtru: ${channel}`); - } - - return filter; -} - -dashboardRouter.get('/tickets', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - - const filter = ticketFilterFrom(req.query as Record, scope); - - /* - * 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. Kdyz zadny - * filtr neni, je cely rozsah zaroven vysledek a seznam se cte jen jednou. - */ - 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: paginate(filtered, page), - total: filtered.length, - statuses, - meId: accessOf(req).personId, - scope: scope.scope, - tenantId: scope.tenantId, - }); -}); - -/** - * Rucne zalozeny ticket. - * - * Dosud ticket vznikal jen z automatizace nebo z prichozi udalosti. Jenze - * pozadavek casto prijde telefonem nebo pri kafi a nekdo ho musi zapsat - - * bez toho konci na papirku a v systemu neni. - * - * **Zakaznik je nepovinny.** Ticket zalozeny rucne je casto ukol, ne pozadavek - * od nekoho zvenku, a nutit k nemu firmu a kontakt by znamenalo vymyslet si je. - */ -const createTicketSchema = z.object({ - subject: z.string().trim().min(1, 'Předmět nesmí být prázdný.'), - body: z.string().default(''), - priority: z.enum(['low', 'normal', 'high', 'critical']).default('normal'), - typeId: z.string().trim().min(1).nullable().optional(), - assigneeId: z.string().trim().min(1).nullable().optional(), - assigneeGroupId: z.string().trim().min(1).nullable().optional(), - tags: z.array(z.string().trim().min(1)).max(20).default([]), - /** Nepovinny. Prazdna pole se neukladaji jako prazdne retezce nasilim. */ - customer: z - .object({ - company: z.string().trim().default(''), - contact: z.string().trim().default(''), - reply: z.string().trim().default(''), - }) - .optional(), -}); - -dashboardRouter.post('/tickets', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - - if (!hasPermission(req.user!, 'ticket.create', scope.tenantId)) { - return res.status(403).json({ - error: 'forbidden', - message: 'Nemáte právo zakládat tickety.', - }); - } - - const parsed = createTicketSchema.safeParse(req.body); - 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. - const tenantId = scope.tenantId; - if (!tenantId) { - return res.status(400).json({ - error: 'no_tenant', - message: 'Vyberte firmu, do které ticket patří.', - }); - } - - const input = parsed.data; - const customer = input.customer; - const hasCustomer = - customer !== undefined && - (customer.company !== '' || customer.contact !== '' || customer.reply !== ''); - - const ticket = createTicket({ - tenantId, - subject: input.subject, - body: input.body, - // Rucne zalozeny ticket prisel z portalu, ne z WhatsAppu ani z e-mailu. - channel: 'portal', - priority: input.priority, - typeId: input.typeId ?? null, - assigneeId: input.assigneeId ?? null, - assigneeGroupId: input.assigneeGroupId ?? null, - tags: input.tags, - ...(hasCustomer - ? { customer: { id: null, company: customer.company, contact: customer.contact, reply: customer.reply } } - : {}), - trace: [ - { - kind: 'note', - label: 'Založeno ručně', - status: 'info', - response: `Ticket založil ${req.user!.email} v portálu.`, - }, - ], - }); - - recordAudit({ - userId: req.user!.id, - userEmail: req.user!.email, - tenantId, - action: 'ticket.create', - target: ticket.id, - detail: { subject: ticket.subject }, - }); - - return res.status(201).json(ticket); -}); - -/* - * Prevzeti, prirazeni, stav a komentar jsou vestavene akce na ticketu, - * viz `routes/ticketActions.ts` - kazda ma sve pravo a projde auditem. - */ - -/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */ -/** - * Stavy, ktere firma opravdu pouziva. - * - * Stav je **volny retezec**, ne ciselnik: ticket muze prijit z cizi aplikace - * s jejim vlastnim stavem. Pevny seznam v rozhrani by na nej nikdy nesedel. - * Tohle je proto jen naseptavac - vraci to, co uz v datech je, a nova hodnota - * projde stejne dobre. - * - * Musi byt registrovane pred `/tickets/:id`, jinak by se `statuses` chytilo - * jako ID ticketu. - */ -dashboardRouter.get('/tickets/statuses', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - - const items = [ - ...new Set( - listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }).map( - (ticket) => ticket.status, - ), - ), - ].sort((a, b) => a.localeCompare(b, 'cs')); - - return res.json({ items }); -}); - -dashboardRouter.get('/tickets/workload', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - return res.json(getWorkload(listPeople(scope.tenantIds), scope.tenantIds, scope.visibility)); -}); - -dashboardRouter.get('/tickets/:id', (req, res) => { - // 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 accessOf(req).tenants.map((tenant) => tenant.id); -} - -// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo. -dashboardRouter.use('/stream', streamRouter); - -// Skripty konektoru. Taky pred obecnymi cestami. -dashboardRouter.use('/scripts', scriptsRouter); -// Skripty firmy. Prevod dat v JS, na rozdil od skriptu sluzeb nevolaji ven. -dashboardRouter.use('/tenant-scripts', tenantScriptRouter); - -// Sluzby a konektory. `/connectors/services` je uvnitr toho routeru. -dashboardRouter.use('/connectors', connectorsRouter); - -// Nastaveni vsech entit. Cele stoji na fabrice crudRouter. -dashboardRouter.use('/settings', settingsRouter); - -// Pozvanky do firmy. -dashboardRouter.use('/invites', inviteRouter); - -// Data pro vlastni widgety. Jeden request na cely prehled. -dashboardRouter.use('/widget-data', widgetDataRouter); - -// Akce na ticketu. Musi byt pred obecnym `/tickets/:id`. -dashboardRouter.use('/tickets', ticketActionsRouter); - -// Pohled zadavatele na jeho vlastni pozadavky. Vlastni router, protoze se -// scopuje podle `helpdeskSourceId`, ne podle vlastnika ticketu. -dashboardRouter.use('/helpdesk', helpdeskRouter); - -// ------------------------------------------------------------------- sluzby - -/** - * Katalog sluzeb pro builder. - * - * Operace, ktere obsluhuje skript, se domeruji z jeho manifestu, takze builder - * vidi skutecne vstupy a vystupy. Podrobnosti v `src/scripts/registry.ts`. - * - * Vraci se **jen sluzby, ktere uzivatel vidi**. Neviditelna sluzba v odpovedi - * neni vubec, ne se stavem "nemate pravo". - */ -dashboardRouter.get('/services', async (req, res) => { - // 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] : []; - - /* - * Ke ktere sluzbe ma firma napojeni. - * - * Katalog se **nefiltruje**, jen se oznaci: log ticketu a detail akce - * podle nej prekladaji ID operaci na jmena, a kdyby sluzba z odpovedi - * zmizela, zustalo by v uz zapsanem radku holé ID. Vybirat z ni ale nema - * smysl, kdyz ji neni cim zavolat - o to se stara builder. - */ - const counts = await connectorCountsByService(tenantIds); - - res.json({ - categories: serviceCategories, - items: withRuntimeOptions( - // Firma se predava kvuli MCP: nastroje jsou vlastnost jejiho napojeni, - // ne katalogu. Bez ni se nevrati zadne. - serviceCatalog(tenantId ?? null) - .filter((service) => visible.has(service.id)) - .map((service) => ({ ...service, connected: (counts.get(service.id) ?? 0) > 0 })), - { - people: listPeople(tenantIds).map((person) => ({ id: person.id, name: person.name })), - groups: listGroups(tenantIds).map((group) => ({ id: group.id, name: group.name })), - scripts: listTenantScripts(tenantIds).map((script) => ({ - id: script.id, - name: script.name, - })), - // Typy ticketu jsou vlastnost firmy. Bez teto nabidky by uzivatel musel - // ID typu nekde vyhledat a prepsat, coz je presne to, co nikdo nedela. - ticketTypes: listTicketTypes(tenantIds).map((type) => ({ - id: type.id, - name: type.name, - })), - }, - ), - // Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu, - // a jakou zakladni adresu ukazat u webhooku. - operatorsByType, - webhookBaseUrl: `${publicBaseUrl()}/webhook`, - }); -}); - -// ------------------------------------------------------------- automatizace - -/** Operator podminky. Vyctem, ne volnym retezcem - neznamy by tise nevetvil. */ -const conditionOperatorSchema = z.enum([ - 'eq', - 'neq', - 'gt', - 'gte', - 'lt', - 'lte', - 'contains', - 'startsWith', - 'isEmpty', - 'isNotEmpty', - 'isTrue', - 'isFalse', -]); - -/** Jedna otazka podminky. Podminka jich muze mit vic, spojene pres `match`. */ -const conditionRuleSchema = z.object({ - fieldId: z.string().min(1, 'Podmínka musí mít vybraný parametr.'), - operator: conditionOperatorSchema, - value: z.string().optional(), -}); - -/** - * Rekurzivni schema kroku. z.lazy je nutne, protoze podminka obsahuje - * dalsi kroky - bez toho by se typ odkazoval sam na sebe drive, nez existuje. - */ -const stepSchema: z.ZodType = z.lazy(() => - z.discriminatedUnion('kind', [ - z.object({ - id: z.string().min(1), - kind: z.literal('action'), - serviceId: z.string().min(1), - operationId: z.string().min(1), - // null = vychozi konektor firmy. Prislusnost k firme se overuje nize. - connectorId: z.string().min(1).nullable().optional(), - // Klic je ID pole z katalogu, hodnota sablona. Overuje se nize. - inputs: z.record(z.string()).optional(), - }), - z.object({ - id: z.string().min(1), - kind: z.literal('foreach'), - /** Cesta k seznamu v datech, napr. `data.order.items`. */ - path: z.string().trim().min(1, 'Smyčka musí mít cestu k seznamu.').max(200), - steps: z.array(stepSchema), - }), - z.object({ - id: z.string().min(1), - kind: z.literal('condition'), - /* - * Otazky podminky. `rules` je dnesni podoba, `fieldId` a spol. stara - - * strom ulozeny driv musi jit ulozit znovu, aniz by se prepisoval. - */ - rules: z.array(conditionRuleSchema).max(10).optional(), - match: z.enum(['all', 'any']).optional(), - fieldId: z.string().min(1).optional(), - operator: conditionOperatorSchema.optional(), - value: z.string().optional(), - yes: z.array(stepSchema), - no: z.array(stepSchema), - }), - ]), -); - -const fieldSchema = z.object({ - id: z.string().min(1), - name: z - .string() - .trim() - .min(1, 'Parametr musí mít název.') - // Zamerne jen bezpecne znaky - nazev je klic v prichozim JSONu. - .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Název parametru: písmena, číslice a _ (nezačíná číslicí).'), - // `object` a `list` sem chodi z katalogu (providedFields), viz normalizeTriggerFields. - // Builder nabizi uzivateli jen skalarni typy, protoze strukturu do sablony - // dosadit nejde - predava se jen jako celek dalsimu kroku. - type: z.enum(['string', 'number', 'boolean', 'date', 'object', 'list']), - required: z.boolean(), - /** - * Kde ta hodnota v prichozim tele je, kdyz to neni primo `name`. - * Bez toho by slo napojit jen ploche telo, viz `TriggerField.path`. - */ - path: z.string().trim().max(200).optional(), -}); - -const flowSchema = z.object({ - trigger: z - .object({ - serviceId: z.string().min(1), - operationId: z.string().min(1), - fields: z.array(fieldSchema), - /** - * Ukazka skutecneho tela. Odvozuje se z ni model, viz data/model.ts. - * - * Strop je kvuli tomu, ze se uklada s automatizaci a cte pri kazdem - * nacteni - cele katalogy sem nepatri. - */ - sample: z - .unknown() - .optional() - .refine( - (value) => value === undefined || JSON.stringify(value).length <= 100_000, - 'Ukázka těla je moc velká, vejde se 100 kB.', - ), - /** Jak casto se ma sluzba obvolavat. Plati jen u spoustecu, co se ptaji. */ - intervalSec: z.number().int().min(10).max(86_400).optional(), - // Token generuje server. Cokoliv od klienta se ignoruje. - webhookToken: z.string().optional(), - }) - .nullable(), - steps: z.array(stepSchema), -}); - -const createSchema = z.object({ - name: z.string().trim().min(3, 'Název musí mít alespoň 3 znaky.'), -}); - -const updateSchema = z.object({ - name: z.string().trim().min(3, 'Název musí mít alespoň 3 znaky.').optional(), - enabled: z.boolean().optional(), - flow: flowSchema.optional(), -}); - -/** - * Spoustec, ktery si data urcuje sam (e-mail, WhatsApp, ticket), nesmi mit - * parametry od klienta. Dosadime katalogovou verzi, a to jeste PRED validaci - - * jinak by podminky odkazujici na katalogova ID vypadaly jako rozbite. - */ -function normalizeTriggerFields(flow: z.infer): z.infer { - if (!flow.trigger) return flow; - - const provided = providedFieldsFor(flow.trigger.serviceId, flow.trigger.operationId); - if (!provided) return flow; - - return { ...flow, trigger: { ...flow.trigger, fields: provided.map((field) => ({ ...field })) } }; -} - -/** - * Overi, ze kazdy krok odkazuje na existujici sluzbu, operaci a konektor. - * Vraci seznam problemu - prazdny znamena, ze je strom v poradku. - * - * `tenantIds` je potreba kvuli konektorum: cizi konektor se musi chovat jako - * neexistujici, jinak by strom mohl volat cizim jmenem. - */ -async function validateFlowReferences( - flow: z.infer, - tenantIds: string[], -): Promise<{ problems: string[]; issues: string[] }> { - const problems: string[] = []; - /** Nedodelky: strom se ulozi, jen automatizace nepujde zapnout. */ - const issues: string[] = []; - - if (!flow.trigger) return { problems, issues }; - - const trigger = findOperation(flow.trigger.serviceId, flow.trigger.operationId, 'trigger'); - if (!trigger) { - problems.push( - `Spouštěč ${flow.trigger.serviceId}/${flow.trigger.operationId} neexistuje v katalogu.`, - ); - } - - // Nazvy parametru musi byt unikatni - jsou to klice v prichozich datech. - const names = new Set(); - for (const field of flow.trigger.fields) { - if (names.has(field.name)) { - problems.push(`Parametr "${field.name}" je uvedený dvakrát.`); - } - names.add(field.name); - } - - // Scope rika, co je v kterem miste stromu videt. Podminka se smi ptat - // jen na parametry, ktere pred ni uz vznikly. - const scopes = collectScopes(flow); - - const walk = async (steps: FlowStep[]): Promise => { - for (const step of steps) { - if (step.kind === 'condition') { - // Kazda otazka zvlast, jinak by prvni spatna schovala ostatni. - for (const rule of rulesOf(step)) { - const field = scopes.all.get(rule.fieldId); - if (!field) { - problems.push(`Podmínka odkazuje na neexistující parametr (${rule.fieldId}).`); - } else if (!operatorAllowedForType(rule.operator, field.type)) { - problems.push( - `Operátor "${rule.operator}" nelze použít na parametr "${field.name}" typu ${field.type}.`, - ); - } - } - await walk(step.yes); - await walk(step.no); - continue; - } - - if (step.kind === 'foreach') { - if (step.path.trim().length === 0) { - problems.push('Smyčka musí mít cestu k seznamu, například data.order.items.'); - } - await walk(step.steps); - continue; - } - - const action = findOperation(step.serviceId, step.operationId, 'action'); - if (!action) { - problems.push(`Akce ${step.serviceId}/${step.operationId} neexistuje v katalogu.`); - continue; - } - - // Nastaveni musi sedet na katalog. Neznamy klic je rozbity strom, - // ne nedodelek - ulozit ho by znamenalo drzet data, ktera nikdo neprecte. - const allowed = new Set((action.inputs ?? []).map((input) => input.id)); - for (const key of Object.keys(step.inputs ?? {})) { - if (!allowed.has(key)) { - problems.push( - `Akce ${step.serviceId}/${step.operationId} nemá nastavitelné pole "${key}".`, - ); - } - } - - // Pole typu json a mapping nesou strukturu zapsanou jako text. Preklep - // v zavorce je nedodelek, ne chyba: rozdelana prace se nezahazuje, jen - // automatizace nepujde zapnout. Bez teto kontroly by se to poznalo - // az z padleho behu. - for (const input of action.inputs ?? []) { - if (input.kind !== 'json' && input.kind !== 'mapping') continue; - const raw = (step.inputs ?? {})[input.id]; - if (raw === undefined || raw.trim() === '') continue; - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (err) { - const detail = err instanceof Error ? err.message : 'neplatný JSON'; - issues.push(`Pole "${input.label}" není platný JSON: ${detail}`); - continue; - } - - if (input.kind === 'mapping') { - issues.push(...validateRules(parsed, `pole "${input.label}"`)); - } - } - - // Vybrany konektor musi patrit te same firme a te same sluzbe. - // Cizi konektor je rozbity strom, ne nedodelek. - if (step.connectorId) { - const connector = await getConnector(step.connectorId, tenantIds); - if (!connector) { - problems.push(`Krok odkazuje na konektor, který neexistuje (${step.connectorId}).`); - } else if (connector.serviceId !== step.serviceId) { - problems.push( - `Konektor ${connector.name} patří jiné službě než krok ${step.serviceId}.`, - ); - } - } else { - const service = findService(step.serviceId); - // Chybejici napojeni je nedodelek, ne chyba - rozdelana prace se ulozi. - if (service && !service.general && tenantIds.length === 1) { - const fallback = await defaultConnectorFor(tenantIds[0], step.serviceId); - if (!fallback) { - issues.push( - `Služba ${service.name} nemá v této firmě konektor. Vytvořte ho v Konektorech.`, - ); - } - } - } - } - }; - await walk(flow.steps); - - return { problems, issues }; -} - -dashboardRouter.get('/automations', (req, res) => { - const scope = scopeOrDeny(req, res); - if (!scope) return; - return res.json({ items: listAutomations(scope.tenantIds) }); -}); - -dashboardRouter.get('/automations/:id', (req, res) => { - const automation = getAutomation(req.params.id, writableTenants(req)); - if (!automation) { - return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); - } - 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 validationError(res, parsed.error); - - // Zakladat se musi do konkretni firmy, pohled "vse" na to nestaci. - const scope = scopeOrDeny(req, res); - if (!scope) return; - if (!scope.tenantId) { - return res.status(400).json({ - error: 'tenant_required', - 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); -}); - -dashboardRouter.put('/automations/:id', async (req, res) => { - const parsed = updateSchema.safeParse(req.body); - 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) { - // 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) => ({ field: 'flow', message })), - }); - } - connectorIssues = issues; - } - - const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, [existing.tenantId]); - if (!updated) { - return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); - } - - // Chybejici konektor je nedodelek: strom se ulozil, jen to nepobezi. - return res.json({ ...updated, issues: [...updated.issues, ...connectorIssues] }); -}); - -/** Nova adresa webhooku. Stara okamzite prestane fungovat - zamer, ne chyba. */ -dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => { - 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', - message: 'Automatizace neexistuje, nebo jejím spouštěčem není webhook.', - }); - } - return res.json(updated); -}); - -dashboardRouter.delete('/automations/:id', (req, res) => { - 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/dashboard/automations.ts b/src/routes/dashboard/automations.ts new file mode 100644 index 0000000..471ddd8 --- /dev/null +++ b/src/routes/dashboard/automations.ts @@ -0,0 +1,468 @@ +/** + * Automatizace: strom akci, jeho validace, webhook a fronta behu. + */ + +import type { Request, Response } from 'express'; +import { z } from 'zod'; +import { + createAutomation, + deleteAutomation, + getAutomation, + listAutomations, + regenerateWebhookToken, + rulesOf, + updateAutomation, + type FlowStep, +} from '../../data/automationStore.js'; +import { operatorAllowedForType } from '../../data/conditions.js'; +import { defaultConnectorFor, getConnector } from '../../data/connectorStore.js'; +import { findOperation, findService, providedFieldsFor } from '../../data/services.js'; +import { collectScopes } from '../../data/flowScope.js'; +import { hasPermission } from '../../data/permissions.js'; +import { queueStats, recentRuns } from '../../runtime/queue.js'; +import { accessOf, scopeOrDeny } from '../../middleware/tenant.js'; +import { validationError } from '../../middleware/validation.js'; +import { validateRules } from '../../runtime/scripts/mapping.js'; +import { pageFrom } from './shared.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const automationsRouter = safeRouter(); + +/** Strop ukazky tela spoustece. Uklada se s automatizaci a cte pri kazdem nacteni. */ +const SAMPLE_MAX_CHARS = 100_000; +/** Kolik poslednich behu se vraci bez `limit`. */ +const DEFAULT_RUNS_LIMIT = 50; + +/** Firmy, na ktere ma uzivatel dosah pri zapisu. */ +function writableTenants(req: Request): string[] { + return accessOf(req).tenants.map((tenant) => tenant.id); +} + +/** Operator podminky. Vyctem, ne volnym retezcem - neznamy by tise nevetvil. */ +const conditionOperatorSchema = z.enum([ + 'eq', + 'neq', + 'gt', + 'gte', + 'lt', + 'lte', + 'contains', + 'startsWith', + 'isEmpty', + 'isNotEmpty', + 'isTrue', + 'isFalse', +]); + +/** Jedna otazka podminky. Podminka jich muze mit vic, spojene pres `match`. */ +const conditionRuleSchema = z.object({ + fieldId: z.string().min(1, 'Podmínka musí mít vybraný parametr.'), + operator: conditionOperatorSchema, + value: z.string().optional(), +}); + +/** + * Rekurzivni schema kroku. z.lazy je nutne, protoze podminka obsahuje + * dalsi kroky - bez toho by se typ odkazoval sam na sebe drive, nez existuje. + */ +const stepSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion('kind', [ + z.object({ + id: z.string().min(1), + kind: z.literal('action'), + serviceId: z.string().min(1), + operationId: z.string().min(1), + // null = vychozi konektor firmy. Prislusnost k firme se overuje nize. + connectorId: z.string().min(1).nullable().optional(), + // Klic je ID pole z katalogu, hodnota sablona. Overuje se nize. + inputs: z.record(z.string()).optional(), + }), + z.object({ + id: z.string().min(1), + kind: z.literal('foreach'), + /** Cesta k seznamu v datech, napr. `data.order.items`. */ + path: z.string().trim().min(1, 'Smyčka musí mít cestu k seznamu.').max(200), + steps: z.array(stepSchema), + }), + z.object({ + id: z.string().min(1), + kind: z.literal('condition'), + /* + * Otazky podminky. `rules` je dnesni podoba, `fieldId` a spol. stara - + * strom ulozeny driv musi jit ulozit znovu, aniz by se prepisoval. + */ + rules: z.array(conditionRuleSchema).max(10).optional(), + match: z.enum(['all', 'any']).optional(), + fieldId: z.string().min(1).optional(), + operator: conditionOperatorSchema.optional(), + value: z.string().optional(), + yes: z.array(stepSchema), + no: z.array(stepSchema), + }), + ]), +); + +const fieldSchema = z.object({ + id: z.string().min(1), + name: z + .string() + .trim() + .min(1, 'Parametr musí mít název.') + // Zamerne jen bezpecne znaky - nazev je klic v prichozim JSONu. + .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Název parametru: písmena, číslice a _ (nezačíná číslicí).'), + // `object` a `list` sem chodi z katalogu (providedFields), viz normalizeTriggerFields. + // Builder nabizi uzivateli jen skalarni typy, protoze strukturu do sablony + // dosadit nejde - predava se jen jako celek dalsimu kroku. + type: z.enum(['string', 'number', 'boolean', 'date', 'object', 'list']), + required: z.boolean(), + /** + * Kde ta hodnota v prichozim tele je, kdyz to neni primo `name`. + * Bez toho by slo napojit jen ploche telo, viz `TriggerField.path`. + */ + path: z.string().trim().max(200).optional(), +}); + +const flowSchema = z.object({ + trigger: z + .object({ + serviceId: z.string().min(1), + operationId: z.string().min(1), + fields: z.array(fieldSchema), + /** + * Ukazka skutecneho tela. Odvozuje se z ni model, viz data/model.ts. + * + * Strop je kvuli tomu, ze se uklada s automatizaci a cte pri kazdem + * nacteni - cele katalogy sem nepatri. + */ + sample: z + .unknown() + .optional() + .refine( + (value) => value === undefined || JSON.stringify(value).length <= SAMPLE_MAX_CHARS, + 'Ukázka těla je moc velká, vejde se 100 kB.', + ), + /** Jak casto se ma sluzba obvolavat. Plati jen u spoustecu, co se ptaji. */ + intervalSec: z.number().int().min(10).max(86_400).optional(), + // Token generuje server. Cokoliv od klienta se ignoruje. + webhookToken: z.string().optional(), + }) + .nullable(), + steps: z.array(stepSchema), +}); + +const createSchema = z.object({ + name: z.string().trim().min(3, 'Název musí mít alespoň 3 znaky.'), +}); + +const updateSchema = z.object({ + name: z.string().trim().min(3, 'Název musí mít alespoň 3 znaky.').optional(), + enabled: z.boolean().optional(), + flow: flowSchema.optional(), +}); + +/** + * Spoustec, ktery si data urcuje sam (e-mail, WhatsApp, ticket), nesmi mit + * parametry od klienta. Dosadime katalogovou verzi, a to jeste PRED validaci - + * jinak by podminky odkazujici na katalogova ID vypadaly jako rozbite. + */ +function normalizeTriggerFields(flow: z.infer): z.infer { + if (!flow.trigger) return flow; + + const provided = providedFieldsFor(flow.trigger.serviceId, flow.trigger.operationId); + if (!provided) return flow; + + return { ...flow, trigger: { ...flow.trigger, fields: provided.map((field) => ({ ...field })) } }; +} + +/** + * Overi, ze kazdy krok odkazuje na existujici sluzbu, operaci a konektor. + * Vraci seznam problemu - prazdny znamena, ze je strom v poradku. + * + * `tenantIds` je potreba kvuli konektorum: cizi konektor se musi chovat jako + * neexistujici, jinak by strom mohl volat cizim jmenem. + */ +async function validateFlowReferences( + flow: z.infer, + tenantIds: string[], +): Promise<{ problems: string[]; issues: string[] }> { + const problems: string[] = []; + /** Nedodelky: strom se ulozi, jen automatizace nepujde zapnout. */ + const issues: string[] = []; + + if (!flow.trigger) return { problems, issues }; + + const trigger = findOperation(flow.trigger.serviceId, flow.trigger.operationId, 'trigger'); + if (!trigger) { + problems.push( + `Spouštěč ${flow.trigger.serviceId}/${flow.trigger.operationId} neexistuje v katalogu.`, + ); + } + + // Nazvy parametru musi byt unikatni - jsou to klice v prichozich datech. + const names = new Set(); + for (const field of flow.trigger.fields) { + if (names.has(field.name)) { + problems.push(`Parametr "${field.name}" je uvedený dvakrát.`); + } + names.add(field.name); + } + + // Scope rika, co je v kterem miste stromu videt. Podminka se smi ptat + // jen na parametry, ktere pred ni uz vznikly. + const scopes = collectScopes(flow); + + const walk = async (steps: FlowStep[]): Promise => { + for (const step of steps) { + if (step.kind === 'condition') { + // Kazda otazka zvlast, jinak by prvni spatna schovala ostatni. + for (const rule of rulesOf(step)) { + const field = scopes.all.get(rule.fieldId); + if (!field) { + problems.push(`Podmínka odkazuje na neexistující parametr (${rule.fieldId}).`); + } else if (!operatorAllowedForType(rule.operator, field.type)) { + problems.push( + `Operátor "${rule.operator}" nelze použít na parametr "${field.name}" typu ${field.type}.`, + ); + } + } + await walk(step.yes); + await walk(step.no); + continue; + } + + if (step.kind === 'foreach') { + if (step.path.trim().length === 0) { + problems.push('Smyčka musí mít cestu k seznamu, například data.order.items.'); + } + await walk(step.steps); + continue; + } + + const action = findOperation(step.serviceId, step.operationId, 'action'); + if (!action) { + problems.push(`Akce ${step.serviceId}/${step.operationId} neexistuje v katalogu.`); + continue; + } + + // Nastaveni musi sedet na katalog. Neznamy klic je rozbity strom, + // ne nedodelek - ulozit ho by znamenalo drzet data, ktera nikdo neprecte. + const allowed = new Set((action.inputs ?? []).map((input) => input.id)); + for (const key of Object.keys(step.inputs ?? {})) { + if (!allowed.has(key)) { + problems.push( + `Akce ${step.serviceId}/${step.operationId} nemá nastavitelné pole "${key}".`, + ); + } + } + + // Pole typu json a mapping nesou strukturu zapsanou jako text. Preklep + // v zavorce je nedodelek, ne chyba: rozdelana prace se nezahazuje, jen + // automatizace nepujde zapnout. Bez teto kontroly by se to poznalo + // az z padleho behu. + for (const input of action.inputs ?? []) { + if (input.kind !== 'json' && input.kind !== 'mapping') continue; + const raw = (step.inputs ?? {})[input.id]; + if (raw === undefined || raw.trim() === '') continue; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + const detail = err instanceof Error ? err.message : 'neplatný JSON'; + issues.push(`Pole "${input.label}" není platný JSON: ${detail}`); + continue; + } + + if (input.kind === 'mapping') { + issues.push(...validateRules(parsed, `pole "${input.label}"`)); + } + } + + // Vybrany konektor musi patrit te same firme a te same sluzbe. + // Cizi konektor je rozbity strom, ne nedodelek. + if (step.connectorId) { + const connector = await getConnector(step.connectorId, tenantIds); + if (!connector) { + problems.push(`Krok odkazuje na konektor, který neexistuje (${step.connectorId}).`); + } else if (connector.serviceId !== step.serviceId) { + problems.push(`Konektor ${connector.name} patří jiné službě než krok ${step.serviceId}.`); + } + } else { + const service = findService(step.serviceId); + // Chybejici napojeni je nedodelek, ne chyba - rozdelana prace se ulozi. + const onlyTenant = tenantIds.length === 1 ? tenantIds[0] : undefined; + if (service && !service.general && onlyTenant !== undefined) { + const fallback = await defaultConnectorFor(onlyTenant, step.serviceId); + if (!fallback) { + issues.push( + `Služba ${service.name} nemá v této firmě konektor. Vytvořte ho v Konektorech.`, + ); + } + } + } + } + }; + await walk(flow.steps); + + return { problems, issues }; +} + +automationsRouter.get('/automations', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + return res.json({ items: listAutomations(scope.tenantIds) }); +}); + +automationsRouter.get('/automations/:id', (req, res) => { + const automation = getAutomation(req.params.id, writableTenants(req)); + if (!automation) { + return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); + } + 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) { + // Bez parametru cesty se hleda prazdne ID, tedy nic - stejne jako drive. + 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; +} + +automationsRouter.post('/automations', (req, res) => { + const parsed = createSchema.safeParse(req.body); + if (!parsed.success) return validationError(res, parsed.error); + + // Zakladat se musi do konkretni firmy, pohled "vse" na to nestaci. + const scope = scopeOrDeny(req, res); + if (!scope) return; + if (!scope.tenantId) { + return res.status(400).json({ + error: 'tenant_required', + 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); +}); + +automationsRouter.put('/automations/:id', async (req, res) => { + const parsed = updateSchema.safeParse(req.body); + 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) { + // 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) => ({ field: 'flow', message })), + }); + } + connectorIssues = issues; + } + + const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, [existing.tenantId]); + if (!updated) { + return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); + } + + // Chybejici konektor je nedodelek: strom se ulozil, jen to nepobezi. + return res.json({ ...updated, issues: [...updated.issues, ...connectorIssues] }); +}); + +/** Nova adresa webhooku. Stara okamzite prestane fungovat - zamer, ne chyba. */ +automationsRouter.post('/automations/:id/webhook/regenerate', (req, res) => { + 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', + message: 'Automatizace neexistuje, nebo jejím spouštěčem není webhook.', + }); + } + return res.json(updated); +}); + +automationsRouter.delete('/automations/:id', (req, res) => { + 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(); +}); + +/** + * Stav fronty behu. + * + * Kdyz neco nefunguje, tohle je prvni misto, kam se clovek podiva: ceka + * fronta, nebo uz to nekolikrat selhalo a vzdalo se? + */ +automationsRouter.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 ?? DEFAULT_RUNS_LIMIT)).slice( + page.offset, + ); + res.setHeader('X-Total-Count', String(stats.pending + stats.running + stats.failed + stats.done)); + + return res.json({ + stats, + items: runs.map((run) => ({ + id: run.id, + automationId: run.automationId, + trigger: run.trigger, + status: run.status, + attempts: run.attempts, + ticketId: run.ticketId, + // Cele hlaseni. Zkratit ho tady znamena, ze se pricina uz nedozvime. + lastError: run.lastError, + nextAttemptAt: run.nextAttemptAt, + createdAt: run.createdAt, + finishedAt: run.finishedAt, + })), + }); +}); diff --git a/src/routes/dashboard/clientCrash.ts b/src/routes/dashboard/clientCrash.ts new file mode 100644 index 0000000..146441d --- /dev/null +++ b/src/routes/dashboard/clientCrash.ts @@ -0,0 +1,134 @@ +/** + * Hlaseni padu portalu z prohlizece. Z hlaseni vznika incident. + */ + +import { createHash } from 'node:crypto'; +import { z } from 'zod'; +import { createIncident } from '../../data/incidentStore.js'; +import { tenantOrDeny } from '../../middleware/tenant.js'; +import { validationError } from '../../middleware/validation.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const clientCrashRouter = safeRouter(); + +/** + * Hlaseni padu portalu. + * + * Kdyz v prohlizeci spadne vykreslovani, uzivatel to vidi, ale my ne. Bez + * tohohle endpointu je jedina stopa v jeho konzoli, kam se nikdo nedostane, + * takze o padu vime jen tehdy, kdyz ho nekdo nahlasi. To znamena, ze o vetsine + * padu nevime vubec. + * + * Incident nese dve casti a je to zamerne: + * - `title` a `impact` cte zakaznik, takze zadne stack trace, + * - `detail` cte spravce platformy a je v nem vsechno: hlaska, misto v kodu, + * strom komponent, adresa stranky a verze buildu. + * + * Cely `detail` je to, **jak se to stalo**. Bez adresy a stromu komponent je + * hlaska "Cannot create property" k nepouziti. + */ + +/** + * Kolik nejvys znaku se z jednoho pole prevezme. + * + * Strom komponent umi byt velmi dlouhy a do incidentu patri jeho zacatek, + * protoze prave nahore je komponenta, ktera spadla. + */ +const FIELD_LIMIT = 4_000; + +/** + * Jak dlouho se tentyz pad povazuje za jeden. + * + * Pad pri vykreslovani se opakuje pri kazdem stisku klavesy. Bez tohohle by + * z jedne chyby vzniklo padesat incidentu a ten pravy by v nich zapadl. + */ +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(), + componentStack: z.string().max(FIELD_LIMIT).optional(), + /** Kde v portalu se to stalo. Bez toho se to nema kde hledat. */ + path: z.string().max(500).optional(), + /** Verze nasazeneho klienta. Rika, jestli uz je v tom oprava. */ + build: z.string().max(200).optional(), +}); + +clientCrashRouter.post('/client-crash', (req, res) => { + // 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 validationError(res, parsed.error); + + const crash = parsed.data; + const where = crash.path ?? 'neznámá stránka'; + + /* + * 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 = 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 = [ + `Stránka: ${where}`, + `Uživatel: ${req.user!.email}`, + `Verze klienta: ${crash.build ?? 'neznámá'}`, + `Prohlížeč: ${String(req.headers['user-agent'] ?? 'neznámý')}`, + '', + `Hláška: ${crash.message}`, + ...(crash.componentStack ? ['', 'Strom komponent:', crash.componentStack] : []), + ...(crash.stack ? ['', 'Zásobník volání:', crash.stack] : []), + ].join('\n'); + + const incident = createIncident({ + tenantId, + title: 'Část portálu se nepodařilo vykreslit', + service: 'Portál', + // Pad vykreslovani neni vypadek sluzby, ale uzivatel u toho nemuze + // pokracovat v praci. Prostredni zavaznost, ne nejvyssi. + severity: 'sev2', + impact: `Stránka ${where} se části uživatelů nezobrazila správně. Ostatní části portálu fungují.`, + detail, + source: 'portál', + }); + + console.error(`[ui] pad portalu na ${where}: ${crash.message} (incident ${incident.id})`); + return res.status(201).json({ created: true, incidentId: incident.id }); +}); diff --git a/src/routes/dashboard/incidents.ts b/src/routes/dashboard/incidents.ts new file mode 100644 index 0000000..8a90258 --- /dev/null +++ b/src/routes/dashboard/incidents.ts @@ -0,0 +1,98 @@ +/** + * Incidenty: seznam, detail a posun stavu. + */ + +import type { Request } from 'express'; +import { z } from 'zod'; +import { + findIncident, + listIncidents, + updateIncidentStatus, + type Incident, +} from '../../data/incidentStore.js'; +import { hasPermission } from '../../data/permissions.js'; +import { recordAudit } from '../../data/audit.js'; +import { scopeOrDeny } from '../../middleware/tenant.js'; +import { validationError } from '../../middleware/validation.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const incidentsRouter = safeRouter(); + +/** + * Incidenty firmy plus platformni. + * + * Klient vidi `title` a `impact`, tedy co to pro nej znamena. `detail` s celym + * hlasenim, ID behu a daty na vstupu vidi **jen spravce platformy** - je to + * nase diagnostika, ne informace pro zakaznika. + */ +incidentsRouter.get('/incidents', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + + const forAdmin = req.user!.platformAdmin; + return res.json({ + items: listIncidents(scope.tenantIds).map((incident) => ({ + ...incident, + detail: forAdmin ? incident.detail : null, + })), + }); +}); + +/** Stejne pravidlo jako u seznamu: diagnostiku vidi jen spravce platformy. */ +function publicIncident(req: Request, incident: Incident): Incident { + return { ...incident, detail: req.user!.platformAdmin ? incident.detail : null }; +} + +incidentsRouter.get('/incidents/:id', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + + const incident = findIncident(req.params.id, scope.tenantIds); + if (!incident) + return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' }); + return res.json(publicIncident(req, incident)); +}); + +const incidentStatusSchema = z.object({ + status: z.enum(['investigating', 'identified', 'monitoring', 'resolved']), +}); + +/** + * Posun incidentu do dalsiho stavu. + * + * Incident firmy meni, kdo ma v te firme `incident.manage`. Platformni incident + * (bez firmy) je nas a meni ho jen spravce platformy - klient by jinak mohl + * "vyresit" vypadek, ktery se tyka vsech. + */ +incidentsRouter.patch('/incidents/:id/status', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + + const incident = findIncident(req.params.id, scope.tenantIds); + if (!incident) + return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' }); + + const allowed = + req.user!.platformAdmin || + (incident.tenantId !== null && hasPermission(req.user!, 'incident.manage', incident.tenantId)); + if (!allowed) { + return res.status(403).json({ error: 'forbidden', message: 'Stav incidentu nemůžete měnit.' }); + } + + const parsed = incidentStatusSchema.safeParse(req.body); + if (!parsed.success) return validationError(res, parsed.error); + + const updated = updateIncidentStatus(incident.id, parsed.data.status); + if (!updated) + return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' }); + + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId: incident.tenantId, + action: 'incident.status', + target: incident.id, + detail: { status: parsed.data.status }, + }); + return res.json(publicIncident(req, updated)); +}); diff --git a/src/routes/dashboard/index.ts b/src/routes/dashboard/index.ts new file mode 100644 index 0000000..cb3270f --- /dev/null +++ b/src/routes/dashboard/index.ts @@ -0,0 +1,74 @@ +/** + * Dashboard: vsechno za prihlasenim. + * + * Tady se jen skladaji routery podle domeny. Prava se spocitaji jednou za + * request (`attachAccess`) a kazdy router z nich cte. Poradi mountu zustava + * jako driv: konkretni cesty pred obecnymi, aby je nic neprebilo. + */ + +import { safeRouter } from '../../middleware/asyncHandler.js'; +import { requireAuth } from '../../middleware/auth.js'; +import { attachAccess } from '../../middleware/tenant.js'; +import { connectorsRouter } from '../connectors.js'; +import { helpdeskRouter } from '../helpdesk.js'; +import { inviteRouter } from '../invites.js'; +import { scriptsRouter } from '../scripts.js'; +import { settingsRouter } from '../settings/index.js'; +import { streamRouter } from '../stream.js'; +import { tenantScriptRouter } from '../tenantScripts.js'; +import { ticketActionsRouter } from '../ticketActions.js'; +import { widgetDataRouter } from '../widgetData.js'; +import { automationsRouter } from './automations.js'; +import { clientCrashRouter } from './clientCrash.js'; +import { incidentsRouter } from './incidents.js'; +import { intakeRouter } from './intake.js'; +import { layoutRouter } from './layout.js'; +import { miscRouter } from './misc.js'; +import { notificationsRouter } from './notifications.js'; +import { peopleRouter } from './people.js'; +import { ticketsRouter } from './tickets.js'; + +export const dashboardRouter = safeRouter(); + +// Cely dashboard je jen pro prihlasene. Prava se spocitaji jednou za request. +dashboardRouter.use(requireAuth, attachAccess); + +dashboardRouter.use(miscRouter); +dashboardRouter.use(incidentsRouter); +dashboardRouter.use(clientCrashRouter); +dashboardRouter.use(layoutRouter); +dashboardRouter.use(peopleRouter); +dashboardRouter.use(intakeRouter); +dashboardRouter.use(notificationsRouter); +// Seznam, stavy a detail ticketu. `/tickets/:id` je tady, akce nize. +dashboardRouter.use(ticketsRouter); + +// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo. +dashboardRouter.use('/stream', streamRouter); + +// Skripty konektoru. Taky pred obecnymi cestami. +dashboardRouter.use('/scripts', scriptsRouter); +// Skripty firmy. Prevod dat v JS, na rozdil od skriptu sluzeb nevolaji ven. +dashboardRouter.use('/tenant-scripts', tenantScriptRouter); + +// Sluzby a konektory. `/connectors/services` je uvnitr toho routeru. +dashboardRouter.use('/connectors', connectorsRouter); + +// Nastaveni vsech entit. Cele stoji na fabrice crudRouter. +dashboardRouter.use('/settings', settingsRouter); + +// Pozvanky do firmy. +dashboardRouter.use('/invites', inviteRouter); + +// Data pro vlastni widgety. Jeden request na cely prehled. +dashboardRouter.use('/widget-data', widgetDataRouter); + +// Akce na ticketu: prevzeti, prirazeni, stav, komentar. Kazda ma sve pravo. +dashboardRouter.use('/tickets', ticketActionsRouter); + +// Pohled zadavatele na jeho vlastni pozadavky. Vlastni router, protoze se +// scopuje podle `helpdeskSourceId`, ne podle vlastnika ticketu. +dashboardRouter.use('/helpdesk', helpdeskRouter); + +// Automatizace a fronta behu. +dashboardRouter.use(automationsRouter); diff --git a/src/routes/dashboard/intake.ts b/src/routes/dashboard/intake.ts new file mode 100644 index 0000000..9d17f68 --- /dev/null +++ b/src/routes/dashboard/intake.ts @@ -0,0 +1,104 @@ +/** + * Adresa pro prijem udalosti do ticketu a jeji obnova. + */ + +import { publicBaseUrl } from '../../config.js'; +import { hasPermission } from '../../data/permissions.js'; +import { recordAudit } from '../../data/audit.js'; +import { + findTenant, + generateIntakeToken, + refreshTenants, + tenantStore, +} from '../../data/tenants.js'; +import { listTicketTypes } from '../../data/ticketTypes.js'; +import { scopeOrDeny } from '../../middleware/tenant.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const intakeRouter = safeRouter(); + +/** + * Adresa pro prijem udalosti do ticketu. + * + * Token je pristupovy udaj, proto ho vidi jen ten, kdo spravuje napojeni. + * Kdo umi zalozit konektor, umi zaridit i prijem - je to tatáz prace. + */ +intakeRouter.get('/intake', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + + if (!hasPermission(req.user!, 'connector.manage', scope.tenantId)) { + return res.status(403).json({ + error: 'forbidden', + message: 'Adresu pro příjem vidí ten, kdo spravuje napojení.', + }); + } + + const tenantId = scope.tenantIds[0]; + const tenant = tenantId ? findTenant(tenantId) : undefined; + if (!tenant) { + return res.status(404).json({ error: 'not_found', message: 'Firma neexistuje.' }); + } + + return res.json({ + tenantId: tenant.id, + tenantName: tenant.name, + url: `${publicBaseUrl()}/webhook/ticket/${tenant.intakeToken}`, + ticketTypes: listTicketTypes([tenant.id]).map((type) => ({ + id: type.id, + name: type.name, + fields: type.fields.map((field) => ({ + key: field.key, + label: field.label, + type: field.type, + required: field.required, + })), + })), + }); +}); + +/** Nova adresa. Stara okamzite prestane fungovat. */ +intakeRouter.post('/intake/regenerate', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + + if (!hasPermission(req.user!, 'connector.manage', scope.tenantId)) { + return res.status(403).json({ + error: 'forbidden', + message: 'Adresu pro příjem mění ten, kdo spravuje napojení.', + }); + } + + const tenantId = scope.tenantIds[0]; + if (!tenantId) { + return res.status(400).json({ error: 'no_tenant', message: 'Vyberte firmu.' }); + } + + return void tenantStore + .update( + tenantId, + { intakeToken: generateIntakeToken() }, + { tenantIds: [], includeGlobal: true }, + ) + .then(async (updated) => { + if (!updated) { + return res.status(404).json({ error: 'not_found', message: 'Firma neexistuje.' }); + } + await refreshTenants(); + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId, + action: 'intake.regenerate', + target: tenantId, + }); + console.info(`[intake] ${tenantId}: adresa pregenerovana, stara neplati`); + return res.json({ url: `${publicBaseUrl()}/webhook/ticket/${updated.intakeToken}` }); + }) + .catch((err: unknown) => { + console.error('[intake] regenerace selhala:', err); + return res + .status(500) + .json({ error: 'internal_error', message: 'Adresu se nepodařilo změnit.' }); + }); +}); diff --git a/src/routes/dashboard/layout.ts b/src/routes/dashboard/layout.ts new file mode 100644 index 0000000..24ba45f --- /dev/null +++ b/src/routes/dashboard/layout.ts @@ -0,0 +1,95 @@ +/** + * Rozlozeni dashboardu: katalog widgetu a ulozene rozlozeni za firmu. + */ + +import { z } from 'zod'; +import { + getLayout, + hasCustomLayout, + resetLayout, + saveLayout, + validateLayout, +} from '../../data/dashboardLayouts.js'; +import { widgetCatalog } from '../../data/widgets.js'; +import { accessOf, tenantOrDeny } from '../../middleware/tenant.js'; +import { validationError } from '../../middleware/validation.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const layoutRouter = safeRouter(); + +/** + * Katalog widgetu: pevne z kodu plus vlastni firmy. + * + * Vlastni widget je pro klienta tentyz tvar jako pevny, jen `custom: true`. + * Diky tomu se rozlozeni dashboardu nemuselo menit. + */ +layoutRouter.get('/widgets', (req, res) => { + /* + * Katalog je za konkretni firmu, stejne jako rozlozeni. Kdyby vracel widgety + * vsech firem uzivatele, sla by polozit dlazdice Automie na dashboard + * Nordisu - v nabidce by byla, ale data by k ni nikdy neprisla. + */ + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + + return res.json({ + 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. + */ +layoutRouter.get('/layout', (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + + return res.json({ + tenantId, + items: getLayout(req.user!.id, tenantId, accessOf(req).personId !== null), + custom: hasCustomLayout(req.user!.id, tenantId), + }); +}); + +const layoutSchema = z.object({ + items: z.array( + z.object({ + id: z.string().min(1), + widgetId: z.string().min(1), + size: z.enum(['third', 'half', 'full']), + }), + ), +}); + +layoutRouter.put('/layout', (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + + const parsed = layoutSchema.safeParse(req.body); + if (!parsed.success) return validationError(res, parsed.error, 'Neplatné rozložení.'); + + const problems = validateLayout( + parsed.data.items, + widgetCatalog([tenantId], req.user!.id, accessOf(req).personId !== null), + ); + if (problems.length > 0) { + console.warn(`[layout] ${req.user!.email}: neplatne rozlozeni - ${problems.join(' ')}`); + return res.status(400).json({ error: 'validation_error', message: problems[0] }); + } + + const items = saveLayout(req.user!.id, tenantId, parsed.data.items); + return res.json({ tenantId, items, custom: true }); +}); + +/** Vraceni na vychozi. Zamerne DELETE - je to smazani ulozeneho nastaveni. */ +layoutRouter.delete('/layout', (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + + return res.json({ + tenantId, + items: resetLayout(req.user!.id, tenantId, accessOf(req).personId !== null), + custom: false, + }); +}); diff --git a/src/routes/dashboard/misc.ts b/src/routes/dashboard/misc.ts new file mode 100644 index 0000000..edd9860 --- /dev/null +++ b/src/routes/dashboard/misc.ts @@ -0,0 +1,102 @@ +/** + * Drobne endpointy dashboardu: prava, prehled, uloziste a katalog sluzeb. + */ + +import { publicBaseUrl } from '../../config.js'; +import { operatorsByType } from '../../data/conditions.js'; +import { listTenantScripts } from '../../data/tenantScripts.js'; +import { connectorCountsByService, storageStatus } from '../../data/connectorStore.js'; +import { + serviceCatalog, + serviceCategories, + visibleServices, + withRuntimeOptions, +} from '../../data/services.js'; +import { getSummary } from '../../data/mock.js'; +import { listGroups, listPeople } from '../../data/people.js'; +import { listTicketTypes } from '../../data/ticketTypes.js'; +import { accessOf, optionalTenantOrDeny, scopeOrDeny } from '../../middleware/tenant.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const miscRouter = safeRouter(); + +/** Co uzivatel smi, aby klient nemusel hadat, ktere prepinace kreslit. */ +miscRouter.get('/access', (req, res) => { + res.json(accessOf(req)); +}); + +miscRouter.get('/summary', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + return res.json(getSummary(scope.tenantIds)); +}); + +/** + * Kam se uklada a jestli to prezije restart. + * + * 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. + */ +miscRouter.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 }); +}); + +/** + * Katalog sluzeb pro builder. + * + * Operace, ktere obsluhuje skript, se domeruji z jeho manifestu, takze builder + * vidi skutecne vstupy a vystupy. Podrobnosti v `src/scripts/registry.ts`. + * + * Vraci se **jen sluzby, ktere uzivatel vidi**. Neviditelna sluzba v odpovedi + * neni vubec, ne se stavem "nemate pravo". + */ +miscRouter.get('/services', async (req, res) => { + // 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] : []; + + /* + * Ke ktere sluzbe ma firma napojeni. + * + * Katalog se **nefiltruje**, jen se oznaci: log ticketu a detail akce + * podle nej prekladaji ID operaci na jmena, a kdyby sluzba z odpovedi + * zmizela, zustalo by v uz zapsanem radku holé ID. Vybirat z ni ale nema + * smysl, kdyz ji neni cim zavolat - o to se stara builder. + */ + const counts = await connectorCountsByService(tenantIds); + + res.json({ + categories: serviceCategories, + items: withRuntimeOptions( + // Firma se predava kvuli MCP: nastroje jsou vlastnost jejiho napojeni, + // ne katalogu. Bez ni se nevrati zadne. + serviceCatalog(tenantId ?? null) + .filter((service) => visible.has(service.id)) + .map((service) => ({ ...service, connected: (counts.get(service.id) ?? 0) > 0 })), + { + people: listPeople(tenantIds).map((person) => ({ id: person.id, name: person.name })), + groups: listGroups(tenantIds).map((group) => ({ id: group.id, name: group.name })), + scripts: listTenantScripts(tenantIds).map((script) => ({ + id: script.id, + name: script.name, + })), + // Typy ticketu jsou vlastnost firmy. Bez teto nabidky by uzivatel musel + // ID typu nekde vyhledat a prepsat, coz je presne to, co nikdo nedela. + ticketTypes: listTicketTypes(tenantIds).map((type) => ({ + id: type.id, + name: type.name, + })), + }, + ), + // Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu, + // a jakou zakladni adresu ukazat u webhooku. + operatorsByType, + webhookBaseUrl: `${publicBaseUrl()}/webhook`, + }); +}); diff --git a/src/routes/dashboard/notifications.ts b/src/routes/dashboard/notifications.ts new file mode 100644 index 0000000..c6f343b --- /dev/null +++ b/src/routes/dashboard/notifications.ts @@ -0,0 +1,56 @@ +/** + * Upozorneni prihlaseneho a pocet jeho otevrenych ticketu. + */ + +import type { Request } from 'express'; +import { listNotifications, markRead, unreadCount } from '../../data/notifications.js'; +import { listTickets } from '../../data/ticketStore.js'; +import { accessOf } from '../../middleware/tenant.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const notificationsRouter = safeRouter(); + +/** + * Upozorneni prihlaseneho. + * + * Cislo u zalozky "Moje tickety" a hlaska pri prirazeni. Nechodi to pres + * stream jako jedina cesta - kdo mel portal zavreny, musi to najit i po + * prihlaseni, proto jsou upozorneni ulozena. + */ +notificationsRouter.get('/notifications', (req, res) => { + const items = listNotifications(req.user!.id); + return res.json({ + items, + unread: unreadCount(req.user!.id), + /** Kolik ticketu ma prihlaseny u sebe. To je to cislo u zalozky. */ + mine: myOpenTickets(req), + }); +}); + +/** Oznaci prectene. Bez seznamu vsechny. */ +notificationsRouter.post('/notifications/read', (req, res) => { + const ids = Array.isArray(req.body?.ids) + ? (req.body.ids as unknown[]).filter((id): id is string => typeof id === 'string') + : undefined; + + return void markRead(req.user!.id, ids) + .then((count) => res.json({ marked: count, unread: unreadCount(req.user!.id) })) + .catch((err: unknown) => { + console.error('[upozorneni] oznaceni selhalo:', err); + return res.status(500).json({ error: 'internal_error', message: 'Nepodařilo se uložit.' }); + }); +}); + +/** Kolik nevyrizenych ma prihlaseny u sebe. */ +function myOpenTickets(req: Request): number { + const access = accessOf(req); + if (!access.personId) return 0; + + // Vlastni tickety jsou ve stropu vzdycky, ale posila se vyslovne - filtr + // na prava nesmi byt nepovinny. + return listTickets({ + tenantIds: access.tenants.map((tenant) => tenant.id), + visibility: { kind: 'scoped', personIds: [access.personId], groupIds: [] }, + assignee: access.personId, + }).filter((ticket) => !ticket.closed).length; +} diff --git a/src/routes/dashboard/people.ts b/src/routes/dashboard/people.ts new file mode 100644 index 0000000..420f8cf --- /dev/null +++ b/src/routes/dashboard/people.ts @@ -0,0 +1,71 @@ +/** + * Resitele: seznam pro nabidky a detail jednoho cloveka. + */ + +import { isMember, listGroups, listPeople } from '../../data/people.js'; +import { getAgentStats, listTickets } from '../../data/ticketStore.js'; +import { accessOf, scopeOrDeny } from '../../middleware/tenant.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const peopleRouter = safeRouter(); + +/** Obdobi statistiky resitele. Stejne jako u widgetu vykonu, aby cisla sedela. */ +const STATS_WINDOW_MS = 30 * 86_400_000; +/** Kolik posledne vyresenych ticketu se ukaze v detailu. */ +const RESOLVED_LIMIT = 20; + +/** + * Resitele vybrane firmy. Klient je potrebuje do nabidky prirazeni i do prehledu. + * + * Skupiny jdou stejnym endpointem zamerne: kdo smi prirazovat ticket, smi ho + * prirazit i skupine, a druhy request na dve polozky nema smysl. Sprava skupin + * je jina vec a ma vlastni pravo v nastaveni. + */ +peopleRouter.get('/people', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + + return res.json({ + items: listPeople(scope.tenantIds), + groups: listGroups(scope.tenantIds).map((group) => ({ id: group.id, name: group.name })), + meId: accessOf(req).personId, + }); +}); +/** + * Detail resitele: kdo to je, jak mu to jde a co ma u sebe. + * + * Statistika i tickety chodi jednim requestem. Stranka o jednom cloveku by + * jinak delala tri dotazy na tri veci, ktere se pocitaji z tehoz seznamu. + */ +peopleRouter.get('/people/:id', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + + const person = listPeople(scope.tenantIds).find((item) => item.id === req.params.id); + if (!person) { + // Cizi resitel se chova jako neexistujici, ne jako odepreni prava. + return res.status(404).json({ error: 'not_found', message: 'Řešitel neexistuje.' }); + } + + // 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() - STATS_WINDOW_MS; + const stats = getAgentStats([person], scope.tenantIds, since, scope.visibility)[0] ?? null; + + return res.json({ + person, + stats, + groups: listGroups(scope.tenantIds) + .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 && ticket.assignee?.id === person.id), + /** Co za posledni dobu vyresil, nejnovejsi nahore. */ + resolved: tickets + .filter((ticket) => ticket.closed && ticket.resolvedById === person.id) + .sort((a, b) => (b.resolvedAt ?? '').localeCompare(a.resolvedAt ?? '')) + .slice(0, RESOLVED_LIMIT), + }); +}); diff --git a/src/routes/dashboard/shared.ts b/src/routes/dashboard/shared.ts new file mode 100644 index 0000000..f2eafa2 --- /dev/null +++ b/src/routes/dashboard/shared.ts @@ -0,0 +1,29 @@ +/** + * Strankovani sdilene routami dashboardu (seznam ticketu, fronta behu). + */ + +/** Nejvetsi povolena stranka. Vic polozek najednou uz klient stejne nevykresli. */ +const MAX_PAGE_LIMIT = 500; + +/** + * 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. + */ +export function pageFrom( + query: Record, + maxLimit = MAX_PAGE_LIMIT, +): { 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, + }; +} + +export 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); +} diff --git a/src/routes/dashboard/tickets.ts b/src/routes/dashboard/tickets.ts new file mode 100644 index 0000000..ef1b013 --- /dev/null +++ b/src/routes/dashboard/tickets.ts @@ -0,0 +1,268 @@ +/** + * Tickety: seznam s filtrem, rucni zalozeni, stavy, vytizeni a detail. + * + * Vestavene akce (prevzeti, prirazeni, stav, komentar) jsou v + * `routes/ticketActions.ts`, mountuje je `dashboard/index.ts`. + */ + +import { z } from 'zod'; +import type { ResolvedScope } from '../../data/access.js'; +import { listPeople } from '../../data/people.js'; +import { hasPermission } from '../../data/permissions.js'; +import { recordAudit } from '../../data/audit.js'; +import { + createTicket, + getWorkload, + listTickets, + type TicketChannel, + type TicketFilter, +} from '../../data/ticketStore.js'; +import { accessOf, scopeOrDeny } from '../../middleware/tenant.js'; +import { validationError } from '../../middleware/validation.js'; +import { visibleTicketOrDeny } from '../ticketActions.js'; +import { pageFrom, paginate } from './shared.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const ticketsRouter = safeRouter(); + +/* + * Stav uz neni ciselnik. Filtr proto bere, co prijde - kdyz to na nic nesedi, + * vrati se prazdny seznam, coz je spravna odpoved na "ukaz mi stav, ktery + * nikdo nema". + */ +const ticketChannels: TicketChannel[] = [ + 'whatsapp', + 'facebook', + 'instagram', + 'email', + 'voice', + 'form', + 'portal', +]; + +/** + * Filtr ze query parametru. Nesmyslnou hodnotu zahodime a zalogujeme - + * je lepsi ukazat vic ticketu nez prazdny seznam bez vysvetleni. + */ +function ticketFilterFrom(query: Record, scope: ResolvedScope): TicketFilter { + const filter: TicketFilter = { tenantIds: scope.tenantIds, visibility: scope.visibility }; + + // Filtry, na ktere se odkazuje z widgetu. `none` znamena "bez toho". + const typeId = query.typeId; + if (typeof typeId === 'string' && typeId !== '') filter.typeId = typeId; + const tag = query.tag; + if (typeof tag === 'string' && tag !== '') filter.tag = tag; + const groupId = query.groupId; + if (typeof groupId === 'string' && groupId !== '') filter.groupId = groupId; + + // Pohled "moje" je silnejsi nez rucni filtr na resitele. + if (scope.scope === 'mine') { + filter.assignee = scope.personId ?? '__nikdo__'; + return applyRest(query, filter); + } + + const assignee = typeof query.assignee === 'string' ? query.assignee : undefined; + if (assignee) filter.assignee = assignee; + + return applyRest(query, filter); +} + +function applyRest(query: Record, filter: TicketFilter): TicketFilter { + /* + * Stav uz neni ciselnik, takze se nekontroluje proti seznamu. Kdyz hodnota + * na nic nesedi, vrati se prazdny seznam - to je spravna odpoved na dotaz + * po stavu, ktery nikdo nema. + */ + const status = typeof query.status === 'string' ? query.status : undefined; + if (status) filter.status = status; + + const channel = typeof query.channel === 'string' ? query.channel : undefined; + if (channel) { + if (ticketChannels.includes(channel as TicketChannel)) + filter.channel = channel as TicketChannel; + else console.warn(`[tickets] neznamy kanal ve filtru: ${channel}`); + } + + return filter; +} + +ticketsRouter.get('/tickets', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + + const filter = ticketFilterFrom(req.query as Record, scope); + + /* + * 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. Kdyz zadny + * filtr neni, je cely rozsah zaroven vysledek a seznam se cte jen jednou. + */ + 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: paginate(filtered, page), + total: filtered.length, + statuses, + meId: accessOf(req).personId, + scope: scope.scope, + tenantId: scope.tenantId, + }); +}); + +/** + * Rucne zalozeny ticket. + * + * Dosud ticket vznikal jen z automatizace nebo z prichozi udalosti. Jenze + * pozadavek casto prijde telefonem nebo pri kafi a nekdo ho musi zapsat - + * bez toho konci na papirku a v systemu neni. + * + * **Zakaznik je nepovinny.** Ticket zalozeny rucne je casto ukol, ne pozadavek + * od nekoho zvenku, a nutit k nemu firmu a kontakt by znamenalo vymyslet si je. + */ +const createTicketSchema = z.object({ + subject: z.string().trim().min(1, 'Předmět nesmí být prázdný.'), + body: z.string().default(''), + priority: z.enum(['low', 'normal', 'high', 'critical']).default('normal'), + typeId: z.string().trim().min(1).nullable().optional(), + assigneeId: z.string().trim().min(1).nullable().optional(), + assigneeGroupId: z.string().trim().min(1).nullable().optional(), + tags: z.array(z.string().trim().min(1)).max(20).default([]), + /** Nepovinny. Prazdna pole se neukladaji jako prazdne retezce nasilim. */ + customer: z + .object({ + company: z.string().trim().default(''), + contact: z.string().trim().default(''), + reply: z.string().trim().default(''), + }) + .optional(), +}); + +ticketsRouter.post('/tickets', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + + if (!hasPermission(req.user!, 'ticket.create', scope.tenantId)) { + return res.status(403).json({ + error: 'forbidden', + message: 'Nemáte právo zakládat tickety.', + }); + } + + const parsed = createTicketSchema.safeParse(req.body); + 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. + const tenantId = scope.tenantId; + if (!tenantId) { + return res.status(400).json({ + error: 'no_tenant', + message: 'Vyberte firmu, do které ticket patří.', + }); + } + + const input = parsed.data; + const customer = input.customer; + const hasCustomer = + customer !== undefined && + (customer.company !== '' || customer.contact !== '' || customer.reply !== ''); + + const ticket = createTicket({ + tenantId, + subject: input.subject, + body: input.body, + // Rucne zalozeny ticket prisel z portalu, ne z WhatsAppu ani z e-mailu. + channel: 'portal', + priority: input.priority, + typeId: input.typeId ?? null, + assigneeId: input.assigneeId ?? null, + assigneeGroupId: input.assigneeGroupId ?? null, + tags: input.tags, + ...(hasCustomer + ? { + customer: { + id: null, + company: customer.company, + contact: customer.contact, + reply: customer.reply, + }, + } + : {}), + trace: [ + { + kind: 'note', + label: 'Založeno ručně', + status: 'info', + response: `Ticket založil ${req.user!.email} v portálu.`, + }, + ], + }); + + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId, + action: 'ticket.create', + target: ticket.id, + detail: { subject: ticket.subject }, + }); + + return res.status(201).json(ticket); +}); + +/* + * Prevzeti, prirazeni, stav a komentar jsou vestavene akce na ticketu, + * viz `routes/ticketActions.ts` - kazda ma sve pravo a projde auditem. + */ + +/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */ +/** + * Stavy, ktere firma opravdu pouziva. + * + * Stav je **volny retezec**, ne ciselnik: ticket muze prijit z cizi aplikace + * s jejim vlastnim stavem. Pevny seznam v rozhrani by na nej nikdy nesedel. + * Tohle je proto jen naseptavac - vraci to, co uz v datech je, a nova hodnota + * projde stejne dobre. + * + * Musi byt registrovane pred `/tickets/:id`, jinak by se `statuses` chytilo + * jako ID ticketu. + */ +ticketsRouter.get('/tickets/statuses', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + + const items = [ + ...new Set( + listTickets({ tenantIds: scope.tenantIds, visibility: scope.visibility }).map( + (ticket) => ticket.status, + ), + ), + ].sort((a, b) => a.localeCompare(b, 'cs')); + + return res.json({ items }); +}); + +ticketsRouter.get('/tickets/workload', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + return res.json(getWorkload(listPeople(scope.tenantIds), scope.tenantIds, scope.visibility)); +}); + +ticketsRouter.get('/tickets/:id', (req, res) => { + // 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); +}); diff --git a/src/routes/invites.ts b/src/routes/invites.ts index de6be13..09f17c0 100644 --- a/src/routes/invites.ts +++ b/src/routes/invites.ts @@ -52,7 +52,13 @@ export const inviteRouter = safeRouter(); * 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 }); +const ACCEPT_WINDOW_MS = 15 * 60_000; +const ACCEPT_MAX_ATTEMPTS = 5; +const acceptLimiter = rateLimit({ + name: 'invite-accept', + windowMs: ACCEPT_WINDOW_MS, + max: ACCEPT_MAX_ATTEMPTS, +}); // ------------------------------------------------------------------ verejne @@ -98,7 +104,8 @@ const acceptSchema = z.object({ * Heslo se **nikdy neposila** - clovek si ho nastavi sam prave tady. */ publicInviteRouter.post('/:code/accept', acceptLimiter, (req, res) => { - const invite = findByCode(req.params.code); + // S middlewarem v ceste Express typ parametru neodvodi, prazdny kod je "nenalezeno". + const invite = findByCode(req.params.code ?? ''); if (!invite) { return res.status(404).json({ error: 'not_found', message: 'Pozvánka neexistuje.' }); } @@ -221,7 +228,9 @@ function managedTenant(req: Request, res: Response): string | null { 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.' }); + res + .status(403) + .json({ error: 'forbidden', message: 'Pozvánky spravuje ten, kdo spravuje uživatele.' }); return null; } return tenantId; diff --git a/src/routes/scripts.ts b/src/routes/scripts.ts index d1a805d..2e9e089 100644 --- a/src/routes/scripts.ts +++ b/src/routes/scripts.ts @@ -14,7 +14,7 @@ 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 { operationIdOf, serviceIdOf } from '../runtime/scripts/manifest.js'; import { ensureLoaded, getScript, @@ -24,8 +24,8 @@ import { saveSource, scriptProblems, scriptsDir, -} from '../scripts/registry.js'; -import { runScript } from '../scripts/runner.js'; +} from '../runtime/scripts/registry.js'; +import { runScript } from '../runtime/scripts/runner.js'; export const scriptsRouter = safeRouter(); @@ -80,7 +80,8 @@ scriptsRouter.post('/reload', requirePlatformAdmin, async (_req, res) => { }); scriptsRouter.get('/:id', async (req, res) => { - const { id } = req.params; + // Prazdne ID neprojde kontrolou tvaru, chybejici parametr tedy konci jako 400. + const { id = '' } = req.params; if (!isValidScriptId(id)) { return res.status(400).json({ error: 'validation_error', message: 'Neplatné ID skriptu.' }); } @@ -116,7 +117,8 @@ const saveSchema = z.object({ }); scriptsRouter.put('/:id', requirePlatformAdmin, async (req, res) => { - const { id } = req.params; + // Prazdne ID neprojde kontrolou tvaru, chybejici parametr tedy konci jako 400. + const { id = '' } = req.params; if (!isValidScriptId(id)) { return res.status(400).json({ error: 'validation_error', @@ -154,7 +156,8 @@ const testSchema = z.object({ * Portal na to upozorni pred stiskem. */ scriptsRouter.post('/:id/test', requirePlatformAdmin, async (req, res) => { - const { id } = req.params; + // Prazdne ID neprojde kontrolou tvaru, chybejici parametr tedy konci jako 400. + const { id = '' } = req.params; if (!isValidScriptId(id)) { return res.status(400).json({ error: 'validation_error', message: 'Neplatné ID skriptu.' }); } diff --git a/src/routes/settings.ts b/src/routes/settings.ts deleted file mode 100644 index e99ca6b..0000000 --- a/src/routes/settings.ts +++ /dev/null @@ -1,1155 +0,0 @@ -/** - * Nastaveni: firmy, uzivatele, role, resitele, skupiny, zalozky, typy ticketu, - * akce a vlastni widgety. - * - * Vsechno stoji na `crudRouter`, takze se tady pise jen to, co je u dane entity - * jine: schema vstupu, jak se z nej sestavi zaznam a co se ma overit. Zbytek - * (firma, 404 na cizi zaznam, prava, chybove tvary) je v te fabrice. - * - * Po kazdem zapisu se obnovi kopie v pameti (`refreshCaches`). Bez toho by - * uzivatel ulozil roli a prava by se zmenila az po restartu. - */ - -import { randomBytes, randomUUID } from 'node:crypto'; -import type { Request, Response } from 'express'; -import { z } from 'zod'; -import { recordAudit } from '../data/audit.js'; -import { bootstrapDataRefresh } from '../data/refresh.js'; -import { - customWidgetStore, - listCustomWidgets, - sizesFor, - validateWidget, - type CustomWidget, -} from '../data/customWidgets.js'; -import { - DEFAULT_CAPACITY, - findPerson, - groupStore, - listAllPeople, - personView, - type Person, -} from '../data/people.js'; -import { - allPermissions, - roleStore, - rolesFor, - type Role, -} from '../data/permissions.js'; -import { - featuresStore, - moduleCatalog, - defaultLimits, - defaultModules, - featuresOf, - type TenantFeatures, -} from '../data/tenantFeatures.js'; -import { - generateIntakeToken, - listTenants, - tenantStore, - type Tenant, -} from '../data/tenants.js'; -import { - listTicketTypes, - ticketScopeFields, - ticketTypeStore, - type TicketType, -} from '../data/ticketTypes.js'; -import { - actionStore, - listActions, - validateAction, - type TicketAction, -} from '../data/ticketActions.js'; -import { - findStoredUser, - findStoredUserByEmail, - hashPassword, - listAllUsers, - refreshUsers, - userStore, - usersOfTenant, - type StoredUser, -} from '../data/users.js'; -import { nowIso } from '../data/store/index.js'; -import type { Membership } from '../types.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 { 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 = 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(req.path); - }); - return next(); -}); - -// ------------------------------------------------------------------ katalogy - -/** Co lze nastavit. Klient z toho kresli zaskrtavatka, nehada si vlastni seznam. */ -settingsRouter.get('/catalog', (req, res) => { - res.json({ - permissions: allPermissions(), - modules: moduleCatalog, - defaultLimits, - widgetRenders: (['stat', 'chart', 'list', 'table', 'gauge'] as const).map((render) => ({ - render, - sizes: sizesFor(render), - })), - platformAdmin: req.user!.platformAdmin, - }); -}); - -// -------------------------------------------------------------------- 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(), - note: z.string().trim().max(500).optional(), - 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, - // Firma nepatri jine firme, proto tenantId null. - build: (input) => ({ - name: input.name, - note: input.note ?? '', - enabled: true, - tenantId: null, - // Token dostane firma hned pri zalozeni, aby prijem udalosti fungoval - // bez dalsiho kroku. Menit ho zvenku nejde, viz updateSchema vyse. - 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()) - ? [`Firma ${tenant.name} už existuje.`] - : []), - ...duplicateIco(tenant, all), - ], - }), -); - -// Zalozeni firmy z registru ARES vcetne lidi, kteri za ni jednaji. -settingsRouter.use('/ares', aresRouter); - -// ---------------------------------------------------------------- uzivatele - -/** - * Clenstvi nese i pole resitele (popisek, kapacita, externi ID, viditelnost). - * Prijimaji se nepovinne a pri uprave se **doplni z ulozeneho clenstvi**, viz - * `keepMembershipFields`: klient, ktery posila jen role, by je jinak smazal. - */ -const membershipSchema = z.object({ - tenantId: z.string().min(1), - roleIds: z.array(z.string().min(1)).min(1, 'Členství musí mít aspoň jednu roli.'), - seesAllTenant: z.boolean().optional(), - role: z.string().trim().max(60).optional(), - capacity: z.number().int().min(1).max(200).optional(), - externalIds: z.array(z.string().trim().min(1).max(120)).max(20).optional(), - enabled: z.boolean().optional(), -}); - -/** Do prichozich clenstvi doplni pole, ktera klient neposlal, z ulozenych. */ -function keepMembershipFields(incoming: Membership[], existing?: StoredUser): Membership[] { - if (!existing) return incoming; - return incoming.map((membership) => { - const stored = existing.memberships.find((item) => item.tenantId === membership.tenantId); - return stored ? { ...stored, ...membership } : membership; - }); -} - -const userCreate = z.object({ - email: z.string().trim().email('Zadejte platný e-mail.'), - name: z.string().trim().min(2).max(80), - password: z.string().min(8, 'Heslo musí mít aspoň 8 znaků.'), - platformAdmin: z.boolean().optional(), - 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 input?.memberships - ? { ok: true, input: { ...input, memberships: keepMembershipFields(input.memberships, existing) } } - : { 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, ...keepMembershipFields(input.memberships, existing)], - }, - }; - } - - return { ok: true, input }; -} - -settingsRouter.use( - '/users', - crudRouter, z.infer>({ - store: userStore, - idPrefix: 'usr', - event: 'user', - createSchema: userCreate, - updateSchema: userUpdate, - writePermission: 'user.manage', - scopeBy: { belongsTo: memberOf, prepare: prepareUserInput }, - build: async (input) => ({ - email: input.email.toLowerCase(), - name: input.name, - passwordHash: await hashPassword(input.password), - platformAdmin: input.platformAdmin ?? false, - memberships: input.memberships, - enabled: true, - // Uzivatel neni majetkem firmy, muze byt ve vic firmach naraz. - tenantId: null, - }), - toPublic: publicUser, - validate: (user, all) => { - const problems: string[] = []; - if (all.some((other) => other.email.toLowerCase() === user.email.toLowerCase())) { - problems.push(`E-mail ${user.email} už někdo má.`); - } - 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; - }, - }), -); - -/** - * Zmena hesla musi projit hashovanim. - * Bez teto vetve by `PATCH` ulozil plaintext do pole `password`, ktere nikdo - * nikdy neprecte, a heslo by se nezmenilo. - */ -settingsRouter.patch('/users/:id/password', async (req, res) => { - const parsed = z.object({ password: z.string().min(8) }).safeParse(req.body); - 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 spravce platformy, nebo spravce - * firmy svym lidem - ne ale spravci platformy, ten je mimo jeho dosah. - */ - const own = target.id === req.user!.id; - 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: await hashPassword(parsed.data.password) } as Partial, - readScope(req), - ); - recordAudit({ - userId: req.user!.id, - userEmail: req.user!.email, - tenantId: null, - action: 'user.password', - target: target.id, - }); - return res.status(204).end(); -}); - -/** - * 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.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 - -const roleCreate = z.object({ - name: z.string().trim().min(2).max(60), - description: z.string().trim().max(300).optional(), - permissions: z.array(z.string()).default([]), -}); - -settingsRouter.use( - '/roles', - crudRouter, Partial>({ - store: roleStore, - idPrefix: 'role', - event: 'role', - createSchema: roleCreate, - updateSchema: z.object({ - name: z.string().trim().min(2).max(60).optional(), - description: z.string().trim().max(300).optional(), - permissions: z.array(z.string()).optional(), - }), - writePermission: 'role.manage', - build: (input, tenantId) => ({ - key: `role_${tenantId}_${input.name.toLowerCase().replace(/[^a-z0-9]+/g, '_')}`, - name: input.name, - description: input.description ?? '', - permissions: input.permissions, - system: false, - }), - validate: (role) => { - const known = new Set(allPermissions().map((item) => item.key)); - const unknown = role.permissions.filter((permission) => !known.has(permission)); - // Neznamé právo je chyba, ne varovani: role by tise nedelala, co se ceka. - return unknown.length > 0 ? [`Neznámá práva: ${unknown.join(', ')}`] : []; - }, - }), -); - -/** Role dostupne firme vcetne systemovych. Pro nabidku u clenstvi. */ -settingsRouter.get('/roles-available', (req, res) => { - const tenantId = tenantOrDeny(req, res); - if (!tenantId) return; - return res.json({ items: rolesFor(tenantId) }); -}); - -// ------------------------------------------------------------------ resitele - -/* - * Resitel je clenstvi uctu ve firme, ne vlastni zaznam (viz data/people.ts). - * Endpointy a pravo `people.manage` zustavaji, ale pod nimi jsou ucty: - * zalozeni resitele zalozi ucet (nebo prida clenstvi uz existujicimu), - * smazani odebere clenstvi. `crudRouter` tu nejde pouzit, protoze zaznam, - * ktery se meni, je ucet bez firmy a odpoved je pohled za jednu firmu. - * - * Sprava uctu (`/users`) zustava spravci platformy. Kdyz tam nekomu zmeni - * jmeno, v Lidech se to projevi samo - pohled se odvozuje. - */ - -const personFields = { - /** Cim se v tymu zabyva. Jen popisek. */ - role: z.string().trim().max(60).optional(), - capacity: z.number().int().min(1).max(200).optional(), - /** ID, pod kterymi cloveka znaji cizi aplikace, napr. voicebotId. */ - externalIds: z.array(z.string().trim().min(1).max(120)).max(20).optional(), - /** Role clenstvi v teto firme. Vychozi je bezny resitel. */ - roleIds: z.array(z.string().min(1)).min(1, 'Členství musí mít aspoň jednu roli.').optional(), -}; - -const personCreate = z.object({ - name: z.string().trim().min(2).max(80), - email: z.string().trim().email('Zadejte platný e-mail.'), - /** Bez hesla dostane nahodne; clovek si ho nastavi pres zmenu hesla. */ - password: z.string().min(8, 'Heslo musí mít aspoň 8 znaků.').optional(), - enabled: z.boolean().optional(), - ...personFields, -}); - -const personUpdate = z.object({ - name: z.string().trim().min(2).max(80).optional(), - email: z.string().trim().email('Zadejte platný e-mail.').optional(), - enabled: z.boolean().optional(), - ...personFields, -}); - -/** Firma a pravo spravovat jeji lidi. Pri odepreni odpovi a vrati null. */ -function managedPeopleTenant(req: Request, res: Response): string | null { - const tenantId = tenantOrDeny(req, res); - if (!tenantId) return null; - if (!hasPermission(req.user!, 'people.manage', tenantId)) { - console.warn(`[crud] ${req.user!.email}: chybi pravo people.manage u person`); - res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' }); - return null; - } - return tenantId; -} - -/** Role z teto firmy nebo systemove. Vraci popis problemu, nebo null. */ -function unknownRoles(roleIds: string[], tenantId: string): string | null { - const known = rolesFor(tenantId); - const unknown = roleIds.filter((ref) => !known.some((role) => role.id === ref || role.key === ref)); - return unknown.length > 0 ? `Role ${unknown.join(', ')} v této firmě neexistuje.` : null; -} - -/** Ucet s clenstvim v teto firme. Cizi se chova jako neexistujici. */ -function memberAccount(id: string, tenantId: string): StoredUser | undefined { - const user = findStoredUser(id); - return user && memberOf(user, tenantId) ? user : undefined; -} - -/** - * Spravce firmy na spravce platformy nesaha, stejne jako u `/users`. Jinak by - * mu pres Lide mohl vzit clenstvi nebo vypnout ucet. - */ -function guardPlatformAdmin(req: Request, res: Response, target: StoredUser): boolean { - if (target.platformAdmin && !req.user!.platformAdmin) { - res.status(403).json({ error: 'forbidden', message: 'Správce platformy upravuje jen správce platformy.' }); - return false; - } - return true; -} - -/** Ohlasi zmenu do streamu, aby si portal seznam opravil na miste. */ -function announcePerson(verb: 'created' | 'updated' | 'deleted', person: Person): void { - publish( - `person.${verb}`, - `person ${verb}: ${person.name}`, - verb === 'deleted' ? { id: person.id } : { id: person.id, person }, - person.tenantId, - ); -} - -settingsRouter.get('/people', (req, res) => { - const tenantId = tenantOrDeny(req, res); - if (!tenantId) return; - return res.json({ items: listAllPeople([tenantId]) }); -}); - -/** Vcetne vypnutych. Pro spravu tymu. Totez co seznam, zustava kvuli klientum. */ -settingsRouter.get('/people-overview', (req, res) => { - const tenantId = tenantOrDeny(req, res); - if (!tenantId) return; - return res.json({ items: listAllPeople([tenantId]) }); -}); - -settingsRouter.get('/people/:id', (req, res) => { - const tenantId = tenantOrDeny(req, res); - if (!tenantId) return; - const person = findPerson(req.params.id, tenantId); - if (!person) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); - return res.json(person); -}); - -settingsRouter.post('/people', async (req, res) => { - const tenantId = managedPeopleTenant(req, res); - if (!tenantId) return; - - const parsed = personCreate.safeParse(req.body); - if (!parsed.success) return validationError(res, parsed.error); - const input = parsed.data; - - const roleIds = input.roleIds ?? ['role_agent']; - const roleProblem = unknownRoles(roleIds, tenantId); - if (roleProblem) return res.status(400).json({ error: 'validation_error', message: roleProblem }); - - const membership: Membership = { - tenantId, - roleIds, - role: input.role ?? '', - capacity: input.capacity ?? DEFAULT_CAPACITY, - externalIds: input.externalIds ?? [], - // Zapnuti je za clenstvi: spravce firmy rozhoduje o cloveku u sebe, ne jinde. - enabled: input.enabled ?? true, - }; - - const email = input.email.toLowerCase(); - const existing = findStoredUserByEmail(email); - let saved: StoredUser | undefined; - - if (existing) { - /* - * Ucet uz je: clovek z jine firmy, nebo nekdo, komu spravce platformy - * zalozil ucet driv. Prida se jen clenstvi; jmeno, heslo ani priznak - * zapnuti se neprepisuji, ty nejsou teto firmy. - */ - if (!guardPlatformAdmin(req, res, existing)) return; - if (memberOf(existing, tenantId)) { - return res.status(400).json({ - error: 'validation_error', - message: `Řešitel s e-mailem ${email} už v této firmě je.`, - }); - } - saved = await userStore.update( - existing.id, - { memberships: [...existing.memberships, membership] } as Partial, - { tenantIds: [], includeGlobal: true }, - ); - } else { - const timestamp = nowIso(); - saved = await userStore.create({ - id: `usr_${randomUUID().slice(0, 8)}`, - tenantId: null, - email, - name: input.name, - // Nahodne heslo se nikam neposila, clovek si nastavi svoje. - passwordHash: await hashPassword(input.password ?? randomBytes(18).toString('base64url')), - platformAdmin: false, - memberships: [membership], - enabled: true, - createdAt: timestamp, - updatedAt: timestamp, - }); - } - if (!saved) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); - - await refreshUsers(); - const person = personView(saved, membership); - announcePerson('created', person); - return res.status(201).json(person); -}); - -settingsRouter.patch('/people/:id', async (req, res) => { - const tenantId = managedPeopleTenant(req, res); - if (!tenantId) return; - - const existing = memberAccount(req.params.id, tenantId); - if (!existing) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); - if (!guardPlatformAdmin(req, res, existing)) return; - - const parsed = personUpdate.safeParse(req.body); - if (!parsed.success) return validationError(res, parsed.error); - const input = parsed.data; - - if (input.roleIds) { - const roleProblem = unknownRoles(input.roleIds, tenantId); - if (roleProblem) return res.status(400).json({ error: 'validation_error', message: roleProblem }); - } - - const email = input.email?.toLowerCase(); - if (email !== undefined) { - const taken = findStoredUserByEmail(email); - if (taken && taken.id !== existing.id) { - return res.status(400).json({ error: 'validation_error', message: `E-mail ${email} už někdo má.` }); - } - } - - // Clenstvi v jinych firmach zustavaji, jak jsou - nejsou teto firmy. - const memberships = existing.memberships.map((item): Membership => { - if (item.tenantId !== tenantId) return item; - return { - ...item, - ...(input.roleIds !== undefined ? { roleIds: input.roleIds } : {}), - ...(input.role !== undefined ? { role: input.role } : {}), - ...(input.capacity !== undefined ? { capacity: input.capacity } : {}), - ...(input.externalIds !== undefined ? { externalIds: input.externalIds } : {}), - // Vypnuti jen tady. Ucet jako celek vypina jen sprava uzivatelu. - ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), - }; - }); - - const patch: Partial = { - memberships, - ...(input.name !== undefined ? { name: input.name } : {}), - ...(email !== undefined ? { email } : {}), - }; - - const updated = await userStore.update(existing.id, patch, { tenantIds: [], includeGlobal: true }); - if (!updated) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); - - await refreshUsers(); - const person = personView(updated, memberships.find((item) => item.tenantId === tenantId)!); - announcePerson('updated', person); - return res.json(person); -}); - -/** - * Smazani resitele = odebrani clenstvi. Ucet zustava: muze byt i v jine - * firme a i kdyz neni, jeho tickety a historie se na nej dal odkazuji. - * Ucet bez jedineho clenstvi se vypne, aby se s nim neslo prihlasit - * do prazdna; spravce platformy se nevypina, ten firmu nepotrebuje. - */ -settingsRouter.delete('/people/:id', async (req, res) => { - const tenantId = managedPeopleTenant(req, res); - if (!tenantId) return; - - const existing = memberAccount(req.params.id, tenantId); - if (!existing) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); - if (!guardPlatformAdmin(req, res, existing)) return; - - const before = personView(existing, existing.memberships.find((item) => item.tenantId === tenantId)!); - const memberships = existing.memberships.filter((item) => item.tenantId !== tenantId); - const patch: Partial = { - memberships, - ...(memberships.length === 0 && !existing.platformAdmin ? { enabled: false } : {}), - }; - - const updated = await userStore.update(existing.id, patch, { tenantIds: [], includeGlobal: true }); - if (!updated) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); - - await refreshUsers(); - announcePerson('deleted', before); - return res.status(204).end(); -}); - -// ------------------------------------------------------------------- skupiny - -/** - * Clen skupiny. - * - * `seesAll` je "vedouci sekce": vidi vsechny tickety skupiny, ne jen svoje. - * Visi to na **clenstvi**, ne na cloveku a ne na skupine, takze jeden clovek - * muze jednu sekci vest a v druhe byt radovy. - */ -const groupMember = z.object({ - personId: z.string().min(1), - seesAll: z.boolean().default(false), -}); - -const groupCreate = z.object({ - name: z.string().trim().min(2).max(60), - members: z.array(groupMember).default([]), -}); - -settingsRouter.use( - '/groups', - crudRouter({ - store: groupStore, - idPrefix: 'grp', - event: 'group', - createSchema: groupCreate, - updateSchema: z.object({ - name: z.string().trim().min(2).max(60).optional(), - members: z.array(groupMember).optional(), - }), - writePermission: 'group.manage', - build: (input: z.infer) => ({ - name: input.name, - members: input.members, - }), - }), -); - -// ------------------------------------------------------- zalozky a limity firmy - -const featuresUpdate = z.object({ - modules: z.array(z.string()).optional(), - limits: z - .object({ - automations: z.number().int().min(0).max(10_000), - widgets: z.number().int().min(0).max(200), - actions: z.number().int().min(0).max(1_000), - connectors: z.number().int().min(0).max(500), - }) - .optional(), - serviceIds: z.array(z.string()).optional(), -}); - -/** Nastaveni firmy. GET vraci i vychozi, kdyz firma vlastni jeste nema. */ -settingsRouter.get('/features', (req, res) => { - const tenantId = tenantOrDeny(req, res); - if (!tenantId) return; - return res.json({ tenantId, features: featuresOf(tenantId) }); -}); - -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 = tenantOrDeny(req, res); - if (!tenantId) return; - - const parsed = featuresUpdate.safeParse(req.body); - 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)); - if (unknown.length > 0) { - return res.status(400).json({ - error: 'validation_error', - message: `Neznámé moduly: ${unknown.join(', ')}`, - }); - } - - // Povinne moduly se doplni vzdy. Bez prehledu a nastaveni nema portal kde zacit. - const required = moduleCatalog.filter((module) => module.required).map((module) => module.key); - const modules = [...new Set([...(parsed.data.modules ?? defaultModules()), ...required])]; - - const existing = (await featuresStore.list({ tenantIds: [tenantId] })).find( - (item) => item.tenantId === tenantId, - ); - - const payload: Partial = { - modules: modules as TenantFeatures['modules'], - limits: parsed.data.limits ?? featuresOf(tenantId).limits, - serviceIds: parsed.data.serviceIds ?? featuresOf(tenantId).serviceIds, - }; - - const saved = existing - ? await featuresStore.update(existing.id, payload, { tenantIds: [tenantId] }) - : await featuresStore.create({ - id: `feat_${tenantId}`, - tenantId, - modules: payload.modules!, - limits: payload.limits!, - serviceIds: payload.serviceIds!, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - recordAudit({ - userId: req.user!.id, - userEmail: req.user!.email, - tenantId, - action: 'tenant.features', - target: tenantId, - 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 }); -}); - -// -------------------------------------------------------------- typy ticketu - -const fieldSchema = z.object({ - id: z.string().min(1).optional(), - key: z - .string() - .trim() - .regex(/^[A-Za-z][A-Za-z0-9_]*$/, 'Klíč: písmena, číslice a _, začíná písmenem.'), - label: z.string().trim().min(1), - type: z.enum(['string', 'number', 'boolean', 'date']), - required: z.boolean(), - hint: z.string().trim().max(200).optional(), - options: z.array(z.object({ value: z.string(), label: z.string() })).optional(), -}); - -const typeCreate = z.object({ - key: z - .string() - .trim() - .regex(/^[a-z][a-z0-9-]*$/, 'Klíč typu: malá písmena, číslice a pomlčky.'), - name: z.string().trim().min(2).max(60), - icon: z.string().trim().max(40).optional(), - statuses: z.array(z.string().trim().min(1)).default([]), - fields: z.array(fieldSchema).default([]), -}); - -/** ID poli musi byt stabilni, odkazuji se na nej podminky. Chybejici dogeneruje. */ -function withFieldIds(fields: z.infer[]): TicketType['fields'] { - return fields.map((field, index) => ({ - id: field.id ?? `fld_${field.key}_${index}`, - key: field.key, - label: field.label, - type: field.type, - required: field.required, - ...(field.hint ? { hint: field.hint } : {}), - ...(field.options ? { options: field.options } : {}), - })); -} - -settingsRouter.use( - '/ticket-types', - 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. - updateSchema: z - .object({ - name: z.string().trim().min(2).max(60).optional(), - icon: z.string().trim().max(40).optional(), - statuses: z.array(z.string().trim().min(1)).optional(), - fields: z.array(fieldSchema).optional(), - }) - .transform(({ fields, ...rest }): Partial => ({ - ...rest, - ...(fields ? { fields: withFieldIds(fields) } : {}), - })), - writePermission: 'ticketType.manage', - build: (input) => ({ - key: input.key, - name: input.name, - icon: input.icon ?? 'LifeBuoy', - statuses: input.statuses, - fields: withFieldIds(input.fields), - }), - validate: (type, all) => { - const problems: string[] = []; - if (all.some((other) => other.key === type.key)) { - problems.push(`Typ s klíčem ${type.key} už v této firmě je.`); - } - const keys = new Set(); - for (const field of type.fields) { - if (keys.has(field.key)) problems.push(`Pole ${field.key} je uvedené dvakrát.`); - keys.add(field.key); - } - return problems; - }, - }), -); - -// -------------------------------------------------------------------- akce - -const conditionSchema = z.object({ - fieldId: z.string().min(1), - operator: z.enum([ - 'eq', 'neq', 'gt', 'gte', 'lt', 'lte', - 'contains', 'startsWith', 'isEmpty', 'isNotEmpty', 'isTrue', 'isFalse', - ]), - value: z.string().optional(), -}); - -/** Telo akce. Vysledek se pretypuje na `ActionBody`, viz komentar u kroku. */ -const bodySchema: z.ZodType = z - .discriminatedUnion('kind', [ - z.object({ - kind: z.literal('operation'), - serviceId: z.string().min(1), - operationId: z.string().min(1), - connectorId: z.string().min(1).nullable().default(null), - inputs: z.record(z.string()).default({}), - }), - // Kroky se tady netypuji: jejich schema je u automatizaci a duplikovat ho - // sem by znamenalo dve definice, ze kterych se jedna casem rozejde. - z.object({ - kind: z.literal('tree'), - steps: z.array(z.record(z.unknown())).default([]), - }), - z.object({ - kind: z.literal('script'), - scriptId: z.string().min(1), - inputs: z.record(z.string()).default({}), - }), - ]) - .transform((body) => body as TicketAction['body']); - -const actionCreate = z.object({ - label: z.string().trim().min(2).max(60), - icon: z.string().trim().max(40).optional(), - style: z.enum(['primary', 'default', 'danger']).default('default'), - order: z.number().int().min(0).max(999).default(10), - ticketTypeIds: z.array(z.string()).default([]), - tags: z.array(z.string().trim().min(1)).default([]), - visibleWhen: z.array(conditionSchema).default([]), - confirm: z.string().trim().max(300).nullable().default(null), - form: z - .array( - z.object({ - id: z.string().min(1), - label: z.string().trim().min(1), - kind: z.enum(['text', 'longtext', 'choice']), - required: z.boolean(), - options: z.array(z.object({ value: z.string(), label: z.string() })).optional(), - hint: z.string().optional(), - }), - ) - .default([]), - body: bodySchema, -}); - -settingsRouter.use( - '/actions', - crudRouter, Partial>({ - store: actionStore, - idPrefix: 'tka', - event: 'action', - createSchema: actionCreate, - updateSchema: z.object({ - label: z.string().trim().min(2).max(60).optional(), - icon: z.string().trim().max(40).optional(), - style: z.enum(['primary', 'default', 'danger']).optional(), - order: z.number().int().min(0).max(999).optional(), - ticketTypeIds: z.array(z.string()).optional(), - tags: z.array(z.string().trim().min(1)).optional(), - visibleWhen: z.array(conditionSchema).optional(), - confirm: z.string().trim().max(300).nullable().optional(), - body: bodySchema.optional(), - enabled: z.boolean().optional(), - }), - writePermission: 'action.manage', - build: (input) => ({ - label: input.label, - icon: input.icon ?? 'Play', - style: input.style, - order: input.order, - ticketTypeIds: input.ticketTypeIds, - tags: input.tags, - visibleWhen: input.visibleWhen, - confirm: input.confirm, - form: input.form, - body: input.body, - enabled: true, - }), - validate: (action) => validateAction(action, listTicketTypes([action.tenantId])), - }), -); - -/** Akce firmy vcetne vypnutych. Pro spravu. */ -settingsRouter.get('/actions-overview', (req, res) => { - const tenantId = tenantOrDeny(req, res); - if (!tenantId) return; - return res.json({ items: listActions([tenantId]) }); -}); - -/** - * Na co se da ve stromu akce odkazovat. - * - * Akci nespousti sluzba, ale clovek na ticketu, takze parametry nejsou od - * spoustece - jsou to udaje ticketu plus vlastni pole jeho typu. Pocita to - * server, aby si klient nedelal druhy seznam, ktery se casem rozejde. - */ -settingsRouter.get('/actions/:id/scope', (req, res) => { - const tenantId = tenantOrDeny(req, res); - if (!tenantId) return; - - const action = listActions([tenantId]).find((item) => item.id === req.params.id); - if (!action) { - return res.status(404).json({ error: 'not_found', message: 'Akce neexistuje.' }); - } - - /* - * Kdyz je akce navazana na vic typu, nabizeji se pole vsech. Krok je pak - * muze pouzit, i kdyz u konkretniho ticketu nekterá chybi - to uz je vec - * behu, ne definice. - */ - const types = listTicketTypes([tenantId]).filter((type) => - action.ticketTypeIds.includes(type.id), - ); - - const seen = new Set(); - const fields = (types.length > 0 ? types : [undefined]) - .flatMap((type) => ticketScopeFields(type)) - .filter((field) => { - if (seen.has(field.name)) return false; - seen.add(field.name); - return true; - }); - - return res.json({ - fields, - // Doptavaci pole akce jsou v sablonach taky k dispozici. - formFields: action.form.map((field) => ({ - id: `form.${field.id}`, - name: field.id, - type: 'string' as const, - required: field.required, - })), - }); -}); - -// -------------------------------------------------------------- vlastni widgety - -const sourceSchema = z.discriminatedUnion('kind', [ - z.object({ - kind: z.literal('ticketCount'), - filter: z.record(z.unknown()).default({}), - groupBy: z - .enum(['assignee', 'group', 'status', 'type', 'tag', 'channel']) - .optional(), - }), - z.object({ - kind: z.literal('ticketList'), - filter: z.record(z.unknown()).default({}), - limit: z.number().int().min(1).max(50).default(5), - }), - z.object({ - kind: z.literal('ticketSeries'), - filter: z.record(z.unknown()).default({}), - bucket: z.enum(['day', 'week']).default('day'), - }), - z.object({ kind: z.literal('workload'), groupIds: z.array(z.string()).optional() }), - z.object({ - kind: z.literal('agentStats'), - period: z.enum(['today', '7d', '30d', 'month', 'all']).default('30d'), - }), - z.object({ - kind: z.literal('connector'), - serviceId: z.string().trim().min(1), - operationId: z.string().trim().min(1), - /** null = vychozi konektor firmy pro tuhle sluzbu. */ - connectorId: z.string().trim().min(1).nullable().default(null), - inputs: z.record(z.string()).default({}), - path: z.string().trim().max(200).optional(), - labelPath: z.string().trim().max(200).optional(), - valuePath: z.string().trim().max(200).optional(), - /** - * Jak dlouho se vysledek drzi. Minimum je 30 s: bez nej by kazde otevreni - * prehledu volalo cizi sluzbu znovu a limity jsou nase i klientovy. - */ - ttlSec: z.number().int().min(30).max(86_400).default(300), - }), -]); - -const widgetCreate = z.object({ - name: z.string().trim().min(2).max(60), - description: z.string().trim().max(200).optional(), - render: z.enum(['stat', 'chart', 'list', 'table', 'gauge']), - source: sourceSchema, - size: z.enum(['third', 'half', 'full']), - target: z.number().optional(), - /** true = widget jen pro me, jinak pro celou firmu. */ - personal: z.boolean().default(false), -}); - -settingsRouter.use( - '/widgets', - crudRouter, Partial>({ - store: customWidgetStore, - idPrefix: 'cw', - event: 'widget', - createSchema: widgetCreate, - updateSchema: z.object({ - name: z.string().trim().min(2).max(60).optional(), - description: z.string().trim().max(200).optional(), - render: z.enum(['stat', 'chart', 'list', 'table', 'gauge']).optional(), - source: sourceSchema.optional(), - size: z.enum(['third', 'half', 'full']).optional(), - target: z.number().optional(), - }), - writePermission: 'widget.manage', - build: (input) => ({ - name: input.name, - description: input.description ?? '', - render: input.render, - source: input.source as CustomWidget['source'], - size: input.size, - ...(input.target !== undefined ? { target: input.target } : {}), - // Osobni widget vidi jen jeho autor. Vyplni se az v route, viz nize. - ownerId: null, - }), - validate: (widget) => validateWidget(widget), - }), -); - -/** Widgety firmy plus osobni prihlaseneho. */ -settingsRouter.get('/widgets-overview', (req, res) => { - const tenantId = tenantOrDeny(req, res); - if (!tenantId) return; - return res.json({ items: listCustomWidgets([tenantId], req.user!.id) }); -}); diff --git a/src/routes/settings/actions.ts b/src/routes/settings/actions.ts new file mode 100644 index 0000000..db8f4ea --- /dev/null +++ b/src/routes/settings/actions.ts @@ -0,0 +1,174 @@ +/** + * Akce na ticketu: definice, prehled a na co se da ve stromu odkazovat. + */ + +import { z } from 'zod'; +import { listTicketTypes, ticketScopeFields } from '../../data/ticketTypes.js'; +import { + actionStore, + listActions, + validateAction, + type TicketAction, +} from '../../data/ticketActions.js'; +import { tenantOrDeny } from '../../middleware/tenant.js'; +import { crudRouter } from '../crud.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const actionsRouter = safeRouter(); + +const conditionSchema = z.object({ + fieldId: z.string().min(1), + operator: z.enum([ + 'eq', + 'neq', + 'gt', + 'gte', + 'lt', + 'lte', + 'contains', + 'startsWith', + 'isEmpty', + 'isNotEmpty', + 'isTrue', + 'isFalse', + ]), + value: z.string().optional(), +}); + +/** Telo akce. Vysledek se pretypuje na `ActionBody`, viz komentar u kroku. */ +const bodySchema: z.ZodType = z + .discriminatedUnion('kind', [ + z.object({ + kind: z.literal('operation'), + serviceId: z.string().min(1), + operationId: z.string().min(1), + connectorId: z.string().min(1).nullable().default(null), + inputs: z.record(z.string()).default({}), + }), + // Kroky se tady netypuji: jejich schema je u automatizaci a duplikovat ho + // sem by znamenalo dve definice, ze kterych se jedna casem rozejde. + z.object({ + kind: z.literal('tree'), + steps: z.array(z.record(z.unknown())).default([]), + }), + z.object({ + kind: z.literal('script'), + scriptId: z.string().min(1), + inputs: z.record(z.string()).default({}), + }), + ]) + .transform((body) => body as TicketAction['body']); + +const actionCreate = z.object({ + label: z.string().trim().min(2).max(60), + icon: z.string().trim().max(40).optional(), + style: z.enum(['primary', 'default', 'danger']).default('default'), + order: z.number().int().min(0).max(999).default(10), + ticketTypeIds: z.array(z.string()).default([]), + tags: z.array(z.string().trim().min(1)).default([]), + visibleWhen: z.array(conditionSchema).default([]), + confirm: z.string().trim().max(300).nullable().default(null), + form: z + .array( + z.object({ + id: z.string().min(1), + label: z.string().trim().min(1), + kind: z.enum(['text', 'longtext', 'choice']), + required: z.boolean(), + options: z.array(z.object({ value: z.string(), label: z.string() })).optional(), + hint: z.string().optional(), + }), + ) + .default([]), + body: bodySchema, +}); + +actionsRouter.use( + '/actions', + crudRouter, Partial>({ + store: actionStore, + idPrefix: 'tka', + event: 'action', + createSchema: actionCreate, + updateSchema: z.object({ + label: z.string().trim().min(2).max(60).optional(), + icon: z.string().trim().max(40).optional(), + style: z.enum(['primary', 'default', 'danger']).optional(), + order: z.number().int().min(0).max(999).optional(), + ticketTypeIds: z.array(z.string()).optional(), + tags: z.array(z.string().trim().min(1)).optional(), + visibleWhen: z.array(conditionSchema).optional(), + confirm: z.string().trim().max(300).nullable().optional(), + body: bodySchema.optional(), + enabled: z.boolean().optional(), + }), + writePermission: 'action.manage', + build: (input) => ({ + label: input.label, + icon: input.icon ?? 'Play', + style: input.style, + order: input.order, + ticketTypeIds: input.ticketTypeIds, + tags: input.tags, + visibleWhen: input.visibleWhen, + confirm: input.confirm, + form: input.form, + body: input.body, + enabled: true, + }), + validate: (action) => validateAction(action, listTicketTypes([action.tenantId])), + }), +); + +/** Akce firmy vcetne vypnutych. Pro spravu. */ +actionsRouter.get('/actions-overview', (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + return res.json({ items: listActions([tenantId]) }); +}); + +/** + * Na co se da ve stromu akce odkazovat. + * + * Akci nespousti sluzba, ale clovek na ticketu, takze parametry nejsou od + * spoustece - jsou to udaje ticketu plus vlastni pole jeho typu. Pocita to + * server, aby si klient nedelal druhy seznam, ktery se casem rozejde. + */ +actionsRouter.get('/actions/:id/scope', (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + + const action = listActions([tenantId]).find((item) => item.id === req.params.id); + if (!action) { + return res.status(404).json({ error: 'not_found', message: 'Akce neexistuje.' }); + } + + /* + * Kdyz je akce navazana na vic typu, nabizeji se pole vsech. Krok je pak + * muze pouzit, i kdyz u konkretniho ticketu nekterá chybi - to uz je vec + * behu, ne definice. + */ + const types = listTicketTypes([tenantId]).filter((type) => + action.ticketTypeIds.includes(type.id), + ); + + const seen = new Set(); + const fields = (types.length > 0 ? types : [undefined]) + .flatMap((type) => ticketScopeFields(type)) + .filter((field) => { + if (seen.has(field.name)) return false; + seen.add(field.name); + return true; + }); + + return res.json({ + fields, + // Doptavaci pole akce jsou v sablonach taky k dispozici. + formFields: action.form.map((field) => ({ + id: `form.${field.id}`, + name: field.id, + type: 'string' as const, + required: field.required, + })), + }); +}); diff --git a/src/routes/settings/catalog.ts b/src/routes/settings/catalog.ts new file mode 100644 index 0000000..682dc7e --- /dev/null +++ b/src/routes/settings/catalog.ts @@ -0,0 +1,24 @@ +/** + * Katalog toho, co jde v nastaveni zvolit: prava, moduly, limity, widgety. + */ + +import { sizesFor } from '../../data/customWidgets.js'; +import { allPermissions } from '../../data/permissions.js'; +import { moduleCatalog, defaultLimits } from '../../data/tenantFeatures.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const catalogRouter = safeRouter(); + +/** Co lze nastavit. Klient z toho kresli zaskrtavatka, nehada si vlastni seznam. */ +catalogRouter.get('/catalog', (req, res) => { + res.json({ + permissions: allPermissions(), + modules: moduleCatalog, + defaultLimits, + widgetRenders: (['stat', 'chart', 'list', 'table', 'gauge'] as const).map((render) => ({ + render, + sizes: sizesFor(render), + })), + platformAdmin: req.user!.platformAdmin, + }); +}); diff --git a/src/routes/settings/features.ts b/src/routes/settings/features.ts new file mode 100644 index 0000000..26dfc6b --- /dev/null +++ b/src/routes/settings/features.ts @@ -0,0 +1,100 @@ +/** + * Zalozky a limity firmy. Nastavuje je spravce platformy. + */ + +import { z } from 'zod'; +import { recordAudit } from '../../data/audit.js'; +import { + featuresStore, + moduleCatalog, + defaultModules, + featuresOf, + type TenantFeatures, +} from '../../data/tenantFeatures.js'; +import { publish } from '../../events/bus.js'; +import { tenantOrDeny } from '../../middleware/tenant.js'; +import { validationError } from '../../middleware/validation.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const featuresRouter = safeRouter(); + +const featuresUpdate = z.object({ + modules: z.array(z.string()).optional(), + limits: z + .object({ + automations: z.number().int().min(0).max(10_000), + widgets: z.number().int().min(0).max(200), + actions: z.number().int().min(0).max(1_000), + connectors: z.number().int().min(0).max(500), + }) + .optional(), + serviceIds: z.array(z.string()).optional(), +}); + +/** Nastaveni firmy. GET vraci i vychozi, kdyz firma vlastni jeste nema. */ +featuresRouter.get('/features', (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + return res.json({ tenantId, features: featuresOf(tenantId) }); +}); + +featuresRouter.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 = tenantOrDeny(req, res); + if (!tenantId) return; + + const parsed = featuresUpdate.safeParse(req.body); + 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)); + if (unknown.length > 0) { + return res.status(400).json({ + error: 'validation_error', + message: `Neznámé moduly: ${unknown.join(', ')}`, + }); + } + + // Povinne moduly se doplni vzdy. Bez prehledu a nastaveni nema portal kde zacit. + const required = moduleCatalog.filter((module) => module.required).map((module) => module.key); + const modules = [...new Set([...(parsed.data.modules ?? defaultModules()), ...required])]; + + const existing = (await featuresStore.list({ tenantIds: [tenantId] })).find( + (item) => item.tenantId === tenantId, + ); + + const payload: Partial = { + modules: modules as TenantFeatures['modules'], + limits: parsed.data.limits ?? featuresOf(tenantId).limits, + serviceIds: parsed.data.serviceIds ?? featuresOf(tenantId).serviceIds, + }; + + const saved = existing + ? await featuresStore.update(existing.id, payload, { tenantIds: [tenantId] }) + : await featuresStore.create({ + id: `feat_${tenantId}`, + tenantId, + modules: payload.modules!, + limits: payload.limits!, + serviceIds: payload.serviceIds!, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId, + action: 'tenant.features', + target: tenantId, + 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 }); +}); diff --git a/src/routes/settings/groups.ts b/src/routes/settings/groups.ts new file mode 100644 index 0000000..083dc3f --- /dev/null +++ b/src/routes/settings/groups.ts @@ -0,0 +1,46 @@ +/** + * Skupiny resitelu a jejich clenove. + */ + +import { z } from 'zod'; +import { groupStore } from '../../data/people.js'; +import { crudRouter } from '../crud.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const groupsRouter = safeRouter(); + +/** + * Clen skupiny. + * + * `seesAll` je "vedouci sekce": vidi vsechny tickety skupiny, ne jen svoje. + * Visi to na **clenstvi**, ne na cloveku a ne na skupine, takze jeden clovek + * muze jednu sekci vest a v druhe byt radovy. + */ +const groupMember = z.object({ + personId: z.string().min(1), + seesAll: z.boolean().default(false), +}); + +const groupCreate = z.object({ + name: z.string().trim().min(2).max(60), + members: z.array(groupMember).default([]), +}); + +groupsRouter.use( + '/groups', + crudRouter({ + store: groupStore, + idPrefix: 'grp', + event: 'group', + createSchema: groupCreate, + updateSchema: z.object({ + name: z.string().trim().min(2).max(60).optional(), + members: z.array(groupMember).optional(), + }), + writePermission: 'group.manage', + build: (input: z.infer) => ({ + name: input.name, + members: input.members, + }), + }), +); diff --git a/src/routes/settings/index.ts b/src/routes/settings/index.ts new file mode 100644 index 0000000..ced3e8f --- /dev/null +++ b/src/routes/settings/index.ts @@ -0,0 +1,51 @@ +/** + * Nastaveni: firmy, uzivatele, role, resitele, skupiny, zalozky, typy ticketu, + * akce a vlastni widgety. + * + * Vsechno stoji na `crudRouter`, takze se u kazde entity pise jen to, co je + * jine: schema vstupu, jak se z nej sestavi zaznam a co se ma overit. Zbytek + * (firma, 404 na cizi zaznam, prava, chybove tvary) je v te fabrice. + * + * Po kazdem zapisu se obnovi kopie v pameti (`bootstrapDataRefresh`). Bez toho + * by uzivatel ulozil roli a prava by se zmenila az po restartu. + */ + +import { bootstrapDataRefresh } from '../../data/refresh.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; +import { requireAuth } from '../../middleware/auth.js'; +import { aresRouter } from '../ares.js'; +import { actionsRouter } from './actions.js'; +import { catalogRouter } from './catalog.js'; +import { featuresRouter } from './features.js'; +import { groupsRouter } from './groups.js'; +import { peopleRouter } from './people.js'; +import { rolesRouter } from './roles.js'; +import { tenantsRouter } from './tenants.js'; +import { ticketTypesRouter } from './ticketTypes.js'; +import { usersRouter } from './users.js'; +import { widgetsRouter } from './widgets.js'; + +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(req.path); + }); + return next(); +}); + +settingsRouter.use(catalogRouter); +settingsRouter.use(tenantsRouter); +// Zalozeni firmy z registru ARES vcetne lidi, kteri za ni jednaji. +settingsRouter.use('/ares', aresRouter); +settingsRouter.use(usersRouter); +settingsRouter.use(rolesRouter); +settingsRouter.use(peopleRouter); +settingsRouter.use(groupsRouter); +settingsRouter.use(featuresRouter); +settingsRouter.use(ticketTypesRouter); +settingsRouter.use(actionsRouter); +settingsRouter.use(widgetsRouter); diff --git a/src/routes/settings/people.ts b/src/routes/settings/people.ts new file mode 100644 index 0000000..f74540a --- /dev/null +++ b/src/routes/settings/people.ts @@ -0,0 +1,307 @@ +/** + * Resitele: clenstvi uctu ve firme, viz komentar nize. + */ + +import { randomBytes, randomUUID } from 'node:crypto'; +import type { Request, Response } from 'express'; +import { z } from 'zod'; +import { + DEFAULT_CAPACITY, + findPerson, + listAllPeople, + personView, + type Person, +} from '../../data/people.js'; +import { hasPermission, rolesFor } from '../../data/permissions.js'; +import { + findStoredUser, + findStoredUserByEmail, + hashPassword, + refreshUsers, + userStore, + type StoredUser, +} from '../../data/users.js'; +import { nowIso } from '../../data/store/index.js'; +import type { Membership } from '../../types.js'; +import { publish } from '../../events/bus.js'; +import { tenantOrDeny } from '../../middleware/tenant.js'; +import { validationError } from '../../middleware/validation.js'; +import { memberOf } from './shared.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const peopleRouter = safeRouter(); + +/* + * Resitel je clenstvi uctu ve firme, ne vlastni zaznam (viz data/people.ts). + * Endpointy a pravo `people.manage` zustavaji, ale pod nimi jsou ucty: + * zalozeni resitele zalozi ucet (nebo prida clenstvi uz existujicimu), + * smazani odebere clenstvi. `crudRouter` tu nejde pouzit, protoze zaznam, + * ktery se meni, je ucet bez firmy a odpoved je pohled za jednu firmu. + * + * Sprava uctu (`/users`) zustava spravci platformy. Kdyz tam nekomu zmeni + * jmeno, v Lidech se to projevi samo - pohled se odvozuje. + */ + +const personFields = { + /** Cim se v tymu zabyva. Jen popisek. */ + role: z.string().trim().max(60).optional(), + capacity: z.number().int().min(1).max(200).optional(), + /** ID, pod kterymi cloveka znaji cizi aplikace, napr. voicebotId. */ + externalIds: z.array(z.string().trim().min(1).max(120)).max(20).optional(), + /** Role clenstvi v teto firme. Vychozi je bezny resitel. */ + roleIds: z.array(z.string().min(1)).min(1, 'Členství musí mít aspoň jednu roli.').optional(), +}; + +const personCreate = z.object({ + name: z.string().trim().min(2).max(80), + email: z.string().trim().email('Zadejte platný e-mail.'), + /** Bez hesla dostane nahodne; clovek si ho nastavi pres zmenu hesla. */ + password: z.string().min(8, 'Heslo musí mít aspoň 8 znaků.').optional(), + enabled: z.boolean().optional(), + ...personFields, +}); + +const personUpdate = z.object({ + name: z.string().trim().min(2).max(80).optional(), + email: z.string().trim().email('Zadejte platný e-mail.').optional(), + enabled: z.boolean().optional(), + ...personFields, +}); + +/** Firma a pravo spravovat jeji lidi. Pri odepreni odpovi a vrati null. */ +function managedPeopleTenant(req: Request, res: Response): string | null { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return null; + if (!hasPermission(req.user!, 'people.manage', tenantId)) { + console.warn(`[crud] ${req.user!.email}: chybi pravo people.manage u person`); + res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' }); + return null; + } + return tenantId; +} + +/** Role z teto firmy nebo systemove. Vraci popis problemu, nebo null. */ +function unknownRoles(roleIds: string[], tenantId: string): string | null { + const known = rolesFor(tenantId); + const unknown = roleIds.filter( + (ref) => !known.some((role) => role.id === ref || role.key === ref), + ); + return unknown.length > 0 ? `Role ${unknown.join(', ')} v této firmě neexistuje.` : null; +} + +/** Ucet s clenstvim v teto firme. Cizi se chova jako neexistujici. */ +function memberAccount(id: string, tenantId: string): StoredUser | undefined { + const user = findStoredUser(id); + return user && memberOf(user, tenantId) ? user : undefined; +} + +/** + * Spravce firmy na spravce platformy nesaha, stejne jako u `/users`. Jinak by + * mu pres Lide mohl vzit clenstvi nebo vypnout ucet. + */ +function guardPlatformAdmin(req: Request, res: Response, target: StoredUser): boolean { + if (target.platformAdmin && !req.user!.platformAdmin) { + res + .status(403) + .json({ error: 'forbidden', message: 'Správce platformy upravuje jen správce platformy.' }); + return false; + } + return true; +} + +/** Ohlasi zmenu do streamu, aby si portal seznam opravil na miste. */ +function announcePerson(verb: 'created' | 'updated' | 'deleted', person: Person): void { + publish( + `person.${verb}`, + `person ${verb}: ${person.name}`, + verb === 'deleted' ? { id: person.id } : { id: person.id, person }, + person.tenantId, + ); +} + +peopleRouter.get('/people', (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + return res.json({ items: listAllPeople([tenantId]) }); +}); + +/** Vcetne vypnutych. Pro spravu tymu. Totez co seznam, zustava kvuli klientum. */ +peopleRouter.get('/people-overview', (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + return res.json({ items: listAllPeople([tenantId]) }); +}); + +peopleRouter.get('/people/:id', (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + const person = findPerson(req.params.id, tenantId); + if (!person) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + return res.json(person); +}); + +peopleRouter.post('/people', async (req, res) => { + const tenantId = managedPeopleTenant(req, res); + if (!tenantId) return; + + const parsed = personCreate.safeParse(req.body); + if (!parsed.success) return validationError(res, parsed.error); + const input = parsed.data; + + const roleIds = input.roleIds ?? ['role_agent']; + const roleProblem = unknownRoles(roleIds, tenantId); + if (roleProblem) return res.status(400).json({ error: 'validation_error', message: roleProblem }); + + const membership: Membership = { + tenantId, + roleIds, + role: input.role ?? '', + capacity: input.capacity ?? DEFAULT_CAPACITY, + externalIds: input.externalIds ?? [], + // Zapnuti je za clenstvi: spravce firmy rozhoduje o cloveku u sebe, ne jinde. + enabled: input.enabled ?? true, + }; + + const email = input.email.toLowerCase(); + const existing = findStoredUserByEmail(email); + let saved: StoredUser | undefined; + + if (existing) { + /* + * Ucet uz je: clovek z jine firmy, nebo nekdo, komu spravce platformy + * zalozil ucet driv. Prida se jen clenstvi; jmeno, heslo ani priznak + * zapnuti se neprepisuji, ty nejsou teto firmy. + */ + if (!guardPlatformAdmin(req, res, existing)) return; + if (memberOf(existing, tenantId)) { + return res.status(400).json({ + error: 'validation_error', + message: `Řešitel s e-mailem ${email} už v této firmě je.`, + }); + } + saved = await userStore.update( + existing.id, + { memberships: [...existing.memberships, membership] } as Partial, + { tenantIds: [], includeGlobal: true }, + ); + } else { + const timestamp = nowIso(); + saved = await userStore.create({ + id: `usr_${randomUUID().slice(0, 8)}`, + tenantId: null, + email, + name: input.name, + // Nahodne heslo se nikam neposila, clovek si nastavi svoje. + passwordHash: await hashPassword(input.password ?? randomBytes(18).toString('base64url')), + platformAdmin: false, + memberships: [membership], + enabled: true, + createdAt: timestamp, + updatedAt: timestamp, + }); + } + if (!saved) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + + await refreshUsers(); + const person = personView(saved, membership); + announcePerson('created', person); + return res.status(201).json(person); +}); + +peopleRouter.patch('/people/:id', async (req, res) => { + const tenantId = managedPeopleTenant(req, res); + if (!tenantId) return; + + const existing = memberAccount(req.params.id, tenantId); + if (!existing) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + if (!guardPlatformAdmin(req, res, existing)) return; + + const parsed = personUpdate.safeParse(req.body); + if (!parsed.success) return validationError(res, parsed.error); + const input = parsed.data; + + if (input.roleIds) { + const roleProblem = unknownRoles(input.roleIds, tenantId); + if (roleProblem) + return res.status(400).json({ error: 'validation_error', message: roleProblem }); + } + + const email = input.email?.toLowerCase(); + if (email !== undefined) { + const taken = findStoredUserByEmail(email); + if (taken && taken.id !== existing.id) { + return res + .status(400) + .json({ error: 'validation_error', message: `E-mail ${email} už někdo má.` }); + } + } + + // Clenstvi v jinych firmach zustavaji, jak jsou - nejsou teto firmy. + const memberships = existing.memberships.map((item): Membership => { + if (item.tenantId !== tenantId) return item; + return { + ...item, + ...(input.roleIds !== undefined ? { roleIds: input.roleIds } : {}), + ...(input.role !== undefined ? { role: input.role } : {}), + ...(input.capacity !== undefined ? { capacity: input.capacity } : {}), + ...(input.externalIds !== undefined ? { externalIds: input.externalIds } : {}), + // Vypnuti jen tady. Ucet jako celek vypina jen sprava uzivatelu. + ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), + }; + }); + + const patch: Partial = { + memberships, + ...(input.name !== undefined ? { name: input.name } : {}), + ...(email !== undefined ? { email } : {}), + }; + + const updated = await userStore.update(existing.id, patch, { + tenantIds: [], + includeGlobal: true, + }); + if (!updated) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + + await refreshUsers(); + const person = personView( + updated, + memberships.find((item) => item.tenantId === tenantId)!, + ); + announcePerson('updated', person); + return res.json(person); +}); + +/** + * Smazani resitele = odebrani clenstvi. Ucet zustava: muze byt i v jine + * firme a i kdyz neni, jeho tickety a historie se na nej dal odkazuji. + * Ucet bez jedineho clenstvi se vypne, aby se s nim neslo prihlasit + * do prazdna; spravce platformy se nevypina, ten firmu nepotrebuje. + */ +peopleRouter.delete('/people/:id', async (req, res) => { + const tenantId = managedPeopleTenant(req, res); + if (!tenantId) return; + + const existing = memberAccount(req.params.id, tenantId); + if (!existing) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + if (!guardPlatformAdmin(req, res, existing)) return; + + const before = personView( + existing, + existing.memberships.find((item) => item.tenantId === tenantId)!, + ); + const memberships = existing.memberships.filter((item) => item.tenantId !== tenantId); + const patch: Partial = { + memberships, + ...(memberships.length === 0 && !existing.platformAdmin ? { enabled: false } : {}), + }; + + const updated = await userStore.update(existing.id, patch, { + tenantIds: [], + includeGlobal: true, + }); + if (!updated) return res.status(404).json({ error: 'not_found', message: 'Záznam neexistuje.' }); + + await refreshUsers(); + announcePerson('deleted', before); + return res.status(204).end(); +}); diff --git a/src/routes/settings/roles.ts b/src/routes/settings/roles.ts new file mode 100644 index 0000000..e4e784a --- /dev/null +++ b/src/routes/settings/roles.ts @@ -0,0 +1,53 @@ +/** + * Role a prava. Systemove role se nemeni, firemni ano. + */ + +import { z } from 'zod'; +import { allPermissions, roleStore, rolesFor, type Role } from '../../data/permissions.js'; +import { tenantOrDeny } from '../../middleware/tenant.js'; +import { crudRouter } from '../crud.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const rolesRouter = safeRouter(); + +const roleCreate = z.object({ + name: z.string().trim().min(2).max(60), + description: z.string().trim().max(300).optional(), + permissions: z.array(z.string()).default([]), +}); + +rolesRouter.use( + '/roles', + crudRouter, Partial>({ + store: roleStore, + idPrefix: 'role', + event: 'role', + createSchema: roleCreate, + updateSchema: z.object({ + name: z.string().trim().min(2).max(60).optional(), + description: z.string().trim().max(300).optional(), + permissions: z.array(z.string()).optional(), + }), + writePermission: 'role.manage', + build: (input, tenantId) => ({ + key: `role_${tenantId}_${input.name.toLowerCase().replace(/[^a-z0-9]+/g, '_')}`, + name: input.name, + description: input.description ?? '', + permissions: input.permissions, + system: false, + }), + validate: (role) => { + const known = new Set(allPermissions().map((item) => item.key)); + const unknown = role.permissions.filter((permission) => !known.has(permission)); + // Neznamé právo je chyba, ne varovani: role by tise nedelala, co se ceka. + return unknown.length > 0 ? [`Neznámá práva: ${unknown.join(', ')}`] : []; + }, + }), +); + +/** Role dostupne firme vcetne systemovych. Pro nabidku u clenstvi. */ +rolesRouter.get('/roles-available', (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + return res.json({ items: rolesFor(tenantId) }); +}); diff --git a/src/routes/settings/shared.ts b/src/routes/settings/shared.ts new file mode 100644 index 0000000..1a17e68 --- /dev/null +++ b/src/routes/settings/shared.ts @@ -0,0 +1,10 @@ +/** + * Pomocne funkce sdilene spravou uzivatelu a resitelu. + */ + +import type { StoredUser } from '../../data/users.js'; + +/** Je uzivatel clenem firmy? Spravce firmy vidi jen sve lidi. */ +export function memberOf(user: StoredUser, tenantId: string): boolean { + return user.memberships.some((membership) => membership.tenantId === tenantId); +} diff --git a/src/routes/settings/tenants.ts b/src/routes/settings/tenants.ts new file mode 100644 index 0000000..cd36c53 --- /dev/null +++ b/src/routes/settings/tenants.ts @@ -0,0 +1,79 @@ +/** + * Firmy. Zaklada je spravce platformy, udaje z ARES jsou nepovinne. + */ + +import { z } from 'zod'; +import { generateIntakeToken, tenantStore, type Tenant } from '../../data/tenants.js'; +import { crudRouter } from '../crud.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const tenantsRouter = safeRouter(); + +/** 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.`] + : []; +} + +tenantsRouter.use( + '/tenants', + crudRouter, Partial>({ + store: tenantStore, + idPrefix: 'tnt', + event: 'tenant', + createSchema: tenantCreate, + updateSchema: z.object({ + name: z.string().trim().min(2).max(80).optional(), + note: z.string().trim().max(500).optional(), + 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, + // Firma nepatri jine firme, proto tenantId null. + build: (input) => ({ + name: input.name, + note: input.note ?? '', + enabled: true, + tenantId: null, + // Token dostane firma hned pri zalozeni, aby prijem udalosti fungoval + // bez dalsiho kroku. Menit ho zvenku nejde, viz updateSchema vyse. + 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()) + ? [`Firma ${tenant.name} už existuje.`] + : []), + ...duplicateIco(tenant, all), + ], + }), +); diff --git a/src/routes/settings/ticketTypes.ts b/src/routes/settings/ticketTypes.ts new file mode 100644 index 0000000..9213caf --- /dev/null +++ b/src/routes/settings/ticketTypes.ts @@ -0,0 +1,90 @@ +/** + * Typy ticketu a jejich vlastni pole. + */ + +import { z } from 'zod'; +import { ticketTypeStore, type TicketType } from '../../data/ticketTypes.js'; +import { crudRouter } from '../crud.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const ticketTypesRouter = safeRouter(); + +const fieldSchema = z.object({ + id: z.string().min(1).optional(), + key: z + .string() + .trim() + .regex(/^[A-Za-z][A-Za-z0-9_]*$/, 'Klíč: písmena, číslice a _, začíná písmenem.'), + label: z.string().trim().min(1), + type: z.enum(['string', 'number', 'boolean', 'date']), + required: z.boolean(), + hint: z.string().trim().max(200).optional(), + options: z.array(z.object({ value: z.string(), label: z.string() })).optional(), +}); + +const typeCreate = z.object({ + key: z + .string() + .trim() + .regex(/^[a-z][a-z0-9-]*$/, 'Klíč typu: malá písmena, číslice a pomlčky.'), + name: z.string().trim().min(2).max(60), + icon: z.string().trim().max(40).optional(), + statuses: z.array(z.string().trim().min(1)).default([]), + fields: z.array(fieldSchema).default([]), +}); + +/** ID poli musi byt stabilni, odkazuji se na nej podminky. Chybejici dogeneruje. */ +function withFieldIds(fields: z.infer[]): TicketType['fields'] { + return fields.map((field, index) => ({ + id: field.id ?? `fld_${field.key}_${index}`, + key: field.key, + label: field.label, + type: field.type, + required: field.required, + ...(field.hint ? { hint: field.hint } : {}), + ...(field.options ? { options: field.options } : {}), + })); +} + +ticketTypesRouter.use( + '/ticket-types', + 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. + updateSchema: z + .object({ + name: z.string().trim().min(2).max(60).optional(), + icon: z.string().trim().max(40).optional(), + statuses: z.array(z.string().trim().min(1)).optional(), + fields: z.array(fieldSchema).optional(), + }) + .transform(({ fields, ...rest }): Partial => ({ + ...rest, + ...(fields ? { fields: withFieldIds(fields) } : {}), + })), + writePermission: 'ticketType.manage', + build: (input) => ({ + key: input.key, + name: input.name, + icon: input.icon ?? 'LifeBuoy', + statuses: input.statuses, + fields: withFieldIds(input.fields), + }), + validate: (type, all) => { + const problems: string[] = []; + if (all.some((other) => other.key === type.key)) { + problems.push(`Typ s klíčem ${type.key} už v této firmě je.`); + } + const keys = new Set(); + for (const field of type.fields) { + if (keys.has(field.key)) problems.push(`Pole ${field.key} je uvedené dvakrát.`); + keys.add(field.key); + } + return problems; + }, + }), +); diff --git a/src/routes/settings/users.ts b/src/routes/settings/users.ts new file mode 100644 index 0000000..91f7c39 --- /dev/null +++ b/src/routes/settings/users.ts @@ -0,0 +1,252 @@ +/** + * Ucty: CRUD z fabriky, zmena hesla a prehled vcetne vypnutych. + */ + +import type { Request } from 'express'; +import { z } from 'zod'; +import { recordAudit } from '../../data/audit.js'; +import { hasPermission, rolesFor } from '../../data/permissions.js'; +import { listTenants } from '../../data/tenants.js'; +import { + hashPassword, + listAllUsers, + userStore, + usersOfTenant, + type StoredUser, +} from '../../data/users.js'; +import type { Membership } from '../../types.js'; +import { requestTenant, tenantOrDeny } from '../../middleware/tenant.js'; +import { validationError } from '../../middleware/validation.js'; +import { crudRouter, readScope } from '../crud.js'; +import { memberOf } from './shared.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const usersRouter = safeRouter(); + +/** + * Clenstvi nese i pole resitele (popisek, kapacita, externi ID, viditelnost). + * Prijimaji se nepovinne a pri uprave se **doplni z ulozeneho clenstvi**, viz + * `keepMembershipFields`: klient, ktery posila jen role, by je jinak smazal. + */ +const membershipSchema = z.object({ + tenantId: z.string().min(1), + roleIds: z.array(z.string().min(1)).min(1, 'Členství musí mít aspoň jednu roli.'), + seesAllTenant: z.boolean().optional(), + role: z.string().trim().max(60).optional(), + capacity: z.number().int().min(1).max(200).optional(), + externalIds: z.array(z.string().trim().min(1).max(120)).max(20).optional(), + enabled: z.boolean().optional(), +}); + +/** Do prichozich clenstvi doplni pole, ktera klient neposlal, z ulozenych. */ +function keepMembershipFields(incoming: Membership[], existing?: StoredUser): Membership[] { + if (!existing) return incoming; + return incoming.map((membership) => { + const stored = existing.memberships.find((item) => item.tenantId === membership.tenantId); + return stored ? { ...stored, ...membership } : membership; + }); +} + +const userCreate = z.object({ + email: z.string().trim().email('Zadejte platný e-mail.'), + name: z.string().trim().min(2).max(80), + password: z.string().min(8, 'Heslo musí mít aspoň 8 znaků.'), + platformAdmin: z.boolean().optional(), + 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; +} +/** + * 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 input?.memberships + ? { + ok: true, + input: { ...input, memberships: keepMembershipFields(input.memberships, existing) }, + } + : { 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, ...keepMembershipFields(input.memberships, existing)], + }, + }; + } + + return { ok: true, input }; +} + +usersRouter.use( + '/users', + crudRouter, z.infer>({ + store: userStore, + idPrefix: 'usr', + event: 'user', + createSchema: userCreate, + updateSchema: userUpdate, + writePermission: 'user.manage', + scopeBy: { belongsTo: memberOf, prepare: prepareUserInput }, + build: async (input) => ({ + email: input.email.toLowerCase(), + name: input.name, + passwordHash: await hashPassword(input.password), + platformAdmin: input.platformAdmin ?? false, + memberships: input.memberships, + enabled: true, + // Uzivatel neni majetkem firmy, muze byt ve vic firmach naraz. + tenantId: null, + }), + toPublic: publicUser, + validate: (user, all) => { + const problems: string[] = []; + if (all.some((other) => other.email.toLowerCase() === user.email.toLowerCase())) { + problems.push(`E-mail ${user.email} už někdo má.`); + } + 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; + }, + }), +); + +/** + * Zmena hesla musi projit hashovanim. + * Bez teto vetve by `PATCH` ulozil plaintext do pole `password`, ktere nikdo + * nikdy neprecte, a heslo by se nezmenilo. + */ +usersRouter.patch('/users/:id/password', async (req, res) => { + const parsed = z.object({ password: z.string().min(8) }).safeParse(req.body); + 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 spravce platformy, nebo spravce + * firmy svym lidem - ne ale spravci platformy, ten je mimo jeho dosah. + */ + const own = target.id === req.user!.id; + 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: await hashPassword(parsed.data.password) } as Partial, + readScope(req), + ); + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId: null, + action: 'user.password', + target: target.id, + }); + return res.status(204).end(); +}); + +/** + * Uzivatele vcetne vypnutych. Seznam pro spravu, ne pro nabidky. + * + * Spravce platformy vidi vsechny, spravce firmy jen lidi sve firmy. + */ +usersRouter.get('/users-overview', (req, res) => { + if (req.user!.platformAdmin) { + 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) }); +}); diff --git a/src/routes/settings/widgets.ts b/src/routes/settings/widgets.ts new file mode 100644 index 0000000..d60762e --- /dev/null +++ b/src/routes/settings/widgets.ts @@ -0,0 +1,103 @@ +/** + * Vlastni widgety firmy a osobni widgety prihlaseneho. + */ + +import { z } from 'zod'; +import { + customWidgetStore, + listCustomWidgets, + validateWidget, + type CustomWidget, +} from '../../data/customWidgets.js'; +import { tenantOrDeny } from '../../middleware/tenant.js'; +import { crudRouter } from '../crud.js'; +import { safeRouter } from '../../middleware/asyncHandler.js'; + +export const widgetsRouter = safeRouter(); + +const sourceSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('ticketCount'), + filter: z.record(z.unknown()).default({}), + groupBy: z.enum(['assignee', 'group', 'status', 'type', 'tag', 'channel']).optional(), + }), + z.object({ + kind: z.literal('ticketList'), + filter: z.record(z.unknown()).default({}), + limit: z.number().int().min(1).max(50).default(5), + }), + z.object({ + kind: z.literal('ticketSeries'), + filter: z.record(z.unknown()).default({}), + bucket: z.enum(['day', 'week']).default('day'), + }), + z.object({ kind: z.literal('workload'), groupIds: z.array(z.string()).optional() }), + z.object({ + kind: z.literal('agentStats'), + period: z.enum(['today', '7d', '30d', 'month', 'all']).default('30d'), + }), + z.object({ + kind: z.literal('connector'), + serviceId: z.string().trim().min(1), + operationId: z.string().trim().min(1), + /** null = vychozi konektor firmy pro tuhle sluzbu. */ + connectorId: z.string().trim().min(1).nullable().default(null), + inputs: z.record(z.string()).default({}), + path: z.string().trim().max(200).optional(), + labelPath: z.string().trim().max(200).optional(), + valuePath: z.string().trim().max(200).optional(), + /** + * Jak dlouho se vysledek drzi. Minimum je 30 s: bez nej by kazde otevreni + * prehledu volalo cizi sluzbu znovu a limity jsou nase i klientovy. + */ + ttlSec: z.number().int().min(30).max(86_400).default(300), + }), +]); + +const widgetCreate = z.object({ + name: z.string().trim().min(2).max(60), + description: z.string().trim().max(200).optional(), + render: z.enum(['stat', 'chart', 'list', 'table', 'gauge']), + source: sourceSchema, + size: z.enum(['third', 'half', 'full']), + target: z.number().optional(), + /** true = widget jen pro me, jinak pro celou firmu. */ + personal: z.boolean().default(false), +}); + +widgetsRouter.use( + '/widgets', + crudRouter, Partial>({ + store: customWidgetStore, + idPrefix: 'cw', + event: 'widget', + createSchema: widgetCreate, + updateSchema: z.object({ + name: z.string().trim().min(2).max(60).optional(), + description: z.string().trim().max(200).optional(), + render: z.enum(['stat', 'chart', 'list', 'table', 'gauge']).optional(), + source: sourceSchema.optional(), + size: z.enum(['third', 'half', 'full']).optional(), + target: z.number().optional(), + }), + writePermission: 'widget.manage', + build: (input) => ({ + name: input.name, + description: input.description ?? '', + render: input.render, + source: input.source as CustomWidget['source'], + size: input.size, + ...(input.target !== undefined ? { target: input.target } : {}), + // Osobni widget vidi jen jeho autor. Vyplni se az v route, viz nize. + ownerId: null, + }), + validate: (widget) => validateWidget(widget), + }), +); + +/** Widgety firmy plus osobni prihlaseneho. */ +widgetsRouter.get('/widgets-overview', (req, res) => { + const tenantId = tenantOrDeny(req, res); + if (!tenantId) return; + return res.json({ items: listCustomWidgets([tenantId], req.user!.id) }); +}); diff --git a/src/routes/ticketActions.ts b/src/routes/ticketActions.ts index 731fa46..5a91fb7 100644 --- a/src/routes/ticketActions.ts +++ b/src/routes/ticketActions.ts @@ -44,8 +44,8 @@ import { type TicketFacts, } from '../data/ticketActions.js'; import { findTicketType, validateTicketFields } from '../data/ticketTypes.js'; -import { runScript } from '../scripts/runner.js'; -import { scriptIdFor } from '../scripts/lookup.js'; +import { runScript } from '../runtime/scripts/runner.js'; +import { scriptIdFor } from '../runtime/scripts/lookup.js'; import { runFlow } from '../runtime/executor.js'; export const ticketActionsRouter = safeRouter(); @@ -60,7 +60,7 @@ export const ticketActionsRouter = safeRouter(); */ 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); + 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; @@ -336,7 +336,9 @@ async function runAction( }); if (!result.ok) { - console.warn(`[actions] ${action.id} na ${ticket.id} od ${actorEmail}: ${result.error?.message}`); + console.warn( + `[actions] ${action.id} na ${ticket.id} od ${actorEmail}: ${result.error?.message}`, + ); } return { @@ -499,7 +501,8 @@ builtinAction({ 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.', + 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. */ @@ -564,7 +567,11 @@ builtinAction({ 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.'); + 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; diff --git a/src/routes/webhook.ts b/src/routes/webhook.ts index 7e237a0..33d80be 100644 --- a/src/routes/webhook.ts +++ b/src/routes/webhook.ts @@ -12,7 +12,7 @@ import { findByIntakeToken } from '../data/tenants.js'; import { intakeEvent } from '../data/ticketStore.js'; import { findTicketType, listTicketTypes } from '../data/ticketTypes.js'; import { publish } from '../events/bus.js'; -import { getPath } from '../scripts/mapping.js'; +import { getPath } from '../runtime/scripts/mapping.js'; import { enqueue } from '../runtime/queue.js'; export const webhookRouter = Router(); @@ -42,7 +42,9 @@ const intakeSchema = z.object({ subject: z.string().trim().min(1).max(200).optional(), body: z.string().max(20_000).optional(), priority: z.enum(['low', 'normal', 'high', 'critical']).optional(), - channel: z.enum(['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal']).optional(), + channel: z + .enum(['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal']) + .optional(), typeId: z.string().trim().min(1).optional(), tags: z.array(z.string().trim().min(1).max(40)).max(20).optional(), /** Vlastni pole typu ticketu. Doplni se i na uz existujici ticket. */ @@ -159,7 +161,11 @@ webhookRouter.get('/ticket/:token', (req, res) => { ticketTypes: listTicketTypes([tenant.id]).map((type) => ({ id: type.id, name: type.name, - fields: type.fields.map((field) => ({ key: field.key, type: field.type, required: field.required })), + fields: type.fields.map((field) => ({ + key: field.key, + type: field.type, + required: field.required, + })), })), }); }); @@ -219,10 +225,15 @@ webhookRouter.post('/:token', (req, res) => { }); } - publish('webhook.received', `Webhook přijal data pro ${automation.id}`, { - automationId: automation.id, - fields: Object.keys(values), - }, automation.tenantId); + 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, @@ -370,12 +381,15 @@ function describeIncoming(value: unknown): string { return typeof value === 'string' ? 'text' : typeof value === 'number' ? 'číslo' : 'ano/ne'; } +/** Kolik znaku hodnoty se vejde do nahledu v tabulce. */ +const PREVIEW_LENGTH = 80; + /** Kratky nahled hodnoty do tabulky. Cele telo je u volani zvlast. */ function previewValue(value: unknown): string { if (value === undefined) return ''; const text = typeof value === 'string' ? value : JSON.stringify(value); if (typeof text !== 'string') return ''; - return text.length > 80 ? `${text.slice(0, 80)}...` : text; + return text.length > PREVIEW_LENGTH ? `${text.slice(0, PREVIEW_LENGTH)}...` : text; } /** Ukazkove telo podle kontraktu, at je videt, jak to ma vypadat. */ @@ -385,14 +399,16 @@ function exampleBody(fields: TriggerField[]): Record { for (const field of fields) { const value = exampleValue(field.type); const path = (field.path ?? field.name).split('.'); + // `split` vraci vzdy aspon jeden prvek, posledni je klic, zbytek cesta k nemu. + const last = path.pop(); + if (last === undefined) continue; let target = example; - for (let i = 0; i < path.length - 1; i += 1) { - const key = path[i]; + for (const key of path) { if (typeof target[key] !== 'object' || target[key] === null) target[key] = {}; target = target[key] as Record; } - target[path[path.length - 1]] = value; + target[last] = value; } return example; diff --git a/src/routes/widgetData.ts b/src/routes/widgetData.ts index 42b1b6e..8c7e24f 100644 --- a/src/routes/widgetData.ts +++ b/src/routes/widgetData.ts @@ -24,9 +24,9 @@ import { } from '../data/customWidgets.js'; import { defaultConnectorFor, getConnector } from '../data/connectorStore.js'; import { findGroup, listGroups, listPeople, personName } from '../data/people.js'; -import { getPath } from '../scripts/mapping.js'; -import { runScript } from '../scripts/runner.js'; -import { scriptIdFor } from '../scripts/lookup.js'; +import { getPath } from '../runtime/scripts/mapping.js'; +import { runScript } from '../runtime/scripts/runner.js'; +import { scriptIdFor } from '../runtime/scripts/lookup.js'; import { channelLabels, defaultStatuses, @@ -41,6 +41,14 @@ import { safeRouter } from '../middleware/asyncHandler.js'; import { scopeOrDeny } from '../middleware/tenant.js'; import { validationError } from '../middleware/validation.js'; +/** Den v milisekundach, pro okna "poslednich N dni". */ +const DAY_MS = 86_400_000; +/** Kolik radku z cizi sluzby se do widgetu vezme. Widget je nahled, ne tabulka. */ +const EXTERNAL_ROWS_LIMIT = 50; +/** Delka casove rady: dva tydny po dnech, nebo dvanact tydnu. */ +const SERIES_DAYS_BY_DAY = 14; +const SERIES_DAYS_BY_WEEK = 12 * 7; + export const widgetDataRouter = safeRouter(); /** @@ -87,9 +95,9 @@ function since(period: WidgetTicketFilter['period']): number | null { case 'today': return new Date(new Date().toDateString()).getTime(); case '7d': - return now - 7 * 86_400_000; + return now - 7 * DAY_MS; case '30d': - return now - 30 * 86_400_000; + return now - 30 * DAY_MS; case 'month': { const date = new Date(); return new Date(date.getFullYear(), date.getMonth(), 1).getTime(); @@ -108,8 +116,10 @@ function since(period: WidgetTicketFilter['period']): number | null { */ function matches(ticket: Ticket, filter: WidgetTicketFilter, personId: string | null): boolean { if (filter.closed !== undefined && ticket.closed !== filter.closed) return false; - if (filter.status && filter.status.length > 0 && !filter.status.includes(ticket.status)) return false; - if (filter.channel && filter.channel.length > 0 && !filter.channel.includes(ticket.channel)) return false; + if (filter.status && filter.status.length > 0 && !filter.status.includes(ticket.status)) + return false; + if (filter.channel && filter.channel.length > 0 && !filter.channel.includes(ticket.channel)) + return false; if (filter.typeIds && filter.typeIds.length > 0) { if (!ticket.typeId || !filter.typeIds.includes(ticket.typeId)) return false; } @@ -267,11 +277,20 @@ function toExternal( const nextAt = new Date(now + ttl).toISOString(); if (typeof picked === 'number') { - return { kind: 'external', value: picked, text: null, rows: [], at, nextAt, ttlSec: ttl / 1_000, stale: false }; + return { + kind: 'external', + value: picked, + text: null, + rows: [], + at, + nextAt, + ttlSec: ttl / 1_000, + stale: false, + }; } if (Array.isArray(picked)) { - const rows = picked.slice(0, 50).map((item, index) => { + const rows = picked.slice(0, EXTERNAL_ROWS_LIMIT).map((item, index) => { const label = source.labelPath ? getPath(item, source.labelPath) : undefined; const value = source.valuePath ? getPath(item, source.valuePath) : undefined; return { @@ -281,17 +300,35 @@ function toExternal( }; }); // Delka pole je casto to jedine cislo, ktere dava smysl. - return { kind: 'external', value: picked.length, text: null, rows, at, nextAt, ttlSec: ttl / 1_000, stale: false }; + return { + kind: 'external', + value: picked.length, + text: null, + rows, + at, + nextAt, + ttlSec: ttl / 1_000, + stale: false, + }; } if (picked === null || picked === undefined) { - return { kind: 'external', value: null, text: null, rows: [], at, nextAt, ttlSec: ttl / 1_000, stale: false }; + return { + kind: 'external', + value: null, + text: null, + rows: [], + at, + nextAt, + ttlSec: ttl / 1_000, + stale: false, + }; } if (typeof picked === 'object') { const rows = Object.entries(picked as Record) .filter(([, value]) => typeof value === 'number') - .slice(0, 50) + .slice(0, EXTERNAL_ROWS_LIMIT) .map(([key, value]) => ({ key, label: key, value: value as number })); return { kind: 'external', @@ -335,12 +372,20 @@ function ticketsHref( const tenantId = scope.tenantIds.length === 1 ? scope.tenantIds[0] : null; if (tenantId) params.set('tenantId', tenantId); - if (filter.status?.length === 1) params.set('status', filter.status[0]); - if (filter.channel?.length === 1) params.set('channel', filter.channel[0]); - if (filter.typeIds?.length === 1) params.set('typeId', filter.typeIds[0]); - if (filter.tags?.length === 1) params.set('tag', filter.tags[0]); - if (filter.assignee?.length === 1) params.set('assignee', filter.assignee[0]); - if (filter.groupIds?.length === 1) params.set('groupId', filter.groupIds[0]); + // Do adresy se dostane jen filtr s jedinou hodnotou, vic jich seznam neumi. + const singles: Array<[string, readonly string[] | undefined]> = [ + ['status', filter.status], + ['channel', filter.channel], + ['typeId', filter.typeIds], + ['tag', filter.tags], + ['assignee', filter.assignee], + ['groupId', filter.groupIds], + ]; + for (const [name, values] of singles) { + if (values?.length !== 1) continue; + const [value] = values; + if (value !== undefined) params.set(name, value); + } if (extra?.groupBy) { const key = extra.key === '' || extra.key === undefined ? 'none' : extra.key; @@ -434,22 +479,20 @@ function computeSource( return { kind: 'tickets', href: ticketsHref(source.filter, scope), - items: tickets - .slice(0, source.limit) - .map((ticket) => ({ - id: ticket.id, - subject: ticket.subject, - status: ticket.status, - assignee: ticket.assignee?.name ?? null, - })), + items: tickets.slice(0, source.limit).map((ticket) => ({ + id: ticket.id, + subject: ticket.subject, + status: ticket.status, + assignee: ticket.assignee?.name ?? null, + })), }; } // Casova rada. Prazdne dny se doplnuji, jinak by graf preskakoval. - const days = source.bucket === 'week' ? 12 * 7 : 14; + const days = source.bucket === 'week' ? SERIES_DAYS_BY_WEEK : SERIES_DAYS_BY_DAY; const points: Array<{ date: string; value: number }> = []; for (let offset = days - 1; offset >= 0; offset -= 1) { - const day = new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10); + const day = new Date(Date.now() - offset * DAY_MS).toISOString().slice(0, 10); points.push({ date: day, value: tickets.filter((ticket) => ticket.createdAt.slice(0, 10) === day).length, @@ -480,7 +523,7 @@ widgetDataRouter.post('/', async (req, res) => { const results: WidgetResult[] = await Promise.all( parsed.data.widgetIds.map(async (id): Promise => { - // Vestaveny widget s vypoctem. Nema definici v ulozisti, jen zdroj dat. + // Vestaveny widget s vypoctem. Nema definici v ulozisti, jen zdroj dat. const builtin = builtinSources[id]; if (builtin) { try { diff --git a/src/runtime/builtinSteps.ts b/src/runtime/builtinSteps.ts index a61c62b..1742f0e 100644 --- a/src/runtime/builtinSteps.ts +++ b/src/runtime/builtinSteps.ts @@ -10,14 +10,19 @@ */ import { defaultConnectorFor, getConnector } from '../data/connectorStore.js'; -import { ALL_PAGES_INPUT, findMcpTool, MAX_TOOL_PAGES, parseOperationId } from '../data/mcpTools.js'; +import { + ALL_PAGES_INPUT, + findMcpTool, + MAX_TOOL_PAGES, + parseOperationId, +} from '../data/mcpTools.js'; import { isMcpService } from '../mcp/dialect.js'; import { callTool, type McpCallResult } from '../mcp/client.js'; import { argumentsFrom, cursorFieldOf, nextCursorFrom, rowsFrom } from '../mcp/schema.js'; -import { DETAIL_BYTES, parseBool, 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'; +import { resolveTarget } from './scripts/connections.js'; import { findTenantScript, noteRun } from '../data/tenantScripts.js'; import { runSandbox } from './sandbox.js'; import { @@ -135,7 +140,9 @@ function boolInput(value: string | undefined): boolean | undefined { * Vnoreny objekt nebo pole by v poli typu `string` skoncilo jako * `[object Object]`, coz je horsi nez ho zahodit a rict to v logu. */ -function scalarFields(value: Record): Record { +function scalarFields( + value: Record, +): Record { const result: Record = {}; for (const [key, item] of Object.entries(value)) { if (item === null || ['string', 'number', 'boolean'].includes(typeof item)) { @@ -147,6 +154,16 @@ function scalarFields(value: Record): Record = { ...(inputs.status?.trim() ? { status: inputs.status.trim() } : {}), }, addTags: inputs.tags - ? inputs.tags.split(',').map((tag) => tag.trim()).filter(Boolean) + ? inputs.tags + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean) : undefined, }); @@ -403,7 +423,7 @@ const handlers: Record = { } // Zaskrtnuto: rovnou tomu, kdo ma nejmene prace. - const handler = handlers['ticket/assign-least-busy']; + const handler = builtinHandler('ticket/assign-least-busy'); const outcome = handler({ ticketId, groupId }, context) as StepOutcome; return { ...outcome, @@ -484,7 +504,8 @@ const handlers: Record = { return { ok: false, summary: `řešitel ${assigneeId} neexistuje`, - detail: 'Zkontrolujte ID řešitele v kroku. Řešitelé jsou členové firmy, spravují se v Nastavení.', + detail: + 'Zkontrolujte ID řešitele v kroku. Řešitelé jsou členové firmy, spravují se v Nastavení.', outputs: {}, }; } @@ -524,13 +545,12 @@ const handlers: Record = { return { ok: false, summary: `nikdo nemá externí ID ${value}`, - detail: - 'Doplňte to ID u řešitele v Nastavení, nebo u kroku vyplňte náhradní skupinu.', + detail: 'Doplňte to ID u řešitele v Nastavení, nebo u kroku vyplňte náhradní skupinu.', outputs: {}, }; } - const handler = handlers['ticket/assign-least-busy']; + const handler = builtinHandler('ticket/assign-least-busy'); return handler({ ticketId, groupId: fallback }, context); } @@ -561,7 +581,10 @@ const handlers: Record = { const ticketId = inputs.ticketId?.trim() || context.ticketId; if (!ticketId) return missing('ticket'); - const wanted = (inputs.tags ?? '').split(',').map((tag) => tag.trim()).filter(Boolean); + const wanted = (inputs.tags ?? '') + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean); if (wanted.length === 0) return missing('tagy'); const current = currentTicket(ticketId, context.tenantId); @@ -727,7 +750,9 @@ function currentTicket(ticketId: string, tenantId: string): Ticket | undefined { function safeJson(text: string): Record { try { const parsed: unknown = JSON.parse(text); - return parsed && typeof parsed === 'object' ? (parsed as Record) : { value: parsed }; + return parsed && typeof parsed === 'object' + ? (parsed as Record) + : { value: parsed }; } catch { // Nerozparsovany text neni duvod krok shodit, ulozi se jak je. return { value: text }; @@ -890,9 +915,7 @@ async function runMcpTool( text, isError: last.isError, structured: last.structured, - ...(allPages - ? { items, pages, pageCount: pages.length, truncated } - : {}), + ...(allPages ? { items, pages, pageCount: pages.length, truncated } : {}), }, }; } diff --git a/src/runtime/executor.ts b/src/runtime/executor.ts index 3f097b1..e3a0665 100644 --- a/src/runtime/executor.ts +++ b/src/runtime/executor.ts @@ -31,11 +31,11 @@ import { } 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 { 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 { scriptIdFor } from './scripts/lookup.js'; +import { runScript } from './scripts/runner.js'; +import { DETAIL_BYTES, truncate } from './scripts/util.js'; import { findBuiltinStep } from './builtinSteps.js'; /** @@ -513,7 +513,11 @@ async function runAction( * 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 { +function publishOutputs( + context: RunContext, + stepId: string, + outputs: Record, +): void { context[stepId] = outputs; for (const [key, value] of Object.entries(outputs)) { context[`${stepId}.${key}`] = value; @@ -567,20 +571,18 @@ function stringify(value: unknown): string { * parametru a klic v datech nejsou totez - podminka si drzi ID, aby ji * prejmenovani nerozbilo, ale data chodi pod jmenem. */ -function conditionValue( - fieldId: string, - context: RunContext, - options: RunOptions, -): unknown { +function conditionValue(fieldId: string, context: RunContext, options: RunOptions): unknown { const candidates = [fieldId]; const name = options.fieldNames?.[fieldId]; if (name) { - candidates.push(name); // Vystup kroku lezi i pod `krok.jmeno`. Tahle podoba je presnejsi nez - // hole jmeno, ktere pozdejsi krok se stejnym vystupem prepise. + // hole jmeno, proto jde **pred nim**: hole jmeno drzi vystup prvniho + // kroku, ktery ho zapsal (viz publishOutputs), takze podminka nad druhym + // krokem se stejnym vystupem by pres hole jmeno cetla cizi hodnotu. const step = fieldId.split('.')[0]; if (step !== fieldId) candidates.push(`${step}.${name}`); + candidates.push(name); } candidates.push(nameOf(fieldId)); @@ -632,7 +634,12 @@ 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 ?? '', options.fieldTypes?.[rule.fieldId] === 'date'); + return compare( + rule.operator, + value, + rule.value ?? '', + options.fieldTypes?.[rule.fieldId] === 'date', + ); } /** @@ -680,6 +687,10 @@ function isoLike(value: string): boolean { * kde to cislo opravdu je. */ function ordered(value: string, expected: string, dateTyped: boolean): [number, number] | null { + // Hodnota, ktera nedorazila, neni nula. `Number('')` je 0, takze by + // "castka <= 1000" platila i pro castku, kterou nikdo neposlal - a to je + // podminka, ktera rozhoduje o penezich. Bez hodnoty se neporovnava. + if (value.trim() === '' || expected.trim() === '') return null; if (!dateTyped) { const numbers: [number, number] = [Number(value), Number(expected)]; if (numbers.every(Number.isFinite)) return numbers; diff --git a/src/runtime/queue.ts b/src/runtime/queue.ts index 08ebdd9..e1d835d 100644 --- a/src/runtime/queue.ts +++ b/src/runtime/queue.ts @@ -71,7 +71,8 @@ export const queueStore = defineStore('runQueue'); * Prodlevy rostou: minuta staci na kratky vypadek, hodina na delsi. Zkouset * to pordad by u trvale rozbite sluzby znamenalo nekonecnou zatez. */ -const BACKOFF_MS = [30_000, 2 * 60_000, 10 * 60_000, 60 * 60_000]; +const MAX_BACKOFF_MS = 60 * 60_000; +const BACKOFF_MS = [30_000, 2 * 60_000, 10 * 60_000, MAX_BACKOFF_MS]; export const MAX_ATTEMPTS = BACKOFF_MS.length + 1; /** @@ -233,11 +234,7 @@ export async function markDone(item: QueueItem): Promise { * jako `failed` a zustane k nahlednuti. Nemazat: bez zaznamu by nikdo * nezjistil, ze se neco nestalo. */ -export async function markFailed( - item: QueueItem, - error: string, - retryable = true, -): Promise { +export async function markFailed(item: QueueItem, error: string, retryable = true): Promise { item.lastError = error; if (!retryable) { @@ -255,7 +252,8 @@ export async function markFailed( item.finishedAt = nowIso(); console.error(`[fronta] ${item.id} se vzdal po ${item.attempts} pokusech: ${error}`); } else { - const wait = BACKOFF_MS[Math.min(item.attempts - 1, BACKOFF_MS.length - 1)]; + // Po neuspechu je attempts aspon 1, index tedy sedi. Zaloha je jen kvuli typum. + const wait = BACKOFF_MS[Math.min(item.attempts - 1, BACKOFF_MS.length - 1)] ?? MAX_BACKOFF_MS; item.status = 'pending'; item.claimedAt = null; item.nextAttemptAt = new Date(Date.now() + wait).toISOString(); @@ -307,8 +305,12 @@ export function recentRuns(tenantIds: string[], limit = 50): QueueItem[] { * by fronta rostla do nekonecna, viz rozpocet v dokumentaci kapacity. */ export async function trimQueue(keepDone = 1_000, keepFailed = 500): Promise { - const done = items.filter((item) => item.status === 'done').sort((a, b) => a.createdAt.localeCompare(b.createdAt)); - const failed = items.filter((item) => item.status === 'failed').sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + const done = items + .filter((item) => item.status === 'done') + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + const failed = items + .filter((item) => item.status === 'failed') + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); const remove = [ ...done.slice(0, Math.max(0, done.length - keepDone)), diff --git a/src/runtime/sandbox.ts b/src/runtime/sandbox.ts index 28b2881..c115108 100644 --- a/src/runtime/sandbox.ts +++ b/src/runtime/sandbox.ts @@ -20,8 +20,8 @@ import { createHash } from 'node:crypto'; import { createContext, Script } from 'node:vm'; -import { getPath } from '../scripts/mapping.js'; -import { parseNumber } from '../scripts/util.js'; +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; diff --git a/src/scripts/connections.ts b/src/runtime/scripts/connections.ts similarity index 95% rename from src/scripts/connections.ts rename to src/runtime/scripts/connections.ts index dae3485..c768800 100644 --- a/src/scripts/connections.ts +++ b/src/runtime/scripts/connections.ts @@ -12,9 +12,9 @@ * adresy (`SERVICES_BASE_URL`). Udaje patri konektoru. */ -import { config } from '../config.js'; -import type { Connector } from '../data/connectorStore.js'; -import { findService, type Service } from '../data/services.js'; +import { config } from '../../config.js'; +import type { Connector } from '../../data/connectorStore.js'; +import { findService, type Service } from '../../data/services.js'; export interface ResolvedTarget { serviceId: string; @@ -63,8 +63,8 @@ export interface ResolvedTarget { */ function baseUrlOverride(serviceId: string): string | null { const variable = `${serviceId.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_BASE_URL`; - const value = (process.env[variable] ?? '').trim().replace(/\/+$/, ''); - return value === '' ? null : value; + // Prostredi se cte jen v config.ts, tady se jen sklada nazev. + return config.serviceBaseUrlOverride(variable); } /** diff --git a/src/scripts/http.ts b/src/runtime/scripts/http.ts similarity index 96% rename from src/scripts/http.ts rename to src/runtime/scripts/http.ts index 406b8ec..7da1c8a 100644 --- a/src/scripts/http.ts +++ b/src/runtime/scripts/http.ts @@ -8,8 +8,13 @@ * - loguje volani bez hlavicek a bez tel, jen metodu, cestu a kod. */ -import { config } from '../config.js'; -import { assertAllowedUrl, describeFetchError, readBodyLimited, tooLargeMessage } from '../net/guard.js'; +import { config } from '../../config.js'; +import { + assertAllowedUrl, + describeFetchError, + readBodyLimited, + tooLargeMessage, +} from '../../net/guard.js'; import type { ResolvedTarget } from './connections.js'; import { ScriptError, @@ -208,7 +213,11 @@ function statusError( const options = { status, detail, request, responseHeaders }; if (retryableStatuses.has(status)) { - return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.${said}`, options); + return new ScriptError( + 'retryable', + `Služba je momentálně nedostupná: ${where}.${said}`, + options, + ); } if (status === 401) { return new ScriptError( @@ -279,7 +288,10 @@ function buildForm(fields: Record): if (typeof value === 'object') { const bytes = Buffer.from(value.base64, 'base64'); if (bytes.byteLength === 0) { - throw new ScriptError('validation', `Soubor ${value.filename} je prázdný nebo to není Base64.`); + throw new ScriptError( + 'validation', + `Soubor ${value.filename} je prázdný nebo to není Base64.`, + ); } if (bytes.byteLength > config.scriptMaxUploadBytes) { throw new ScriptError( @@ -327,7 +339,8 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp { path: url.pathname, url: `${url.origin}${url.pathname}`, }; - const hasBody = form === undefined && body !== undefined && method !== 'GET' && method !== 'DELETE'; + const hasBody = + form === undefined && body !== undefined && method !== 'GET' && method !== 'DELETE'; const startedAt = Date.now(); onCall(); diff --git a/src/scripts/lookup.ts b/src/runtime/scripts/lookup.ts similarity index 90% rename from src/scripts/lookup.ts rename to src/runtime/scripts/lookup.ts index bd7ac52..9d8fa48 100644 --- a/src/scripts/lookup.ts +++ b/src/runtime/scripts/lookup.ts @@ -6,7 +6,7 @@ * Katalog uz odpoved zna, protoze operace ze skriptu nesou `scriptId`. */ -import { findOperation } from '../data/services.js'; +import { findOperation } from '../../data/services.js'; export function scriptIdFor(serviceId: string, operationId: string): string | undefined { const operation = findOperation(serviceId, operationId, 'action'); diff --git a/src/scripts/manifest.ts b/src/runtime/scripts/manifest.ts similarity index 93% rename from src/scripts/manifest.ts rename to src/runtime/scripts/manifest.ts index b886590..14098d4 100644 --- a/src/scripts/manifest.ts +++ b/src/runtime/scripts/manifest.ts @@ -6,8 +6,13 @@ * jedno by se casem rozeslo a strom by nabizel parametr, ktery skript nezna. */ -import type { OperationField, ProvidedField, ServiceOperation } from '../data/services.js'; -import { scriptManifestSchema, type FieldIssue, type ScriptField, type ScriptManifest } from './types.js'; +import type { OperationField, ProvidedField, ServiceOperation } from '../../data/services.js'; +import { + scriptManifestSchema, + type FieldIssue, + type ScriptField, + type ScriptManifest, +} from './types.js'; /** `idoklad.get-issued-invoice` -> `idoklad` */ export function serviceIdOf(scriptId: string): string { @@ -20,8 +25,7 @@ export function operationIdOf(scriptId: string): string { } export type ParseResult = - | { ok: true; manifest: ScriptManifest } - | { ok: false; issues: FieldIssue[] }; + { ok: true; manifest: ScriptManifest } | { ok: false; issues: FieldIssue[] }; /** * Overi manifest a zaroven to, ze odpovida nazvu souboru. diff --git a/src/scripts/mapping.ts b/src/runtime/scripts/mapping.ts similarity index 94% rename from src/scripts/mapping.ts rename to src/runtime/scripts/mapping.ts index 0c321ed..169b0b5 100644 --- a/src/scripts/mapping.ts +++ b/src/runtime/scripts/mapping.ts @@ -58,17 +58,19 @@ export function getPath(source: unknown, path: string): unknown { /** Zapise hodnotu na cestu a cestou vytvori chybejici objekty. */ function setPath(target: Record, path: string, value: ScriptJson): void { const parts = path.split('.').filter((part) => part !== ''); - if (parts.length === 0) return; + // Posledni dil je klic, zbytek cesta k nemu. Prazdna cesta nema kam zapsat. + const last = parts.pop(); + if (last === undefined) return; let current: Record = target; - for (const part of parts.slice(0, -1)) { + for (const part of parts) { const next = current[part]; if (next === undefined || next === null || typeof next !== 'object' || Array.isArray(next)) { current[part] = {}; } current = current[part] as Record; } - current[parts[parts.length - 1]] = value; + current[last] = value; } // ------------------------------------------------------------------- prevody @@ -131,7 +133,12 @@ function toNumber(value: unknown, label: string): number { return parsed; } -function applyOne(value: unknown, operation: TransformOp, label: string, depth: number): ScriptJson { +function applyOne( + value: unknown, + operation: TransformOp, + label: string, + depth: number, +): ScriptJson { switch (operation.op) { case 'trim': return String(value ?? '').trim(); @@ -164,9 +171,12 @@ function applyOne(value: unknown, operation: TransformOp, label: string, depth: case 'default': return isEmpty(value) ? operation.value : (value as ScriptJson); case 'replace': - return String(value ?? '').split(operation.find).join(operation.with); + return String(value ?? '') + .split(operation.find) + .join(operation.with); case 'slice': { - if (Array.isArray(value)) return value.slice(operation.start ?? 0, operation.end) as ScriptJson; + if (Array.isArray(value)) + return value.slice(operation.start ?? 0, operation.end) as ScriptJson; return String(value ?? '').slice(operation.start ?? 0, operation.end); } case 'split': @@ -266,9 +276,9 @@ export function fillJson(template: ScriptJson, source: unknown, depth = 0): Scri } if (typeof template === 'string') { - const whole = wholeToken.exec(template); - if (whole) { - const value = getPath(source, whole[1]); + const wholePath = wholeToken.exec(template)?.[1]; + if (wholePath !== undefined) { + const value = getPath(source, wholePath); return (value ?? null) as ScriptJson; } return template.replace(anyToken, (_match, path: string) => { diff --git a/src/scripts/registry.ts b/src/runtime/scripts/registry.ts similarity index 95% rename from src/scripts/registry.ts rename to src/runtime/scripts/registry.ts index 14b098f..487c3df 100644 --- a/src/scripts/registry.ts +++ b/src/runtime/scripts/registry.ts @@ -16,16 +16,13 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { config } from '../config.js'; -import { services, setScriptActions, type ServiceOperation } from '../data/services.js'; +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'; +import type { ScriptProblem } from '../../shared/scripts.js'; -export type ScriptRunFn = ( - inputs: ScriptValues, - ctx: ScriptContext, -) => Promise | unknown; +export type ScriptRunFn = (inputs: ScriptValues, ctx: ScriptContext) => Promise | unknown; export interface LoadedScript { manifest: ScriptManifest; @@ -86,7 +83,11 @@ async function importScript( try { module = (await import(url)) as { manifest?: unknown; run?: unknown }; } catch (err) { - return { file: fileName, scriptId: expectedId, message: `Soubor se nepodařilo načíst: ${problemMessage(err)}` }; + return { + file: fileName, + scriptId: expectedId, + message: `Soubor se nepodařilo načíst: ${problemMessage(err)}`, + }; } if (typeof module.run !== 'function') { @@ -264,8 +265,7 @@ export async function readSource(id: string): Promise { } export type SaveResult = - | { ok: true; manifest: ScriptManifest } - | { ok: false; message: string; issues?: FieldIssue[] }; + { ok: true; manifest: ScriptManifest } | { ok: false; message: string; issues?: FieldIssue[] }; /** * Ulozi kod skriptu. diff --git a/src/scripts/runner.ts b/src/runtime/scripts/runner.ts similarity index 96% rename from src/scripts/runner.ts rename to src/runtime/scripts/runner.ts index d492252..a6ba2fa 100644 --- a/src/scripts/runner.ts +++ b/src/runtime/scripts/runner.ts @@ -11,8 +11,8 @@ */ import { createHash } from 'node:crypto'; -import { config } from '../config.js'; -import type { Connector } from '../data/connectorStore.js'; +import { config } from '../../config.js'; +import type { Connector } from '../../data/connectorStore.js'; import { resolveTarget, scriptConfig, targetSecrets } from './connections.js'; import { createHttp } from './http.js'; import { serviceIdOf } from './manifest.js'; @@ -92,9 +92,7 @@ export async function runScript( const logs: ScriptLogEntry[] = []; let httpCalls = 0; - const finish = ( - partial: Pick, - ): ScriptRunResult => ({ + const finish = (partial: Pick): ScriptRunResult => ({ scriptId, logs, durationMs: Date.now() - startedAt, @@ -199,8 +197,10 @@ export async function runScript( // Do logu serveru cely detail, at je to dohledatelne i bez portalu. console.warn( `[scripts] ${scriptId} selhal (${error.kind}): ${error.message}` + - (error.detail ? ` -${error.detail}` : ''), + (error.detail + ? ` +${error.detail}` + : ''), ); return finish({ ok: false, outputs: {}, error }); } finally { diff --git a/src/scripts/types.ts b/src/runtime/scripts/types.ts similarity index 98% rename from src/scripts/types.ts rename to src/runtime/scripts/types.ts index c54968d..0bfa914 100644 --- a/src/scripts/types.ts +++ b/src/runtime/scripts/types.ts @@ -29,7 +29,7 @@ import type { ScriptRunResult, ScriptValue, ScriptValues, -} from '../shared/scripts.js'; +} from '../../shared/scripts.js'; /** * Manifest, hodnoty a vysledek behu jsou sdilene s webem, viz src/shared/scripts.ts. @@ -298,6 +298,12 @@ export interface ScriptUtil { * poznat hned a s nazvem pole, ne az na chybejicim parametru ve strome. */ need(value: T | null | undefined, label: string): T; + /** + * Base64 z textu v UTF-8, aby diakritika prezila. + * Skript nema `Buffer` ani `btoa` - nesmi zaviset na globalech prostredi, + * aby bezel stejne i v sandboxu. + */ + base64(value: string): string; /** * Hodnota na ceste: `customer.email`, `items.0.name` i `items[0].name`. * Chybejici cesta vraci `undefined`, ne vyjimku. @@ -340,4 +346,3 @@ export interface ScriptModule { } // -------------------------------------------------------------------- vysledek - diff --git a/src/scripts/util.ts b/src/runtime/scripts/util.ts similarity index 94% rename from src/scripts/util.ts rename to src/runtime/scripts/util.ts index 3ca3434..c8b5ad9 100644 --- a/src/scripts/util.ts +++ b/src/runtime/scripts/util.ts @@ -7,7 +7,7 @@ * z nich by to resil spatne. */ -import { config } from '../config.js'; +import { config } from '../../config.js'; import { applyRules, fillJson, getPath } from './mapping.js'; import { ScriptError, type ScriptUtil } from './types.js'; @@ -36,7 +36,9 @@ const falsy = new Set(['false', '0', 'no', 'n', 'ne', 'off']); */ export function parseBool(value: unknown): boolean | null { if (typeof value === 'boolean') return value; - const lowered = String(value ?? '').trim().toLowerCase(); + const lowered = String(value ?? '') + .trim() + .toLowerCase(); if (truthy.has(lowered)) return true; if (falsy.has(lowered)) return false; return null; @@ -64,10 +66,10 @@ export function parseNumber(value: unknown): number | null { * OAuth server bez `expires_in` i EasyWeb. */ export function jwtExpiry(token: string): number | null { - const parts = token.split('.'); - if (parts.length < 2) return null; + const [, payloadPart] = token.split('.'); + if (payloadPart === undefined) return null; try { - const json = Buffer.from(parts[1], 'base64url').toString('utf8'); + const json = Buffer.from(payloadPart, 'base64url').toString('utf8'); const payload = JSON.parse(json) as { exp?: unknown }; return typeof payload.exp === 'number' ? payload.exp * 1000 : null; } catch { @@ -189,6 +191,11 @@ function need(value: T | null | undefined, label: string): T { return value; } +/** Base64 z textu pres bajty UTF-8. Skript na Buffer nesaha, tohle je za nej. */ +function base64(value: string): string { + return Buffer.from(value, 'utf8').toString('base64'); +} + export const scriptUtil: ScriptUtil = { unwrap, pick, @@ -199,6 +206,7 @@ export const scriptUtil: ScriptUtil = { date, round, need, + base64, day, list, addresses, diff --git a/src/scripts/values.ts b/src/runtime/scripts/values.ts similarity index 96% rename from src/scripts/values.ts rename to src/runtime/scripts/values.ts index d8c3566..8e92ccd 100644 --- a/src/scripts/values.ts +++ b/src/runtime/scripts/values.ts @@ -11,16 +11,17 @@ * - parametr, ktery v manifestu neni, se zahodi a zaloguje. */ -import { config } from '../config.js'; +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[] }; + { ok: true; values: ScriptValues } | { ok: false; issues: FieldIssue[] }; function isMissing(value: unknown): boolean { - return value === undefined || value === null || (typeof value === 'string' && value.trim() === ''); + return ( + value === undefined || value === null || (typeof value === 'string' && value.trim() === '') + ); } /** diff --git a/src/runtime/worker.ts b/src/runtime/worker.ts index b6c7d90..8add345 100644 --- a/src/runtime/worker.ts +++ b/src/runtime/worker.ts @@ -182,10 +182,15 @@ async function execute(item: QueueItem): Promise { if (result.ok) { await markDone(item); - publish('automation.run', `Automatizace ${automation.name} proběhla`, { - automationId: automation.id, - ok: true, - }, item.tenantId); + publish( + 'automation.run', + `Automatizace ${automation.name} proběhla`, + { + automationId: automation.id, + ok: true, + }, + item.tenantId, + ); return; } @@ -211,10 +216,15 @@ async function execute(item: QueueItem): Promise { reportIncident(item, automation.name, message, result); } - publish('automation.run', `Automatizace ${automation.name} skončila chybou`, { - automationId: automation.id, - ok: false, - }, item.tenantId); + 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. Chyba v kodu se // opakovanim nespravi, tak se rovnou zalozi incident. @@ -273,7 +283,7 @@ function reportIncident( tenantId: item.tenantId, // Klient nema cist nazev kroku ani ID behu. Ma poznat, co nefunguje. title: `Automatizace ${automationName} neproběhla`, - service: failed ? failed.label.split('/')[0] : 'Automatizace', + service: failed?.label.split('/')[0] ?? 'Automatizace', severity: 'sev3', impact: clientMessage(item, failed), detail, diff --git a/src/shared/scripts.ts b/src/shared/scripts.ts index 3e47722..3861454 100644 --- a/src/shared/scripts.ts +++ b/src/shared/scripts.ts @@ -10,12 +10,7 @@ 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 }; + string | number | boolean | null | ScriptJson[] | { [key: string]: ScriptJson }; export type ScriptValue = ScriptJson; export type ScriptValues = Record; diff --git a/src/shared/tickets.ts b/src/shared/tickets.ts index 5b81279..adefda6 100644 --- a/src/shared/tickets.ts +++ b/src/shared/tickets.ts @@ -17,13 +17,7 @@ export type TicketPriority = 'low' | 'normal' | 'high' | 'critical'; /** Odkud pozadavek prisel. */ export type TicketChannel = - | 'whatsapp' - | 'facebook' - | 'instagram' - | 'email' - | 'voice' - | 'form' - | 'portal'; + 'whatsapp' | 'facebook' | 'instagram' | 'email' | 'voice' | 'form' | 'portal'; export interface TicketCustomer { /** ID firmy v CRM. null = zakaznika se nepodarilo dohledat. */ diff --git a/src/shared/widgets.ts b/src/shared/widgets.ts index 8fd19c0..8bc7769 100644 --- a/src/shared/widgets.ts +++ b/src/shared/widgets.ts @@ -143,7 +143,13 @@ export type WidgetValue = } | { kind: 'workload'; - rows: Array<{ personId: string; name: string; open: number; overCapacity: boolean; href: string }>; + rows: Array<{ + personId: string; + name: string; + open: number; + overCapacity: boolean; + href: string; + }>; href?: string; } | { kind: 'agents'; rows: AgentStatsRow[] } diff --git a/tests/data/access.test.ts b/tests/data/access.test.ts new file mode 100644 index 0000000..8ef918e --- /dev/null +++ b/tests/data/access.test.ts @@ -0,0 +1,220 @@ +/** + * Co uzivatel vidi a smi (src/data/access.ts): pohledy, resitel, strop + * viditelnosti. Uloziste v pameti, vychozi sada firem, roli a vlastni skupiny. + */ + +import { beforeAll, describe, expect, test } from 'vitest'; +import { accessFor, isDenied, resolveScope, visibilityFor } from '../../src/data/access.js'; +import { groupStore, refreshGroups } from '../../src/data/people.js'; +import { refreshRoles, roleStore, systemRoles } from '../../src/data/permissions.js'; +import { initStores, nowIso } from '../../src/data/store/index.js'; +import { featuresStore, refreshFeatures, seedFeatures } from '../../src/data/tenantFeatures.js'; +import { refreshTenants, seedTenants, tenantStore } from '../../src/data/tenants.js'; +import { refreshUsers, seedUsers, userStore } from '../../src/data/users.js'; +import type { User } from '../../src/types.js'; + +function user(overrides: Partial): User { + return { + id: 'usr_x', + email: 'x@example.cz', + passwordHash: '', + name: 'X', + platformAdmin: false, + memberships: [], + ...overrides, + }; +} + +beforeAll(async () => { + initStores({ databaseReady: false }); + await tenantStore.init(seedTenants); + await userStore.init(seedUsers); + await roleStore.init(systemRoles); + await featuresStore.init(seedFeatures); + await groupStore.init(() => { + const timestamp = nowIso(); + return [ + { + id: 'grp_sklad', + tenantId: 'tnt_automia', + name: 'Sklad', + members: [ + { personId: 'usr_vedouci', seesAll: true }, + { personId: 'usr_skladnik', seesAll: false }, + ], + createdAt: timestamp, + updatedAt: timestamp, + }, + { + id: 'grp_ucetni', + tenantId: 'tnt_automia', + name: 'Ucetni', + members: [{ personId: 'usr_skladnik', seesAll: false }], + createdAt: timestamp, + updatedAt: timestamp, + }, + ]; + }); + await Promise.all([refreshTenants(), refreshUsers(), refreshRoles(), refreshFeatures(), refreshGroups()]); +}); + +describe('accessFor', () => { + test('clen firmy ma pohledy tenant a mine a je resitelem pod ID uctu', () => { + const member = user({ + id: 'usr_clen', + memberships: [{ tenantId: 'tnt_nordis', roleIds: ['agent'] }], + }); + const access = accessFor(member); + + expect(access.scopes).toEqual(['tenant', 'mine']); + expect(access.defaultTenantId).toBe('tnt_nordis'); + expect(access.personId).toBe('usr_clen'); + expect(access.tenants.map((tenant) => tenant.id)).toEqual(['tnt_nordis']); + expect(access.roleNames).toEqual(['Řešitel']); + expect(access.platformAdmin).toBe(false); + }); + + test('spravce platformy bez clenstvi ma pohled all, ale neni resitel', () => { + const admin = user({ id: 'usr_platforma', platformAdmin: true }); + const access = accessFor(admin, 'tnt_nordis'); + + expect(access.scopes).toEqual(['all', 'tenant']); + expect(access.personId).toBeNull(); + expect(access.tenants.length).toBe(3); + expect(access.permissions).toContain('tenant.manage'); + }); + + test('ucet bez clenstvi nema zadny pohled ani firmu', () => { + const nobody = user({ id: 'usr_nikdo' }); + const access = accessFor(nobody); + + expect(access.scopes).toEqual([]); + expect(access.defaultTenantId).toBeNull(); + expect(access.personId).toBeNull(); + expect(access.permissions).toEqual([]); + }); + + test('prava se pocitaji za vybranou firmu, ne za soucet clenstvi', () => { + const external = user({ + id: 'usr_externista', + memberships: [ + { tenantId: 'tnt_automia', roleIds: ['agent'] }, + { tenantId: 'tnt_nordis', roleIds: ['admin'] }, + ], + }); + + expect(accessFor(external, 'tnt_nordis').canAssignOthers).toBe(true); + expect(accessFor(external, 'tnt_automia').canAssignOthers).toBe(false); + expect(accessFor(external, 'tnt_automia').roleNames).toEqual(['Řešitel']); + }); + + test('clenstvi v nezname firme se nepocita', () => { + const member = user({ + id: 'usr_ghost', + memberships: [{ tenantId: 'tnt_neexistuje', roleIds: ['admin'] }], + }); + const access = accessFor(member); + expect(access.tenants).toEqual([]); + expect(access.scopes).toEqual([]); + }); +}); + +describe('visibilityFor', () => { + test('spravce platformy vidi vse, clen bez firmy nic', () => { + expect(visibilityFor(user({ platformAdmin: true }), 'tnt_automia')).toEqual({ kind: 'all' }); + expect(visibilityFor(user({ id: 'usr_a' }), null)).toEqual({ + kind: 'scoped', + personIds: [], + groupIds: [], + }); + }); + + test('seesAllTenant na clenstvi otevira celou firmu', () => { + const boss = user({ + id: 'usr_boss', + memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'], seesAllTenant: true }], + }); + expect(visibilityFor(boss, 'tnt_automia').kind).toBe('all'); + }); + + test('starsi zaznam bez priznaku: kdo smi prehazovat, vidi vse, resitel jen sebe', () => { + const admin = user({ id: 'usr_old_admin', memberships: [{ tenantId: 'tnt_automia', roleIds: ['admin'] }] }); + const agent = user({ id: 'usr_old_agent', memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'] }] }); + + expect(visibilityFor(admin, 'tnt_automia').kind).toBe('all'); + expect(visibilityFor(agent, 'tnt_automia')).toEqual({ + kind: 'scoped', + personIds: ['usr_old_agent'], + groupIds: [], + }); + }); + + test('vedouci sekce vidi frontu skupiny a jeji lidi, radovy clen jen sebe', () => { + const lead = user({ + id: 'usr_vedouci', + memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'], seesAllTenant: false }], + }); + const worker = user({ + id: 'usr_skladnik', + memberships: [{ tenantId: 'tnt_automia', roleIds: ['agent'], seesAllTenant: false }], + }); + + const leadView = visibilityFor(lead, 'tnt_automia'); + expect(leadView.kind).toBe('scoped'); + if (leadView.kind === 'scoped') { + expect(leadView.groupIds).toEqual(['grp_sklad']); + expect([...leadView.personIds].sort()).toEqual(['usr_skladnik', 'usr_vedouci']); + } + + // Skladnik je ve dvou skupinach, ale ani jednu nevede. + expect(visibilityFor(worker, 'tnt_automia')).toEqual({ + kind: 'scoped', + personIds: ['usr_skladnik'], + groupIds: [], + }); + + const access = accessFor(lead, 'tnt_automia'); + expect(access.seesOthers).toBe(true); + expect(access.visibleGroups.map((group) => group.id)).toEqual(['grp_sklad']); + }); + + test('seesAllTenant false vitezi nad pravem prehazovat', () => { + const limited = user({ + id: 'usr_limited', + memberships: [{ tenantId: 'tnt_automia', roleIds: ['admin'], seesAllTenant: false }], + }); + expect(visibilityFor(limited, 'tnt_automia').kind).toBe('scoped'); + }); +}); + +describe('resolveScope', () => { + const member = user({ id: 'usr_scope', memberships: [{ tenantId: 'tnt_nordis', roleIds: ['agent'] }] }); + + test('pohled all je jen pro spravce platformy', () => { + const denied = resolveScope(member, { scope: 'all' }); + expect(isDenied(denied)).toBe(true); + if (isDenied(denied)) expect(denied.status).toBe(403); + }); + + test('cizi firma je 404, ne tiche prepnuti', () => { + const denied = resolveScope(member, { scope: 'tenant', tenantId: 'tnt_automia' }); + expect(isDenied(denied)).toBe(true); + if (isDenied(denied)) expect(denied.status).toBe(404); + }); + + test('vlastni firma vraci strop a resitele', () => { + const resolved = resolveScope(member, {}); + expect(isDenied(resolved)).toBe(false); + if (!isDenied(resolved)) { + expect(resolved.scope).toBe('tenant'); + expect(resolved.tenantIds).toEqual(['tnt_nordis']); + expect(resolved.personId).toBe('usr_scope'); + expect(resolved.visibility).toEqual({ kind: 'scoped', personIds: ['usr_scope'], groupIds: [] }); + } + }); + + test('neznamy pohled se odmitne', () => { + const denied = resolveScope(member, { scope: 'vsechno' }); + expect(isDenied(denied) && denied.error).toBe('unknown_scope'); + }); +}); diff --git a/tests/data/migratePeople.test.ts b/tests/data/migratePeople.test.ts new file mode 100644 index 0000000..ab7992f --- /dev/null +++ b/tests/data/migratePeople.test.ts @@ -0,0 +1,186 @@ +/** + * Migrace starych resitelu na ucty (src/data/migratePeople.ts). + * + * Stare zaznamy zije jen v soukromem ulozisti `person`, do ktereho se + * v rezimu pameti zvenku nedostane. Test proto bezi v rezimu souboru + * nad docasnou slozkou: pred startem do ni zapise snapshoty (resitele, + * tickety, skupiny, automatizace), pak spusti stejny bootstrap jako + * `src/index.ts` a po nem se podiva, co zustalo. + * + * `DATA_DIR` se nastavuje pred importem modulu, proto dynamicke importy. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'csbot-migrace-')); +process.env.DATA_DIR = dir; + +type Users = typeof import('../../src/data/users.js'); +type Tickets = typeof import('../../src/data/ticketStore.js'); +type People = typeof import('../../src/data/people.js'); +type Automations = typeof import('../../src/data/automationStore.js'); +type Stores = typeof import('../../src/data/store/index.js'); + +let users: Users; +let ticketStore: Tickets; +let people: People; +let automations: Automations; +let stores: Stores; + +const now = '2026-09-01T10:00:00.000Z'; + +function snapshot(kind: string, items: unknown[]): void { + fs.writeFileSync( + path.join(dir, `${kind}.json`), + JSON.stringify({ version: 1, savedAt: now, items }, null, 2), + 'utf8', + ); +} + +function ticket(id: string, tenantId: string, assigneeId: string | null, resolvedById: string | null = null) { + return { + id, + tenantId, + subject: `Ticket ${id}`, + body: '', + sourceRef: null, + channel: 'email', + customer: { id: null, company: '', contact: '', reply: '' }, + status: 'Nový', + priority: 'normal', + assigneeId, + resolvedById, + createdAt: now, + updatedAt: now, + trace: [], + events: [], + }; +} + +beforeAll(async () => { + snapshot('person', [ + // Novy clovek: ucet neexistuje, zalozi se. + { id: 'ppl_1', tenantId: 'tnt_nordis', name: 'Pavel Nový', email: 'Pavel.Novy@nordis.cz', role: 'Sklad', capacity: 4, externalIds: ['ext-7'], createdAt: now, updatedAt: now }, + // Existujici ucet (usr_2) bez clenstvi v LogiTrans: clenstvi pribude. + { id: 'ppl_2', tenantId: 'tnt_logitrans', name: 'Karel Vomáčka', email: 'karel.vomacka@automia.cz', role: 'Externista', capacity: 3, createdAt: now, updatedAt: now }, + // Bez e-mailu: ucet s nahradni adresou, aby tickety neprisly o resitele. + { id: 'ppl_3', tenantId: 'tnt_nordis', name: 'Bez Mailu', email: '', enabled: false, createdAt: now, updatedAt: now }, + ]); + snapshot('ticket', [ + ticket('TK-1', 'tnt_nordis', 'ppl_1'), + ticket('TK-2', 'tnt_logitrans', null, 'ppl_2'), + ticket('TK-3', 'tnt_nordis', 'usr_bartos'), + ]); + snapshot('personGroup', [ + { id: 'grp_a', tenantId: 'tnt_nordis', name: 'Sklad', members: [{ personId: 'ppl_1', seesAll: true }, { personId: 'usr_bartos', seesAll: false }], createdAt: now, updatedAt: now }, + { id: 'grp_b', tenantId: 'tnt_nordis', name: 'Stara', personIds: ['ppl_1', 'ppl_3'], createdAt: now, updatedAt: now }, + ]); + snapshot('automation', [ + { + id: 'AUT-01', + tenantId: 'tnt_nordis', + name: 'Prirazeni', + kind: 'workflow', + enabled: false, + runsToday: 0, + successRate: 100, + avgDurationMs: 0, + lastRunAt: now, + flow: { + trigger: null, + steps: [ + { id: 'st_1', kind: 'action', serviceId: 'ticket', operationId: 'assign', inputs: { assigneeId: 'ppl_1', note: 'ppl_1x' } }, + ], + }, + createdAt: now, + updatedAt: now, + }, + ]); + + const bootstrap = await import('../../src/data/bootstrap.js'); + users = await import('../../src/data/users.js'); + ticketStore = await import('../../src/data/ticketStore.js'); + people = await import('../../src/data/people.js'); + automations = await import('../../src/data/automationStore.js'); + stores = await import('../../src/data/store/index.js'); + + const mode = await bootstrap.bootstrapData({ databaseReady: false }); + expect(mode).toBe('file'); + await stores.flushStores(); +}); + +afterAll(async () => { + await stores?.flushStores(); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('migratePeople', () => { + test('novy resitel dostane ucet s clenstvim, popiskem, kapacitou a externim ID', () => { + const account = users.findStoredUserByEmail('pavel.novy@nordis.cz'); + expect(account).toBeDefined(); + expect(account!.id).toMatch(/^usr_/); + expect(account!.enabled).toBe(true); + expect(account!.platformAdmin).toBe(false); + expect(account!.memberships).toEqual([ + { tenantId: 'tnt_nordis', roleIds: ['role_agent'], role: 'Sklad', capacity: 4, externalIds: ['ext-7'] }, + ]); + expect(people.findPersonByExternalId('ext-7', ['tnt_nordis'])?.id).toBe(account!.id); + }); + + test('existujici ucet dostane jen nove clenstvi, stara zustavaji', () => { + const karel = users.findStoredUser('usr_2')!; + expect(karel.memberships.map((m) => m.tenantId).sort()).toEqual(['tnt_automia', 'tnt_logitrans', 'tnt_nordis']); + const added = karel.memberships.find((m) => m.tenantId === 'tnt_logitrans')!; + expect(added).toMatchObject({ roleIds: ['role_agent'], role: 'Externista', capacity: 3, externalIds: [] }); + expect(karel.memberships.find((m) => m.tenantId === 'tnt_automia')?.roleIds).toEqual(['agent']); + }); + + test('resitel bez e-mailu dostane nahradni adresu a vypnuty ucet', () => { + const account = users.findStoredUserByEmail('ppl_3@placeholder.cz'); + expect(account).toBeDefined(); + expect(account!.enabled).toBe(false); + expect(account!.memberships[0]?.tenantId).toBe('tnt_nordis'); + }); + + test('tickety odkazuji na ucty, cizi resitele zustavaji', () => { + const pavel = users.findStoredUserByEmail('pavel.novy@nordis.cz')!; + expect(ticketStore.findTicket('TK-1', ['tnt_nordis'])?.assignee?.id).toBe(pavel.id); + expect(ticketStore.findTicket('TK-2', ['tnt_logitrans'])?.resolvedById).toBe('usr_2'); + expect(ticketStore.findTicket('TK-3', ['tnt_nordis'])?.assignee?.id).toBe('usr_bartos'); + }); + + test('skupiny v nove i stare podobe maji preznacene cleny', () => { + const pavel = users.findStoredUserByEmail('pavel.novy@nordis.cz')!; + const bezMailu = users.findStoredUserByEmail('ppl_3@placeholder.cz')!; + + expect(people.findGroup('grp_a')?.members).toEqual([ + { personId: pavel.id, seesAll: true }, + { personId: 'usr_bartos', seesAll: false }, + ]); + expect(people.findGroup('grp_b')?.personIds).toEqual([pavel.id, bezMailu.id]); + }); + + test('strom automatizace ma nove ID, podretezec se nesaha', () => { + const pavel = users.findStoredUserByEmail('pavel.novy@nordis.cz')!; + const detail = automations.getAutomation('AUT-01', ['tnt_nordis'])!; + const step = detail.flow.steps[0]; + expect(step?.kind).toBe('action'); + if (step?.kind === 'action') { + expect(step.inputs?.assigneeId).toBe(pavel.id); + expect(step.inputs?.note).toBe('ppl_1x'); + } + }); + + test('stare zaznamy jsou po migraci smazane a zapsane na disk', () => { + const file = JSON.parse(fs.readFileSync(path.join(dir, 'person.json'), 'utf8')) as { items: unknown[] }; + expect(file.items).toEqual([]); + + const pavel = users.findStoredUserByEmail('pavel.novy@nordis.cz')!; + const tickets = fs.readFileSync(path.join(dir, 'ticket.json'), 'utf8'); + expect(tickets).toContain(pavel.id); + expect(tickets).not.toContain('"ppl_1"'); + }); +}); diff --git a/tests/data/permissions.test.ts b/tests/data/permissions.test.ts new file mode 100644 index 0000000..8848168 --- /dev/null +++ b/tests/data/permissions.test.ts @@ -0,0 +1,147 @@ +/** + * Prava za firmu (src/data/permissions.ts). + * + * Rezim pameti: `DATA_DIR` je prazdny (tests/setup.ts), takze `roleStore` + * drzi zaznamy jen v procesu a kazdy soubor testu ma vlastni kopii. + */ + +import { beforeAll, describe, expect, test } from 'vitest'; +import { + hasPermission, + invalidatePermissions, + permissionsOf, + refreshRoles, + roleStore, + syncSystemRoles, + systemRoles, + type Role, +} from '../../src/data/permissions.js'; +import { initStores, nowIso } from '../../src/data/store/index.js'; +import type { User } from '../../src/types.js'; + +function user(overrides: Partial): User { + return { + id: 'usr_test', + email: 'test@example.cz', + passwordHash: '', + name: 'Test', + platformAdmin: false, + memberships: [], + ...overrides, + }; +} + +/** Vlastni role jedne firmy. */ +function customRole(tenantId: string, id: string, permissions: string[]): Role { + const timestamp = nowIso(); + return { + id, + tenantId, + key: id, + name: `Role ${id}`, + description: '', + permissions, + system: false, + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +beforeAll(async () => { + initStores({ databaseReady: false }); + await roleStore.init(systemRoles); + await roleStore.create(customRole('tnt_a', 'role_a_reader', ['ticket.view', 'ticket.comment'])); + await roleStore.create(customRole('tnt_b', 'role_b_boss', ['ticket.assign.others', 'user.manage'])); + await refreshRoles(); +}); + +describe('permissionsOf', () => { + test('clen dvou firem ma v kazde jen prava sve role', () => { + const member = user({ + memberships: [ + { tenantId: 'tnt_a', roleIds: ['agent'] }, + { tenantId: 'tnt_b', roleIds: ['role_admin'] }, + ], + }); + + expect(hasPermission(member, 'user.manage', 'tnt_b')).toBe(true); + expect(hasPermission(member, 'user.manage', 'tnt_a')).toBe(false); + expect(hasPermission(member, 'ticket.view', 'tnt_a')).toBe(true); + // Spravce firmy nema platformni prava ani ve sve firme. + expect(hasPermission(member, 'tenant.manage', 'tnt_b')).toBe(false); + }); + + test('spravce platformy ma vsechno bez ohledu na firmu', () => { + const admin = user({ platformAdmin: true }); + expect(hasPermission(admin, 'tenant.manage', 'tnt_a')).toBe(true); + expect(hasPermission(admin, 'ticket.view', null)).toBe(true); + expect(hasPermission(admin, 'user.manage', 'tnt_neexistuje')).toBe(true); + }); + + test('neznama firma nebo zadna firma znamena zadna prava', () => { + const member = user({ memberships: [{ tenantId: 'tnt_a', roleIds: ['admin'] }] }); + expect(permissionsOf(member, 'tnt_neexistuje').size).toBe(0); + expect(permissionsOf(member, null).size).toBe(0); + }); + + test('role cizi firmy v clenstvi se ignoruje', () => { + const member = user({ + id: 'usr_cizi_role', + memberships: [{ tenantId: 'tnt_a', roleIds: ['role_b_boss', 'role_a_reader'] }], + }); + + const owned = permissionsOf(member, 'tnt_a'); + expect(owned.has('ticket.view')).toBe(true); + expect(owned.has('ticket.assign.others')).toBe(false); + expect(owned.has('user.manage')).toBe(false); + }); + + test('odkaz na roli smi byt ID i klic systemove role', () => { + const byKey = user({ id: 'usr_key', memberships: [{ tenantId: 'tnt_a', roleIds: ['agent'] }] }); + const byId = user({ id: 'usr_id', memberships: [{ tenantId: 'tnt_a', roleIds: ['role_agent'] }] }); + expect([...permissionsOf(byKey, 'tnt_a')].sort()).toEqual([...permissionsOf(byId, 'tnt_a')].sort()); + }); + + test('neznama role a nezname pravo se preskoci, prihlaseni nespadne', async () => { + await roleStore.create(customRole('tnt_a', 'role_a_stale', ['ticket.view', 'zmizele.pravo'])); + await refreshRoles(); + + const member = user({ + id: 'usr_stale', + memberships: [{ tenantId: 'tnt_a', roleIds: ['role_a_stale', 'role_smazana'] }], + }); + expect([...permissionsOf(member, 'tnt_a')]).toEqual(['ticket.view']); + }); +}); + +describe('syncSystemRoles', () => { + test('srovna prava systemove role s kodem a vlastni role necha byt', async () => { + const admin = systemRoles().find((role) => role.id === 'role_admin')!; + + // Instalace, kde spravci firmy chybi nove pravo a nekdo roli prejmenoval. + await roleStore.update( + 'role_admin', + { name: 'Vedeni', permissions: admin.permissions.filter((p) => p !== 'helpdesk.view') }, + { tenantIds: [], includeGlobal: true }, + ); + await refreshRoles(); + invalidatePermissions(); + + await syncSystemRoles(); + + const stored = new Map((await roleStore.listAll()).map((role) => [role.id, role])); + expect([...stored.get('role_admin')!.permissions].sort()).toEqual([...admin.permissions].sort()); + // Nazev se neprepisuje, jen prava. + expect(stored.get('role_admin')!.name).toBe('Vedeni'); + // Vlastni role firem zustavaji, jak byly. + expect(stored.get('role_a_reader')!.permissions).toEqual(['ticket.view', 'ticket.comment']); + expect(stored.get('role_b_boss')!.permissions).toEqual(['ticket.assign.others', 'user.manage']); + }); + + test('stejna prava znamenaji zadny zapis', async () => { + const before = (await roleStore.listAll()).find((role) => role.id === 'role_agent')!.updatedAt; + await syncSystemRoles(); + const after = (await roleStore.listAll()).find((role) => role.id === 'role_agent')!.updatedAt; + expect(after).toBe(before); + }); +}); diff --git a/tests/data/tickets.test.ts b/tests/data/tickets.test.ts new file mode 100644 index 0000000..0bb9a76 --- /dev/null +++ b/tests/data/tickets.test.ts @@ -0,0 +1,201 @@ +/** + * Uloziste ticketu (src/data/tickets): prijem udalosti, filtr na firmu + * a zmena stavu. Uloziste v pameti, ucty z vychozi sady. + */ + +import { beforeAll, describe, expect, test } from 'vitest'; +import { initStores } from '../../src/data/store/index.js'; +import { + assignTicket, + createTicket, + defaultStatuses, + findByExternalId, + getTicket, + initTickets, + intakeEvent, + listTickets, + updateTicketStatus, +} from '../../src/data/ticketStore.js'; +import { refreshUsers, seedUsers, userStore } from '../../src/data/users.js'; + +const ALL = { kind: 'all' } as const; + +beforeAll(async () => { + initStores({ databaseReady: false }); + await userStore.init(seedUsers); + await refreshUsers(); + await initTickets(); +}); + +describe('intakeEvent', () => { + test('prvni udalost zaklada ticket s vychozim stavem', () => { + const result = intakeEvent({ + tenantId: 'tnt_automia', + externalId: 'ORD-1', + type: 'order.created', + label: 'Objednávka vznikla', + payload: { total: 100 }, + apply: { company: 'Firma s.r.o.', channel: 'email' }, + }); + + expect(result.created).toBe(true); + expect(result.repeated).toBe(false); + expect(result.ticket.tenantId).toBe('tnt_automia'); + expect(result.ticket.status).toBe(defaultStatuses[0]); + expect(result.ticket.closed).toBe(false); + expect(result.ticket.subject).toBe('Objednávka vznikla'); + expect(result.ticket.customer.company).toBe('Firma s.r.o.'); + expect(getTicket(result.ticket.id, ['tnt_automia'])?.events).toHaveLength(1); + }); + + test('dalsi udalost se stejnym externim ID ve stejne firme se navesi na ticket', () => { + const first = findByExternalId('tnt_automia', 'ORD-1')!; + const result = intakeEvent({ + tenantId: 'tnt_automia', + externalId: 'ORD-1', + type: 'order.paid', + payload: { paid: true }, + // Prazdna hodnota nemaze, co uz na ticketu je. + apply: { company: '', contact: 'Jan Novák' }, + addTags: ['zaplaceno'], + }); + + expect(result.created).toBe(false); + expect(result.repeated).toBe(false); + expect(result.ticket.id).toBe(first.id); + expect(result.ticket.customer.company).toBe('Firma s.r.o.'); + expect(result.ticket.customer.contact).toBe('Jan Novák'); + expect(result.ticket.tags).toEqual(['zaplaceno']); + expect(getTicket(first.id, ['tnt_automia'])?.events).toHaveLength(2); + }); + + test('presne stejna udalost jako posledne se jen pricte a ticket se nemeni', () => { + const before = findByExternalId('tnt_automia', 'ORD-1')!; + const result = intakeEvent({ + tenantId: 'tnt_automia', + externalId: 'ORD-1', + type: 'order.paid', + payload: { paid: true }, + }); + + expect(result.repeated).toBe(true); + expect(result.created).toBe(false); + expect(result.event.repeats).toBe(2); + expect(result.ticket.updatedAt).toBe(before.updatedAt); + expect(getTicket(before.id, ['tnt_automia'])?.events).toHaveLength(2); + }); + + test('stejne externi ID v jine firme je jiny ticket', () => { + const result = intakeEvent({ tenantId: 'tnt_nordis', externalId: 'ORD-1', type: 'order.created' }); + expect(result.created).toBe(true); + expect(result.ticket.id).not.toBe(findByExternalId('tnt_automia', 'ORD-1')!.id); + expect(findByExternalId('tnt_nordis', 'ORD-1')?.id).toBe(result.ticket.id); + }); + + test('bez externiho ID vznika pokazde novy ticket', () => { + const a = intakeEvent({ tenantId: 'tnt_automia', externalId: null, type: 'form.sent' }); + const b = intakeEvent({ tenantId: 'tnt_automia', externalId: null, type: 'form.sent' }); + expect(a.created).toBe(true); + expect(b.created).toBe(true); + expect(a.ticket.id).not.toBe(b.ticket.id); + }); + + test('stav zadany pri zalozeni se pouzije a neznamy resitel se zahodi', () => { + const result = intakeEvent({ + tenantId: 'tnt_automia', + externalId: 'CALL-9', + type: 'call.started', + create: { status: 'in-progress' }, + apply: { assigneeId: 'usr_neexistuje' }, + }); + expect(result.ticket.status).toBe('in-progress'); + expect(result.ticket.assignee).toBeNull(); + }); +}); + +describe('listTickets', () => { + test('filtr na firmu je povinny a cizi firma nic nedostane', () => { + const automia = listTickets({ tenantIds: ['tnt_automia'], visibility: ALL }); + expect(automia.length).toBeGreaterThan(0); + expect(automia.every((ticket) => ticket.tenantId === 'tnt_automia')).toBe(true); + + expect(listTickets({ tenantIds: ['tnt_logitrans'], visibility: ALL })).toEqual([]); + expect(listTickets({ tenantIds: [], visibility: ALL })).toEqual([]); + + const nordis = listTickets({ tenantIds: ['tnt_nordis'], visibility: ALL }); + expect(nordis.map((ticket) => ticket.externalId)).toEqual(['ORD-1']); + }); + + test('strop viditelnosti schova cizi prirazene tickety, nezarazene vidi kazdy', () => { + const mine = createTicket({ tenantId: 'tnt_automia', subject: 'moje', channel: 'portal', priority: 'normal', assigneeId: 'usr_3' }); + const theirs = createTicket({ tenantId: 'tnt_automia', subject: 'cizi', channel: 'portal', priority: 'normal', assigneeId: 'usr_2' }); + const queue = createTicket({ tenantId: 'tnt_automia', subject: 'fronta', channel: 'portal', priority: 'normal' }); + + const visible = listTickets({ + tenantIds: ['tnt_automia'], + visibility: { kind: 'scoped', personIds: ['usr_3'], groupIds: [] }, + }).map((ticket) => ticket.id); + + expect(visible).toContain(mine.id); + expect(visible).toContain(queue.id); + expect(visible).not.toContain(theirs.id); + expect(getTicket(theirs.id, ['tnt_automia'], [], { kind: 'scoped', personIds: ['usr_3'], groupIds: [] })).toBeUndefined(); + }); + + test('detail cizi firmy je nedostupny', () => { + const ticket = findByExternalId('tnt_automia', 'ORD-1')!; + expect(getTicket(ticket.id, ['tnt_nordis'])).toBeUndefined(); + expect(getTicket(ticket.id, ['tnt_automia'])?.id).toBe(ticket.id); + }); +}); + +describe('updateTicketStatus', () => { + test('vyrizeni nastavi closed, resolvedAt a resitele, ktery ho mel u sebe', () => { + const ticket = createTicket({ tenantId: 'tnt_automia', subject: 'stav', channel: 'email', priority: 'high' }); + expect(assignTicket(ticket.id, 'usr_2', ['tnt_automia'])?.assignee?.id).toBe('usr_2'); + + // Text stavu sam o sobe nic nevyrizuje, o tom rozhoduje priznak. + const renamed = updateTicketStatus(ticket.id, defaultStatuses[3], ['tnt_automia']); + expect(renamed?.status).toBe(defaultStatuses[3]); + expect(renamed?.closed).toBe(false); + expect(renamed?.resolvedAt).toBeNull(); + + const closed = updateTicketStatus(ticket.id, defaultStatuses[3], ['tnt_automia'], true)!; + expect(closed.closed).toBe(true); + expect(closed.resolvedAt).not.toBeNull(); + expect(closed.resolvedById).toBe('usr_2'); + expect(closed.firstResponseAt).not.toBeNull(); + + const reopened = updateTicketStatus(ticket.id, defaultStatuses[1], ['tnt_automia'], false)!; + expect(reopened.closed).toBe(false); + expect(reopened.resolvedAt).toBeNull(); + expect(reopened.resolvedById).toBeNull(); + expect(reopened.reopenCount).toBe(1); + }); + + test('vyrizene tickety jdou v seznamu dolu', () => { + const done = createTicket({ tenantId: 'tnt_automia', subject: 'hotovo', channel: 'email', priority: 'low' }); + updateTicketStatus(done.id, defaultStatuses[3], ['tnt_automia'], true); + + const list = listTickets({ tenantIds: ['tnt_automia'], visibility: ALL }); + const firstClosed = list.findIndex((ticket) => ticket.closed); + const lastOpen = list.map((ticket) => ticket.closed).lastIndexOf(false); + expect(firstClosed).toBeGreaterThan(-1); + expect(lastOpen).toBeLessThan(firstClosed); + }); + + test('cizi firma stav nezmeni a ticket zustane, jak byl', () => { + const ticket = findByExternalId('tnt_automia', 'ORD-1')!; + expect(updateTicketStatus(ticket.id, 'hacked', ['tnt_nordis'], true)).toBeUndefined(); + const after = findByExternalId('tnt_automia', 'ORD-1')!; + expect(after.status).toBe(ticket.status); + expect(after.closed).toBe(false); + }); + + test('resitel z jine firmy se nepriradi', () => { + const ticket = findByExternalId('tnt_nordis', 'ORD-1')!; + // usr_3 je jen v Automii. + expect(assignTicket(ticket.id, 'usr_3', ['tnt_nordis'])).toBeUndefined(); + expect(assignTicket(ticket.id, 'usr_bartos', ['tnt_nordis'])?.assignee?.id).toBe('usr_bartos'); + }); +}); diff --git a/tests/net/guard.test.ts b/tests/net/guard.test.ts new file mode 100644 index 0000000..314460a --- /dev/null +++ b/tests/net/guard.test.ts @@ -0,0 +1,114 @@ +/** + * Zabrana proti volani dovnitr site a cteni tela s limitem (src/net/guard.ts). + * `ALLOW_PRIVATE_TARGETS` je v testech vypnuty (tests/setup.ts). + */ + +import { describe, expect, test } from 'vitest'; +import { + isPrivateHost, + readBodyLimited, + readJsonLimited, + urlProblem, +} from '../../src/net/guard.js'; + +describe('urlProblem', () => { + test('verejna adresa pres http(s) projde', () => { + expect(urlProblem(new URL('https://api.example.cz/v1'))).toBeNull(); + expect(urlProblem(new URL('http://example.cz:8080/'))).toBeNull(); + }); + + test('jiny protokol nez http(s) se odmitne', () => { + expect(urlProblem(new URL('ftp://example.cz/'))).toContain('ftp:'); + expect(urlProblem(new URL('file:///etc/passwd'))).toContain('file:'); + }); + + test.each([ + 'http://localhost:3000/', + 'http://127.0.0.1/', + 'http://10.1.2.3/', + 'http://192.168.1.10/', + 'http://172.16.0.1/', + 'http://172.31.255.1/', + 'http://169.254.169.254/latest/meta-data', + 'http://[::1]:8080/', + 'http://0.0.0.0/', + ])('adresa do vnitrni site se odmitne: %s', (address) => { + const problem = urlProblem(new URL(address)); + expect(problem).not.toBeNull(); + expect(problem).toContain('vnitřní sítě'); + }); + + test('172.32.x a 11.x uz privatni nejsou', () => { + expect(isPrivateHost('172.32.0.1')).toBe(false); + expect(isPrivateHost('11.0.0.1')).toBe(false); + expect(isPrivateHost('LOCALHOST')).toBe(true); + }); +}); + +/** Odpoved, ktera prijde po kouscich. `chunks` jsou texty za sebou. */ +function streamed(chunks: string[], headers: Record = {}): Response { + const encoder = new TextEncoder(); + let index = 0; + const body = new ReadableStream({ + pull(controller) { + const chunk = chunks[index]; + if (chunk === undefined) { + controller.close(); + return; + } + controller.enqueue(encoder.encode(chunk)); + index += 1; + }, + }); + return new Response(body, { headers }); +} + +describe('readBodyLimited', () => { + test('telo v limitu se precte cele', async () => { + const result = await readBodyLimited(streamed(['ab', 'cd', 'ef']), 100); + expect(result).toEqual({ text: 'abcdef', tooLarge: false }); + }); + + test('cteni se zastavi na limitu a text je neuplny', async () => { + const result = await readBodyLimited(streamed(['1234', '5678', '9012']), 6); + expect(result.tooLarge).toBe(true); + // Prvni kousek prosel, druhy uz limit prekrocil a nepripojil se. + expect(result.text).toBe('1234'); + }); + + test('deklarovana delka nad limitem se ani nezacne cist', async () => { + const result = await readBodyLimited(streamed(['abc'], { 'content-length': '5000' }), 10); + expect(result).toEqual({ text: '', tooLarge: true }); + }); + + test('vicebajtove znaky rozdelene mezi kousky se slozi spravne', async () => { + const bytes = new TextEncoder().encode('příliš'); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(bytes.slice(0, 3)); + controller.enqueue(bytes.slice(3)); + controller.close(); + }, + }); + const result = await readBodyLimited(new Response(body), 100); + expect(result.text).toBe('příliš'); + }); + + test('odpoved bez tela je prazdna a v limitu', async () => { + const result = await readBodyLimited(new Response(null), 10); + expect(result).toEqual({ text: '', tooLarge: false }); + }); +}); + +describe('readJsonLimited', () => { + test('platny JSON, prazdne telo, rozbity JSON a telo nad limitem', async () => { + expect(await readJsonLimited(streamed(['{"a":', '1}']), 100)).toEqual({ + tooLarge: false, + text: '{"a":1}', + json: { a: 1 }, + }); + expect((await readJsonLimited(streamed([' ']), 100)).json).toBeNull(); + expect((await readJsonLimited(streamed(['{oops']), 100)).json).toBeUndefined(); + expect((await readJsonLimited(streamed(['{"a":1}']), 3)).tooLarge).toBe(true); + }); +}); diff --git a/tests/routes/health.test.ts b/tests/routes/health.test.ts new file mode 100644 index 0000000..86ef07b --- /dev/null +++ b/tests/routes/health.test.ts @@ -0,0 +1,110 @@ +/** + * Aplikace pres HTTP (src/app.ts): health, prihlaseni a prava. + * + * Data se nacitaji stejnym bootstrapem jako v `src/index.ts`, jen v pameti + * (`DATA_DIR` prazdny, viz tests/setup.ts). Zadny port se neotevira, + * supertest vola aplikaci primo. + */ + +import request from 'supertest'; +import { beforeAll, describe, expect, test } from 'vitest'; +import type { Express } from 'express'; +import { createApp } from '../../src/app.js'; +import { bootstrapData } from '../../src/data/bootstrap.js'; + +let app: Express; + +beforeAll(async () => { + const mode = await bootstrapData({ databaseReady: false }); + expect(mode).toBe('memory'); + app = createApp(); +}); + +async function login(email: string, password: string): Promise { + return request(app).post('/api/auth/login').send({ email, password }); +} + +describe('health', () => { + test('GET /health odpovi 200 bez databaze', async () => { + const response = await request(app).get('/health'); + expect(response.status).toBe(200); + expect(response.body.status).toBe('ok'); + expect(typeof response.body.uptimeSec).toBe('number'); + }); + + test('GET /health/ready hlasi uloziste v pameti', async () => { + const response = await request(app).get('/health/ready'); + expect(response.status).toBe(200); + expect(response.body.database.enabled).toBe(false); + }); + + test('neznama API cesta vraci JSON 404', async () => { + const response = await request(app).get('/api/neexistuje'); + expect(response.status).toBe(404); + expect(response.body.error).toBe('not_found'); + }); +}); + +describe('prihlaseni a prava', () => { + test('bez tokenu je dashboard 401', async () => { + const response = await request(app).get('/api/dashboard/access'); + expect(response.status).toBe(401); + expect(response.body.error).toBe('unauthorized'); + }); + + test('neplatny token je 401', async () => { + const response = await request(app) + .get('/api/dashboard/access') + .set('Authorization', 'Bearer neplatny'); + expect(response.status).toBe(401); + }); + + test('spatne heslo i neznamy e-mail daji stejnou odpoved', async () => { + const wrong = await login('admin@automia.cz', 'spatne'); + const unknown = await login('nikdo@automia.cz', 'demo1234'); + expect(wrong.status).toBe(401); + expect(unknown.status).toBe(401); + expect(wrong.body).toEqual(unknown.body); + }); + + test('po prihlaseni vraci /access role a pohledy', async () => { + const loginResponse = await login('admin@automia.cz', 'demo1234'); + expect(loginResponse.status).toBe(200); + expect(typeof loginResponse.body.token).toBe('string'); + // Hash hesla nikdy neopousti server. + expect(loginResponse.body.user.passwordHash).toBeUndefined(); + + const response = await request(app) + .get('/api/dashboard/access') + .set('Authorization', `Bearer ${loginResponse.body.token}`); + expect(response.status).toBe(200); + expect(response.body.platformAdmin).toBe(true); + expect(response.body.roleNames).toEqual(['Správce firmy']); + expect(response.body.scopes).toEqual(['all', 'tenant', 'mine']); + expect(response.body.defaultTenantId).toBe('tnt_automia'); + expect(response.body.permissions).toContain('tenant.manage'); + }); + + test('clen dvou firem dostane prava za firmu z query', async () => { + const token = (await login('karel.vomacka@automia.cz', 'demo1234')).body.token as string; + + const automia = await request(app) + .get('/api/dashboard/access') + .set('Authorization', `Bearer ${token}`); + expect(automia.status).toBe(200); + expect(automia.body.roleNames).toEqual(['Řešitel']); + expect(automia.body.permissions).not.toContain('user.manage'); + + const nordis = await request(app) + .get('/api/dashboard/access?tenantId=tnt_nordis') + .set('Authorization', `Bearer ${token}`); + expect(nordis.body.roleNames).toEqual(['Správce firmy']); + expect(nordis.body.permissions).toContain('user.manage'); + + // Cizi firma: prava se spocitaji za ni, ale clenstvi nesedi, takze nic. + const foreign = await request(app) + .get('/api/dashboard/tickets?tenantId=tnt_logitrans') + .set('Authorization', `Bearer ${token}`); + expect(foreign.status).toBe(404); + }); +}); diff --git a/tests/runtime/executor.test.ts b/tests/runtime/executor.test.ts new file mode 100644 index 0000000..ee7cca7 --- /dev/null +++ b/tests/runtime/executor.test.ts @@ -0,0 +1,556 @@ +/** + * Vykonavani stromu kroku (src/runtime/executor.ts). + * + * Vyhodnoceni podminky neni exportovane, testuje se pres `runFlow`. Vnitrni + * kroky, skripty a konektory jsou nahrazene atrapami: strom se tu vykonava + * bez cizi sluzby a bez uloziste, aby slo overit jen rozhodovani executoru. + */ + +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { StepContext, StepOutcome } from '../../src/runtime/builtinSteps.js'; +import type { FlowStep } from '../../src/data/automationStore.js'; +import type { ScriptRunResult } from '../../src/shared/scripts.js'; + +vi.mock('../../src/runtime/builtinSteps.js', () => ({ findBuiltinStep: vi.fn() })); +vi.mock('../../src/runtime/scripts/lookup.js', () => ({ scriptIdFor: vi.fn() })); +vi.mock('../../src/runtime/scripts/runner.js', () => ({ runScript: vi.fn() })); +vi.mock('../../src/data/connectorStore.js', () => ({ + getConnector: vi.fn(), + defaultConnectorFor: vi.fn(), +})); + +import { findBuiltinStep } from '../../src/runtime/builtinSteps.js'; +import { scriptIdFor } from '../../src/runtime/scripts/lookup.js'; +import { runScript } from '../../src/runtime/scripts/runner.js'; +import { defaultConnectorFor, getConnector } from '../../src/data/connectorStore.js'; +import { matchOf, rulesOf } from '../../src/data/automationStore.js'; +import { runFlow, type RunContext, type RunOptions } from '../../src/runtime/executor.js'; + +type Handler = ( + inputs: Record, + context: StepContext, +) => Promise | StepOutcome; +type ConditionStep = Extract; + +/** Vnitrni kroky sluzby `test`, ktere v testu existuji. */ +const handlers: Record = {}; + +function options(extra: Partial = {}): RunOptions { + return { tenantId: 'tnt_a', ticketId: null, idempotencyKey: null, ...extra }; +} + +function condition( + rules: ConditionStep['rules'], + extra: Partial> = {}, +): ConditionStep { + return { id: 'cond', kind: 'condition', rules, yes: [], no: [], ...extra }; +} + +function action(id: string, operationId = 'ok', inputs: Record = {}): FlowStep { + return { id, kind: 'action', serviceId: 'test', operationId, inputs }; +} + +async function branchOf(step: ConditionStep, context: RunContext, extra: Partial = {}) { + const result = await runFlow([step], context, options(extra)); + return result.steps[0]?.branch; +} + +function scriptResult(partial: Partial): ScriptRunResult { + return { + ok: true, + scriptId: 'svc/op', + outputs: {}, + logs: [], + durationMs: 1, + httpCalls: 0, + error: null, + ...partial, + }; +} + +beforeEach(() => { + vi.mocked(findBuiltinStep).mockReset(); + vi.mocked(findBuiltinStep).mockImplementation((serviceId, operationId) => + serviceId === 'test' ? handlers[operationId] : undefined, + ); + vi.mocked(scriptIdFor).mockReset(); + vi.mocked(scriptIdFor).mockReturnValue(undefined); + vi.mocked(runScript).mockReset(); + vi.mocked(getConnector).mockReset(); + vi.mocked(defaultConnectorFor).mockReset(); + vi.mocked(defaultConnectorFor).mockResolvedValue(undefined); + + handlers.ok = (inputs) => ({ + ok: true, + summary: 'hotovo', + outputs: { echo: inputs.value ?? '' }, + }); +}); + +describe('podminky nad retezci', () => { + test('eq, neq, contains a startsWith bez ohledu na velikost pismen', async () => { + const context = { status: 'Open', subject: 'Reklamace faktury 2026' }; + expect( + await branchOf(condition([{ fieldId: 'status', operator: 'eq', value: 'Open' }]), context), + ).toBe('yes'); + expect( + await branchOf(condition([{ fieldId: 'status', operator: 'eq', value: 'open' }]), context), + ).toBe('no'); + expect( + await branchOf(condition([{ fieldId: 'status', operator: 'neq', value: 'Closed' }]), context), + ).toBe('yes'); + expect( + await branchOf( + condition([{ fieldId: 'subject', operator: 'contains', value: 'FAKTURY' }]), + context, + ), + ).toBe('yes'); + expect( + await branchOf( + condition([{ fieldId: 'subject', operator: 'startsWith', value: 'reklamace' }]), + context, + ), + ).toBe('yes'); + expect( + await branchOf( + condition([{ fieldId: 'subject', operator: 'startsWith', value: 'faktury' }]), + context, + ), + ).toBe('no'); + }); + + test('isEmpty plati pro prazdny text, null i chybejici hodnotu', async () => { + const empty = condition([{ fieldId: 'note', operator: 'isEmpty' }]); + expect(await branchOf(empty, { note: ' ' })).toBe('yes'); + expect(await branchOf(empty, { note: null })).toBe('yes'); + expect(await branchOf(empty, {})).toBe('yes'); + expect(await branchOf(empty, { note: 'x' })).toBe('no'); + expect( + await branchOf(condition([{ fieldId: 'note', operator: 'isNotEmpty' }]), { note: 'x' }), + ).toBe('yes'); + }); + + test('isTrue a isFalse rozumi i textovym podobam', async () => { + const isTrue = condition([{ fieldId: 'vip', operator: 'isTrue' }]); + const isFalse = condition([{ fieldId: 'vip', operator: 'isFalse' }]); + expect(await branchOf(isTrue, { vip: true })).toBe('yes'); + expect(await branchOf(isTrue, { vip: '1' })).toBe('yes'); + expect(await branchOf(isTrue, { vip: 'yes' })).toBe('no'); + expect(await branchOf(isFalse, { vip: '0' })).toBe('yes'); + expect(await branchOf(isFalse, {})).toBe('yes'); + }); +}); + +describe('podminky nad cisly a daty', () => { + test('gt, gte, lt, lte porovnavaji cisla, ne texty', async () => { + const context = { total: 1500, count: '9' }; + expect( + await branchOf(condition([{ fieldId: 'total', operator: 'gt', value: '1000' }]), context), + ).toBe('yes'); + expect( + await branchOf(condition([{ fieldId: 'total', operator: 'lt', value: '1000' }]), context), + ).toBe('no'); + expect( + await branchOf(condition([{ fieldId: 'total', operator: 'gte', value: '1500' }]), context), + ).toBe('yes'); + expect( + await branchOf(condition([{ fieldId: 'total', operator: 'lte', value: '1499' }]), context), + ).toBe('no'); + // Textove by "9" > "10" platilo, ciselne ne. + expect( + await branchOf(condition([{ fieldId: 'count', operator: 'gt', value: '10' }]), context), + ).toBe('no'); + }); + + test('co neni cislo, se porovnat neda a podminka neplati', async () => { + expect( + await branchOf(condition([{ fieldId: 'total', operator: 'gt', value: '1' }]), { + total: 'hodne', + }), + ).toBe('no'); + expect( + await branchOf(condition([{ fieldId: 'total', operator: 'gte', value: '1' }]), { + total: '12,5', + }), + ).toBe('no'); + }); + + /* + * Chybejici nebo prazdna hodnota se v `ordered` (executor.ts:684) prevede + * pres `Number('')` na 0, takze "castka <= 1000" plati i pro castku, + * ktera nedorazila. Test zustava vypnuty, dokud se to v src neopravi. + */ + test('prazdna hodnota se pri gt/lt neporovnava jako nula', async () => { + expect( + await branchOf(condition([{ fieldId: 'total', operator: 'lte', value: '1000' }]), {}), + ).toBe('no'); + expect( + await branchOf(condition([{ fieldId: 'total', operator: 'lt', value: '1' }]), { total: '' }), + ).toBe('no'); + expect( + await branchOf(condition([{ fieldId: 'total', operator: 'gte', value: '0' }]), { + total: null, + }), + ).toBe('no'); + }); + + test('parametr typu datum se porovnava jako cas', async () => { + const context = { due: '2026-09-10' }; + const typed = { fieldTypes: { due: 'date' as const } }; + expect( + await branchOf( + condition([{ fieldId: 'due', operator: 'gt', value: '2026-09-01' }]), + context, + typed, + ), + ).toBe('yes'); + expect( + await branchOf( + condition([{ fieldId: 'due', operator: 'lt', value: '2026-09-01' }]), + context, + typed, + ), + ).toBe('no'); + expect( + await branchOf( + condition([{ fieldId: 'due', operator: 'gt', value: '2026-09-10T12:00:00Z' }]), + context, + typed, + ), + ).toBe('no'); + expect( + await branchOf( + condition([{ fieldId: 'due', operator: 'eq', value: '2026-09-10' }]), + context, + typed, + ), + ).toBe('yes'); + }); + + test('hodnoty, ktere vypadaji jako ISO datum, se porovnaji jako cas i bez typu', async () => { + const context = { createdAt: '2026-09-10T08:30:00.000Z' }; + expect( + await branchOf( + condition([{ fieldId: 'createdAt', operator: 'gt', value: '2026-09-10' }]), + context, + ), + ).toBe('yes'); + expect( + await branchOf( + condition([{ fieldId: 'createdAt', operator: 'lt', value: '2026-09-11' }]), + context, + ), + ).toBe('yes'); + expect( + await branchOf( + condition([{ fieldId: 'createdAt', operator: 'gt', value: '2026-12-01' }]), + context, + ), + ).toBe('no'); + }); +}); + +describe('podminky nad seznamy a spojeni otazek', () => { + test('contains nad seznamem najde polozku', async () => { + const context = { tags: ['čeká na zabalení', 'vip'] }; + expect( + await branchOf(condition([{ fieldId: 'tags', operator: 'contains', value: 'vip' }]), context), + ).toBe('yes'); + expect( + await branchOf( + condition([{ fieldId: 'tags', operator: 'contains', value: 'expres' }]), + context, + ), + ).toBe('no'); + expect( + await branchOf(condition([{ fieldId: 'tags', operator: 'isEmpty' }]), { tags: [] }), + ).toBe('no'); + }); + + test('all vyzaduje vsechny otazky, any staci jedna', async () => { + const rules: ConditionStep['rules'] = [ + { fieldId: 'status', operator: 'eq', value: 'open' }, + { fieldId: 'priority', operator: 'eq', value: 'high' }, + ]; + const context = { status: 'open', priority: 'low' }; + expect(await branchOf(condition(rules), context)).toBe('no'); + expect(await branchOf(condition(rules, { match: 'any' }), context)).toBe('yes'); + }); + + test('parametr podle ID se najde pres jmeno i pres vystup kroku', async () => { + const names = { fieldNames: { f_9x1: 'callSid', 'st_kontakt.out_found': 'found' } }; + expect( + await branchOf( + condition([{ fieldId: 'f_9x1', operator: 'eq', value: 'CA123' }]), + { callSid: 'CA123' }, + names, + ), + ).toBe('yes'); + expect( + await branchOf( + condition([{ fieldId: 'st_kontakt.out_found', operator: 'isTrue' }]), + { 'st_kontakt.found': true }, + names, + ), + ).toBe('yes'); + }); + + /* + * `conditionValue` (executor.ts:579-583) zkousi hole jmeno vystupu driv nez + * `krok.jmeno`, i kdyz komentar v kodu rika, ze presnejsi je to druhe. + * Hole jmeno navic drzi vystup **prvniho** kroku (publishOutputs ho + * neprepisuje), takze podminka nad druhym krokem se stejnym vystupem cte + * hodnotu z prvniho. Test zustava vypnuty, dokud se to v src neopravi. + */ + test('vystup kroku ma prednost pred holym jmenem z jineho kroku', async () => { + const names = { fieldNames: { 'st_b.out_found': 'found' } }; + expect( + await branchOf( + condition([{ fieldId: 'st_b.out_found', operator: 'isTrue' }]), + { 'st_a.found': false, found: false, 'st_b.found': true }, + names, + ), + ).toBe('yes'); + }); + + test('podminka bez otazky neplati a do logu jde, co chybelo', async () => { + const result = await runFlow([condition([])], {}, options()); + expect(result.ok).toBe(true); + expect(result.steps[0]?.branch).toBe('no'); + expect(result.steps[0]?.label).toBe('Podmínka bez otázky'); + + const missing = await runFlow( + [condition([{ fieldId: 'x', operator: 'eq', value: '1' }])], + {}, + options(), + ); + expect(missing.steps[0]?.detail).toContain('nedorazilo'); + }); + + test('vykona se jen zvolena vetev', async () => { + const calls: string[] = []; + handlers.log = (inputs) => { + calls.push(inputs.value ?? ''); + return { ok: true, summary: 'ok', outputs: {} }; + }; + const step = condition([{ fieldId: 'go', operator: 'isTrue' }], { + yes: [action('a', 'log', { value: 'yes' })], + no: [action('b', 'log', { value: 'no' })], + }); + await runFlow([step], { go: 'true' }, options()); + await runFlow([step], { go: 'false' }, options()); + expect(calls).toEqual(['yes', 'no']); + }); +}); + +describe('stara podoba podminky', () => { + test('rulesOf prevede jednu otazku na kroku a matchOf ma vychozi all', () => { + const legacy: ConditionStep = { + id: 'c', + kind: 'condition', + fieldId: 'status', + operator: 'eq', + value: 'open', + yes: [], + no: [], + }; + expect(rulesOf(legacy)).toEqual([{ fieldId: 'status', operator: 'eq', value: 'open' }]); + expect(matchOf(legacy)).toBe('all'); + + // Nova podoba ma prednost, prazdny seznam otazek se bere jako stara podoba. + expect(rulesOf({ ...legacy, rules: [{ fieldId: 'a', operator: 'isEmpty' }] })).toEqual([ + { fieldId: 'a', operator: 'isEmpty' }, + ]); + expect(rulesOf({ ...legacy, rules: [] })).toHaveLength(1); + expect(rulesOf({ id: 'c', kind: 'condition', fieldId: 'x', yes: [], no: [] })).toEqual([]); + }); + + test('krok ulozeny ve stare podobe vetvi stejne jako novy', async () => { + const legacy: ConditionStep = { + id: 'c', + kind: 'condition', + fieldId: 'status', + operator: 'eq', + value: 'open', + yes: [], + no: [], + }; + expect(await branchOf(legacy, { status: 'open' })).toBe('yes'); + expect(await branchOf(legacy, { status: 'closed' })).toBe('no'); + }); +}); + +describe('stropy', () => { + test('strom nad MAX_STEPS se vubec nespusti', async () => { + const steps = Array.from({ length: 51 }, (_, index) => action(`s${index}`)); + const result = await runFlow(steps, {}, options()); + expect(result.ok).toBe(false); + expect(result.retryable).toBe(false); + expect(result.steps).toEqual([]); + expect(result.error).toContain('51'); + expect(result.error).toContain('50'); + + const fifty = await runFlow(steps.slice(0, 50), {}, options()); + expect(fifty.ok).toBe(true); + expect(fifty.steps).toHaveLength(50); + }); + + test('maxSteps ve volbach strop prepise a vetve podminek se pocitaji', async () => { + const nested = condition([{ fieldId: 'x', operator: 'isEmpty' }], { + yes: [action('a')], + no: [action('b')], + }); + expect((await runFlow([nested], {}, options({ maxSteps: 2 }))).error).toContain('3'); + expect((await runFlow([nested], {}, options({ maxSteps: 3 }))).ok).toBe(true); + }); + + test('smycka se meri staticky: maly strom smi vykonat vic nez MAX_STEPS kroku', async () => { + const items = Array.from({ length: 200 }, (_, index) => ({ n: index })); + const loop: FlowStep = { + id: 'loop', + kind: 'foreach', + path: 'items', + steps: [condition([{ fieldId: 'item.n', operator: 'gte', value: '0' }]), action('a')], + }; + const result = await runFlow([loop], { items }, options()); + expect(result.ok).toBe(true); + // Radek smycky plus dva kroky za kazdou polozku. + expect(result.steps).toHaveLength(1 + 2 * 200); + expect(result.context['loop.count']).toBe(200); + expect((result.context['loop.results'] as unknown[]).length).toBe(200); + }); + + test('MAX_ACTIONS zastavi beh, ktery vykonal tisic kroku', async () => { + const items = Array.from({ length: 200 }, (_, index) => ({ n: index })); + const loop: FlowStep = { + id: 'loop', + kind: 'foreach', + path: 'items', + steps: Array.from({ length: 5 }, (_, index) => ({ + ...condition([{ fieldId: 'item.n', operator: 'gte', value: '0' }]), + id: `c${index}`, + })), + }; + const result = await runFlow([loop], { items }, options()); + expect(result.ok).toBe(false); + expect(result.error).toContain('1000'); + expect(result.steps).toHaveLength(1000); + expect(result.retryable).toBe(false); + }); + + test('seznam nad MAX_LOOP_ITEMS a hodnota, ktera neni seznam, beh zastavi bez opakovani', async () => { + const loop: FlowStep = { id: 'loop', kind: 'foreach', path: 'items', steps: [action('a')] }; + const tooMany = await runFlow( + [loop], + { items: Array.from({ length: 201 }, () => 1) }, + options(), + ); + expect(tooMany.ok).toBe(false); + expect(tooMany.retryable).toBe(false); + expect(tooMany.error).toContain('200'); + + const notList = await runFlow([loop], { items: 'text' }, options()); + expect(notList.ok).toBe(false); + expect(notList.retryable).toBe(false); + expect(notList.steps[0]?.detail).toContain('string'); + }); +}); + +describe('vystupy a sablony', () => { + test('vystup kroku je pod jmenem kroku i pod holym jmenem, holé jmeno se neprepisuje', async () => { + handlers.emit = (inputs) => ({ ok: true, summary: 'ok', outputs: { id: inputs.value ?? '' } }); + const steps = [ + action('first', 'emit', { value: '{{seed}}' }), + action('second', 'emit', { value: '{{first.id}}-2' }), + ]; + const result = await runFlow(steps, { seed: 'S1' }, options()); + expect(result.ok).toBe(true); + expect(result.context['first.id']).toBe('S1'); + expect(result.context['second.id']).toBe('S1-2'); + expect(result.context.id).toBe('S1'); + expect(result.context.first).toEqual({ id: 'S1' }); + }); +}); + +describe('priznak retryable', () => { + test('vyjimka z vnitrniho kroku se neopakuje, krok muze opakovani vyslovne povolit', async () => { + handlers.boom = () => { + throw new Error('rozbite'); + }; + handlers.later = () => ({ + ok: false, + summary: 'server nedostupny', + outputs: {}, + retryable: true, + }); + handlers.never = () => ({ ok: false, summary: 'spatne heslo', outputs: {} }); + + const boom = await runFlow([action('a', 'boom'), action('b')], {}, options()); + expect(boom.ok).toBe(false); + expect(boom.retryable).toBe(false); + expect(boom.steps).toHaveLength(1); + expect(boom.steps[0]?.detail).toBe('rozbite'); + + expect((await runFlow([action('a', 'later')], {}, options())).retryable).toBe(true); + expect((await runFlow([action('a', 'never')], {}, options())).retryable).toBe(false); + }); + + test('operace bez skriptu konci hned a bez opakovani', async () => { + const result = await runFlow( + [{ id: 'x', kind: 'action', serviceId: 'cizi', operationId: 'op' }], + {}, + options(), + ); + expect(result.ok).toBe(false); + expect(result.retryable).toBe(false); + expect(result.steps[0]?.summary).toBe('operace nemá výkonnou část'); + }); + + test('chyba skriptu se neopakuje, vypadek spojeni ano', async () => { + vi.mocked(scriptIdFor).mockReturnValue('svc/op'); + const step: FlowStep = { id: 'x', kind: 'action', serviceId: 'svc', operationId: 'op' }; + + vi.mocked(runScript).mockResolvedValueOnce( + scriptResult({ + ok: false, + error: { kind: 'internal', message: 'Skript selhal: TypeError', retryable: false }, + }), + ); + const scriptError = await runFlow([step], {}, options()); + expect(scriptError.ok).toBe(false); + expect(scriptError.retryable).toBe(false); + expect(scriptError.steps[0]?.summary).toBe('Skript selhal: TypeError'); + + vi.mocked(runScript).mockResolvedValueOnce( + scriptResult({ + ok: false, + error: { kind: 'timeout', message: 'Server neodpověděl v limitu.', retryable: true }, + }), + ); + expect((await runFlow([step], {}, options())).retryable).toBe(true); + + vi.mocked(runScript).mockResolvedValueOnce(scriptResult({ outputs: { invoiceId: 42 } })); + const success = await runFlow([step], {}, options({ idempotencyKey: 'run-1' })); + expect(success.ok).toBe(true); + expect(success.context['x.invoiceId']).toBe(42); + expect(vi.mocked(runScript).mock.calls[2]?.[2]?.idempotencyKey).toBe('run-1:x'); + }); + + test('nedostupne uloziste konektoru je docasna prekazka', async () => { + vi.mocked(scriptIdFor).mockReturnValue('svc/op'); + vi.mocked(getConnector).mockRejectedValue(new Error('ECONNREFUSED')); + const step: FlowStep = { + id: 'x', + kind: 'action', + serviceId: 'svc', + operationId: 'op', + connectorId: 'con_1', + }; + + const result = await runFlow([step], {}, options()); + expect(result.ok).toBe(false); + expect(result.retryable).toBe(true); + expect(result.steps[0]?.summary).toBe('napojení se nepodařilo načíst'); + expect(runScript).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/runtime/scripts/util.test.ts b/tests/runtime/scripts/util.test.ts new file mode 100644 index 0000000..3f8ef0f --- /dev/null +++ b/tests/runtime/scripts/util.test.ts @@ -0,0 +1,98 @@ +/** + * Pomocne funkce skriptu a redakce tajemstvi (src/runtime/scripts/util.ts). + */ + +import { describe, expect, test } from 'vitest'; +import { + createRedactor, + parseBool, + parseNumber, + pick, + truncate, +} from '../../../src/runtime/scripts/util.js'; + +describe('createRedactor', () => { + const secret = 'p@ss w0rd/"x"'; + const redact = createRedactor([secret, undefined, 'kratke', '12345']); + + test('zakryje holou hodnotu i v hlavicce Bearer', () => { + expect(redact(`token=${secret};`)).toBe('token=***;'); + expect(redact(`Authorization: Bearer ${secret}`)).toBe('Authorization: Bearer ***'); + }); + + test('zakryje procentove kodovanou podobu v adrese', () => { + const encoded = encodeURIComponent(secret); + expect(encoded).not.toBe(secret); + expect(redact(`https://api.example.cz/?key=${encoded}&x=1`)).toBe( + 'https://api.example.cz/?key=***&x=1', + ); + }); + + test('zakryje podobu escapovanou v JSONu', () => { + const body = JSON.stringify({ error: `bad token ${secret}` }); + expect(body).not.toContain(secret); + expect(redact(body)).toBe('{"error":"bad token ***"}'); + }); + + test('hodnoty kratsi nez sest znaku a undefined se nezakryvaji', () => { + expect(redact('cislo 12345 a slovo kratke')).toBe('cislo 12345 a slovo ***'); + expect(createRedactor([undefined, 'abc'])('abc abc')).toBe('abc abc'); + }); +}); + +describe('parseBool', () => { + test('rozumi ceskym i anglickym podobam', () => { + expect(parseBool('ano')).toBe(true); + expect(parseBool(' Yes ')).toBe(true); + expect(parseBool(1)).toBe(true); + expect(parseBool('ne')).toBe(false); + expect(parseBool('OFF')).toBe(false); + expect(parseBool(false)).toBe(false); + }); + + test('co neni ani ano, ani ne, je null', () => { + expect(parseBool('mozna')).toBeNull(); + expect(parseBool('')).toBeNull(); + expect(parseBool(undefined)).toBeNull(); + }); +}); + +describe('parseNumber', () => { + test('cislo s ceskou desetinnou carkou', () => { + expect(parseNumber('12,5')).toBe(12.5); + expect(parseNumber(' 1250.75 ')).toBe(1250.75); + expect(parseNumber(7)).toBe(7); + }); + + test('prazdno, text a nekonecno nejsou nula', () => { + expect(parseNumber('')).toBeNull(); + expect(parseNumber('abc')).toBeNull(); + expect(parseNumber(Number.NaN)).toBeNull(); + expect(parseNumber(Number.POSITIVE_INFINITY)).toBeNull(); + expect(parseNumber(true)).toBeNull(); + expect(parseNumber(null)).toBeNull(); + }); +}); + +describe('pick', () => { + test('presny klic ma prednost, jinak bez ohledu na velikost pismen', () => { + expect(pick({ DocumentNumber: 'A', documentNumber: 'B' }, 'documentNumber')).toBe('B'); + expect(pick({ DocumentNumber: 'A' }, 'documentNumber')).toBe('A'); + // Presna shoda kterehokoliv jmena vitezi nad shodou bez ohledu na velikost pismen. + expect(pick({ Id: 1, name: 'x' }, 'id', 'name')).toBe('x'); + expect(pick({ Id: 1 }, 'id', 'name')).toBe(1); + }); + + test('chybejici pole a nespravny vstup vraci undefined', () => { + expect(pick({ a: 1 }, 'b')).toBeUndefined(); + expect(pick(null, 'a')).toBeUndefined(); + expect(pick('text', 'length')).toBeUndefined(); + }); +}); + +describe('truncate', () => { + test('kratky text necha byt, dlouhy zkrati s poznamkou', () => { + expect(truncate('abc', 10)).toBe('abc'); + expect(truncate('abcdefghij', 4)).toBe('abcd (zkráceno, celkem 10 znaků)'); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..8567c67 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,24 @@ +/** + * Prostredi testu. Bezi pred kazdym testovacim souborem (vitest `setupFiles`). + * + * Konfigurace (`src/config.ts`) se cte z `process.env` pri importu modulu, + * proto se promenne nastavuji tady a ne az v testu. Prazdny `DATA_DIR` + * znamena rezim pameti (`storesMode() === 'memory'`), prazdny `DATABASE_URL` + * vypne Postgres. `SECRETS_KEY` je pevny, aby `initSecrets` nezkousel + * zakladat soubor s klicem. + */ + +import { vi } from 'vitest'; + +process.env.NODE_ENV = 'test'; +process.env.DATA_DIR = ''; +process.env.DATABASE_URL = ''; +process.env.SECRETS_KEY = 'vitest-secrets-key'; +process.env.JWT_SECRET = 'vitest-jwt-secret'; +process.env.SEED_DEMO = '0'; +process.env.WEBHOOK_TOKEN_TEST = 'vitest-webhook-token-0123456789'; +delete process.env.ALLOW_PRIVATE_TARGETS; + +// Bootstrap a uloziste loguji kazdy krok. V testu je to jen sum. +vi.spyOn(console, 'info').mockImplementation(() => undefined); +vi.spyOn(console, 'warn').mockImplementation(() => undefined); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..e43276f --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "types": ["node"], + "paths": { "@shared/*": ["../src/shared/*"] } + }, + "include": ["./**/*.ts", "../src/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 570754a..4400b77 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "rootDir": "src", "strict": true, "noUnusedLocals": true, + "noUncheckedIndexedAccess": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "resolveJsonModule": true, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fe8ce71 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import path from 'node:path'; +import { defineConfig } from 'vitest/config'; + +/** + * Testy serveru lezi v `tests/` se stejnou cestou jako testovany modul + * (`tests/data/permissions.test.ts` testuje `src/data/permissions.ts`). + * Bezi v Node, bez databaze a bez site: co potrebuje ven, dostane vstrikle. + */ +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + environment: 'node', + // Promenne prostredi pro rezim pameti, viz tests/setup.ts. + setupFiles: ['tests/setup.ts'], + // Testy sdileji proces, ale kazdy soubor si drzi vlastni kopie ulozist. + isolate: true, + }, + resolve: { + alias: { + '@shared': path.resolve(import.meta.dirname, 'src/shared'), + }, + }, +}); diff --git a/web/src/components/ErrorBoundary.tsx b/web/src/components/ErrorBoundary.tsx index 7ff6193..34880ca 100644 --- a/web/src/components/ErrorBoundary.tsx +++ b/web/src/components/ErrorBoundary.tsx @@ -5,6 +5,9 @@ import { buildInfo } from '@/config/version'; import { getLocale, translate } from '@/i18n'; import { apiFetch } from '@/lib/api'; +/** Delka stacku posilaneho na server. Cely stack z balicku je zbytecne dlouhy. */ +const STACK_LIMIT = 4_000; + /** * Pojistka proti pádu vykreslovani. * @@ -65,8 +68,8 @@ function report(error: Error, info: ErrorInfo): void { method: 'POST', body: { message: error.message, - stack: error.stack?.slice(0, 4_000), - componentStack: info.componentStack?.slice(0, 4_000) ?? undefined, + stack: error.stack?.slice(0, STACK_LIMIT), + componentStack: info.componentStack?.slice(0, STACK_LIMIT) ?? undefined, path, build: `${buildInfo.version} ${buildInfo.builtAt}${buildInfo.commit ? ` ${buildInfo.commit}` : ''}`, }, diff --git a/web/src/components/dashboard/AresTenantDialog.tsx b/web/src/components/dashboard/AresTenantDialog.tsx index 79c3755..7b4df99 100644 --- a/web/src/components/dashboard/AresTenantDialog.tsx +++ b/web/src/components/dashboard/AresTenantDialog.tsx @@ -1,5 +1,5 @@ import { Search } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; import { Field } from '@/components/ui/form/Field'; @@ -8,7 +8,13 @@ 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'; +import { useApiQuery } from '@/hooks/useApiQuery'; +import { useSubmit } from '@/hooks/useSubmit'; +import { useSyncFromSource } from '@/hooks/useSyncFromSource'; + +/** Od kolika znaku ma smysl ptat se ARES a jak kratky nazev firmy jeste projde. */ +const MIN_QUERY_LENGTH = 2; +const MIN_NAME_LENGTH = 2; /** * Zalozeni firmy z registru ARES. @@ -51,6 +57,16 @@ interface Created { const PLACEHOLDER_SUFFIX = '@placeholder.cz'; +/** Osoby z rejstriku jako radky formulare: vsechny vybrane, e-mail jde prepsat. */ +function toRows(persons: AresPerson[]): PersonRow[] { + return persons.map((person, index) => ({ + ...person, + key: `${index}-${person.email}`, + selected: true, + placeholder: person.email, + })); +} + export function AresTenantDialog({ open, onClose, @@ -60,6 +76,30 @@ export function AresTenantDialog({ onClose: () => void; /** Zavola se po zalozeni, aby se seznam firem nacetl znovu. */ onCreated: (tenantId: string) => void; +}) { + return ( + + {/* + Obsah zije jen v otevrenem dialogu. Zavreni ho odmontuje, takze nove + otevreni zacina od zacatku a ne u minule firmy - bez dorovnavani stavu. + */} + + + ); +} + +function AresTenantForm({ + onClose, + onCreated, +}: { + onClose: () => void; + onCreated: (tenantId: string) => void; }) { const [query, setQuery] = useState(''); const [companies, setCompanies] = useState(null); @@ -67,60 +107,27 @@ export function AresTenantDialog({ 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 personsQuery = useApiQuery<{ persons: AresPerson[] }>( + `/api/dashboard/settings/ares/companies/${company?.ico ?? ''}/persons`, + { enabled: company !== null }, + ); + // Radky jsou upravitelna kopie odpovedi; jina firma znamena jinou odpoved. + useSyncFromSource(personsQuery.data, (data) => setPersons(data ? toRows(data.persons) : null)); + const personsError = personsQuery.error; + // Kdyz rejstrik selze, firma jde zalozit bez uctu - jako kdyz osoby neuvadi. + const personsReady = persons !== null || personsError !== null; const create = useSubmit(async () => { if (!company) throw new Error('Nejdřív vyberte firmu.'); @@ -131,7 +138,11 @@ export function AresTenantDialog({ ico: company.ico, name: name.trim() || undefined, note: note.trim() || undefined, - persons: selected.map((row) => ({ name: row.name.trim(), email: row.email.trim(), roles: row.roles })), + persons: selected.map((row) => ({ + name: row.name.trim(), + email: row.email.trim(), + roles: row.roles, + })), }, }); setCreated(result); @@ -147,13 +158,7 @@ export function AresTenantDialog({ const selectedCount = (persons ?? []).filter((row) => row.selected).length; return ( - + <> {created ? (

@@ -205,7 +210,11 @@ export function AresTenantDialog({ autoFocus /> - @@ -265,7 +274,11 @@ export function AresTenantDialog({ -