Vlastni skripty firmy: prevod dat v JS, v logu vstup i vystup

Klikaci pravidla jsou u peti poli rychlejsi, ale u modelu objednavky je jich
dvacet a v tom se necte. Vedle nich proto skript firmy: prevod z A do B
napsany v JS, jeden na zakaznika.

- Skripty se ukladaji do uloziste, ne na disk. Disk je uvnitr kontejneru
  a redeploy ho vymaze.
- Krok Transformace dat - Vlastni skript. Vysledek jde dal jako krok.result.
- V logu ticketu je u kroku vstup i vystup. Prave to byl duvod, proc skript
  nad pravidly vyhral.
- Zkouska bez ulozeni: v portalu se vlepi skutecne telo a hned je videt, co
  z toho leze.
- Skripty jsou v zalozce Akce, vedle definic akci. Obojí je popis toho, co
  aplikace ve firme umi, a spravuje to tentyz clovek.

Skript je ciste prevod hodnot: dostane input, vrati objekt. Nema require,
import, process, fetch ani console, bezi nejvys 2 s a vysledek se vejde do
256 kB. node:vm neni bezpecnostni hranice proti nekomu, kdo se chce dostat
ven - je to izolace proti nehode a proti zacykleni.

Pri zkousce se ukazalo, ze casovy limit nepokryval samotny beh: runInContext
jen vyrobil funkci a zavolat ji zvenku znamenalo, ze while (true) uvnitr
zablokovalo proces navzdy. Kod se ted vola uvnitr runInContext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-20 08:08:09 +02:00
co-authored by Claude Opus 5
parent 99561c4682
commit e4779caf82
12 changed files with 1063 additions and 6 deletions
+1
View File
@@ -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 |
+29
View File
@@ -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`.
+48
View File
@@ -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
+3
View File
@@ -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<TenantEntity> },
{ store: notificationStore as EntityStore<TenantEntity> },
{ store: inviteStore as EntityStore<TenantEntity> },
{ store: tenantScriptStore as EntityStore<TenantEntity> },
];
/**
@@ -96,6 +98,7 @@ export async function refreshCaches(): Promise<void> {
refreshCustomWidgets(),
refreshNotifications(),
refreshInvites(),
refreshTenantScripts(),
]);
// Prava k akcim vznikaji z definic akci, takze se registruji az po jejich nacteni.
+48 -4
View File
@@ -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<NonNullable<OperationField['optionsFrom']>, 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 })),
],
};
+129
View File
@@ -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>('tenantScript');
const cache = withCache(tenantScriptStore);
export async function refreshTenantScripts(): Promise<void> {
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),
})),
};
`;
+8
View File
@@ -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,
+210
View File
@@ -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<typeof requireAuth>[0], res: Parameters<typeof requireAuth>[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,
});
});
+52
View File
@@ -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<string, Handler> = {
* 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.
+160
View File
@@ -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<string, unknown>)[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 };
}
}
@@ -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<ScriptsResponse>('/api/dashboard/tenant-scripts');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draft, setDraft] = useState<ScriptRecord | null>(null);
const [sample, setSample] = useState('{\n "data": {}\n}');
const [test, setTest] = useState<TestResult | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<ScriptRecord>('/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<TestResult>('/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 (
<div className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<p className="max-w-2xl text-sm text-white/50">
Převod dat napsaný v JavaScriptu. Skript dostane objekt v proměnné{' '}
<code className="text-brand-300/80">input</code> a vrátí objekt přes{' '}
<code className="text-brand-300/80">return</code>. Ve stromu se pak použije krokem
Transformace dat - Vlastní skript. V logu ticketu je u něj vidět vstup i výstup.
</p>
<Button onClick={() => void create()} disabled={busy} size="sm">
<Plus className="size-3.5" />
Nový skript
</Button>
</div>
{error && (
<p className="flex items-start gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
{error}
</p>
)}
<DataState
loading={scripts.loading}
error={scripts.error}
onRetry={scripts.reload}
empty={items.length === 0}
emptyLabel="Zatím žádný skript. Hodí se, když je pravidel tolik, že je kód čitelnější."
>
<div className="grid gap-4 lg:grid-cols-[16rem_1fr]">
<ul className="space-y-1.5">
{items.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => setSelectedId(item.id)}
className={cn(
'w-full rounded-xl border px-3 py-2.5 text-left transition-colors',
item.id === selectedId
? 'border-brand-400/50 bg-brand-500/10'
: 'border-ink-600/60 hover:border-brand-400/30',
)}
>
<span className="flex flex-wrap items-center gap-2">
<Code2 className="size-3.5 shrink-0 text-brand-300" />
<span className="min-w-0 truncate text-sm font-medium text-white">
{item.name}
</span>
{!item.enabled && <Badge tone="warn">vypnutý</Badge>}
</span>
{item.lastRunAt && (
<span className="mt-1 block text-xs text-white/35">
naposledy {formatDateTime(item.lastRunAt)}
{item.lastRunOk === false ? ', chybou' : ''}
</span>
)}
</button>
</li>
))}
</ul>
{draft && (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs text-white/55">Název</span>
<input
value={draft.name}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
className={inputClass}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-white/55">K čemu to je</span>
<input
value={draft.description}
onChange={(event) => setDraft({ ...draft, description: event.target.value })}
placeholder="Objednávka ze Shoptetu na doklad pro iDoklad"
className={inputClass}
/>
</label>
</div>
<label className="block">
<span className="mb-1 block text-xs text-white/55">
Kód
<span className="ml-2 text-white/30">
k dispozici je input a util.get, util.num, util.day
</span>
</span>
<textarea
value={draft.code}
onChange={(event) => setDraft({ ...draft, code: event.target.value })}
rows={16}
spellCheck={false}
className={cn(inputClass, 'resize-y font-mono text-xs leading-relaxed')}
/>
</label>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void save()} disabled={busy || !dirty} size="sm">
<Save className="size-3.5" />
{dirty ? 'Uložit' : 'Uloženo'}
</Button>
<label className="flex items-center gap-2 text-sm text-white/60">
<input
type="checkbox"
checked={draft.enabled}
onChange={(event) => setDraft({ ...draft, enabled: event.target.checked })}
className="size-4 accent-brand-400"
/>
Zapnutý
</label>
<button
type="button"
onClick={() => void remove()}
disabled={busy}
className="ml-auto flex items-center gap-1.5 text-xs text-white/35 transition-colors hover:text-danger-400"
>
<Trash2 className="size-3.5" />
Smazat
</button>
</div>
<div className="rounded-xl border border-ink-600/50 bg-ink-900/40 p-3">
<p className="text-xs font-semibold tracking-wide text-white/45 uppercase">
Zkouška
</p>
<p className="mt-1 text-xs text-white/35">
Vlepte skutečné tělo. Zkouší se rozepsaný kód, nic se neodesílá ven.
</p>
<textarea
value={sample}
onChange={(event) => setSample(event.target.value)}
rows={5}
spellCheck={false}
className={cn(inputClass, 'mt-2 resize-y font-mono text-xs')}
/>
<Button onClick={() => void runTest()} disabled={busy} size="sm" className="mt-2">
<Play className="size-3.5" />
Zkusit
</Button>
{test && (
<div className="mt-3">
<p
className={cn(
'flex items-center gap-1.5 text-xs',
test.ok ? 'text-ok-400' : 'text-danger-400',
)}
>
{test.ok ? (
<CheckCircle2 className="size-3.5" />
) : (
<AlertTriangle className="size-3.5" />
)}
{test.ok ? `Hotovo za ${test.durationMs} ms` : 'Skript spadl'}
</p>
<pre className="mt-1.5 max-h-72 overflow-auto rounded-lg bg-ink-950/70 p-2.5 font-mono text-xs whitespace-pre-wrap text-white/70">
{test.ok ? JSON.stringify(test.output, null, 2) : test.error}
</pre>
</div>
)}
</div>
</div>
)}
</div>
</DataState>
</div>
);
}
+36 -2
View File
@@ -2,6 +2,7 @@ import { GitBranch, Info } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { EntityAdmin, parseJsonField, type AdminField } from '@/components/dashboard/EntityAdmin';
import { TenantScripts } from '@/components/dashboard/TenantScripts';
import { Badge } from '@/components/ui/Badge';
import { apiFetch } from '@/lib/api';
import { usePageMeta } from '@/lib/usePageMeta';
@@ -37,6 +38,12 @@ export default function Actions() {
usePageMeta({ title: 'Akce na ticketu - portál Automia' });
const [types, setTypes] = useState<Array<{ id: string; name: string }>>([]);
/*
* Skripty jsou tu vedle akci zamerne. Obojí je **definice toho, co aplikace
* ve firme umi**, obojí spravuje tentyz clovek pod pravem `action.manage`
* a skript je casto to, co akce nebo automatizace uvnitr pouziva.
*/
const [tab, setTab] = useState<'akce' | 'skripty'>('akce');
useEffect(() => {
apiFetch<{ items: Array<{ id: string; name: string }> }>(
@@ -100,12 +107,37 @@ export default function Actions() {
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Akce na ticketu</h1>
<h1 className="text-2xl font-bold text-white">
{tab === 'akce' ? 'Akce na ticketu' : 'Skripty firmy'}
</h1>
<p className="mt-1 text-sm text-white/50">
Tlačítko, které řešitel zmáčkne na ticketu. Váže se na typ nebo tag.
{tab === 'akce'
? 'Tlačítko, které řešitel zmáčkne na ticketu. Váže se na typ nebo tag.'
: 'Převod dat z jednoho tvaru do druhého, napsaný v JavaScriptu.'}
</p>
</header>
<div className="flex flex-wrap gap-2">
{(['akce', 'skripty'] as const).map((key) => (
<button
key={key}
type="button"
onClick={() => setTab(key)}
className={
tab === key
? 'rounded-lg bg-brand-500/15 px-3 py-1.5 text-sm font-medium text-brand-100'
: 'rounded-lg px-3 py-1.5 text-sm font-medium text-white/50 transition-colors hover:text-white'
}
>
{key === 'akce' ? 'Akce' : 'Skripty'}
</button>
))}
</div>
{tab === 'skripty' && <TenantScripts />}
{tab === 'akce' && (
<>
<p className="flex gap-2 rounded-xl border border-ink-600/60 px-4 py-3 text-sm leading-relaxed text-white/45">
<Info className="mt-0.5 size-4 shrink-0 text-brand-300" />
<span>
@@ -202,6 +234,8 @@ export default function Actions() {
body: undefined,
})}
/>
</>
)}
</div>
);
}