diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index de657ea..a3b8bce 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -38,6 +38,7 @@ React aplikaci ze slozky `dist/public`. | Konektory za firmu | hotovo | pristupove udaje v konektoru, overeni napojeni | | Transformace dat | hotovo | pravidla i sablona JSON, kroky si predavaji struktury | | Prace nad celym modelem | hotovo | ukazka tela, cesty v sablonach, smycka nad seznamem | +| Vlastni skripty firmy | hotovo | prevod dat v JS, v logu vstup i vystup | | Sprava clenstvi z portalu | hotovo | firmy a role v Nastaveni, lide a pozvanky v Lidech | | Role a prava jako data | hotovo | 26 prav v katalogu, vlastni role za firmu | | Zalozky a limity za firmu | hotovo | navigace chodi ze serveru, ne z kodu klienta | diff --git a/documentation/13-transformace-dat.md b/documentation/13-transformace-dat.md index 94de291..697ee49 100644 --- a/documentation/13-transformace-dat.md +++ b/documentation/13-transformace-dat.md @@ -41,6 +41,35 @@ neprojde kontrolou. Radek s vystupem kroku je nejrychleji rostouci tabulka v systemu (viz [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md)), takze hranice patri do kontroly parametru, ne az do uklidu databaze. +## Tri zpusoby, jak prevest data + +| Zpusob | Kdy se hodi | +| --- | --- | +| **Pravidla** (`transform/map-fields`) | par poli, prevody hodnot, klikatelne | +| **Sablona JSON** (`transform/to-json`) | hlavni prace je ve **tvaru** vysledku | +| **Skript firmy** (`transform/custom`) | slozitejsi prevod, kde je kod citelnejsi nez dvacet pravidel | + +Skript je JS: dostane `input`, vrati objekt. Nevola nic ven a v logu je u nej +**vstup i vystup**, takze kdyz vysledek nesedi, neni potreba hadat, co do +prevodu vlezlo. Podrobnosti v [99-zmeny.md](99-zmeny.md), zaznam z 2026-08-20. + +```js +const order = input.data.order; + +return { + documentSerialNumber: order.code, + dateOfIssue: util.day(order.creationTime), + items: order.items.map((p) => ({ + name: p.name, + amount: util.num(p.amount), + unitPrice: util.num(p.unitPrice.withoutVat), + })), +}; +``` + +Pravidla tim neztraceji smysl: u peti poli je vyber ze seznamu rychlejsi a +nemuze se v nem udelat preklep v zavorce. + ## Dva rezimy Obe moznosti stoji na tom samem enginu v `src/scripts/mapping.ts`. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index dd96924..fc1d32e 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,54 @@ Nejnovejsi nahore. +## 2026-08-20 - vlastni skripty firmy: prevod dat v JS + +Klikaci pravidla jsou u peti poli rychlejsi, ale u modelu objednavky je jich +dvacet a v tom se necte. Proto vedle nich **skript firmy**: prevod z A do B +napsany v JavaScriptu, jeden na zakaznika. + +### Pridano + +- **Skripty firmy** (`src/data/tenantScripts.ts`). Uklada se do uloziste, ne na + disk - disk je uvnitr kontejneru a redeploy ho vymaze. +- **Krok Transformace dat - Vlastni skript.** Vybere se skript a zdrojova data, + vysledek jde dal jako `{{krok.result}}`. +- **V logu je vstup i vystup.** Prave to byl duvod, proc skript nad pravidly + vyhral: kdyz vysledek nesedi, neni potreba hadat, co do prevodu vlezlo. +- **Zkouska bez ulozeni.** V portalu se vlepi skutecne telo a hned je videt, co + z toho leze. Bez toho by se chyba poznala az z padleho behu. +- Skripty jsou v zalozce **Akce**, vedle definic akci. Obojí je popis toho, co + aplikace ve firme umi, a spravuje to tentyz clovek pod pravem `action.manage`. + +### Co skript smi a co ne + +Skript je **ciste prevod hodnot**: dostane `input`, vrati objekt. Nema `require`, +`import`, `process`, `fetch` ani `console`. Volani ven patri do kroku konektoru, +ktery ma pristupove udaje, opakovani i zapis do logu. + +| Pojistka | Proc | +| --- | --- | +| limit 2 s | zacykleny skript by jinak zablokoval workera vsem firmam | +| vysledek do 256 kB | vetsi objekt uz stejne nikdo dal nezpracuje | +| kod do 20 000 znaku | delsi uz neni prevod, ale aplikace | +| zadny stav mezi behy | stav, ktery prezije beh, je zdroj nejhur hledanych chyb | + +**Cim to neni.** `node:vm` neni bezpecnostni hranice proti nekomu, kdo se chce +dostat ven. Je to izolace proti nehode a proti zacykleni. Skript pise spravce +te same firmy, tedy nekdo, kdo jeji data stejne vidi. Kdyby mel skripty psat +nekdo zvenku, patri to do samostatneho procesu s vlastnimi pravy. + +### Opraveno pri tom + +- **Casovy limit nepokryval samotny beh.** Prvni verze pouzila `runInContext` + jen na vyrobu funkce a zavolala ji az potom zvenku - `while (true) {}` uvnitr + ni zablokovalo proces navzdy. Zjisteno pri zkousce, ktera se zasekla. Kod se + ted vola uvnitr `runInContext`, takze limit plati. +- Chyba z limitu nese prototyp z kontextu skriptu, takze `instanceof Error` na + ni neplati. Pozna se podle textu. +- `console` v novem kontextu existuje samo od sebe a psalo by do logu serveru. + Odebrano. + ## 2026-08-20 - prace nad celym modelem, ne nad plochym seznamem poli Odesilatel posle cely model - objednavka ze Shoptetu ma zanoreni, ceny diff --git a/src/data/bootstrap.ts b/src/data/bootstrap.ts index 93c4550..2082e77 100644 --- a/src/data/bootstrap.ts +++ b/src/data/bootstrap.ts @@ -14,6 +14,7 @@ import { refreshCustomWidgets, customWidgetStore, seedCustomWidgets } from './cu import { auditStore } from './audit.js'; import { notificationStore, refreshNotifications } from './notifications.js'; import { inviteStore, refreshInvites } from './invites.js'; +import { tenantScriptStore, refreshTenantScripts } from './tenantScripts.js'; import { onTicket } from './ticketHooks.js'; import { onTicketEvent } from '../runtime/triggers.js'; import { initQueue } from '../runtime/queue.js'; @@ -63,6 +64,7 @@ const entities: Array<{ { store: auditStore as EntityStore }, { store: notificationStore as EntityStore }, { store: inviteStore as EntityStore }, + { store: tenantScriptStore as EntityStore }, ]; /** @@ -96,6 +98,7 @@ export async function refreshCaches(): Promise { refreshCustomWidgets(), refreshNotifications(), refreshInvites(), + refreshTenantScripts(), ]); // Prava k akcim vznikaji z definic akci, takze se registruji az po jejich nacteni. diff --git a/src/data/services.ts b/src/data/services.ts index 5da831b..266b820 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -123,7 +123,7 @@ export interface OperationField { * Odkud se doplni nabidka za behu. Katalog vznika pri importu modulu, * ale resitele a skupiny se nacitaji z uloziste az pozdeji. */ - optionsFrom?: 'people' | 'groups'; + optionsFrom?: 'people' | 'groups' | 'scripts'; hint?: string; } @@ -1739,6 +1739,34 @@ export const services: Service[] = [ 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}}.', + }, + ], + outputFields: [ + { id: 'transform.result', name: 'result', type: 'object', required: true }, + ], + }, ], }, { @@ -1890,15 +1918,31 @@ export function actionsFor(serviceId: string): ServiceOperation[] { */ export function withRuntimeOptions( items: Service[], - options: { people: Array<{ id: string; name: string }>; groups: Array<{ id: string; name: string }> }, + options: { + people: Array<{ id: string; name: string }>; + groups: Array<{ id: string; name: string }>; + scripts?: 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 -', + }; + const fill = (field: OperationField): OperationField => { if (!field.optionsFrom) return field; - const source = field.optionsFrom === 'people' ? options.people : options.groups; + const source = + field.optionsFrom === 'people' + ? options.people + : field.optionsFrom === 'groups' + ? options.groups + : (options.scripts ?? []); return { ...field, options: [ - { value: '', label: field.optionsFrom === 'people' ? 'Nechat ve frontě' : 'Bez skupiny' }, + { value: '', label: empty[field.optionsFrom] }, ...source.map((item) => ({ value: item.id, label: item.name })), ], }; diff --git a/src/data/tenantScripts.ts b/src/data/tenantScripts.ts new file mode 100644 index 0000000..18e9728 --- /dev/null +++ b/src/data/tenantScripts.ts @@ -0,0 +1,129 @@ +/** + * Vlastni skripty firmy: prevod dat z A do B napsany v JS. + * + * Proc vedle klikacich pravidel: + * - u slozitejsiho prevodu je **kod citelnejsi nez dvacet pravidel**. Kdo umi + * JS, napise podminku a smycku rychleji, nez je naklika, a hlavne to pak + * prectе i za pul roku, + * - kazdy zakaznik ma jiny tvar dat. Jeden skript na firmu je presne ta + * hranice, na ktere se to da udrzovat, + * - **v logu je videt vstup i vystup**, takze kdyz neco nesedi, neni potreba + * hadat, co do prevodu vlezlo. + * + * Klikaci pravidla tim neztraceji smysl: u peti poli je vyber ze seznamu + * rychlejsi a nemuze se v nem udelat preklep v zavorce. + * + * Skript **nesmi nic volat ven**. Je to ciste prevod hodnot: dostane objekt, + * vrati objekt. Volani cizich sluzeb dela krok konektoru, ktery ma pristupove + * udaje, opakovani i zapis do logu - viz `runtime/sandbox.ts`. + */ + +import { defineStore, nowIso, type TenantEntity } from './store/index.js'; +import { withCache } from './store/cached.js'; + +export interface TenantScript extends TenantEntity { + tenantId: string; + /** Jmeno, ktere se ukazuje v nabidce kroku. */ + name: string; + /** K cemu to je. Ctou to lide, kteri skript nepsali. */ + description: string; + /** + * Telo funkce. Dostane `input` a `util`, vraci objekt. + * + * Uklada se jako text, ne jako zkompilovana funkce - kod musi jit precist + * a zmenit z portalu, jinak by to nebylo o nic lepsi nez soubor na disku. + */ + code: string; + /** false = krok se skriptem selze s tim, ze je vypnuty. */ + enabled: boolean; + /** Kdy naposledy probehl a jak dopadl. Napovi, jestli se vubec pouziva. */ + lastRunAt: string | null; + lastRunOk: boolean | null; +} + +export const tenantScriptStore = defineStore('tenantScript'); +const cache = withCache(tenantScriptStore); + +export async function refreshTenantScripts(): Promise { + await cache.refresh(); +} + +/** Kolik znaku smi skript mit. Delsi uz neni prevod, ale aplikace. */ +export const MAX_CODE_LENGTH = 20_000; + +export function listTenantScripts(tenantIds: string[]): TenantScript[] { + return cache + .all() + .filter((script) => tenantIds.includes(script.tenantId)) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')); +} + +/** Skript firmy. Cizi skript se chova jako neexistujici, ne jako zakazany. */ +export function findTenantScript(id: string, tenantIds: string[]): TenantScript | undefined { + const script = cache.all().find((item) => item.id === id); + if (!script || !tenantIds.includes(script.tenantId)) return undefined; + return script; +} + +export function newTenantScript(input: { + tenantId: string; + name: string; + description?: string; + code: string; +}): TenantScript { + const timestamp = nowIso(); + return { + id: `scr_${Math.random().toString(36).slice(2, 10)}`, + tenantId: input.tenantId, + name: input.name, + description: input.description ?? '', + code: input.code, + enabled: true, + lastRunAt: null, + lastRunOk: null, + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +/** + * Poznamena, jak dopadl posledni beh. + * + * Zapisuje se **bez cekani**: kdyby zapis selhal, nesmi to shodit beh, ktery + * uz probehl. Je to informace do prehledu, ne soucast vysledku. + */ +export function noteRun(id: string, tenantId: string, ok: boolean): void { + const script = cache.all().find((item) => item.id === id); + if (!script) return; + + script.lastRunAt = nowIso(); + script.lastRunOk = ok; + + void tenantScriptStore + .update(id, { lastRunAt: script.lastRunAt, lastRunOk: ok }, { tenantIds: [tenantId], includeGlobal: false }) + .catch((err: unknown) => { + console.warn(`[skripty] beh skriptu ${id} se nepodarilo poznamenat:`, err); + }); +} + +/** Vzor pro novy skript. Radeji neco, co rovnou bezi, nez prazdne okno. */ +export const SCRIPT_TEMPLATE = `// Vstup je v \`input\`, vrat objekt. +// Priklad: objednavka ze Shoptetu na doklad pro iDoklad. + +const order = input.data.order; + +return { + documentSerialNumber: order.code, + variableSymbol: order.code, + dateOfIssue: order.creationTime.slice(0, 10), + currencyCode: order.price.currencyCode, + items: order.items.map((item) => ({ + name: item.name, + code: item.code, + amount: Number(item.amount), + unit: item.unit || 'ks', + unitPrice: Number(item.unitPrice.withoutVat), + vatRate: Number(item.unitPrice.vatRate), + })), +}; +`; diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 2991762..91d85c5 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -17,6 +17,7 @@ import { type FlowStep, } from '../data/automationStore.js'; import { operatorAllowedForType, operatorsByType } from '../data/conditions.js'; +import { listTenantScripts } from '../data/tenantScripts.js'; import { defaultConnectorFor, getConnector, @@ -70,6 +71,7 @@ import { settingsRouter } from './settings.js'; import { ticketActionsRouter } from './ticketActions.js'; import { widgetDataRouter } from './widgetData.js'; import { scriptsRouter } from './scripts.js'; +import { tenantScriptRouter } from './tenantScripts.js'; import { streamRouter } from './stream.js'; export const dashboardRouter = Router(); @@ -729,6 +731,8 @@ 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); @@ -771,6 +775,10 @@ dashboardRouter.get('/services', (req, res) => { { 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, + })), }, ), // Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu, diff --git a/src/routes/tenantScripts.ts b/src/routes/tenantScripts.ts new file mode 100644 index 0000000..5557fb5 --- /dev/null +++ b/src/routes/tenantScripts.ts @@ -0,0 +1,210 @@ +/** + * Skripty firmy: prevod dat z A do B napsany v JS. + * + * Zamerne to **neni** `routes/scripts.ts`. Ten spravuje skripty sluzeb, ktere + * volaji cizi API, patri celé platforme a smi je menit jen jeji spravce. + * Tohle je prevod hodnot uvnitr jedne firmy: nic nevola ven, vidi jen data, + * ktera mu krok preda, a spravuje si ho firma sama. + * + * Pravo je `action.manage`, tedy totez co u definic akci. Obojí je popis toho, + * co aplikace ve firme umi - ne bezna prace s tickety. + */ + +import { Router } from 'express'; +import { z } from 'zod'; +import { recordAudit } from '../data/audit.js'; +import { + findTenantScript, + listTenantScripts, + newTenantScript, + refreshTenantScripts, + tenantScriptStore, + MAX_CODE_LENGTH, + SCRIPT_TEMPLATE, +} from '../data/tenantScripts.js'; +import { hasPermission } from '../data/permissions.js'; +import { requireAuth } from '../middleware/auth.js'; +import { runSandbox } from '../runtime/sandbox.js'; +import { currentTenant } from './crud.js'; + +export const tenantScriptRouter = Router(); + +tenantScriptRouter.use(requireAuth); + +/** Kdo smi menit. Cteni staci prihlaseni - builder potrebuje nabidku skriptu. */ +const PERMISSION = 'action.manage'; + +/** Vrati false a odpovi, kdyz volajici na zmenu nema pravo. */ +function mayWrite(req: Parameters[0], res: Parameters[1]): boolean { + if (hasPermission(req.user!, PERMISSION)) return true; + console.warn(`[skripty] ${req.user!.email}: chybi pravo ${PERMISSION}`); + res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' }); + return false; +} + +const bodySchema = z.object({ + name: z.string().trim().min(2, 'Skript musí mít název.').max(80), + description: z.string().trim().max(300).optional(), + code: z + .string() + .min(1, 'Skript nemůže být prázdný.') + .max(MAX_CODE_LENGTH, `Skript se vejde do ${MAX_CODE_LENGTH} znaků.`), + enabled: z.boolean().optional(), +}); + +tenantScriptRouter.get('/', (req, res) => { + const tenantId = currentTenant(req, res); + if (!tenantId) return; + + return res.json({ + items: listTenantScripts([tenantId]), + /** Vzor pro novy skript. Radeji neco, co rovnou bezi, nez prazdne okno. */ + template: SCRIPT_TEMPLATE, + maxLength: MAX_CODE_LENGTH, + }); +}); + +tenantScriptRouter.post('/', (req, res) => { + const tenantId = currentTenant(req, res); + if (!tenantId) return; + if (!mayWrite(req, res)) return; + + const parsed = bodySchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.', + }); + } + + const script = newTenantScript({ tenantId, ...parsed.data }); + + return void tenantScriptStore + .create(script) + .then(async () => { + await refreshTenantScripts(); + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId, + action: 'script.create', + target: script.id, + detail: { nazev: script.name }, + }); + return res.status(201).json(script); + }) + .catch((err: unknown) => { + console.error('[skripty] zalozeni selhalo:', err); + return res + .status(500) + .json({ error: 'internal_error', message: 'Skript se nepodařilo uložit.' }); + }); +}); + +tenantScriptRouter.put('/:id', (req, res) => { + const tenantId = currentTenant(req, res); + if (!tenantId) return; + if (!mayWrite(req, res)) return; + + if (!findTenantScript(req.params.id, [tenantId])) { + return res.status(404).json({ error: 'not_found', message: 'Skript neexistuje.' }); + } + + const parsed = bodySchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.', + }); + } + + return void tenantScriptStore + .update(req.params.id, parsed.data, { tenantIds: [tenantId], includeGlobal: false }) + .then(async (updated) => { + if (!updated) { + return res.status(404).json({ error: 'not_found', message: 'Skript neexistuje.' }); + } + await refreshTenantScripts(); + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId, + action: 'script.update', + target: req.params.id, + detail: { nazev: parsed.data.name }, + }); + return res.json(updated); + }) + .catch((err: unknown) => { + console.error('[skripty] uprava selhala:', err); + return res + .status(500) + .json({ error: 'internal_error', message: 'Skript se nepodařilo uložit.' }); + }); +}); + +tenantScriptRouter.delete('/:id', (req, res) => { + const tenantId = currentTenant(req, res); + if (!tenantId) return; + if (!mayWrite(req, res)) return; + + return void tenantScriptStore + .remove(req.params.id, { tenantIds: [tenantId], includeGlobal: false }) + .then(async (removed) => { + if (!removed) { + return res.status(404).json({ error: 'not_found', message: 'Skript neexistuje.' }); + } + await refreshTenantScripts(); + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId, + action: 'script.delete', + target: req.params.id, + }); + return res.status(204).end(); + }) + .catch((err: unknown) => { + console.error('[skripty] smazani selhalo:', err); + return res + .status(500) + .json({ error: 'internal_error', message: 'Skript se nepodařilo smazat.' }); + }); +}); + +const testSchema = z.object({ + code: z.string().min(1).max(MAX_CODE_LENGTH), + /** Vstup, na kterem se to zkusi. Typicky ukazka tela od spoustece. */ + input: z.unknown(), +}); + +/** + * Zkuseni skriptu bez ulozeni. + * + * Tohle je duvod, proc se skript pise tady a ne do souboru: napisu prevod, + * vlepim skutecne telo a hned vidim, co z toho leze. Bez toho se chyba pozna + * az z padleho behu o hodinu pozdeji. + */ +tenantScriptRouter.post('/test', (req, res) => { + const tenantId = currentTenant(req, res); + if (!tenantId) return; + if (!mayWrite(req, res)) return; + + const parsed = testSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: parsed.error.issues[0]?.message ?? 'Neplatné údaje.', + }); + } + + const result = runSandbox(parsed.data.code, parsed.data.input ?? {}); + + // 200 i kdyz skript spadl. Chyba ve skriptu neni chyba API. + return res.json({ + ok: result.ok, + output: result.value, + error: result.error, + durationMs: result.durationMs, + }); +}); diff --git a/src/runtime/builtinSteps.ts b/src/runtime/builtinSteps.ts index c26ecd9..b0e0f06 100644 --- a/src/runtime/builtinSteps.ts +++ b/src/runtime/builtinSteps.ts @@ -10,6 +10,8 @@ */ import { createIncident } from '../data/incidentStore.js'; +import { findTenantScript, noteRun } from '../data/tenantScripts.js'; +import { runSandbox } from './sandbox.js'; import { findGroup, findPerson, findPersonByExternalId, listPeople } from '../data/people.js'; import { assignTicket, @@ -513,6 +515,56 @@ const handlers: Record = { * za tri dny) takhle delat nejde - to patri do fronty jako beh naplanovany * na pozdeji, viz `nextAttemptAt`. Strop je proto minuta. */ + /** + * Vlastni skript firmy: prevod dat napsany v JS. + * + * Do logu jde **vstup i vystup**. Prave to je duvod, proc krok existuje: + * kdyz vysledek nesedi, nema se hadat, co do prevodu vlezlo. + */ + 'transform/custom': (inputs, context) => { + const id = inputs.scriptId?.trim(); + if (!id) return missing('vybraný skript'); + + const script = findTenantScript(id, [context.tenantId]); + if (!script) { + return { + ok: false, + summary: 'skript neexistuje', + detail: `Skript ${id} ve firmě není. Nejspíš ho někdo smazal.`, + outputs: {}, + }; + } + if (!script.enabled) { + return { + ok: false, + summary: `skript ${script.name} je vypnutý`, + detail: 'Zapněte ho v záložce Skripty, nebo krok ze stromu odeberte.', + outputs: {}, + }; + } + + const source = inputs.source ? safeJson(inputs.source) : {}; + const result = runSandbox(script.code, source); + noteRun(script.id, script.tenantId, result.ok); + + if (!result.ok) { + return { + ok: false, + summary: `${script.name}: ${result.error?.split('\n')[0] ?? 'chyba'}`, + // Cely vstup i chyba. Bez vstupu se chyba v prevodu hleda naslepo. + detail: JSON.stringify({ vstup: source, chyba: result.error }, null, 2), + outputs: {}, + }; + } + + return { + ok: true, + summary: `${script.name}: hotovo za ${result.durationMs} ms`, + detail: JSON.stringify({ vstup: source, vystup: result.value }, null, 2), + outputs: { result: result.value }, + }; + }, + 'flow/pause': pause, /* * Tytéž kroky pod klici z katalogu. diff --git a/src/runtime/sandbox.ts b/src/runtime/sandbox.ts new file mode 100644 index 0000000..b1dbe8c --- /dev/null +++ b/src/runtime/sandbox.ts @@ -0,0 +1,160 @@ +/** + * Spusteni vlastniho skriptu firmy. + * + * Skript je **ciste prevod hodnot**: dostane objekt, vrati objekt. Nic nevola + * ven a nic si nepamatuje mezi behy. Diky tomu se da pustit rychle, opakovat + * bez nasledku a v logu z nej staci vstup a vystup. + * + * Co skript nema k dispozici a proc: + * - `require`, `import`, `process`, `fetch`: volani ven patri do kroku + * konektoru, ktery ma pristupove udaje, opakovani i zapis do logu, + * - `setTimeout` a Promise: beh musi skoncit v jednom kroku. Cekani ve fronte + * se dela krokem Pauza, ne uspanim workera. + * + * **Cim to neni.** `node:vm` neni bezpecnostni hranice proti nekomu, kdo se + * chce dostat ven - je to izolace proti nehode a proti zacyklení. Skript pise + * spravce te same firmy, tedy nekdo, kdo uz stejne vidi jeji data. Kdyby mel + * skripty psat nekdo zvenku, musi to jit do samostatneho procesu s vlastnimi + * pravy, ne do teto funkce. + */ + +import { createContext, Script } from 'node:vm'; + +/** Kolik ms smi skript bezet. Delsi vypocet blokuje workera ostatnim firmam. */ +const TIMEOUT_MS = 2_000; + +/** Strop na velikost vysledku. Vetsi objekt uz nikdo dal nezpracuje. */ +const MAX_RESULT_BYTES = 256 * 1024; + +export interface SandboxResult { + ok: boolean; + /** Vysledek skriptu. Vzdy objekt, i kdyz skript vrati neco jineho. */ + value: unknown; + /** Cele hlaseni chyby vcetne toho, na cem to spadlo. */ + error: string | null; + durationMs: number; +} + +/** + * Male pomucky, ktere by si jinak kazdy skript psal znovu. + * + * Zamerne jich je malo. Kazda dalsi je vec, kterou musi ctenar skriptu znat - + * a `input.data.order.items.map(...)` umi kazdy, kdo umi JS. + */ +const util = { + /** Hodnota na ceste, `undefined` kdyz tam nic neni. `get(o, 'a.b.0.c')`. */ + get(source: unknown, path: string): unknown { + let current: unknown = source; + for (const part of path.split('.')) { + if (current === null || current === undefined) return undefined; + current = (current as Record)[part]; + } + return current; + }, + /** Cislo z cehokoliv. Ceska desetinna carka je bezna, nema smysl na ni padat. */ + num(value: unknown): number | null { + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + const parsed = Number(String(value ?? '').trim().replace(',', '.')); + return Number.isFinite(parsed) ? parsed : null; + }, + /** Datum jako `2026-08-20`. Prazdne, kdyz to datum neni. */ + day(value: unknown): string { + const parsed = Date.parse(String(value ?? '')); + return Number.isNaN(parsed) ? '' : new Date(parsed).toISOString().slice(0, 10); + }, +}; + +/** + * Spusti telo skriptu nad vstupem. + * + * **Nikdy nevyhodi vyjimku.** Chyba je vysledek, stejna dohoda jako + * u `runScript` a `runFlow` - volajici pak nemusi resit try/catch a chyba se + * vzdycky dostane do logu. + */ +export function runSandbox(code: string, input: unknown): SandboxResult { + const startedAt = Date.now(); + + /* + * Kod se zabali do funkce, ktera se **rovnou zavola**. Dve veci najednou: + * - v kodu jde psat `return`, coz je to, co clovek ceka, + * - beh je uvnitr `runInContext`, takze na nej plati casovy limit. + * + * Zavolat funkci az potom zvenku by limit obeslo: `runInContext` by jen + * vyrobil funkci a `while (true) {}` uvnitr ni by zablokovalo workera + * navzdy. Presne to se pri prvni zkousce stalo. + */ + const wrapped = `(function (input, util) {\n'use strict';\n${code}\n})(__input, __util);`; + + try { + const script = new Script(wrapped, { filename: 'skript-firmy.js' }); + + /* + * Kontext nese jen vstup a pomucky. Skript nevidi ani `console`, ani + * globalni objekty hostitele, takze nema kudy sahnout ven. + */ + const context = createContext( + { + __input: structuredClone(input), + __util: util, + /* + * `console` v novem kontextu existuje samo od sebe a psalo by do logu + * serveru. Prevod dat nema co vypisovat - kdyz je potreba videt, co se + * stalo, je v logu ticketu vstup i vystup. + */ + console: undefined, + }, + { codeGeneration: { strings: false, wasm: false } }, + ); + + const value = script.runInContext(context, { timeout: TIMEOUT_MS }) as unknown; + + if (value === undefined || value === null) { + return { + ok: false, + value: null, + error: 'Skript nic nevrátil. Poslední řádek má být return s objektem.', + durationMs: Date.now() - startedAt, + }; + } + + const size = JSON.stringify(value)?.length ?? 0; + if (size > MAX_RESULT_BYTES) { + return { + ok: false, + value: null, + error: `Výsledek má ${Math.round(size / 1024)} kB, vejde se ${MAX_RESULT_BYTES / 1024} kB.`, + durationMs: Date.now() - startedAt, + }; + } + + /* + * Vysledek vznikl uvnitr kontextu. Prevedeme ho na prosta data, aby se dal + * ulozit i porovnat - objekt z ciziho kontextu neprojde kontrolou + * `instanceof` a dal by se choval nepredvidatelne. + */ + return { + ok: true, + value: JSON.parse(JSON.stringify(value)) as unknown, + error: null, + durationMs: Date.now() - startedAt, + }; + } catch (err) { + /* + * Hlaska musi obsahovat i misto. Bez cisla radku se v padesatiradkovem + * prevodu hleda chyba hur nez ve stromu kroku. + */ + /* + * Chyba z limitu nese prototyp z kontextu skriptu, takze `instanceof Error` + * na ni neplati. Pozna se podle textu - overeno zkouskou s `while (true)`. + */ + const timedOut = /timed out/i.test(String(err)); + const message = timedOut + ? `Skript běžel déle než ${TIMEOUT_MS} ms a byl zastaven. Nejspíš je v něm ` + + 'nekonečná smyčka, nebo zpracovává příliš velký seznam.' + : err instanceof Error + ? `${err.name}: ${err.message}${err.stack ? `\n${err.stack.split('\n').slice(0, 4).join('\n')}` : ''}` + : String(err); + + return { ok: false, value: null, error: message, durationMs: Date.now() - startedAt }; + } +} diff --git a/web/src/components/dashboard/TenantScripts.tsx b/web/src/components/dashboard/TenantScripts.tsx new file mode 100644 index 0000000..2a570cd --- /dev/null +++ b/web/src/components/dashboard/TenantScripts.tsx @@ -0,0 +1,339 @@ +import { AlertTriangle, CheckCircle2, Code2, Play, Plus, Save, Trash2 } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { DataState } from '@/components/dashboard/DataState'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { apiFetch } from '@/lib/api'; +import { cn } from '@/lib/cn'; +import { formatDateTime } from '@/lib/format'; +import { useApiQuery } from '@/lib/useApiQuery'; + +/** + * Skripty firmy: prevod dat z A do B napsany v JS. + * + * Proc vedle klikacich pravidel: u slozitejsiho prevodu je kod citelnejsi nez + * dvacet pravidel, a hlavne ho jde prectе i za pul roku. U peti poli je naopak + * vyber ze seznamu rychlejsi. + * + * Skript **nic nevola ven**. Dostane objekt, vrati objekt. Proto se da zkusit + * hned tady na skutecnem tele, bez toho, aby se neco odeslalo. + */ + +interface ScriptRecord { + id: string; + name: string; + description: string; + code: string; + enabled: boolean; + lastRunAt: string | null; + lastRunOk: boolean | null; + updatedAt: string; +} + +interface ScriptsResponse { + items: ScriptRecord[]; + template: string; + maxLength: number; +} + +interface TestResult { + ok: boolean; + output: unknown; + error: string | null; + durationMs: number; +} + +const inputClass = + 'w-full rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none'; + +export function TenantScripts() { + const scripts = useApiQuery('/api/dashboard/tenant-scripts'); + + const [selectedId, setSelectedId] = useState(null); + const [draft, setDraft] = useState(null); + const [sample, setSample] = useState('{\n "data": {}\n}'); + const [test, setTest] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const items = useMemo(() => scripts.data?.items ?? [], [scripts.data]); + + // Prvni skript se vybere sam, jinak stranka po nacteni vypada prazdne. + useEffect(() => { + if (selectedId === null && items.length > 0) setSelectedId(items[0].id); + }, [items, selectedId]); + + useEffect(() => { + const found = items.find((item) => item.id === selectedId); + setDraft(found ? { ...found } : null); + setTest(null); + }, [items, selectedId]); + + const dirty = + draft !== null && + JSON.stringify(draft) !== JSON.stringify(items.find((item) => item.id === draft.id)); + + async function create() { + setBusy(true); + setError(null); + try { + const created = await apiFetch('/api/dashboard/tenant-scripts', { + method: 'POST', + body: { + name: `Převod ${items.length + 1}`, + description: '', + code: scripts.data?.template ?? 'return input;', + }, + }); + scripts.reload(); + setSelectedId(created.id); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Skript se nepodařilo založit.'); + } finally { + setBusy(false); + } + } + + async function save() { + if (!draft) return; + setBusy(true); + setError(null); + try { + await apiFetch(`/api/dashboard/tenant-scripts/${draft.id}`, { + method: 'PUT', + body: { + name: draft.name, + description: draft.description, + code: draft.code, + enabled: draft.enabled, + }, + }); + scripts.reload(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Skript se nepodařilo uložit.'); + } finally { + setBusy(false); + } + } + + async function remove() { + if (!draft) return; + setBusy(true); + setError(null); + try { + await apiFetch(`/api/dashboard/tenant-scripts/${draft.id}`, { method: 'DELETE' }); + setSelectedId(null); + scripts.reload(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Skript se nepodařilo smazat.'); + } finally { + setBusy(false); + } + } + + /** + * Zkousi se **rozepsany kod**, ne ulozeny. + * + * Tohle je duvod, proc se skript pise tady a ne do souboru: napisu prevod, + * vlepim skutecne telo a hned vidim, co z toho leze. + */ + async function runTest() { + if (!draft) return; + setBusy(true); + setError(null); + try { + const parsed = JSON.parse(sample.trim() === '' ? '{}' : sample); + const result = await apiFetch('/api/dashboard/tenant-scripts/test', { + method: 'POST', + body: { code: draft.code, input: parsed }, + }); + setTest(result); + } catch (err: unknown) { + setTest(null); + setError( + err instanceof SyntaxError + ? `Ukázka není platný JSON: ${err.message}` + : err instanceof Error + ? err.message + : 'Zkoušku se nepodařilo spustit.', + ); + } finally { + setBusy(false); + } + } + + return ( +
+
+

+ Převod dat napsaný v JavaScriptu. Skript dostane objekt v proměnné{' '} + input a vrátí objekt přes{' '} + return. Ve stromu se pak použije krokem + Transformace dat - Vlastní skript. V logu ticketu je u něj vidět vstup i výstup. +

+ +
+ + {error && ( +

+ + {error} +

+ )} + + +
+
    + {items.map((item) => ( +
  • + +
  • + ))} +
+ + {draft && ( +
+
+ + +
+ +